* Add openapi provider catalog design notes * Add OpenAPI spec format adapter support * Add Google and Microsoft spec format adapters * Preserve streaming spec format refresh * Move provider add surfaces into catalog presets * Fix provider catalog health checks * Fix Google Directory health check args * Format design notes * Add openapi provider service split migration * Fix service split blob migration * Read all provider operation rows for the migration plan * Assign provider operations by fresh-add spec ownership * Fix Microsoft service split ownership * Map singular mail folder and users profile segments * Seed add form identity from the catalog preset name * Use OIDC id token for OAuth identity labels * Handle OAuth health without probes * Canonicalize Microsoft scopes and decode JWT claims as UTF-8 * Update lockfile for provider package dependency changes * Package provider service split migration * Retarget orphan guardrail policies as dormant rows in the cloud runner * Retarget orphan approve policies too * Fold provider catalogs into openapi
Unified · Split
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; | 13 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines | |||
| 42 | * falls back to the credential-driven default. */ | 48 | * falls back to the credential-driven default. */ |
| 43 | readonly workosVaultClient?: WorkOSVaultClient; | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | readonly activeToolkitSlug?: string; | 50 | readonly activeToolkitSlug?: string; |
| 45 | /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets | ||
| 46 | * `microsoft.addGraph` point at a loopback emulator instead of the pinned | ||
| 47 | * Microsoft Graph URLs. Off by default; production leaves it unset. */ | ||
| 48 | readonly allowLocalNetwork?: boolean; | ||
| 49 | } | 51 | } |
| 50 | 52 | ||
| 51 | export default defineExecutorConfig({ | 53 | export default defineExecutorConfig({ |
| 52 | plugins: ({ | 54 | plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => |
| 53 | workosCredentials, | ||
| 54 | workosVaultClient, | ||
| 55 | activeToolkitSlug, | ||
| 56 | allowLocalNetwork, | ||
| 57 | }: CloudPluginDeps = {}) => | ||
| 58 | [ | 55 | [ |
| 59 | openApiHttpPlugin(), | 56 | openApiHttpPlugin({ |
| 60 | googleHttpPlugin(), | 57 | presets: [...googleCatalog, ...microsoftCatalog], |
| 61 | microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | 58 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 59 | }), | ||
| 62 | mcpHttpPlugin({ | 60 | mcpHttpPlugin({ |
| 63 | dangerouslyAllowStdioMCP: false, | 61 | dangerouslyAllowStdioMCP: false, |
| 64 | }), | 62 | }), |
| @@ -41,10 +41,8 @@ · 40 unmodified lines | |||
| 41 | "@executor-js/execution": "workspace:*", | 41 | "@executor-js/execution": "workspace:*", |
| 42 | "@executor-js/fumadb": "workspace:*", | 42 | "@executor-js/fumadb": "workspace:*", |
| 43 | "@executor-js/host-mcp": "workspace:*", | 43 | "@executor-js/host-mcp": "workspace:*", |
| 44 | "@executor-js/plugin-google": "workspace:*", | ||
| 45 | "@executor-js/plugin-graphql": "workspace:*", | 44 | "@executor-js/plugin-graphql": "workspace:*", |
| 46 | "@executor-js/plugin-mcp": "workspace:*", | 45 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 48 | "@executor-js/plugin-openapi": "workspace:*", | 46 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | "@executor-js/plugin-toolkits": "workspace:*", | 47 | "@executor-js/plugin-toolkits": "workspace:*", |
| 50 | "@executor-js/plugin-workos-vault": "workspace:*", | 48 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -66,7 +66,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 65 unmodified lines | |||
| 66 | }, | 66 | }, |
| 67 | activeToolkitSlug: | 67 | activeToolkitSlug: |
| 68 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | 68 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, |
| 69 | allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", | ||
| 70 | }), | 69 | }), |
| 71 | }); | 70 | }); |
| 72 | 71 | ||
| @@ -35,11 +35,9 @@ · 34 unmodified lines | |||
| 35 | "@executor-js/local": "workspace:*", | 35 | "@executor-js/local": "workspace:*", |
| 36 | "@executor-js/plugin-desktop-settings": "workspace:*", | 36 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 37 | "@executor-js/plugin-file-secrets": "workspace:*", | 37 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 38 | "@executor-js/plugin-google": "workspace:*", | ||
| 39 | "@executor-js/plugin-graphql": "workspace:*", | 38 | "@executor-js/plugin-graphql": "workspace:*", |
| 40 | "@executor-js/plugin-keychain": "workspace:*", | 39 | "@executor-js/plugin-keychain": "workspace:*", |
| 41 | "@executor-js/plugin-mcp": "workspace:*", | 40 | "@executor-js/plugin-mcp": "workspace:*", |
| 42 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 43 | "@executor-js/plugin-onepassword": "workspace:*", | 41 | "@executor-js/plugin-onepassword": "workspace:*", |
| 44 | "@executor-js/plugin-openapi": "workspace:*", | 42 | "@executor-js/plugin-openapi": "workspace:*", |
| 45 | "@executor-js/runtime-quickjs": "workspace:*", | 43 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 13 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -19,9 +25,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines | |||
| 19 | export default defineExecutorConfig({ | 25 | export default defineExecutorConfig({ |
| 20 | plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => | 26 | plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => |
| 21 | [ | 27 | [ |
| 22 | openApiHttpPlugin(), | 28 | openApiHttpPlugin({ |
| 23 | googleHttpPlugin(), | 29 | presets: [...googleCatalog, ...microsoftCatalog], |
| 24 | microsoftHttpPlugin(), | 30 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 31 | }), | ||
| 25 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 26 | graphqlHttpPlugin(), | 33 | graphqlHttpPlugin(), |
| 27 | toolkitsPlugin({ activeToolkitSlug }), | 34 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -24,11 +24,10 @@ · 23 unmodified lines | |||
| 24 | "@executor-js/fumadb": "workspace:*", | 24 | "@executor-js/fumadb": "workspace:*", |
| 25 | "@executor-js/host-mcp": "workspace:*", | 25 | "@executor-js/host-mcp": "workspace:*", |
| 26 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 26 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 27 | "@executor-js/plugin-google": "workspace:*", | ||
| 28 | "@executor-js/plugin-graphql": "workspace:*", | 27 | "@executor-js/plugin-graphql": "workspace:*", |
| 29 | "@executor-js/plugin-mcp": "workspace:*", | 28 | "@executor-js/plugin-mcp": "workspace:*", |
| 30 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 31 | "@executor-js/plugin-openapi": "workspace:*", | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 30 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 32 | "@executor-js/plugin-toolkits": "workspace:*", | 31 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | "@executor-js/react": "workspace:*", | 32 | "@executor-js/react": "workspace:*", |
| 34 | "@executor-js/runtime-quickjs": "workspace:*", | 33 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -110,11 +110,11 @@ const insertOperationStorage = ( · 109 unmodified lines | |||
| 110 | args: [ | 110 | args: [ |
| 111 | row.tenant, | 111 | row.tenant, |
| 112 | row.pluginId, | 112 | row.pluginId, |
| 113 | `${row.integration}.items.list`, | 113 | `${row.integration}.calendar.events.list`, |
| 114 | JSON.stringify({ | 114 | JSON.stringify({ |
| 115 | integration: row.integration, | 115 | integration: row.integration, |
| 116 | toolName: "items.list", | 116 | toolName: "calendar.events.list", |
| 117 | binding: { method: "get", pathTemplate: "/items" }, | 117 | binding: { method: "get", pathTemplate: "/calendar/events" }, |
| 118 | }), | 118 | }), |
| 119 | now, | 119 | now, |
| 120 | now, | 120 | now, |
| @@ -239,6 +239,7 @@ describe("runCloudflareDataMigrations", () => { · 118 unmodified lines | |||
| 239 | const d1 = makeFakeD1(db.client); | 239 | const d1 = makeFakeD1(db.client); |
| 240 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ | 240 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ |
| 241 | "2026-06-20-google-openapi-ownership", | 241 | "2026-06-20-google-openapi-ownership", |
| 242 | "2026-07-08-provider-service-split", | ||
| 242 | ]); | 243 | ]); |
| 243 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); | 244 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); |
| 244 | 245 | ||
| @@ -276,10 +277,12 @@ describe("runCloudflareDataMigrations", () => { · 31 unmodified lines | |||
| 276 | }), | 277 | }), |
| 277 | ); | 278 | ); |
| 278 | objects.set("o:org_1/openapi/spec/googlehash", "google spec"); | 279 | objects.set("o:org_1/openapi/spec/googlehash", "google spec"); |
| 280 | objects.set("o:org_1/google/defs/googlehash", "google defs"); | ||
| 279 | 281 | ||
| 280 | const d1 = makeFakeD1(db.client); | 282 | const d1 = makeFakeD1(db.client); |
| 281 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ | 283 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ |
| 282 | "2026-06-20-google-openapi-ownership", | 284 | "2026-06-20-google-openapi-ownership", |
| 285 | "2026-07-08-provider-service-split", | ||
| 283 | ]); | 286 | ]); |
| 284 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); | 287 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); |
| 285 | 288 | ||
| @@ -288,12 +291,58 @@ describe("runCloudflareDataMigrations", () => { · 2 unmodified lines | |||
| 288 | const integrations = yield* Effect.promise(() => | 291 | const integrations = yield* Effect.promise(() => |
| 289 | db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"), | 292 | db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"), |
| 290 | ); | 293 | ); |
| 291 | expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]); | 294 | expect(integrations.rows).toEqual([{ slug: "google_calendar", plugin_id: "openapi" }]); |
| 292 | 295 | ||
| 293 | const storage = yield* Effect.promise(() => | 296 | const storage = yield* Effect.promise(() => |
| 294 | db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"), | 297 | db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"), |
| 295 | ); | 298 | ); |
| 296 | expect(storage.rows).toEqual([{ plugin_id: "google", key: "google.items.list" }]); | 299 | expect(storage.rows).toEqual([{ plugin_id: "openapi", key: expect.stringMatching(/^op\./) }]); |
| 300 | |||
| 301 | yield* Effect.promise(() => db.close()); | ||
| 302 | }), | ||
| 303 | ); | ||
| 304 | |||
| 305 | it.effect("splits Google monolith ownership and copies R2 spec and defs objects", () => | ||
| 306 | Effect.gen(function* () { | ||
| 307 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 308 | const { bucket, objects } = makeFakeR2(); | ||
| 309 | |||
| 310 | yield* Effect.promise(() => | ||
| 311 | insertIntegration(db.client, { | ||
| 312 | rowId: "google-monolith-row", | ||
| 313 | tenant: "org_1", | ||
| 314 | slug: "google", | ||
| 315 | pluginId: "google", | ||
| 316 | config: { | ||
| 317 | specHash: "mono-hash", | ||
| 318 | googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 319 | }, | ||
| 320 | }), | ||
| 321 | ); | ||
| 322 | yield* Effect.promise(() => | ||
| 323 | insertOperationStorage(db.client, { | ||
| 324 | tenant: "org_1", | ||
| 325 | pluginId: "google", | ||
| 326 | integration: "google", | ||
| 327 | }), | ||
| 328 | ); | ||
| 329 | objects.set("o:org_1/google/spec/mono-hash", "google spec"); | ||
| 330 | objects.set("o:org_1/google/defs/mono-hash", "google defs"); | ||
| 331 | |||
| 332 | const d1 = makeFakeD1(db.client); | ||
| 333 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ | ||
| 334 | "2026-06-20-google-openapi-ownership", | ||
| 335 | "2026-07-08-provider-service-split", | ||
| 336 | ]); | ||
| 337 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); | ||
| 338 | |||
| 339 | expect(objects.get("o:org_1/openapi/spec/mono-hash")).toBe("google spec"); | ||
| 340 | expect(objects.get("o:org_1/openapi/defs/mono-hash")).toBe("google defs"); | ||
| 341 | |||
| 342 | const integrations = yield* Effect.promise(() => | ||
| 343 | db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"), | ||
| 344 | ); | ||
| 345 | expect(integrations.rows).toEqual([{ slug: "google_calendar", plugin_id: "openapi" }]); | ||
| 297 | 346 | ||
| 298 | yield* Effect.promise(() => db.close()); | 347 | yield* Effect.promise(() => db.close()); |
| 299 | }), | 348 | }), |
| @@ -7,7 +7,13 @@ import { · 6 unmodified lines | |||
| 7 | type SqliteDataMigration, | 7 | type SqliteDataMigration, |
| 8 | type SqliteDataMigrationClient, | 8 | type SqliteDataMigrationClient, |
| 9 | } from "@executor-js/sdk"; | 9 | } from "@executor-js/sdk"; |
| 10 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | 10 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; |
| 11 | |||
| 12 | import { | ||
| 13 | providerServiceSplitDataMigration, | ||
| 14 | runSqliteProviderServiceSplitMigration, | ||
| 15 | type BlobRow, | ||
| 16 | } from "@executor-js/plugin-provider-service-split"; | ||
| 11 | 17 | ||
| 12 | const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]); | 18 | const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]); |
| 13 | 19 | ||
| @@ -161,6 +167,61 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 147 unmodified lines | |||
| 161 | }), | 167 | }), |
| 162 | }); | 168 | }); |
| 163 | 169 | ||
| 170 | const copyProviderServiceSplitBlobsToR2 = ( | ||
| 171 | client: SqliteDataMigrationClient, | ||
| 172 | bucket: R2Bucket, | ||
| 173 | ): Effect.Effect<readonly BlobRow[], DataMigrationError> => | ||
| 174 | Effect.tryPromise({ | ||
| 175 | try: async () => { | ||
| 176 | const present: BlobRow[] = []; | ||
| 177 | const result = await client.execute( | ||
| 178 | `SELECT tenant, plugin_id, json_extract(config, '$.specHash') AS spec_hash | ||
| 179 | FROM integration | ||
| 180 | WHERE ((plugin_id = 'google' AND slug = 'google') | ||
| 181 | OR (plugin_id = 'microsoft' AND slug = 'microsoft')) | ||
| 182 | AND config IS NOT NULL | ||
| 183 | AND json_valid(config) | ||
| 184 | AND json_extract(config, '$.specHash') IS NOT NULL | ||
| 185 | AND json_extract(config, '$.specHash') <> ''`, | ||
| 186 | ); | ||
| 187 | const seen = new Set<string>(); | ||
| 188 | for (const row of result.rows) { | ||
| 189 | if ( | ||
| 190 | typeof row.tenant !== "string" || | ||
| 191 | typeof row.plugin_id !== "string" || | ||
| 192 | typeof row.spec_hash !== "string" | ||
| 193 | ) { | ||
| 194 | continue; | ||
| 195 | } | ||
| 196 | for (const key of [`spec/${row.spec_hash}`, `defs/${row.spec_hash}`]) { | ||
| 197 | const sourceNamespace = `o:${row.tenant}/${row.plugin_id}`; | ||
| 198 | const sourceKey = r2ObjectName(row.tenant, row.plugin_id, key); | ||
| 199 | const targetKey = r2ObjectName(row.tenant, "openapi", key); | ||
| 200 | const source = await bucket.get(sourceKey); | ||
| 201 | if (source == null) continue; | ||
| 202 | const presenceKey = `${sourceNamespace}/${key}`; | ||
| 203 | if (!seen.has(presenceKey)) { | ||
| 204 | seen.add(presenceKey); | ||
| 205 | present.push({ | ||
| 206 | id: JSON.stringify([sourceNamespace, key]), | ||
| 207 | namespace: sourceNamespace, | ||
| 208 | key, | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | if ((await bucket.head(targetKey)) == null) { | ||
| 212 | await bucket.put(targetKey, await source.text()); | ||
| 213 | } | ||
| 214 | } | ||
| 215 | } | ||
| 216 | return present; | ||
| 217 | }, | ||
| 218 | catch: (cause) => | ||
| 219 | new DataMigrationError({ | ||
| 220 | migration: providerServiceSplitDataMigration.name, | ||
| 221 | cause, | ||
| 222 | }), | ||
| 223 | }); | ||
| 224 | |||
| 164 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ | 225 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ |
| 165 | { | 226 | { |
| 166 | name: googleOpenApiOwnershipDataMigration.name, | 227 | name: googleOpenApiOwnershipDataMigration.name, |
| @@ -170,6 +231,17 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite · 3 unmodified lines | |||
| 170 | yield* googleOpenApiOwnershipDataMigration.run(client); | 231 | yield* googleOpenApiOwnershipDataMigration.run(client); |
| 171 | }), | 232 | }), |
| 172 | }, | 233 | }, |
| 234 | { | ||
| 235 | name: providerServiceSplitDataMigration.name, | ||
| 236 | run: (client) => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const blobs = bucket ? yield* copyProviderServiceSplitBlobsToR2(client, bucket) : []; | ||
| 239 | yield* runSqliteProviderServiceSplitMigration(client, { | ||
| 240 | blobBackend: bucket ? "external" : "database", | ||
| 241 | blobs, | ||
| 242 | }).pipe(Effect.asVoid); | ||
| 243 | }), | ||
| 244 | }, | ||
| 173 | ]; | 245 | ]; |
| 174 | 246 | ||
| 175 | export const runCloudflareDataMigrations = ( | 247 | export const runCloudflareDataMigrations = ( |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 2 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 2 | import { |
| 3 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 3 | googleCatalog, |
| 4 | googleDiscoveryAdapter, | ||
| 5 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 6 | import { | ||
| 7 | microsoftCatalog, | ||
| 8 | microsoftGraphAdapter, | ||
| 9 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 10 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 11 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 12 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -22,9 +28,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines | |||
| 22 | options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, | 28 | options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, |
| 23 | ) => | 29 | ) => |
| 24 | [ | 30 | [ |
| 25 | openApiHttpPlugin(), | 31 | openApiHttpPlugin({ |
| 26 | googleHttpPlugin(), | 32 | presets: [...googleCatalog, ...microsoftCatalog], |
| 27 | microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }), | 33 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 34 | }), | ||
| 28 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 35 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 29 | graphqlHttpPlugin(), | 36 | graphqlHttpPlugin(), |
| 30 | toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), | 37 | toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), |
| @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; · 5 unmodified lines | |||
| 6 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; | 6 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; |
| 7 | import { Schema } from "effect"; | 7 | import { Schema } from "effect"; |
| 8 | import { unstable_dev, type Unstable_DevWorker } from "wrangler"; | 8 | import { unstable_dev, type Unstable_DevWorker } from "wrangler"; |
| 9 | import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 9 | 10 | ||
| 10 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| 11 | // End-to-end test for the Cloudflare host: boots the REAL worker on workerd via | 12 | // End-to-end test for the Cloudflare host: boots the REAL worker on workerd via |
| @@ -187,13 +188,48 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 175 unmodified lines | |||
| 187 | expect(integration?.slug).toBe(slug); | 188 | expect(integration?.slug).toBe(slug); |
| 188 | }, 60_000); | 189 | }, 60_000); |
| 189 | 190 | ||
| 190 | it("registers the Microsoft Graph API route in the runtime plugin set", async () => { | 191 | it("exposes Microsoft catalog presets through the OpenAPI integration catalog", async () => { |
| 191 | const res = await worker.fetch("/api/microsoft/graph", { | 192 | const microsoftFilesPreset = microsoftCatalog.find( |
| 193 | (preset) => preset.defaultSlug === "microsoft_files", | ||
| 194 | ); | ||
| 195 | expect(microsoftFilesPreset).toBeTruthy(); | ||
| 196 | |||
| 197 | const removedProviderRoute = await worker.fetch("/api/microsoft/graph", { | ||
| 192 | method: "POST", | 198 | method: "POST", |
| 193 | headers: { "content-type": "application/json" }, | 199 | headers: { "content-type": "application/json" }, |
| 194 | body: JSON.stringify({}), | 200 | body: JSON.stringify({}), |
| 195 | }); | 201 | }); |
| 196 | expect(res.status).toBe(400); | 202 | expect(removedProviderRoute.status).toBe(404); |
| 203 | |||
| 204 | const slug = `microsoft-files-${runId}`; | ||
| 205 | const add = await worker.fetch("/api/openapi/specs", { | ||
| 206 | method: "POST", | ||
| 207 | headers: { "content-type": "application/json" }, | ||
| 208 | body: JSON.stringify({ | ||
| 209 | spec: { kind: "blob", value: SPEC }, | ||
| 210 | slug, | ||
| 211 | name: microsoftFilesPreset?.name, | ||
| 212 | description: microsoftFilesPreset?.summary, | ||
| 213 | family: microsoftFilesPreset?.family, | ||
| 214 | authenticationTemplate: microsoftFilesPreset?.authTemplate, | ||
| 215 | }), | ||
| 216 | }); | ||
| 217 | expect(add.status).toBe(200); | ||
| 218 | |||
| 219 | const res = await worker.fetch(`/api/integrations/${slug}`); | ||
| 220 | expect(res.status).toBe(200); | ||
| 221 | const integration = (await res.json()) as { | ||
| 222 | readonly slug: string; | ||
| 223 | readonly kind: string; | ||
| 224 | readonly family?: string; | ||
| 225 | readonly authMethods: readonly { readonly template: string; readonly kind: string }[]; | ||
| 226 | }; | ||
| 227 | expect(integration.slug).toBe(slug); | ||
| 228 | expect(integration.kind).toBe("openapi"); | ||
| 229 | expect(integration.family).toBe("microsoft"); | ||
| 230 | expect(integration.authMethods.some((method) => method.template === "azureAdDelegated")).toBe( | ||
| 231 | true, | ||
| 232 | ); | ||
| 197 | }); | 233 | }); |
| 198 | 234 | ||
| 199 | it("gates the API when dev-auth is on but treats the request as the dev admin", async () => { | 235 | it("gates the API when dev-auth is on but treats the request as the dev admin", async () => { |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 13 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -21,18 +27,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines | |||
| 21 | 27 | ||
| 22 | interface SelfHostPluginDeps { | 28 | interface SelfHostPluginDeps { |
| 23 | readonly activeToolkitSlug?: string; | 29 | readonly activeToolkitSlug?: string; |
| 24 | /** Mirrors `HostConfig.allowLocalNetwork` (EXECUTOR_ALLOW_LOCAL_NETWORK): | ||
| 25 | * lets `microsoft.addGraph` point at a loopback emulator instead of the | ||
| 26 | * pinned Microsoft Graph URLs. Off by default. */ | ||
| 27 | readonly allowLocalNetwork?: boolean; | ||
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | export default defineExecutorConfig({ | 32 | export default defineExecutorConfig({ |
| 31 | plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) => | 33 | plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => |
| 32 | [ | 34 | [ |
| 33 | openApiHttpPlugin(), | 35 | openApiHttpPlugin({ |
| 34 | googleHttpPlugin(), | 36 | presets: [...googleCatalog, ...microsoftCatalog], |
| 35 | microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | 37 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 38 | }), | ||
| 36 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), | 39 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 37 | graphqlHttpPlugin(), | 40 | graphqlHttpPlugin(), |
| 38 | toolkitsPlugin({ activeToolkitSlug }), | 41 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -27,11 +27,10 @@ · 26 unmodified lines | |||
| 27 | "@executor-js/fumadb": "workspace:*", | 27 | "@executor-js/fumadb": "workspace:*", |
| 28 | "@executor-js/host-mcp": "workspace:*", | 28 | "@executor-js/host-mcp": "workspace:*", |
| 29 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 29 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 30 | "@executor-js/plugin-google": "workspace:*", | ||
| 31 | "@executor-js/plugin-graphql": "workspace:*", | 30 | "@executor-js/plugin-graphql": "workspace:*", |
| 32 | "@executor-js/plugin-mcp": "workspace:*", | 31 | "@executor-js/plugin-mcp": "workspace:*", |
| 33 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 34 | "@executor-js/plugin-openapi": "workspace:*", | 32 | "@executor-js/plugin-openapi": "workspace:*", |
| 33 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 35 | "@executor-js/plugin-toolkits": "workspace:*", | 34 | "@executor-js/plugin-toolkits": "workspace:*", |
| 36 | "@executor-js/react": "workspace:*", | 35 | "@executor-js/react": "workspace:*", |
| 37 | "@executor-js/runtime-quickjs": "workspace:*", | 36 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -12,8 +12,9 @@ import { · 11 unmodified lines | |||
| 12 | openApiSpecBlobDataMigration, | 12 | openApiSpecBlobDataMigration, |
| 13 | } from "@executor-js/plugin-openapi"; | 13 | } from "@executor-js/plugin-openapi"; |
| 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; | 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; |
| 15 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | 15 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; |
| 16 | 16 | ||
| 17 | import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split"; | ||
| 17 | import { authConfigTransforms } from "./auth-config-migration"; | 18 | import { authConfigTransforms } from "./auth-config-migration"; |
| 18 | 19 | ||
| 19 | export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ | 20 | export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ |
| @@ -31,4 +32,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines | |||
| 31 | openApiSpecBlobDataMigration, | 32 | openApiSpecBlobDataMigration, |
| 32 | graphqlIntrospectionBlobDataMigration, | 33 | graphqlIntrospectionBlobDataMigration, |
| 33 | googleOpenApiOwnershipDataMigration, | 34 | googleOpenApiOwnershipDataMigration, |
| 35 | providerServiceSplitDataMigration, | ||
| 34 | ]; | 36 | ]; |
| @@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines | |||
| 44 | executorConfig.plugins({ | 44 | executorConfig.plugins({ |
| 45 | activeToolkitSlug: | 45 | activeToolkitSlug: |
| 46 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | 46 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, |
| 47 | // Read the env directly (same computation as loadConfig().allowLocalNetwork): | ||
| 48 | // plugins() runs per request, and loadConfig does filesystem work | ||
| 49 | // (data dir, secret key) that must not ride the request path. | ||
| 50 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", | ||
| 51 | }), | 47 | }), |
| 52 | }, | 48 | }, |
| 53 | ); | 49 | ); |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { keychainPlugin } from "@executor-js/plugin-keychain"; | 13 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| @@ -26,9 +32,10 @@ interface LocalPluginDeps { · 18 unmodified lines | |||
| 26 | export default defineExecutorConfig({ | 32 | export default defineExecutorConfig({ |
| 27 | plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => | 33 | plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => |
| 28 | [ | 34 | [ |
| 29 | openApiHttpPlugin(), | 35 | openApiHttpPlugin({ |
| 30 | googleHttpPlugin(), | 36 | presets: [...googleCatalog, ...microsoftCatalog], |
| 31 | microsoftHttpPlugin(), | 37 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 38 | }), | ||
| 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), | 39 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 33 | graphqlHttpPlugin(), | 40 | graphqlHttpPlugin(), |
| 34 | toolkitsPlugin({ activeToolkitSlug }), | 41 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -32,13 +32,12 @@ · 31 unmodified lines | |||
| 32 | "@executor-js/plugin-desktop-settings": "workspace:*", | 32 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 33 | "@executor-js/plugin-example": "workspace:*", | 33 | "@executor-js/plugin-example": "workspace:*", |
| 34 | "@executor-js/plugin-file-secrets": "workspace:*", | 34 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 35 | "@executor-js/plugin-google": "workspace:*", | ||
| 36 | "@executor-js/plugin-graphql": "workspace:*", | 35 | "@executor-js/plugin-graphql": "workspace:*", |
| 37 | "@executor-js/plugin-keychain": "workspace:*", | 36 | "@executor-js/plugin-keychain": "workspace:*", |
| 38 | "@executor-js/plugin-mcp": "workspace:*", | 37 | "@executor-js/plugin-mcp": "workspace:*", |
| 39 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 40 | "@executor-js/plugin-onepassword": "workspace:*", | 38 | "@executor-js/plugin-onepassword": "workspace:*", |
| 41 | "@executor-js/plugin-openapi": "workspace:*", | 39 | "@executor-js/plugin-openapi": "workspace:*", |
| 40 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 42 | "@executor-js/plugin-toolkits": "workspace:*", | 41 | "@executor-js/plugin-toolkits": "workspace:*", |
| 43 | "@executor-js/react": "workspace:*", | 42 | "@executor-js/react": "workspace:*", |
| 44 | "@executor-js/runtime-quickjs": "workspace:*", | 43 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -17,8 +17,9 @@ import { · 16 unmodified lines | |||
| 17 | openApiSpecBlobDataMigration, | 17 | openApiSpecBlobDataMigration, |
| 18 | } from "@executor-js/plugin-openapi"; | 18 | } from "@executor-js/plugin-openapi"; |
| 19 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; | 19 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; |
| 20 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | 20 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; |
| 21 | 21 | ||
| 22 | import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split"; | ||
| 22 | import { authConfigTransforms } from "./auth-config-migration"; | 23 | import { authConfigTransforms } from "./auth-config-migration"; |
| 23 | import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration"; | 24 | import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration"; |
| 24 | 25 | ||
| @@ -42,6 +43,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines | |||
| 42 | openApiSpecBlobDataMigration, | 43 | openApiSpecBlobDataMigration, |
| 43 | graphqlIntrospectionBlobDataMigration, | 44 | graphqlIntrospectionBlobDataMigration, |
| 44 | googleOpenApiOwnershipDataMigration, | 45 | googleOpenApiOwnershipDataMigration, |
| 46 | providerServiceSplitDataMigration, | ||
| 45 | // GC dead DCR oauth_client rows (old always-register duplicates) and backfill | 47 | // GC dead DCR oauth_client rows (old always-register duplicates) and backfill |
| 46 | // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C). | 48 | // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C). |
| 47 | oauthClientGcSqliteMigration, | 49 | oauthClientGcSqliteMigration, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | |||
| 10 | "@effect/language-service": "^0.85.1", | 10 | "@effect/language-service": "^0.85.1", |
| 11 | "@effect/tsgo": "^0.5.2", | 11 | "@effect/tsgo": "^0.5.2", |
| 12 | "@effect/vitest": "catalog:", | 12 | "@effect/vitest": "catalog:", |
| 13 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 13 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", | 14 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 14 | "@vitest/expect": "catalog:", | 15 | "@vitest/expect": "catalog:", |
| 15 | "@vitest/mocker": "catalog:", | 16 | "@vitest/mocker": "catalog:", |
| @@ -66,10 +67,8 @@ · 50 unmodified lines | |||
| 66 | "@executor-js/execution": "workspace:*", | 67 | "@executor-js/execution": "workspace:*", |
| 67 | "@executor-js/fumadb": "workspace:*", | 68 | "@executor-js/fumadb": "workspace:*", |
| 68 | "@executor-js/host-mcp": "workspace:*", | 69 | "@executor-js/host-mcp": "workspace:*", |
| 69 | "@executor-js/plugin-google": "workspace:*", | ||
| 70 | "@executor-js/plugin-graphql": "workspace:*", | 70 | "@executor-js/plugin-graphql": "workspace:*", |
| 71 | "@executor-js/plugin-mcp": "workspace:*", | 71 | "@executor-js/plugin-mcp": "workspace:*", |
| 72 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 73 | "@executor-js/plugin-openapi": "workspace:*", | 72 | "@executor-js/plugin-openapi": "workspace:*", |
| 74 | "@executor-js/plugin-toolkits": "workspace:*", | 73 | "@executor-js/plugin-toolkits": "workspace:*", |
| 75 | "@executor-js/plugin-workos-vault": "workspace:*", | 74 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -142,11 +141,9 @@ · 66 unmodified lines | |||
| 142 | "@executor-js/local": "workspace:*", | 141 | "@executor-js/local": "workspace:*", |
| 143 | "@executor-js/plugin-desktop-settings": "workspace:*", | 142 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 144 | "@executor-js/plugin-file-secrets": "workspace:*", | 143 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 145 | "@executor-js/plugin-google": "workspace:*", | ||
| 146 | "@executor-js/plugin-graphql": "workspace:*", | 144 | "@executor-js/plugin-graphql": "workspace:*", |
| 147 | "@executor-js/plugin-keychain": "workspace:*", | 145 | "@executor-js/plugin-keychain": "workspace:*", |
| 148 | "@executor-js/plugin-mcp": "workspace:*", | 146 | "@executor-js/plugin-mcp": "workspace:*", |
| 149 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 150 | "@executor-js/plugin-onepassword": "workspace:*", | 147 | "@executor-js/plugin-onepassword": "workspace:*", |
| 151 | "@executor-js/plugin-openapi": "workspace:*", | 148 | "@executor-js/plugin-openapi": "workspace:*", |
| 152 | "@executor-js/runtime-quickjs": "workspace:*", | 149 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -176,11 +173,10 @@ · 23 unmodified lines | |||
| 176 | "@executor-js/fumadb": "workspace:*", | 173 | "@executor-js/fumadb": "workspace:*", |
| 177 | "@executor-js/host-mcp": "workspace:*", | 174 | "@executor-js/host-mcp": "workspace:*", |
| 178 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 175 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 179 | "@executor-js/plugin-google": "workspace:*", | ||
| 180 | "@executor-js/plugin-graphql": "workspace:*", | 176 | "@executor-js/plugin-graphql": "workspace:*", |
| 181 | "@executor-js/plugin-mcp": "workspace:*", | 177 | "@executor-js/plugin-mcp": "workspace:*", |
| 182 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 183 | "@executor-js/plugin-openapi": "workspace:*", | 178 | "@executor-js/plugin-openapi": "workspace:*", |
| 179 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 184 | "@executor-js/plugin-toolkits": "workspace:*", | 180 | "@executor-js/plugin-toolkits": "workspace:*", |
| 185 | "@executor-js/react": "workspace:*", | 181 | "@executor-js/react": "workspace:*", |
| 186 | "@executor-js/runtime-quickjs": "workspace:*", | 182 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -225,11 +221,10 @@ · 38 unmodified lines | |||
| 225 | "@executor-js/fumadb": "workspace:*", | 221 | "@executor-js/fumadb": "workspace:*", |
| 226 | "@executor-js/host-mcp": "workspace:*", | 222 | "@executor-js/host-mcp": "workspace:*", |
| 227 | "@executor-js/plugin-encrypted-secrets": "workspace:*", | 223 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 228 | "@executor-js/plugin-google": "workspace:*", | ||
| 229 | "@executor-js/plugin-graphql": "workspace:*", | 224 | "@executor-js/plugin-graphql": "workspace:*", |
| 230 | "@executor-js/plugin-mcp": "workspace:*", | 225 | "@executor-js/plugin-mcp": "workspace:*", |
| 231 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 232 | "@executor-js/plugin-openapi": "workspace:*", | 226 | "@executor-js/plugin-openapi": "workspace:*", |
| 227 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 233 | "@executor-js/plugin-toolkits": "workspace:*", | 228 | "@executor-js/plugin-toolkits": "workspace:*", |
| 234 | "@executor-js/react": "workspace:*", | 229 | "@executor-js/react": "workspace:*", |
| 235 | "@executor-js/runtime-quickjs": "workspace:*", | 230 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -276,13 +271,12 @@ · 40 unmodified lines | |||
| 276 | "@executor-js/plugin-desktop-settings": "workspace:*", | 271 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 277 | "@executor-js/plugin-example": "workspace:*", | 272 | "@executor-js/plugin-example": "workspace:*", |
| 278 | "@executor-js/plugin-file-secrets": "workspace:*", | 273 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 279 | "@executor-js/plugin-google": "workspace:*", | ||
| 280 | "@executor-js/plugin-graphql": "workspace:*", | 274 | "@executor-js/plugin-graphql": "workspace:*", |
| 281 | "@executor-js/plugin-keychain": "workspace:*", | 275 | "@executor-js/plugin-keychain": "workspace:*", |
| 282 | "@executor-js/plugin-mcp": "workspace:*", | 276 | "@executor-js/plugin-mcp": "workspace:*", |
| 283 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 284 | "@executor-js/plugin-onepassword": "workspace:*", | 277 | "@executor-js/plugin-onepassword": "workspace:*", |
| 285 | "@executor-js/plugin-openapi": "workspace:*", | 278 | "@executor-js/plugin-openapi": "workspace:*", |
| 279 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 286 | "@executor-js/plugin-toolkits": "workspace:*", | 280 | "@executor-js/plugin-toolkits": "workspace:*", |
| 287 | "@executor-js/react": "workspace:*", | 281 | "@executor-js/react": "workspace:*", |
| 288 | "@executor-js/runtime-quickjs": "workspace:*", | 282 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -346,10 +340,8 @@ · 57 unmodified lines | |||
| 346 | "@executor-js/api": "workspace:*", | 340 | "@executor-js/api": "workspace:*", |
| 347 | "@executor-js/emulate": "^0.13.3", | 341 | "@executor-js/emulate": "^0.13.3", |
| 348 | "@executor-js/mcporter": "^0.11.4", | 342 | "@executor-js/mcporter": "^0.11.4", |
| 349 | "@executor-js/plugin-google": "workspace:*", | ||
| 350 | "@executor-js/plugin-graphql": "workspace:*", | 343 | "@executor-js/plugin-graphql": "workspace:*", |
| 351 | "@executor-js/plugin-mcp": "workspace:*", | 344 | "@executor-js/plugin-mcp": "workspace:*", |
| 352 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 353 | "@executor-js/plugin-openapi": "workspace:*", | 345 | "@executor-js/plugin-openapi": "workspace:*", |
| 354 | "@executor-js/plugin-toolkits": "workspace:*", | 346 | "@executor-js/plugin-toolkits": "workspace:*", |
| 355 | "@executor-js/sdk": "workspace:*", | 347 | "@executor-js/sdk": "workspace:*", |
| @@ -381,7 +373,6 @@ · 25 unmodified lines | |||
| 381 | "version": "0.0.47", | 373 | "version": "0.0.47", |
| 382 | "dependencies": { | 374 | "dependencies": { |
| 383 | "@executor-js/plugin-file-secrets": "workspace:*", | 375 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 384 | "@executor-js/plugin-google": "workspace:*", | ||
| 385 | "@executor-js/plugin-graphql": "workspace:*", | 376 | "@executor-js/plugin-graphql": "workspace:*", |
| 386 | "@executor-js/plugin-keychain": "workspace:*", | 377 | "@executor-js/plugin-keychain": "workspace:*", |
| 387 | "@executor-js/plugin-mcp": "workspace:*", | 378 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -849,43 +840,6 @@ · 461 unmodified lines | |||
| 849 | "effect": "catalog:", | 840 | "effect": "catalog:", |
| 850 | }, | 841 | }, |
| 851 | }, | 842 | }, |
| 852 | "packages/plugins/google": { | ||
| 853 | "name": "@executor-js/plugin-google", | ||
| 854 | "version": "1.5.28", | ||
| 855 | "dependencies": { | ||
| 856 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 857 | "@executor-js/sdk": "workspace:*", | ||
| 858 | "lucide-react": "^1.7.0", | ||
| 859 | }, | ||
| 860 | "devDependencies": { | ||
| 861 | "@effect/atom-react": "catalog:", | ||
| 862 | "@effect/vitest": "catalog:", | ||
| 863 | "@executor-js/api": "workspace:*", | ||
| 864 | "@executor-js/react": "workspace:*", | ||
| 865 | "@types/node": "catalog:", | ||
| 866 | "@types/react": "catalog:", | ||
| 867 | "bun-types": "catalog:", | ||
| 868 | "effect": "catalog:", | ||
| 869 | "react": "catalog:", | ||
| 870 | "tsup": "catalog:", | ||
| 871 | "vitest": "catalog:", | ||
| 872 | }, | ||
| 873 | "peerDependencies": { | ||
| 874 | "@effect/atom-react": "catalog:", | ||
| 875 | "@executor-js/api": "workspace:*", | ||
| 876 | "@executor-js/react": "workspace:*", | ||
| 877 | "@tanstack/react-router": "catalog:", | ||
| 878 | "effect": "catalog:", | ||
| 879 | "react": "catalog:", | ||
| 880 | }, | ||
| 881 | "optionalPeers": [ | ||
| 882 | "@effect/atom-react", | ||
| 883 | "@executor-js/api", | ||
| 884 | "@executor-js/react", | ||
| 885 | "@tanstack/react-router", | ||
| 886 | "react", | ||
| 887 | ], | ||
| 888 | }, | ||
| 889 | "packages/plugins/graphql": { | 843 | "packages/plugins/graphql": { |
| 890 | "name": "@executor-js/plugin-graphql", | 844 | "name": "@executor-js/plugin-graphql", |
| 891 | "version": "1.5.29", | 845 | "version": "1.5.29", |
| @@ -984,43 +938,6 @@ · 92 unmodified lines | |||
| 984 | "react", | 938 | "react", |
| 985 | ], | 939 | ], |
| 986 | }, | 940 | }, |
| 987 | "packages/plugins/microsoft": { | ||
| 988 | "name": "@executor-js/plugin-microsoft", | ||
| 989 | "version": "1.5.28", | ||
| 990 | "dependencies": { | ||
| 991 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 992 | "@executor-js/sdk": "workspace:*", | ||
| 993 | "lucide-react": "^1.7.0", | ||
| 994 | }, | ||
| 995 | "devDependencies": { | ||
| 996 | "@effect/atom-react": "catalog:", | ||
| 997 | "@effect/vitest": "catalog:", | ||
| 998 | "@executor-js/api": "workspace:*", | ||
| 999 | "@executor-js/react": "workspace:*", | ||
| 1000 | "@types/node": "catalog:", | ||
| 1001 | "@types/react": "catalog:", | ||
| 1002 | "bun-types": "catalog:", | ||
| 1003 | "effect": "catalog:", | ||
| 1004 | "react": "catalog:", | ||
| 1005 | "tsup": "catalog:", | ||
| 1006 | "vitest": "catalog:", | ||
| 1007 | }, | ||
| 1008 | "peerDependencies": { | ||
| 1009 | "@effect/atom-react": "catalog:", | ||
| 1010 | "@executor-js/api": "workspace:*", | ||
| 1011 | "@executor-js/react": "workspace:*", | ||
| 1012 | "@tanstack/react-router": "catalog:", | ||
| 1013 | "effect": "catalog:", | ||
| 1014 | "react": "catalog:", | ||
| 1015 | }, | ||
| 1016 | "optionalPeers": [ | ||
| 1017 | "@effect/atom-react", | ||
| 1018 | "@executor-js/api", | ||
| 1019 | "@executor-js/react", | ||
| 1020 | "@tanstack/react-router", | ||
| 1021 | "react", | ||
| 1022 | ], | ||
| 1023 | }, | ||
| 1024 | "packages/plugins/onepassword": { | 941 | "packages/plugins/onepassword": { |
| 1025 | "name": "@executor-js/plugin-onepassword", | 942 | "name": "@executor-js/plugin-onepassword", |
| 1026 | "version": "1.5.29", | 943 | "version": "1.5.29", |
| @@ -1096,6 +1013,23 @@ · 69 unmodified lines | |||
| 1096 | "react", | 1013 | "react", |
| 1097 | ], | 1014 | ], |
| 1098 | }, | 1015 | }, |
| 1016 | "packages/plugins/provider-service-split": { | ||
| 1017 | "name": "@executor-js/plugin-provider-service-split", | ||
| 1018 | "version": "0.0.0", | ||
| 1019 | "dependencies": { | ||
| 1020 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 1021 | "@executor-js/sdk": "workspace:*", | ||
| 1022 | "effect": "catalog:", | ||
| 1023 | }, | ||
| 1024 | "devDependencies": { | ||
| 1025 | "@effect/vitest": "catalog:", | ||
| 1026 | "@types/node": "catalog:", | ||
| 1027 | "bun-types": "catalog:", | ||
| 1028 | "tsup": "catalog:", | ||
| 1029 | "typescript": "catalog:", | ||
| 1030 | "vitest": "catalog:", | ||
| 1031 | }, | ||
| 1032 | }, | ||
| 1099 | "packages/plugins/toolkits": { | 1033 | "packages/plugins/toolkits": { |
| 1100 | "name": "@executor-js/plugin-toolkits", | 1034 | "name": "@executor-js/plugin-toolkits", |
| 1101 | "version": "1.5.21", | 1035 | "version": "1.5.21", |
| @@ -1784,20 +1718,18 @@ · 682 unmodified lines | |||
| 1784 | 1718 | ||
| 1785 | "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"], | 1719 | "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"], |
| 1786 | 1720 | ||
| 1787 | "@executor-js/plugin-google": ["@executor-js/plugin-google@workspace:packages/plugins/google"], | ||
| 1788 | |||
| 1789 | "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], | 1721 | "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], |
| 1790 | 1722 | ||
| 1791 | "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], | 1723 | "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], |
| 1792 | 1724 | ||
| 1793 | "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"], | 1725 | "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"], |
| 1794 | 1726 | ||
| 1795 | "@executor-js/plugin-microsoft": ["@executor-js/plugin-microsoft@workspace:packages/plugins/microsoft"], | ||
| 1796 | |||
| 1797 | "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"], | 1727 | "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"], |
| 1798 | 1728 | ||
| 1799 | "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], | 1729 | "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], |
| 1800 | 1730 | ||
| 1731 | "@executor-js/plugin-provider-service-split": ["@executor-js/plugin-provider-service-split@workspace:packages/plugins/provider-service-split"], | ||
| 1732 | |||
| 1801 | "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], | 1733 | "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], |
| 1802 | 1734 | ||
| 1803 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], | 1735 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| @@ -3172,6 +3104,46 @@ · 1368 unmodified lines | |||
| 3172 | 3104 | ||
| 3173 | "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z+rclKC7FqkUsqQ+ErgWJmf5J55LEl/rooFq71prC6V0vCBa5yLMmLBmFxZLLj2BsCBuwbN2O7aWUWrpCUEkmw=="], | 3105 | "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z+rclKC7FqkUsqQ+ErgWJmf5J55LEl/rooFq71prC6V0vCBa5yLMmLBmFxZLLj2BsCBuwbN2O7aWUWrpCUEkmw=="], |
| 3174 | 3106 | ||
| 3107 | "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], | ||
| 3108 | |||
| 3109 | "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], | ||
| 3110 | |||
| 3111 | "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], | ||
| 3112 | |||
| 3113 | "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], | ||
| 3114 | |||
| 3115 | "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], | ||
| 3116 | |||
| 3117 | "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], | ||
| 3118 | |||
| 3119 | "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], | ||
| 3120 | |||
| 3121 | "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], | ||
| 3122 | |||
| 3123 | "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], | ||
| 3124 | |||
| 3125 | "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], | ||
| 3126 | |||
| 3127 | "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], | ||
| 3128 | |||
| 3129 | "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], | ||
| 3130 | |||
| 3131 | "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], | ||
| 3132 | |||
| 3133 | "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], | ||
| 3134 | |||
| 3135 | "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], | ||
| 3136 | |||
| 3137 | "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], | ||
| 3138 | |||
| 3139 | "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], | ||
| 3140 | |||
| 3141 | "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], | ||
| 3142 | |||
| 3143 | "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], | ||
| 3144 | |||
| 3145 | "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], | ||
| 3146 | |||
| 3175 | "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], | 3147 | "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], |
| 3176 | 3148 | ||
| 3177 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], | 3149 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], |
| @@ -5920,9 +5892,9 @@ · 2742 unmodified lines | |||
| 5920 | 5892 | ||
| 5921 | "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], | 5893 | "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], |
| 5922 | 5894 | ||
| 5923 | "@executor-js/example-all-plugins/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], | 5895 | "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], |
| 5924 | 5896 | ||
| 5925 | "@executor-js/example-promise-sdk/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], | 5897 | "@executor-js/example-promise-sdk/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], |
| 5926 | 5898 | ||
| 5927 | "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], | 5899 | "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], |
| 5928 | 5900 | ||
| @@ -24,10 +24,8 @@ · 23 unmodified lines | |||
| 24 | "@executor-js/api": "workspace:*", | 24 | "@executor-js/api": "workspace:*", |
| 25 | "@executor-js/emulate": "^0.13.3", | 25 | "@executor-js/emulate": "^0.13.3", |
| 26 | "@executor-js/mcporter": "^0.11.4", | 26 | "@executor-js/mcporter": "^0.11.4", |
| 27 | "@executor-js/plugin-google": "workspace:*", | ||
| 28 | "@executor-js/plugin-graphql": "workspace:*", | 27 | "@executor-js/plugin-graphql": "workspace:*", |
| 29 | "@executor-js/plugin-mcp": "workspace:*", | 28 | "@executor-js/plugin-mcp": "workspace:*", |
| 30 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 31 | "@executor-js/plugin-openapi": "workspace:*", | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 32 | "@executor-js/plugin-toolkits": "workspace:*", | 30 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | "@executor-js/sdk": "workspace:*", | 31 | "@executor-js/sdk": "workspace:*", |
| @@ -0,0 +1,351 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | import type { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 6 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 7 | import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; | ||
| 8 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 9 | import { | ||
| 10 | AuthTemplateSlug, | ||
| 11 | ConnectionName, | ||
| 12 | IntegrationSlug, | ||
| 13 | OAuthClientSlug, | ||
| 14 | } from "@executor-js/sdk/shared"; | ||
| 15 | |||
| 16 | import { scenario } from "../src/scenario"; | ||
| 17 | import { Api, Browser, Target } from "../src/services"; | ||
| 18 | import type { Identity, Target as TargetShape } from "../src/target"; | ||
| 19 | import type { BrowserSurface } from "../src/surfaces/browser"; | ||
| 20 | |||
| 21 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 22 | type Client = HttpApiClient.ForApi<typeof api>; | ||
| 23 | const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); | ||
| 24 | const CONNECTION = ConnectionName.make("main"); | ||
| 25 | const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com"; | ||
| 26 | |||
| 27 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 28 | |||
| 29 | const decodeHtml = (value: string): string => | ||
| 30 | value | ||
| 31 | .replaceAll("&", "&") | ||
| 32 | .replaceAll(""", '"') | ||
| 33 | .replaceAll("'", "'") | ||
| 34 | .replaceAll("<", "<") | ||
| 35 | .replaceAll(">", ">"); | ||
| 36 | |||
| 37 | const inputFields = (html: string): Record<string, string> => { | ||
| 38 | const fields: Record<string, string> = {}; | ||
| 39 | for (const input of html.matchAll(/<input\b[^>]*>/gi)) { | ||
| 40 | const tag = input[0]; | ||
| 41 | const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; | ||
| 42 | const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; | ||
| 43 | if (name) fields[decodeHtml(name)] = decodeHtml(value); | ||
| 44 | } | ||
| 45 | return fields; | ||
| 46 | }; | ||
| 47 | |||
| 48 | const formAction = (html: string, fallback: string): string => { | ||
| 49 | const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1]; | ||
| 50 | return action ? decodeHtml(action) : fallback; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const completeGoogleConsent = (authorizationUrl: string) => | ||
| 54 | Effect.promise(async () => { | ||
| 55 | const page = await fetch(authorizationUrl); | ||
| 56 | if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`); | ||
| 57 | const html = await page.text(); | ||
| 58 | const callback = await fetch(formAction(html, authorizationUrl), { | ||
| 59 | method: "POST", | ||
| 60 | body: new URLSearchParams(inputFields(html)), | ||
| 61 | redirect: "manual", | ||
| 62 | }); | ||
| 63 | const location = callback.headers.get("location"); | ||
| 64 | if (callback.status !== 302 || !location) { | ||
| 65 | throw new Error(`Google emulator consent did not redirect: ${callback.status}`); | ||
| 66 | } | ||
| 67 | const code = new URL(location).searchParams.get("code"); | ||
| 68 | if (!code) throw new Error("Google emulator callback did not include a code"); | ||
| 69 | return code; | ||
| 70 | }); | ||
| 71 | |||
| 72 | const createGoogleEmulator = Effect.promise(async () => { | ||
| 73 | const response = await fetch("https://google.emulators.dev/_emulate/instances", { | ||
| 74 | method: "POST", | ||
| 75 | }); | ||
| 76 | if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`); | ||
| 77 | const instance = (await response.json()) as { readonly providerBaseUrl: string }; | ||
| 78 | const client = await connectEmulator({ baseUrl: instance.providerBaseUrl }); | ||
| 79 | return { client, baseUrl: instance.providerBaseUrl }; | ||
| 80 | }); | ||
| 81 | |||
| 82 | const addGooglePresetFromCatalog = ( | ||
| 83 | browser: BrowserSurface, | ||
| 84 | identity: Identity, | ||
| 85 | presetName: string, | ||
| 86 | slug: string, | ||
| 87 | ) => | ||
| 88 | browser.session(identity, async ({ page, step }) => { | ||
| 89 | await step(`Open ${presetName} from the connect catalog`, async () => { | ||
| 90 | await page.goto("/integrations", { waitUntil: "networkidle" }); | ||
| 91 | await page | ||
| 92 | .getByRole("button", { name: /Connect/ }) | ||
| 93 | .first() | ||
| 94 | .click(); | ||
| 95 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | ||
| 96 | await dialog.waitFor(); | ||
| 97 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | ||
| 98 | await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | ||
| 99 | }); | ||
| 100 | |||
| 101 | await step(`Add the ${presetName} integration`, async () => { | ||
| 102 | await page.waitForURL(/\/integrations\/add\/openapi/); | ||
| 103 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | ||
| 104 | const button = page.getByRole("button", { name: "Add integration" }); | ||
| 105 | await button.waitFor({ timeout: 120_000 }); | ||
| 106 | await button.click({ timeout: 120_000 }); | ||
| 107 | await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 }); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | const connectGoogleAccount = (input: { | ||
| 112 | readonly client: Client; | ||
| 113 | readonly emulator: EmulatorClient; | ||
| 114 | readonly emulatorBaseUrl: string; | ||
| 115 | readonly integrationBaseUrl?: string; | ||
| 116 | readonly target: TargetShape; | ||
| 117 | readonly integration: IntegrationSlug; | ||
| 118 | readonly oauthClient: OAuthClientSlug; | ||
| 119 | }) => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString(); | ||
| 122 | const credential = yield* Effect.promise(() => | ||
| 123 | input.emulator.credentials.mint({ | ||
| 124 | type: "oauth-authorization-code", | ||
| 125 | redirect_uris: [redirectUri], | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | if (!credential.client_id || !credential.client_secret) { | ||
| 129 | return yield* Effect.die(`Google emulator did not mint an OAuth client`); | ||
| 130 | } | ||
| 131 | |||
| 132 | yield* input.client.openapi.configure({ | ||
| 133 | params: { slug: input.integration }, | ||
| 134 | payload: { baseUrl: input.integrationBaseUrl ?? input.emulatorBaseUrl }, | ||
| 135 | }); | ||
| 136 | yield* input.client.oauth.createClient({ | ||
| 137 | payload: { | ||
| 138 | owner: "org", | ||
| 139 | slug: input.oauthClient, | ||
| 140 | grant: "authorization_code", | ||
| 141 | authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`, | ||
| 142 | tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`, | ||
| 143 | clientId: credential.client_id, | ||
| 144 | clientSecret: credential.client_secret, | ||
| 145 | originIntegration: input.integration, | ||
| 146 | }, | ||
| 147 | }); | ||
| 148 | |||
| 149 | const started = yield* input.client.oauth.start({ | ||
| 150 | payload: { | ||
| 151 | client: input.oauthClient, | ||
| 152 | clientOwner: "org", | ||
| 153 | owner: "org", | ||
| 154 | name: CONNECTION, | ||
| 155 | integration: input.integration, | ||
| 156 | template: GOOGLE_AUTH_TEMPLATE, | ||
| 157 | redirectUri, | ||
| 158 | }, | ||
| 159 | }); | ||
| 160 | expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect"); | ||
| 161 | if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected"); | ||
| 162 | |||
| 163 | const code = yield* completeGoogleConsent(started.authorizationUrl); | ||
| 164 | const completed = yield* input.client.oauth.complete({ | ||
| 165 | payload: { state: started.state, code }, | ||
| 166 | }); | ||
| 167 | expect(completed.integration, "OAuth completion creates the connection").toBe( | ||
| 168 | input.integration, | ||
| 169 | ); | ||
| 170 | }); | ||
| 171 | |||
| 172 | scenario( | ||
| 173 | "Google · Calendar and Gmail catalog health checks run against the emulator", | ||
| 174 | { timeout: 300_000 }, | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const target = yield* Target; | ||
| 177 | const browser = yield* Browser; | ||
| 178 | const { client: makeClient } = yield* Api; | ||
| 179 | const identity = yield* target.newIdentity(); | ||
| 180 | const client = yield* makeClient(api, identity); | ||
| 181 | const emulator = yield* createGoogleEmulator; | ||
| 182 | |||
| 183 | const rows = [ | ||
| 184 | { | ||
| 185 | presetName: "Google Calendar", | ||
| 186 | slug: IntegrationSlug.make("google_calendar"), | ||
| 187 | oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")), | ||
| 188 | expectedHealthOperation: "calendar.calendarList.list", | ||
| 189 | expectedLedgerOperation: "calendar.calendarList.list", | ||
| 190 | emulatorPathPrefix: "/calendar/v3", | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | presetName: "Gmail", | ||
| 194 | slug: IntegrationSlug.make("google_gmail"), | ||
| 195 | oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")), | ||
| 196 | expectedHealthOperation: "gmail.users.labels.list", | ||
| 197 | expectedLedgerOperation: "gmail.users.labels.list", | ||
| 198 | emulatorPathPrefix: "", | ||
| 199 | }, | ||
| 200 | ] as const; | ||
| 201 | |||
| 202 | yield* Effect.ensuring( | ||
| 203 | Effect.gen(function* () { | ||
| 204 | for (const row of rows) { | ||
| 205 | yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug)); | ||
| 206 | |||
| 207 | const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } }); | ||
| 208 | expect(stored?.operation, `${row.presetName} stored health check`).toBe( | ||
| 209 | row.expectedHealthOperation, | ||
| 210 | ); | ||
| 211 | |||
| 212 | yield* connectGoogleAccount({ | ||
| 213 | client, | ||
| 214 | emulator: emulator.client, | ||
| 215 | emulatorBaseUrl: emulator.baseUrl, | ||
| 216 | integrationBaseUrl: `${emulator.baseUrl}${row.emulatorPathPrefix ?? ""}`, | ||
| 217 | target, | ||
| 218 | integration: row.slug, | ||
| 219 | oauthClient: row.oauthClient, | ||
| 220 | }); | ||
| 221 | |||
| 222 | const connections = yield* client.connections.list({ | ||
| 223 | query: { owner: "org", integration: row.slug }, | ||
| 224 | }); | ||
| 225 | const connected = connections.find((connection) => connection.name === CONNECTION); | ||
| 226 | expect( | ||
| 227 | connected?.identityLabel, | ||
| 228 | `${row.presetName} stores OAuth identity from the id_token before health checks`, | ||
| 229 | ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL); | ||
| 230 | expect( | ||
| 231 | connected?.lastHealth, | ||
| 232 | `${row.presetName} has not run a health check before the explicit probe`, | ||
| 233 | ).toBeNull(); | ||
| 234 | |||
| 235 | const tools = yield* client.tools.list({ | ||
| 236 | query: { integration: row.slug, connection: CONNECTION }, | ||
| 237 | }); | ||
| 238 | expect( | ||
| 239 | tools.length, | ||
| 240 | `${row.presetName} exposes tools for the connection`, | ||
| 241 | ).toBeGreaterThan(0); | ||
| 242 | |||
| 243 | const health = yield* client.connections.checkHealth({ | ||
| 244 | params: { owner: "org", integration: row.slug, name: CONNECTION }, | ||
| 245 | query: { ifStaleMs: 0 }, | ||
| 246 | }); | ||
| 247 | expect( | ||
| 248 | health.status, | ||
| 249 | `${row.presetName} health check is healthy: ${JSON.stringify(health)}`, | ||
| 250 | ).toBe("healthy"); | ||
| 251 | } | ||
| 252 | |||
| 253 | const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); | ||
| 254 | for (const row of rows) { | ||
| 255 | expect( | ||
| 256 | ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), | ||
| 257 | `${row.presetName} health check reached the Google emulator`, | ||
| 258 | ).toBe(true); | ||
| 259 | } | ||
| 260 | }), | ||
| 261 | Effect.gen(function* () { | ||
| 262 | for (const row of rows) { | ||
| 263 | yield* client.connections | ||
| 264 | .remove({ | ||
| 265 | params: { owner: "org", integration: row.slug, name: CONNECTION }, | ||
| 266 | }) | ||
| 267 | .pipe(Effect.ignore); | ||
| 268 | yield* client.oauth | ||
| 269 | .removeClient({ params: { slug: row.oauthClient }, payload: { owner: "org" } }) | ||
| 270 | .pipe(Effect.ignore); | ||
| 271 | yield* client.openapi.removeSpec({ params: { slug: row.slug } }).pipe(Effect.ignore); | ||
| 272 | } | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | |||
| 278 | scenario( | ||
| 279 | "Google · OAuth catalog connection without a health check is healthy from the grant", | ||
| 280 | { timeout: 300_000 }, | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const target = yield* Target; | ||
| 283 | const browser = yield* Browser; | ||
| 284 | const { client: makeClient } = yield* Api; | ||
| 285 | const identity = yield* target.newIdentity(); | ||
| 286 | const client = yield* makeClient(api, identity); | ||
| 287 | const emulator = yield* createGoogleEmulator; | ||
| 288 | const slug = IntegrationSlug.make("google_sheets"); | ||
| 289 | const oauthClient = OAuthClientSlug.make(unique("google_sheets_oauth")); | ||
| 290 | |||
| 291 | yield* Effect.ensuring( | ||
| 292 | Effect.gen(function* () { | ||
| 293 | yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug)); | ||
| 294 | |||
| 295 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); | ||
| 296 | expect(stored, "Google Sheets catalog preset declares no health check").toBeNull(); | ||
| 297 | |||
| 298 | yield* connectGoogleAccount({ | ||
| 299 | client, | ||
| 300 | emulator: emulator.client, | ||
| 301 | emulatorBaseUrl: emulator.baseUrl, | ||
| 302 | target, | ||
| 303 | integration: slug, | ||
| 304 | oauthClient, | ||
| 305 | }); | ||
| 306 | |||
| 307 | const connections = yield* client.connections.list({ | ||
| 308 | query: { owner: "org", integration: slug }, | ||
| 309 | }); | ||
| 310 | const connected = connections.find((connection) => connection.name === CONNECTION); | ||
| 311 | expect( | ||
| 312 | connected?.identityLabel, | ||
| 313 | "Google Sheets stores grant identity before any probe is configured", | ||
| 314 | ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL); | ||
| 315 | expect( | ||
| 316 | connected?.lastHealth, | ||
| 317 | "Google Sheets has not run a health check before the explicit check", | ||
| 318 | ).toBeNull(); | ||
| 319 | |||
| 320 | const health = yield* client.connections.checkHealth({ | ||
| 321 | params: { owner: "org", integration: slug, name: CONNECTION }, | ||
| 322 | query: { ifStaleMs: 0 }, | ||
| 323 | }); | ||
| 324 | expect( | ||
| 325 | health.status, | ||
| 326 | `Google Sheets no-probe health is healthy: ${JSON.stringify(health)}`, | ||
| 327 | ).toBe("healthy"); | ||
| 328 | expect(health.detail).toBe("Credential resolved (no probe configured)."); | ||
| 329 | |||
| 330 | const refreshed = yield* client.connections.list({ | ||
| 331 | query: { owner: "org", integration: slug }, | ||
| 332 | }); | ||
| 333 | expect( | ||
| 334 | refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel, | ||
| 335 | "Google Sheets still shows the OAuth grant identity after no-probe health", | ||
| 336 | ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL); | ||
| 337 | }), | ||
| 338 | Effect.gen(function* () { | ||
| 339 | yield* client.connections | ||
| 340 | .remove({ | ||
| 341 | params: { owner: "org", integration: slug, name: CONNECTION }, | ||
| 342 | }) | ||
| 343 | .pipe(Effect.ignore); | ||
| 344 | yield* client.oauth | ||
| 345 | .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) | ||
| 346 | .pipe(Effect.ignore); | ||
| 347 | yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | ||
| 348 | }), | ||
| 349 | ); | ||
| 350 | }), | ||
| 351 | ); | ||
| @@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines | |||
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | 6 | ||
| 7 | scenario( | 7 | scenario( |
| 8 | "Google Photos: the focused preset opens a Photos-scoped add flow", | 8 | "Google Photos: separated catalog presets open a Photos service add flow", |
| 9 | {}, | 9 | {}, |
| 10 | Effect.gen(function* () { | 10 | Effect.gen(function* () { |
| 11 | const target = yield* Target; | 11 | const target = yield* Target; |
| @@ -13,30 +13,31 @@ scenario( · 1 unmodified line | |||
| 13 | const identity = yield* target.newIdentity(); | 13 | const identity = yield* target.newIdentity(); |
| 14 | 14 | ||
| 15 | yield* browser.session(identity, async ({ page, step }) => { | 15 | yield* browser.session(identity, async ({ page, step }) => { |
| 16 | await step("Find the Google Photos preset from the integrations picker", async () => { | 16 | await step( |
| 17 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 17 | "Find the separated Google Photos presets from the integrations picker", |
| 18 | await page.getByRole("button", { name: "Connect" }).click(); | 18 | async () => { |
| 19 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 19 | await page.goto("/integrations", { waitUntil: "networkidle" }); |
| 20 | await dialog.waitFor(); | 20 | await page.getByRole("button", { name: "Connect" }).click(); |
| 21 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); | 21 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 22 | await dialog.getByRole("link", { name: /^Google Photos\b/ }).waitFor(); | 22 | await dialog.waitFor(); |
| 23 | }); | 23 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); |
| 24 | await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor(); | ||
| 25 | await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor(); | ||
| 26 | }, | ||
| 27 | ); | ||
| 24 | 28 | ||
| 25 | await step("Open the Google Photos scoped add flow", async () => { | 29 | await step("Open the Google Photos Library add flow", async () => { |
| 26 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 30 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 27 | await dialog.getByRole("link", { name: /^Google Photos\b/ }).click(); | 31 | await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click(); |
| 28 | await page.waitForURL(/\/integrations\/add\/google/); | 32 | await page.waitForURL(/\/integrations\/add\/openapi/); |
| 29 | await page.getByRole("heading", { name: "Add Google integration" }).waitFor(); | 33 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 30 | }); | 34 | }); |
| 31 | 35 | ||
| 32 | await step("The Photos preset defaults to the focused namespace and products", async () => { | 36 | await step("The Library link carries the focused OpenAPI preset", async () => { |
| 33 | await page.locator('input[value="Google Photos"]').waitFor(); | 37 | const url = new URL(page.url()); |
| 34 | await page.locator('input[value="google_photos"]').waitFor(); | 38 | expect(url.searchParams.get("preset")).toBe("google-photos-library"); |
| 35 | await page.getByText("Google Photos Library").first().waitFor(); | 39 | expect(url.searchParams.get("url")).toContain("photoslibrary"); |
| 36 | await page.getByText("Google Photos Picker").first().waitFor(); | 40 | await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary"); |
| 37 | await page.getByText("2 Google APIs").waitFor(); | ||
| 38 | expect(await page.locator('input[value="Google"]').count()).toBe(0); | ||
| 39 | expect(await page.locator('input[value="google"]').count()).toBe(0); | ||
| 40 | }); | 41 | }); |
| 41 | }); | 42 | }); |
| 42 | }), | 43 | }), |
| @@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines | |||
| 5 | import { Effect } from "effect"; | 5 | import { Effect } from "effect"; |
| 6 | import { composePluginApi } from "@executor-js/api/server"; | 6 | import { composePluginApi } from "@executor-js/api/server"; |
| 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; | 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; |
| 8 | import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | 8 | import { |
| 9 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 9 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 10 | microsoftCatalog, | ||
| 11 | microsoftGraphAdapter, | ||
| 12 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 13 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 10 | import { | 14 | import { |
| 11 | AuthTemplateSlug, | 15 | AuthTemplateSlug, |
| 12 | ConnectionName, | 16 | ConnectionName, |
| @@ -17,7 +21,9 @@ import { · 4 unmodified lines | |||
| 17 | import { scenario } from "../src/scenario"; | 21 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Target } from "../src/services"; | 22 | import { Api, Target } from "../src/services"; |
| 19 | 23 | ||
| 20 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 24 | const api = composePluginApi([ |
| 25 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 26 | ] as const); | ||
| 21 | 27 | ||
| 22 | type OAuthTemplateView = { | 28 | type OAuthTemplateView = { |
| 23 | readonly slug: string; | 29 | readonly slug: string; |
| @@ -80,18 +86,7 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul · 56 unmodified lines | |||
| 80 | 86 | ||
| 81 | scenario( | 87 | scenario( |
| 82 | "Microsoft · client credentials against the emulator mint a Graph connection and call /users", | 88 | "Microsoft · client credentials against the emulator mint a Graph connection and call /users", |
| 83 | { | 89 | { timeout: 180_000 }, |
| 84 | // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the | ||
| 85 | // canonical Graph spec in the streamable block-YAML profile — it structurally | ||
| 86 | // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB | ||
| 87 | // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on | ||
| 88 | // anything else. The @executor-js/emulate Microsoft emulator serves a small | ||
| 89 | // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix | ||
| 90 | // needs the emulator to serve a block-YAML-profile Graph spec (or a | ||
| 91 | // non-Workers compile path); tracked separately. | ||
| 92 | skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile", | ||
| 93 | timeout: 180_000, | ||
| 94 | }, | ||
| 95 | Effect.scoped( | 90 | Effect.scoped( |
| 96 | Effect.gen(function* () { | 91 | Effect.gen(function* () { |
| 97 | const target = yield* Target; | 92 | const target = yield* Target; |
| @@ -110,18 +105,26 @@ scenario( · 12 unmodified lines | |||
| 110 | 105 | ||
| 111 | yield* Effect.ensuring( | 106 | yield* Effect.ensuring( |
| 112 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 113 | yield* client.microsoft.addGraph({ | 108 | yield* client.openapi.addSpec({ |
| 114 | payload: { | 109 | payload: { |
| 115 | presetIds: ["users"], | 110 | spec: { kind: "url", url: emulator.openapiUrl }, |
| 116 | customScopes: [], | ||
| 117 | slug: integration, | 111 | slug: integration, |
| 118 | name: "Microsoft Graph Emulator", | 112 | name: "Microsoft Graph Emulator", |
| 119 | baseUrl: emulator.url, | 113 | baseUrl: emulator.url, |
| 120 | specUrl: emulator.openapiUrl, | 114 | family: "microsoft", |
| 115 | authenticationTemplate: [ | ||
| 116 | { | ||
| 117 | slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 118 | kind: "oauth2", | ||
| 119 | authorizationUrl: oauth.authorizationUrl, | ||
| 120 | tokenUrl: oauth.tokenUrl, | ||
| 121 | scopes: ["https://graph.microsoft.com/.default"], | ||
| 122 | }, | ||
| 123 | ], | ||
| 121 | }, | 124 | }, |
| 122 | }); | 125 | }); |
| 123 | 126 | ||
| 124 | const config = yield* client.microsoft.getConfig({ | 127 | const config = yield* client.openapi.getConfig({ |
| 125 | params: { slug: integration }, | 128 | params: { slug: integration }, |
| 126 | }); | 129 | }); |
| 127 | const appOnlyTemplate = config?.authenticationTemplate?.find( | 130 | const appOnlyTemplate = config?.authenticationTemplate?.find( |
| @@ -213,7 +216,7 @@ scenario( · 85 unmodified lines | |||
| 213 | params: { slug: oauthClient }, | 216 | params: { slug: oauthClient }, |
| 214 | payload: { owner: "org" }, | 217 | payload: { owner: "org" }, |
| 215 | }); | 218 | }); |
| 216 | yield* client.microsoft.removeGraph({ params: { slug: integration } }); | 219 | yield* client.openapi.removeSpec({ params: { slug: integration } }); |
| 217 | }).pipe(Effect.ignore), | 220 | }).pipe(Effect.ignore), |
| 218 | ); | 221 | ); |
| 219 | }), | 222 | }), |
| @@ -6,10 +6,12 @@ import { composePluginApi } from "@executor-js/api/server"; · 5 unmodified lines | |||
| 6 | import { | 6 | import { |
| 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | MICROSOFT_AUTHORIZATION_URL, | 8 | MICROSOFT_AUTHORIZATION_URL, |
| 9 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | 9 | MICROSOFT_GRAPH_OPENAPI_URL, |
| 10 | MICROSOFT_TOKEN_URL, | 10 | MICROSOFT_TOKEN_URL, |
| 11 | } from "@executor-js/plugin-microsoft"; | 11 | microsoftCatalog, |
| 12 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 12 | microsoftGraphAdapter, |
| 13 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 14 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 13 | import { | 15 | import { |
| 14 | AuthTemplateSlug, | 16 | AuthTemplateSlug, |
| 15 | ConnectionName, | 17 | ConnectionName, |
| @@ -20,24 +22,26 @@ import { · 4 unmodified lines | |||
| 20 | import { scenario } from "../src/scenario"; | 22 | import { scenario } from "../src/scenario"; |
| 21 | import { Api, Target } from "../src/services"; | 23 | import { Api, Target } from "../src/services"; |
| 22 | 24 | ||
| 23 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 25 | const api = composePluginApi([ |
| 26 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 27 | ] as const); | ||
| 24 | 28 | ||
| 25 | type ToolView = { | 29 | type ToolView = { |
| 26 | readonly name: string; | 30 | readonly name: string; |
| 27 | }; | 31 | }; |
| 28 | 32 | ||
| 29 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 33 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 34 | const MICROSOFT_FILES_PRESET_ID = "files"; | ||
| 30 | 35 | ||
| 31 | // Compiling the ~37MB Graph spec inside dev workerd needs more headroom than | 36 | // Compiling the Graph spec inside dev workerd needs more headroom than GitHub's |
| 32 | // GitHub's 2-core runners have: /api/microsoft/graph 500s and the dev stack is | 37 | // 2-core runners have. Local runs and the production Workers streaming path are |
| 33 | // dead for every scenario after it in the shard. Local runs (and the | 38 | // unaffected, so CI keeps this narrow catalog scenario quarantined. |
| 34 | // production Workers streaming path) are unaffected — CI-only quarantine. | ||
| 35 | const CI_GRAPH_SPEC_SKIP = process.env.CI | 39 | const CI_GRAPH_SPEC_SKIP = process.env.CI |
| 36 | ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard" | 40 | ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard" |
| 37 | : undefined; | 41 | : undefined; |
| 38 | 42 | ||
| 39 | scenario( | 43 | scenario( |
| 40 | "Microsoft Graph: default add stores common Microsoft 365 workloads", | 44 | "Microsoft Graph: catalog add stores the OneDrive files service", |
| 41 | { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP }, | 45 | { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP }, |
| 42 | Effect.gen(function* () { | 46 | Effect.gen(function* () { |
| 43 | const target = yield* Target; | 47 | const target = yield* Target; |
| @@ -45,59 +49,41 @@ scenario( · 1 unmodified line | |||
| 45 | const identity = yield* target.newIdentity(); | 49 | const identity = yield* target.newIdentity(); |
| 46 | const client = yield* makeApiClient(api, identity); | 50 | const client = yield* makeApiClient(api, identity); |
| 47 | 51 | ||
| 48 | const integration = unique("msgraph_default"); | 52 | const integration = unique("msgraph_files"); |
| 49 | const connection = ConnectionName.make("main"); | 53 | const connection = ConnectionName.make("main"); |
| 50 | const oauthClient = OAuthClientSlug.make(unique("msgraph_app")); | 54 | const oauthClient = OAuthClientSlug.make(unique("msgraph_app")); |
| 51 | 55 | ||
| 52 | yield* Effect.ensuring( | 56 | yield* Effect.ensuring( |
| 53 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| 54 | const added = yield* client.microsoft.addGraph({ | 58 | const added = yield* client.openapi.addSpec({ |
| 55 | payload: { | 59 | payload: { |
| 56 | presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS], | 60 | spec: { |
| 57 | customScopes: [], | 61 | kind: "url", |
| 62 | url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, | ||
| 63 | }, | ||
| 58 | slug: integration, | 64 | slug: integration, |
| 59 | name: "Microsoft Graph Defaults", | 65 | name: "Microsoft Graph Files", |
| 66 | family: "microsoft", | ||
| 67 | specFormat: "microsoft-graph", | ||
| 60 | }, | 68 | }, |
| 61 | }); | 69 | }); |
| 62 | expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration); | 70 | expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration); |
| 63 | expect( | 71 | expect( |
| 64 | added.toolCount, | 72 | added.toolCount, |
| 65 | "the default Microsoft Graph add extracts common user-facing operations", | 73 | "the Microsoft files catalog preset extracts a focused operation subtree", |
| 66 | ).toBeGreaterThan(100); | 74 | ).toBeGreaterThan(10); |
| 67 | 75 | ||
| 68 | const config = yield* client.microsoft.getConfig({ | 76 | const config = yield* client.openapi.getConfig({ |
| 69 | params: { slug: integration }, | 77 | params: { slug: integration }, |
| 70 | }); | 78 | }); |
| 71 | expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([ | 79 | const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => |
| 72 | ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | 80 | template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" |
| 73 | ]); | 81 | ? [...template.scopes] |
| 74 | expect( | 82 | : [], |
| 75 | config?.microsoftGraphCoversFullGraph, | 83 | ); |
| 76 | "the default selection is not full Graph", | 84 | expect(delegatedScopes, "the files delegated OAuth asks for file scopes").toContain( |
| 77 | ).toBe(false); | ||
| 78 | expect( | ||
| 79 | config?.microsoftGraphScopes, | ||
| 80 | "default delegated OAuth requests the selected common workload scopes", | ||
| 81 | ).toEqual([ | ||
| 82 | "offline_access", | ||
| 83 | "User.Read", | ||
| 84 | "Mail.ReadWrite", | ||
| 85 | "Mail.Send", | ||
| 86 | "MailboxSettings.ReadWrite", | ||
| 87 | "Calendars.ReadWrite", | ||
| 88 | "Contacts.ReadWrite", | ||
| 89 | "People.Read.All", | ||
| 90 | "Tasks.ReadWrite", | ||
| 91 | "Files.ReadWrite.All", | 85 | "Files.ReadWrite.All", |
| 92 | "Sites.ReadWrite.All", | 86 | ); |
| 93 | "Notes.ReadWrite", | ||
| 94 | "Chat.ReadWrite", | ||
| 95 | "Team.ReadBasic.All", | ||
| 96 | "Channel.ReadBasic.All", | ||
| 97 | "ChannelMessage.Read.All", | ||
| 98 | "ChannelMessage.Send", | ||
| 99 | "OnlineMeetings.ReadWrite", | ||
| 100 | ]); | ||
| 101 | 87 | ||
| 102 | yield* client.oauth.createClient({ | 88 | yield* client.oauth.createClient({ |
| 103 | payload: { | 89 | payload: { |
| @@ -128,12 +114,12 @@ scenario( · 24 unmodified lines | |||
| 128 | const authorizeUrl = new URL(authorizationUrl || "https://invalid.example"); | 114 | const authorizeUrl = new URL(authorizationUrl || "https://invalid.example"); |
| 129 | expect( | 115 | expect( |
| 130 | authorizeUrl.toString().length, | 116 | authorizeUrl.toString().length, |
| 131 | "default Microsoft Graph OAuth authorize URLs stay under ordinary proxy limits", | 117 | "Microsoft files OAuth authorize URLs stay under ordinary proxy limits", |
| 132 | ).toBeLessThan(2_000); | 118 | ).toBeLessThan(2_000); |
| 133 | expect( | 119 | expect( |
| 134 | authorizeUrl.searchParams.get("scope"), | 120 | authorizeUrl.searchParams.get("scope"), |
| 135 | "default Microsoft Graph OAuth asks for common workload scopes", | 121 | "Microsoft files OAuth asks for the focused scope set", |
| 136 | ).toBe(config?.microsoftGraphScopes?.join(" ")); | 122 | ).toBe(delegatedScopes?.join(" ")); |
| 137 | 123 | ||
| 138 | yield* client.connections.create({ | 124 | yield* client.connections.create({ |
| 139 | payload: { | 125 | payload: { |
| @@ -149,15 +135,15 @@ scenario( · 9 unmodified lines | |||
| 149 | query: { integration: IntegrationSlug.make(integration), connection }, | 135 | query: { integration: IntegrationSlug.make(integration), connection }, |
| 150 | }); | 136 | }); |
| 151 | const names = tools.map((tool: ToolView) => tool.name); | 137 | const names = tools.map((tool: ToolView) => tool.name); |
| 152 | const messageTools = names.filter((name) => name.toLowerCase().includes("message")); | 138 | const driveTools = names.filter((name) => name.toLowerCase().includes("drive")); |
| 153 | const siteTools = names.filter((name) => name.toLowerCase().includes("site")); | 139 | const shareTools = names.filter((name) => name.toLowerCase().includes("share")); |
| 154 | expect( | 140 | expect(driveTools, "the retrieved catalog includes Microsoft drive operations").not.toEqual( |
| 155 | messageTools, | ||
| 156 | "the retrieved catalog includes Microsoft message operations", | ||
| 157 | ).not.toEqual([]); | ||
| 158 | expect(siteTools, "the retrieved catalog includes SharePoint site operations").not.toEqual( | ||
| 159 | [], | 141 | [], |
| 160 | ); | 142 | ); |
| 143 | expect( | ||
| 144 | shareTools, | ||
| 145 | "the retrieved catalog includes Microsoft sharing operations", | ||
| 146 | ).not.toEqual([]); | ||
| 161 | }), | 147 | }), |
| 162 | Effect.gen(function* () { | 148 | Effect.gen(function* () { |
| 163 | yield* client.connections | 149 | yield* client.connections |
| @@ -169,8 +155,8 @@ scenario( · 5 unmodified lines | |||
| 169 | }, | 155 | }, |
| 170 | }) | 156 | }) |
| 171 | .pipe(Effect.ignore); | 157 | .pipe(Effect.ignore); |
| 172 | yield* client.microsoft | 158 | yield* client.openapi |
| 173 | .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | 159 | .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) |
| 174 | .pipe(Effect.ignore); | 160 | .pipe(Effect.ignore); |
| 175 | yield* client.oauth | 161 | yield* client.oauth |
| 176 | .removeClient({ | 162 | .removeClient({ |
| @@ -5,34 +5,40 @@ import { Effect } from "effect"; · 4 unmodified lines | |||
| 5 | import { composePluginApi } from "@executor-js/api/server"; | 5 | import { composePluginApi } from "@executor-js/api/server"; |
| 6 | import { | 6 | import { |
| 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | MICROSOFT_GRAPH_ALL_PRESET_IDS, | 8 | MICROSOFT_GRAPH_OPENAPI_URL, |
| 9 | MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | 9 | microsoftCatalog, |
| 10 | } from "@executor-js/plugin-microsoft"; | 10 | microsoftGraphAdapter, |
| 11 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 11 | } from "@executor-js/plugin-openapi/providers/microsoft"; |
| 12 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 12 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | 13 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 13 | 14 | ||
| 14 | import { scenario } from "../src/scenario"; | 15 | import { scenario } from "../src/scenario"; |
| 15 | import { Api, Target } from "../src/services"; | 16 | import { Api, Target } from "../src/services"; |
| 16 | 17 | ||
| 17 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 18 | const api = composePluginApi([ |
| 19 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 20 | ] as const); | ||
| 18 | 21 | ||
| 19 | type ToolView = { | 22 | type ToolView = { |
| 20 | readonly name: string; | 23 | readonly name: string; |
| 21 | }; | 24 | }; |
| 22 | 25 | ||
| 23 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 26 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 27 | const MICROSOFT_FILES_PRESET_ID = "files"; | ||
| 28 | const MICROSOFT_FILES_DELEGATED_SCOPES = [ | ||
| 29 | "offline_access", | ||
| 30 | "User.Read", | ||
| 31 | "Files.ReadWrite.All", | ||
| 32 | "Sites.ReadWrite.All", | ||
| 33 | ] as const; | ||
| 24 | 34 | ||
| 25 | // Adding *every* Graph workload pulls the full Microsoft Graph OpenAPI document | 35 | // Adding a catalog service extracts only that service's Microsoft Graph subtree |
| 26 | // (~37MB, ~16.5k operations) and persists a binding per operation. That whole- | 36 | // and persists a binding per operation. This is the regression guard for both |
| 27 | // document path used to 503 on the Cloudflare worker: parsing the spec, and | 37 | // former worker pressure sites: the add streams compile and persist, and |
| 28 | // then re-parsing it on every tools/list, each rebuilt a ~300MB JS tree that | 38 | // tools/list serves from persisted bindings plus the content-addressed defs blob |
| 29 | // blew the 128MB isolate. This scenario is the regression guard for both sites | 39 | // without re-parsing the Graph spec. |
| 30 | // at real scale: the add streams the compile + persist, and tools/list serves | ||
| 31 | // the catalog back from the persisted bindings (+ the content-addressed defs | ||
| 32 | // blob) without ever re-parsing the spec. It drives only the public API, so a | ||
| 33 | // green run is evidence the full catalog lands and serves end to end. | ||
| 34 | scenario( | 40 | scenario( |
| 35 | "Microsoft Graph: the full catalog adds and serves without re-parsing the spec", | 41 | "Microsoft Graph: the files catalog service adds and serves without re-parsing the spec", |
| 36 | { timeout: 300_000 }, | 42 | { timeout: 300_000 }, |
| 37 | Effect.gen(function* () { | 43 | Effect.gen(function* () { |
| 38 | const target = yield* Target; | 44 | const target = yield* Target; |
| @@ -40,39 +46,41 @@ scenario( · 1 unmodified line | |||
| 40 | const identity = yield* target.newIdentity(); | 46 | const identity = yield* target.newIdentity(); |
| 41 | const client = yield* makeApiClient(api, identity); | 47 | const client = yield* makeApiClient(api, identity); |
| 42 | 48 | ||
| 43 | const integration = unique("msgraph_full"); | 49 | const integration = unique("msgraph_files"); |
| 44 | const connection = ConnectionName.make("main"); | 50 | const connection = ConnectionName.make("main"); |
| 45 | 51 | ||
| 46 | yield* Effect.ensuring( | 52 | yield* Effect.ensuring( |
| 47 | Effect.gen(function* () { | 53 | Effect.gen(function* () { |
| 48 | // Add path (1st former OOM site): the full spec is fetched and | 54 | // Add path, first former OOM site: the Graph spec is fetched and |
| 49 | // stream-compiled into one persisted binding per operation. | 55 | // stream-compiled into one persisted binding per selected operation. |
| 50 | const added = yield* client.microsoft.addGraph({ | 56 | const added = yield* client.openapi.addSpec({ |
| 51 | payload: { | 57 | payload: { |
| 52 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | 58 | spec: { |
| 53 | customScopes: [], | 59 | kind: "url", |
| 60 | url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, | ||
| 61 | }, | ||
| 54 | slug: integration, | 62 | slug: integration, |
| 55 | name: "Microsoft Graph (full)", | 63 | name: "Microsoft Graph Files", |
| 64 | family: "microsoft", | ||
| 65 | specFormat: "microsoft-graph", | ||
| 56 | }, | 66 | }, |
| 57 | }); | 67 | }); |
| 58 | expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration); | 68 | expect(added.slug, "the Microsoft files source keeps the requested slug").toBe(integration); |
| 59 | expect( | 69 | expect( |
| 60 | added.toolCount, | 70 | added.toolCount, |
| 61 | "adding every Graph workload extracts the whole catalog (thousands of operations)", | 71 | "adding the files catalog service extracts a focused Graph operation subtree", |
| 62 | ).toBeGreaterThan(5_000); | 72 | ).toBeGreaterThan(10); |
| 63 | 73 | ||
| 64 | const config = yield* client.microsoft.getConfig({ params: { slug: integration } }); | 74 | const config = yield* client.openapi.getConfig({ params: { slug: integration } }); |
| 65 | expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual( | 75 | const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => |
| 66 | [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | 76 | template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" |
| 77 | ? [...template.scopes] | ||
| 78 | : [], | ||
| 67 | ); | 79 | ); |
| 68 | expect( | 80 | expect( |
| 69 | config?.microsoftGraphCoversFullGraph, | 81 | delegatedScopes, |
| 70 | "selecting every workload is recognized as full Graph", | 82 | "the files service delegates only the file-service scope set", |
| 71 | ).toBe(true); | 83 | ).toEqual([...MICROSOFT_FILES_DELEGATED_SCOPES]); |
| 72 | expect( | ||
| 73 | config?.microsoftGraphScopes, | ||
| 74 | "full Graph delegates the app-registration default scope set", | ||
| 75 | ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]); | ||
| 76 | 84 | ||
| 77 | yield* client.connections.create({ | 85 | yield* client.connections.create({ |
| 78 | payload: { | 86 | payload: { |
| @@ -84,24 +92,22 @@ scenario( · 5 unmodified lines | |||
| 84 | }, | 92 | }, |
| 85 | }); | 93 | }); |
| 86 | 94 | ||
| 87 | // Serve path (2nd former OOM site): tools/list rebuilds the catalog from | 95 | // Serve path, second former OOM site: tools/list rebuilds the catalog |
| 88 | // the persisted bindings. The whole catalog must come back, with real | 96 | // from persisted bindings, with real descriptions, and without |
| 89 | // descriptions, and without re-parsing the 37MB spec. | 97 | // re-parsing the Graph spec. |
| 90 | const tools = yield* client.tools.list({ | 98 | const tools = yield* client.tools.list({ |
| 91 | query: { integration: IntegrationSlug.make(integration), connection }, | 99 | query: { integration: IntegrationSlug.make(integration), connection }, |
| 92 | }); | 100 | }); |
| 93 | expect( | 101 | expect( |
| 94 | tools.length, | 102 | tools.length, |
| 95 | "the served catalog returns the whole set of operations, not a re-parse failure", | 103 | "the served catalog returns the files operation subtree, not a re-parse failure", |
| 96 | ).toBeGreaterThan(5_000); | 104 | ).toBeGreaterThan(10); |
| 97 | 105 | ||
| 98 | const names = tools.map((tool: ToolView) => tool.name); | 106 | const names = tools.map((tool: ToolView) => tool.name); |
| 99 | const messageTools = names.filter((name) => name.toLowerCase().includes("message")); | 107 | const driveTools = names.filter((name) => name.toLowerCase().includes("drive")); |
| 100 | const siteTools = names.filter((name) => name.toLowerCase().includes("site")); | 108 | const shareTools = names.filter((name) => name.toLowerCase().includes("share")); |
| 101 | const userTools = names.filter((name) => name.toLowerCase().includes("user")); | 109 | expect(driveTools, "the served catalog spans drive operations").not.toEqual([]); |
| 102 | expect(messageTools, "the served catalog spans mail operations").not.toEqual([]); | 110 | expect(shareTools, "the served catalog spans sharing operations").not.toEqual([]); |
| 103 | expect(siteTools, "the served catalog spans SharePoint site operations").not.toEqual([]); | ||
| 104 | expect(userTools, "the served catalog spans directory user operations").not.toEqual([]); | ||
| 105 | }), | 111 | }), |
| 106 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 107 | yield* client.connections | 113 | yield* client.connections |
| @@ -113,8 +119,8 @@ scenario( · 5 unmodified lines | |||
| 113 | }, | 119 | }, |
| 114 | }) | 120 | }) |
| 115 | .pipe(Effect.ignore); | 121 | .pipe(Effect.ignore); |
| 116 | yield* client.microsoft | 122 | yield* client.openapi |
| 117 | .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | 123 | .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) |
| 118 | .pipe(Effect.ignore); | 124 | .pipe(Effect.ignore); |
| 119 | }), | 125 | }), |
| 120 | ); | 126 | ); |
| @@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines | |||
| 25 | import { AccountHttpApi } from "@executor-js/api"; | 25 | import { AccountHttpApi } from "@executor-js/api"; |
| 26 | import { composePluginApi } from "@executor-js/api/server"; | 26 | import { composePluginApi } from "@executor-js/api/server"; |
| 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; | 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; |
| 28 | import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | 28 | import { |
| 29 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 29 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 30 | microsoftCatalog, | ||
| 31 | microsoftGraphAdapter, | ||
| 32 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 33 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 30 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; | 34 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; |
| 31 | 35 | ||
| 32 | import { scenario } from "../src/scenario"; | 36 | import { scenario } from "../src/scenario"; |
| 33 | import { Api, Browser, Mcp, Target } from "../src/services"; | 37 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 34 | import type { McpSession } from "../src/surfaces/mcp"; | 38 | import type { McpSession } from "../src/surfaces/mcp"; |
| 35 | 39 | ||
| 36 | const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const); | 40 | const microsoftApi = composePluginApi([ |
| 41 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 42 | ] as const); | ||
| 37 | 43 | ||
| 38 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 44 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 39 | 45 | ||
| @@ -310,18 +316,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 270 unmodified lines | |||
| 310 | 316 | ||
| 311 | scenario( | 317 | scenario( |
| 312 | "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects", | 318 | "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects", |
| 313 | { | 319 | { timeout: 240_000 }, |
| 314 | // Blocked (pre-existing, not this PR): this scenario drives the handoff | ||
| 315 | // through `microsoft.addGraph`, which only accepts the canonical Graph spec | ||
| 316 | // in the streamable block-YAML profile (structural split to avoid OOMing the | ||
| 317 | // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/ | ||
| 318 | // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec | ||
| 319 | // outside that profile, so addGraph hard-errors. The other two OAuth-client | ||
| 320 | // scenarios in this file (createHandoff, approval-gating) do not touch Graph | ||
| 321 | // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately. | ||
| 322 | skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve", | ||
| 323 | timeout: 240_000, | ||
| 324 | }, | ||
| 325 | Effect.gen(function* () { | 320 | Effect.gen(function* () { |
| 326 | const target = yield* Target; | 321 | const target = yield* Target; |
| 327 | const { client: makeApiClient } = yield* Api; | 322 | const { client: makeApiClient } = yield* Api; |
| @@ -356,14 +351,22 @@ scenario( · 28 unmodified lines | |||
| 356 | Effect.gen(function* () { | 351 | Effect.gen(function* () { |
| 357 | // Register the Microsoft Graph integration so the console has an OAuth | 352 | // Register the Microsoft Graph integration so the console has an OAuth |
| 358 | // method to register a client against. | 353 | // method to register a client against. |
| 359 | yield* client.microsoft.addGraph({ | 354 | yield* client.openapi.addSpec({ |
| 360 | payload: { | 355 | payload: { |
| 361 | presetIds: ["users"], | 356 | spec: { kind: "url", url: emulator.openapiUrl }, |
| 362 | customScopes: [], | ||
| 363 | slug: integration, | 357 | slug: integration, |
| 364 | name: "Microsoft Graph Emulator", | 358 | name: "Microsoft Graph Emulator", |
| 365 | baseUrl: emulator.baseUrl, | 359 | baseUrl: emulator.baseUrl, |
| 366 | specUrl: emulator.openapiUrl, | 360 | family: "microsoft", |
| 361 | authenticationTemplate: [ | ||
| 362 | { | ||
| 363 | slug: template, | ||
| 364 | kind: "oauth2", | ||
| 365 | authorizationUrl: oauth.authorizationUrl, | ||
| 366 | tokenUrl: oauth.tokenUrl, | ||
| 367 | scopes: ["https://graph.microsoft.com/.default"], | ||
| 368 | }, | ||
| 369 | ], | ||
| 367 | }, | 370 | }, |
| 368 | }); | 371 | }); |
| 369 | 372 | ||
| @@ -422,7 +425,7 @@ scenario( · 52 unmodified lines | |||
| 422 | }); | 425 | }); |
| 423 | 426 | ||
| 424 | await step("Register the app", async () => { | 427 | await step("Register the app", async () => { |
| 425 | await page.getByRole("button", { name: "Register app" }).click(); | 428 | await page.getByRole("button", { name: "Register app", exact: true }).click(); |
| 426 | // onCreated returns to the Add-connection view — the register form closes. | 429 | // onCreated returns to the Add-connection view — the register form closes. |
| 427 | await page | 430 | await page |
| 428 | .getByRole("heading", { name: "Register OAuth app" }) | 431 | .getByRole("heading", { name: "Register OAuth app" }) |
| @@ -482,7 +485,7 @@ scenario( · 53 unmodified lines | |||
| 482 | payload: { owner: "org" }, | 485 | payload: { owner: "org" }, |
| 483 | }) | 486 | }) |
| 484 | .pipe(Effect.ignore); | 487 | .pipe(Effect.ignore); |
| 485 | yield* client.microsoft.removeGraph({ params: { slug: integration } }).pipe(Effect.ignore); | 488 | yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); |
| 486 | }).pipe(Effect.ignore), | 489 | }).pipe(Effect.ignore), |
| 487 | ); | 490 | ); |
| 488 | }), | 491 | }), |
| @@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines | |||
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | 6 | ||
| 7 | scenario( | 7 | scenario( |
| 8 | "Provider plugins · Google and Microsoft own their add flows outside OpenAPI", | 8 | "Provider catalog · Google and Microsoft services are OpenAPI presets", |
| 9 | {}, | 9 | {}, |
| 10 | Effect.gen(function* () { | 10 | Effect.gen(function* () { |
| 11 | const target = yield* Target; | 11 | const target = yield* Target; |
| @@ -19,56 +19,46 @@ scenario( · 7 unmodified lines | |||
| 19 | await page.getByRole("dialog", { name: "Connect an integration" }).waitFor(); | 19 | await page.getByRole("dialog", { name: "Connect an integration" }).waitFor(); |
| 20 | }); | 20 | }); |
| 21 | 21 | ||
| 22 | await step( | 22 | await step("The picker exposes OpenAPI plus provider service presets", async () => { |
| 23 | "The provider list exposes OpenAPI, Google, and Microsoft separately", | 23 | const dialog = page.getByRole("dialog", { |
| 24 | async () => { | 24 | name: "Connect an integration", |
| 25 | const dialog = page.getByRole("dialog", { | 25 | }); |
| 26 | name: "Connect an integration", | 26 | const search = dialog.getByPlaceholder(/Search or paste a URL/); |
| 27 | }); | 27 | await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); |
| 28 | await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); | 28 | |
| 29 | await dialog.getByRole("link", { name: "Google", exact: true }).waitFor(); | 29 | await search.fill("gmail"); |
| 30 | await dialog.getByRole("link", { name: "Microsoft", exact: true }).waitFor(); | 30 | await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); |
| 31 | }, | 31 | |
| 32 | ); | 32 | await search.fill("onedrive"); |
| 33 | await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); | ||
| 34 | }); | ||
| 33 | 35 | ||
| 34 | await step("OpenAPI add remains generic", async () => { | 36 | await step("OpenAPI add remains generic", async () => { |
| 35 | await page.goto("/integrations/add/openapi", { | 37 | await page.goto("/integrations/add/openapi", { |
| 36 | waitUntil: "domcontentloaded", | 38 | waitUntil: "domcontentloaded", |
| 37 | }); | 39 | }); |
| 38 | await page.getByRole("heading", { name: "Add OpenAPI Integration" }).waitFor(); | 40 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 39 | await page.getByText("OpenAPI Spec").waitFor(); | 41 | await page.getByText("OpenAPI Spec").waitFor(); |
| 40 | expect(await page.getByText("Customize your Google connection").count()).toBe(0); | 42 | expect(await page.getByText("Customize your Google connection").count()).toBe(0); |
| 41 | expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0); | 43 | expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0); |
| 42 | }); | 44 | }); |
| 43 | 45 | ||
| 44 | await step("Google has its own product picker", async () => { | 46 | await step("A Google service preset opens the OpenAPI add flow", async () => { |
| 45 | await page.goto("/integrations/add/google", { | 47 | await page.goto( |
| 46 | waitUntil: "domcontentloaded", | 48 | "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest", |
| 47 | }); | 49 | { waitUntil: "domcontentloaded" }, |
| 48 | await page.getByRole("heading", { name: "Add Google integration" }).waitFor(); | 50 | ); |
| 49 | await page.getByText("Customize your Google connection").waitFor(); | 51 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 50 | await page.getByText("Gmail").first().waitFor(); | 52 | await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); |
| 51 | await page.getByText("Google Calendar").first().waitFor(); | ||
| 52 | }); | 53 | }); |
| 53 | 54 | ||
| 54 | await step("Microsoft has its own Graph scope picker", async () => { | 55 | await step("A Microsoft service preset opens the OpenAPI add flow", async () => { |
| 55 | await page.goto("/integrations/add/microsoft", { | 56 | await page.goto( |
| 56 | waitUntil: "domcontentloaded", | 57 | "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoftgraph%2Fmsgraph-metadata%2Fmaster%2Fopenapi%2Fv1.0%2Fopenapi.yaml%23preset%3Dfiles", |
| 57 | }); | 58 | { waitUntil: "domcontentloaded" }, |
| 58 | await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor(); | 59 | ); |
| 59 | await page.getByText("Customize Microsoft Graph").waitFor(); | 60 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 60 | expect(await page.getByText("All Microsoft Graph", { exact: true }).count()).toBe(0); | 61 | await expect.poll(() => page.locator("textarea").inputValue()).toContain("preset=files"); |
| 61 | await page.getByText("Productivity").waitFor(); | ||
| 62 | await page.getByText("Directory and identity").waitFor(); | ||
| 63 | await page.getByText("Outlook Mail").waitFor(); | ||
| 64 | await page.getByText("OneDrive Files").waitFor(); | ||
| 65 | await page.getByText("OneNote", { exact: true }).waitFor(); | ||
| 66 | await page.getByText("Teams Channels").waitFor(); | ||
| 67 | await page.locator('img[src*="svgl.app/library/microsoft-outlook.svg"]').first().waitFor(); | ||
| 68 | await page.locator('img[src*="svgl.app/library/microsoft-onedrive.svg"]').waitFor(); | ||
| 69 | await page.getByRole("button", { name: /View scopes/ }).click(); | ||
| 70 | await page.getByText("offline_access").waitFor(); | ||
| 71 | await page.getByText("Mail.ReadWrite").waitFor(); | ||
| 72 | }); | 62 | }); |
| 73 | }); | 63 | }); |
| 74 | }), | 64 | }), |
| @@ -10,7 +10,6 @@ · 9 unmodified lines | |||
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor-js/plugin-file-secrets": "workspace:*", | 12 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 13 | "@executor-js/plugin-google": "workspace:*", | ||
| 14 | "@executor-js/plugin-graphql": "workspace:*", | 13 | "@executor-js/plugin-graphql": "workspace:*", |
| 15 | "@executor-js/plugin-keychain": "workspace:*", | 14 | "@executor-js/plugin-keychain": "workspace:*", |
| 16 | "@executor-js/plugin-mcp": "workspace:*", | 15 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -33,12 +33,19 @@ import { · 32 unmodified lines | |||
| 33 | } from "@executor-js/sdk"; | 33 | } from "@executor-js/sdk"; |
| 34 | 34 | ||
| 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 36 | import { googlePlugin } from "@executor-js/plugin-google"; | 36 | import { |
| 37 | googleCatalog, | ||
| 38 | googleDiscoveryAdapter, | ||
| 39 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 37 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; | 40 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; |
| 38 | import { keychainPlugin } from "@executor-js/plugin-keychain"; | 41 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| 39 | import { mcpPlugin } from "@executor-js/plugin-mcp"; | 42 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 40 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; | 43 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; |
| 41 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; | 44 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; |
| 45 | import { | ||
| 46 | microsoftCatalog, | ||
| 47 | microsoftGraphAdapter, | ||
| 48 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 42 | import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; | 49 | import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; |
| 43 | 50 | ||
| 44 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| @@ -78,10 +85,12 @@ const plugins = [ · 33 unmodified lines | |||
| 78 | // Integration plugins — these declare their own schemas (tables), register | 85 | // Integration plugins — these declare their own schemas (tables), register |
| 79 | // integrations via their extension methods (`addSpec` / `addServer` / | 86 | // integrations via their extension methods (`addSpec` / `addServer` / |
| 80 | // `addIntegration`), and produce tools per connection. | 87 | // `addIntegration`), and produce tools per connection. |
| 81 | googlePlugin(), | ||
| 82 | graphqlPlugin(), | 88 | graphqlPlugin(), |
| 83 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), | 89 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 84 | openApiPlugin(), | 90 | openApiPlugin({ |
| 91 | presets: [...googleCatalog, ...microsoftCatalog], | ||
| 92 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | ||
| 93 | }), | ||
| 85 | 94 | ||
| 86 | // workos-vault is a cloud-hosted credential provider. It would contribute a | 95 | // workos-vault is a cloud-hosted credential provider. It would contribute a |
| 87 | // "workos-vault" provider if credentials were available. We skip it here | 96 | // "workos-vault" provider if credentials were available. We skip it here |
| @@ -418,7 +427,7 @@ const program = Effect.gen(function* () { · 330 unmodified lines | |||
| 418 | console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); | 427 | console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); |
| 419 | 428 | ||
| 420 | // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." }); | 429 | // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." }); |
| 421 | // executor.google.addBundle({ urls: ["..."], slug: "google" }); | 430 | // Google catalog rows are added through executor.openapi.addSpec with specFormat "google-discovery". |
| 422 | // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." }); | 431 | // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." }); |
| 423 | 432 | ||
| 424 | // ------------------------------------------------------------------------- | 433 | // ------------------------------------------------------------------------- |
| @@ -41,9 +41,6 @@ const config: KnipConfig = { · 40 unmodified lines | |||
| 41 | "packages/plugins/file-secrets": { | 41 | "packages/plugins/file-secrets": { |
| 42 | includeEntryExports: true, | 42 | includeEntryExports: true, |
| 43 | }, | 43 | }, |
| 44 | "packages/plugins/google": { | ||
| 45 | includeEntryExports: true, | ||
| 46 | }, | ||
| 47 | "packages/plugins/onepassword": { | 44 | "packages/plugins/onepassword": { |
| 48 | includeEntryExports: true, | 45 | includeEntryExports: true, |
| 49 | ignoreDependencies: ["@executor-js/react"], | 46 | ignoreDependencies: ["@executor-js/react"], |
| @@ -75,6 +75,7 @@ · 74 unmodified lines | |||
| 75 | "@effect/language-service": "^0.85.1", | 75 | "@effect/language-service": "^0.85.1", |
| 76 | "@effect/tsgo": "^0.5.2", | 76 | "@effect/tsgo": "^0.5.2", |
| 77 | "@effect/vitest": "catalog:", | 77 | "@effect/vitest": "catalog:", |
| 78 | "@executor-js/plugin-provider-service-split": "workspace:*", | ||
| 78 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", | 79 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 79 | "@vitest/expect": "catalog:", | 80 | "@vitest/expect": "catalog:", |
| 80 | "@vitest/mocker": "catalog:", | 81 | "@vitest/mocker": "catalog:", |
| @@ -58,6 +58,7 @@ const ConnectionResponse = Schema.Struct({ · 57 unmodified lines | |||
| 58 | oauthClient: Schema.NullOr(OAuthClientSlug), | 58 | oauthClient: Schema.NullOr(OAuthClientSlug), |
| 59 | oauthClientOwner: Schema.NullOr(Owner), | 59 | oauthClientOwner: Schema.NullOr(Owner), |
| 60 | oauthScope: Schema.NullOr(Schema.String), | 60 | oauthScope: Schema.NullOr(Schema.String), |
| 61 | missingOAuthScopes: Schema.Array(Schema.String), | ||
| 61 | // Last persisted health-check verdict (written by every checkHealth run), | 62 | // Last persisted health-check verdict (written by every checkHealth run), |
| 62 | // so the list can show alive/expired at a glance without probing. | 63 | // so the list can show alive/expired at a glance without probing. |
| 63 | lastHealth: Schema.NullOr(HealthCheckResult), | 64 | lastHealth: Schema.NullOr(HealthCheckResult), |
| @@ -28,6 +28,7 @@ const toResponse = (c: Connection) => ({ · 27 unmodified lines | |||
| 28 | oauthClient: c.oauthClient ?? null, | 28 | oauthClient: c.oauthClient ?? null, |
| 29 | oauthClientOwner: c.oauthClientOwner ?? null, | 29 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 30 | oauthScope: c.oauthScope ?? null, | 30 | oauthScope: c.oauthScope ?? null, |
| 31 | missingOAuthScopes: c.missingOAuthScopes ?? [], | ||
| 31 | lastHealth: c.lastHealth ?? null, | 32 | lastHealth: c.lastHealth ?? null, |
| 32 | }); | 33 | }); |
| 33 | 34 | ||
| @@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines | |||
| 15 | canRefresh: i.canRefresh, | 15 | canRefresh: i.canRefresh, |
| 16 | authMethods: i.authMethods, | 16 | authMethods: i.authMethods, |
| 17 | ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}), | 17 | ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}), |
| 18 | ...(i.family ? { family: i.family } : {}), | ||
| 18 | }); | 19 | }); |
| 19 | 20 | ||
| 20 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => | 21 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => |
| @@ -45,6 +45,7 @@ const connectionToResponse = (c: Connection) => ({ · 44 unmodified lines | |||
| 45 | oauthClient: c.oauthClient ?? null, | 45 | oauthClient: c.oauthClient ?? null, |
| 46 | oauthClientOwner: c.oauthClientOwner ?? null, | 46 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 47 | oauthScope: c.oauthScope ?? null, | 47 | oauthScope: c.oauthScope ?? null, |
| 48 | missingOAuthScopes: c.missingOAuthScopes ?? [], | ||
| 48 | }); | 49 | }); |
| 49 | 50 | ||
| 50 | const startResultToResponse = (result: ConnectResult) => | 51 | const startResultToResponse = (result: ConnectResult) => |
| @@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines | |||
| 81 | authMethods: Schema.Array(AuthMethodDescriptorSchema), | 81 | authMethods: Schema.Array(AuthMethodDescriptorSchema), |
| 82 | /** Non-secret URL derived from opaque integration config for favicons. */ | 82 | /** Non-secret URL derived from opaque integration config for favicons. */ |
| 83 | displayUrl: Schema.optional(Schema.String), | 83 | displayUrl: Schema.optional(Schema.String), |
| 84 | /** Catalog family derived from opaque integration config for grouped display. */ | ||
| 85 | family: Schema.optional(Schema.String), | ||
| 84 | }); | 86 | }); |
| 85 | 87 | ||
| 86 | const UpdateIntegrationPayload = Schema.Struct({ | 88 | const UpdateIntegrationPayload = Schema.Struct({ |
| @@ -50,6 +50,7 @@ const ConnectionResponse = Schema.Struct({ · 49 unmodified lines | |||
| 50 | oauthClient: Schema.NullOr(OAuthClientSlug), | 50 | oauthClient: Schema.NullOr(OAuthClientSlug), |
| 51 | oauthClientOwner: Schema.NullOr(Owner), | 51 | oauthClientOwner: Schema.NullOr(Owner), |
| 52 | oauthScope: Schema.NullOr(Schema.String), | 52 | oauthScope: Schema.NullOr(Schema.String), |
| 53 | missingOAuthScopes: Schema.Array(Schema.String), | ||
| 53 | }); | 54 | }); |
| 54 | 55 | ||
| 55 | // --------------------------------------------------------------------------- | 56 | // --------------------------------------------------------------------------- |
| @@ -22,6 +22,7 @@ import { HttpApi } from "effect/unstable/httpapi"; · 21 unmodified lines | |||
| 22 | import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | 22 | import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 23 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 23 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 24 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | 24 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 25 | import type { HealthCheckSpec } from "./health-check"; | ||
| 25 | 26 | ||
| 26 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 27 | // Re-exports — the curated set of primitives a plugin author needs to | 28 | // Re-exports — the curated set of primitives a plugin author needs to |
| @@ -112,8 +113,33 @@ export interface IntegrationPreset { · 84 unmodified lines | |||
| 112 | readonly icon?: string; | 113 | readonly icon?: string; |
| 113 | /** Shown in the top-level grid on the integrations page when true. */ | 114 | /** Shown in the top-level grid on the integrations page when true. */ |
| 114 | readonly featured?: boolean; | 115 | readonly featured?: boolean; |
| 116 | readonly family?: string; | ||
| 117 | readonly specFormat?: string; | ||
| 118 | readonly defaultSlug?: string; | ||
| 119 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | ||
| 120 | readonly healthCheck?: HealthCheckSpec; | ||
| 115 | } | 121 | } |
| 116 | 122 | ||
| 123 | export type IntegrationPresetAuthentication = | ||
| 124 | | { | ||
| 125 | readonly slug: string; | ||
| 126 | readonly kind: "oauth2"; | ||
| 127 | readonly authorizationUrl: string; | ||
| 128 | readonly tokenUrl: string; | ||
| 129 | readonly resource?: string | null; | ||
| 130 | readonly scopes: readonly string[]; | ||
| 131 | readonly supportsClientIdMetadataDocument?: boolean; | ||
| 132 | } | ||
| 133 | | { | ||
| 134 | readonly kind: "apiKey"; | ||
| 135 | readonly slug?: string; | ||
| 136 | readonly name?: string; | ||
| 137 | readonly placements?: readonly unknown[]; | ||
| 138 | readonly headers?: Readonly<Record<string, readonly unknown[]>>; | ||
| 139 | readonly queryParams?: Readonly<Record<string, readonly unknown[]>>; | ||
| 140 | readonly cookies?: Readonly<Record<string, readonly unknown[]>>; | ||
| 141 | }; | ||
| 142 | |||
| 117 | export interface IntegrationAccountHandoff { | 143 | export interface IntegrationAccountHandoff { |
| 118 | /** Changes on each handoff URL, so the accounts UI can open once per link. */ | 144 | /** Changes on each handoff URL, so the accounts UI can open once per link. */ |
| 119 | readonly key: string; | 145 | readonly key: string; |
| @@ -54,6 +54,9 @@ export interface Connection { · 53 unmodified lines | |||
| 54 | * declared scopes to decide whether this connection must reconnect to grant | 54 | * declared scopes to decide whether this connection must reconnect to grant |
| 55 | * newly-needed access. Null for static creds / when the AS omitted `scope`. */ | 55 | * newly-needed access. Null for static creds / when the AS omitted `scope`. */ |
| 56 | readonly oauthScope?: string | null; | 56 | readonly oauthScope?: string | null; |
| 57 | /** Requested authorization-code scopes the provider did not grant, excluding | ||
| 58 | * identity/refresh scopes. Informational only: connect still succeeds. */ | ||
| 59 | readonly missingOAuthScopes?: readonly string[]; | ||
| 57 | /** Last health-check verdict, persisted by every `checkHealth` run. Answers | 60 | /** Last health-check verdict, persisted by every `checkHealth` run. Answers |
| 58 | * "has this expired?" at a glance in the connections list without probing. | 61 | * "has this expired?" at a glance in the connections list without probing. |
| 59 | * Null/absent = never checked. */ | 62 | * Null/absent = never checked. */ |
| @@ -618,6 +618,29 @@ describe("tool catalog sync safety", () => { · 617 unmodified lines | |||
| 618 | ); | 618 | ); |
| 619 | }); | 619 | }); |
| 620 | 620 | ||
| 621 | describe("connections.checkHealth", () => { | ||
| 622 | it.effect("keeps API-key connections without a probe unknown", () => | ||
| 623 | Effect.gen(function* () { | ||
| 624 | const executor = yield* setup(); | ||
| 625 | yield* executor.connections.create({ | ||
| 626 | owner: "org", | ||
| 627 | name: ConnectionName.make("main"), | ||
| 628 | integration: INTEG, | ||
| 629 | template: TEMPLATE, | ||
| 630 | value: "secret-token", | ||
| 631 | }); | ||
| 632 | |||
| 633 | const result = yield* executor.connections.checkHealth({ | ||
| 634 | owner: "org", | ||
| 635 | integration: INTEG, | ||
| 636 | name: ConnectionName.make("main"), | ||
| 637 | }); | ||
| 638 | |||
| 639 | expect(result.status).toBe("unknown"); | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | }); | ||
| 643 | |||
| 621 | describe("execute over a connection", () => { | 644 | describe("execute over a connection", () => { |
| 622 | it.effect("resolves the credential value and hands it to invokeTool", () => | 645 | it.effect("resolves the credential value and hands it to invokeTool", () => |
| 623 | Effect.gen(function* () { | 646 | Effect.gen(function* () { |
| @@ -86,6 +86,7 @@ import type { · 85 unmodified lines | |||
| 86 | AuthMethodDescriptor, | 86 | AuthMethodDescriptor, |
| 87 | Integration, | 87 | Integration, |
| 88 | IntegrationConfig, | 88 | IntegrationConfig, |
| 89 | IntegrationDisplayDescriptor, | ||
| 89 | RegisterIntegrationInput, | 90 | RegisterIntegrationInput, |
| 90 | } from "./integration"; | 91 | } from "./integration"; |
| 91 | import { | 92 | import { |
| @@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines | |||
| 522 | const rowToIntegration = ( | 523 | const rowToIntegration = ( |
| 523 | row: IntegrationRow, | 524 | row: IntegrationRow, |
| 524 | authMethods: readonly AuthMethodDescriptor[] = [], | 525 | authMethods: readonly AuthMethodDescriptor[] = [], |
| 525 | displayUrl?: string, | 526 | display?: IntegrationDisplayDescriptor, |
| 526 | ): Integration => ({ | 527 | ): Integration => ({ |
| 527 | slug: IntegrationSlug.make(row.slug), | 528 | slug: IntegrationSlug.make(row.slug), |
| 528 | // Pre-split rows have no `name`; their description WAS the display name. | 529 | // Pre-split rows have no `name`; their description WAS the display name. |
| @@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines | |||
| 534 | canRemove: Boolean(row.can_remove), | 535 | canRemove: Boolean(row.can_remove), |
| 535 | canRefresh: Boolean(row.can_refresh), | 536 | canRefresh: Boolean(row.can_refresh), |
| 536 | authMethods, | 537 | authMethods, |
| 537 | ...(displayUrl ? { displayUrl } : {}), | 538 | ...(display?.url ? { displayUrl: display.url } : {}), |
| 539 | ...(display?.family ? { family: display.family } : {}), | ||
| 538 | }); | 540 | }); |
| 539 | 541 | ||
| 540 | const rowToIntegrationRecord = ( | 542 | const rowToIntegrationRecord = ( |
| @@ -548,6 +550,15 @@ const rowToIntegrationRecord = ( · 7 unmodified lines | |||
| 548 | const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult); | 550 | const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult); |
| 549 | const decodeHealthCheckSpec = Schema.decodeUnknownOption(HealthCheckSpec); | 551 | const decodeHealthCheckSpec = Schema.decodeUnknownOption(HealthCheckSpec); |
| 550 | 552 | ||
| 553 | const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] => { | ||
| 554 | const decoded = decodeJsonColumn(value); | ||
| 555 | if (decoded == null || typeof decoded !== "object" || Array.isArray(decoded)) return []; | ||
| 556 | const scopes = (decoded as Record<string, unknown>).missingOAuthScopes; | ||
| 557 | return Array.isArray(scopes) | ||
| 558 | ? scopes.filter((scope): scope is string => typeof scope === "string") | ||
| 559 | : []; | ||
| 560 | }; | ||
| 561 | |||
| 551 | const rowToConnection = (row: ConnectionRow): Connection => { | 562 | const rowToConnection = (row: ConnectionRow): Connection => { |
| 552 | const owner = row.owner as Owner; | 563 | const owner = row.owner as Owner; |
| 553 | const integration = IntegrationSlug.make(row.integration); | 564 | const integration = IntegrationSlug.make(row.integration); |
| @@ -566,6 +577,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 12 unmodified lines | |||
| 566 | oauthClientOwner: | 577 | oauthClientOwner: |
| 567 | row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner), | 578 | row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner), |
| 568 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), | 579 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), |
| 580 | missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), | ||
| 569 | lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), | 581 | lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), |
| 570 | }; | 582 | }; |
| 571 | }; | 583 | }; |
| @@ -1759,17 +1771,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1187 unmodified lines | |||
| 1759 | } | 1771 | } |
| 1760 | }; | 1772 | }; |
| 1761 | 1773 | ||
| 1762 | const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => { | 1774 | const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => { |
| 1763 | const runtime = runtimes.get(row.plugin_id); | 1775 | const runtime = runtimes.get(row.plugin_id); |
| 1764 | const describe = runtime?.plugin.describeIntegrationDisplay; | 1776 | const describe = runtime?.plugin.describeIntegrationDisplay; |
| 1765 | if (!describe) return undefined; | 1777 | if (!describe) return {}; |
| 1766 | const record = rowToIntegrationRecord(row); | 1778 | const record = rowToIntegrationRecord(row); |
| 1767 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read | 1779 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read |
| 1768 | try { | 1780 | try { |
| 1769 | const display = describe(record); | 1781 | const display = describe(record); |
| 1770 | return display.url && display.url.length > 0 ? display.url : undefined; | 1782 | return { |
| 1783 | ...(display.url && display.url.length > 0 ? { url: display.url } : {}), | ||
| 1784 | ...(display.family && display.family.length > 0 ? { family: display.family } : {}), | ||
| 1785 | }; | ||
| 1771 | } catch { | 1786 | } catch { |
| 1772 | return undefined; | 1787 | return {}; |
| 1773 | } | 1788 | } |
| 1774 | }; | 1789 | }; |
| 1775 | 1790 | ||
| @@ -1801,7 +1816,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 25 unmodified lines | |||
| 1801 | const rows = yield* core.findMany("integration", {}); | 1816 | const rows = yield* core.findMany("integration", {}); |
| 1802 | const staticIntegrations = staticSources().map(staticSourceToIntegration); | 1817 | const staticIntegrations = staticSources().map(staticSourceToIntegration); |
| 1803 | const dbIntegrations = rows.map((row) => | 1818 | const dbIntegrations = rows.map((row) => |
| 1804 | rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), | 1819 | rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)), |
| 1805 | ); | 1820 | ); |
| 1806 | // A scoped toolkit must not advertise providers it grants no tools from | 1821 | // A scoped toolkit must not advertise providers it grants no tools from |
| 1807 | // (mirrors `connectionsList`). Static sources are system namespaces, not | 1822 | // (mirrors `connectionsList`). Static sources are system namespaces, not |
| @@ -1828,7 +1843,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines | |||
| 1828 | if (staticSource) return staticSourceToIntegration(staticSource); | 1843 | if (staticSource) return staticSourceToIntegration(staticSource); |
| 1829 | const row = yield* findIntegrationRow(slug); | 1844 | const row = yield* findIntegrationRow(slug); |
| 1830 | return row | 1845 | return row |
| 1831 | ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)) | 1846 | ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)) |
| 1832 | : null; | 1847 | : null; |
| 1833 | }); | 1848 | }); |
| 1834 | 1849 | ||
| @@ -2442,6 +2457,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 607 unmodified lines | |||
| 2442 | expires_at: input.expiresAt, | 2457 | expires_at: input.expiresAt, |
| 2443 | oauth_scope: input.oauthScope, | 2458 | oauth_scope: input.oauthScope, |
| 2444 | oauth_token_url: input.oauthTokenUrl ?? null, | 2459 | oauth_token_url: input.oauthTokenUrl ?? null, |
| 2460 | provider_state: | ||
| 2461 | input.missingOAuthScopes && input.missingOAuthScopes.length > 0 | ||
| 2462 | ? { missingOAuthScopes: input.missingOAuthScopes } | ||
| 2463 | : null, | ||
| 2445 | updated_at: now, | 2464 | updated_at: now, |
| 2446 | }; | 2465 | }; |
| 2447 | if (existing) { | 2466 | if (existing) { |
| @@ -2474,7 +2493,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines | |||
| 2474 | expires_at: input.expiresAt, | 2493 | expires_at: input.expiresAt, |
| 2475 | oauth_scope: input.oauthScope, | 2494 | oauth_scope: input.oauthScope, |
| 2476 | oauth_token_url: input.oauthTokenUrl ?? null, | 2495 | oauth_token_url: input.oauthTokenUrl ?? null, |
| 2477 | provider_state: null, | 2496 | provider_state: |
| 2497 | input.missingOAuthScopes && input.missingOAuthScopes.length > 0 | ||
| 2498 | ? { missingOAuthScopes: input.missingOAuthScopes } | ||
| 2499 | : null, | ||
| 2478 | created_at: now, | 2500 | created_at: now, |
| 2479 | updated_at: now, | 2501 | updated_at: now, |
| 2480 | }); | 2502 | }); |
| @@ -2508,7 +2530,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 27 unmodified lines | |||
| 2508 | expires_at: input.expiresAt, | 2530 | expires_at: input.expiresAt, |
| 2509 | oauth_scope: input.oauthScope, | 2531 | oauth_scope: input.oauthScope, |
| 2510 | oauth_token_url: input.oauthTokenUrl ?? null, | 2532 | oauth_token_url: input.oauthTokenUrl ?? null, |
| 2511 | provider_state: null, | 2533 | provider_state: |
| 2534 | input.missingOAuthScopes && input.missingOAuthScopes.length > 0 | ||
| 2535 | ? { missingOAuthScopes: input.missingOAuthScopes } | ||
| 2536 | : null, | ||
| 2512 | created_at: now, | 2537 | created_at: now, |
| 2513 | updated_at: now, | 2538 | updated_at: now, |
| 2514 | } as ConnectionRow); | 2539 | } as ConnectionRow); |
| @@ -2683,6 +2708,39 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 168 unmodified lines | |||
| 2683 | }), | 2708 | }), |
| 2684 | ); | 2709 | ); |
| 2685 | 2710 | ||
| 2711 | const healthFromCredentialResolutionFailure = ( | ||
| 2712 | failure: CredentialResolutionError, | ||
| 2713 | ): HealthCheckResult => | ||
| 2714 | failure.reauthRequired === true | ||
| 2715 | ? { | ||
| 2716 | status: "expired", | ||
| 2717 | checkedAt: Date.now(), | ||
| 2718 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field | ||
| 2719 | detail: failure.message, | ||
| 2720 | } | ||
| 2721 | : { | ||
| 2722 | status: "degraded", | ||
| 2723 | checkedAt: Date.now(), | ||
| 2724 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field | ||
| 2725 | detail: failure.message, | ||
| 2726 | }; | ||
| 2727 | |||
| 2728 | // Genuine storage failures propagate: an infra blip must fail the request, | ||
| 2729 | // not persist as a "degraded" verdict on the connection. | ||
| 2730 | const oauthCredentialHealthWithoutProbe = ( | ||
| 2731 | row: ConnectionRow, | ||
| 2732 | ): Effect.Effect<HealthCheckResult, StorageFailure> => | ||
| 2733 | resolveConnectionValues(row).pipe( | ||
| 2734 | Effect.as({ | ||
| 2735 | status: "healthy" as const, | ||
| 2736 | checkedAt: Date.now(), | ||
| 2737 | detail: "Credential resolved (no probe configured).", | ||
| 2738 | }), | ||
| 2739 | Effect.catchTag("CredentialResolutionError", (failure) => | ||
| 2740 | Effect.succeed(healthFromCredentialResolutionFailure(failure)), | ||
| 2741 | ), | ||
| 2742 | ); | ||
| 2743 | |||
| 2686 | // Resolve an in-flight credential's value map (key-first validation) without | 2744 | // Resolve an in-flight credential's value map (key-first validation) without |
| 2687 | // saving anything. Mirrors `resolveConnectionValues` for the saved-row path: | 2745 | // saving anything. Mirrors `resolveConnectionValues` for the saved-row path: |
| 2688 | // pasted `value`/`values` are used directly; `from` origins resolve through | 2746 | // pasted `value`/`values` are used directly; `from` origins resolve through |
| @@ -2742,6 +2800,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 53 unmodified lines | |||
| 2742 | const runtime = runtimes.get(integrationRow.plugin_id); | 2800 | const runtime = runtimes.get(integrationRow.plugin_id); |
| 2743 | const check = runtime?.plugin.checkHealth; | 2801 | const check = runtime?.plugin.checkHealth; |
| 2744 | if (!runtime || !check) return unknownHealth(); | 2802 | if (!runtime || !check) return unknownHealth(); |
| 2803 | const spec = describeHealthCheckForRow(integrationRow) ?? undefined; | ||
| 2804 | if (spec === undefined && connectionRow.oauth_client != null) { | ||
| 2805 | const result = yield* oauthCredentialHealthWithoutProbe(connectionRow); | ||
| 2806 | yield* persistHealthResult(ref, result); | ||
| 2807 | return result; | ||
| 2808 | } | ||
| 2745 | 2809 | ||
| 2746 | const result = yield* Effect.gen(function* () { | 2810 | const result = yield* Effect.gen(function* () { |
| 2747 | const values = yield* resolveConnectionValues(connectionRow); | 2811 | const values = yield* resolveConnectionValues(connectionRow); |
| @@ -2760,7 +2824,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines | |||
| 2760 | }; | 2824 | }; |
| 2761 | // Core resolves the declared spec (its own column) and hands it to the | 2825 | // Core resolves the declared spec (its own column) and hands it to the |
| 2762 | // plugin; plugins no longer read it out of their config. | 2826 | // plugin; plugins no longer read it out of their config. |
| 2763 | const spec = describeHealthCheckForRow(integrationRow) ?? undefined; | ||
| 2764 | return yield* foldPluginFailure( | 2827 | return yield* foldPluginFailure( |
| 2765 | check({ ctx: runtime.ctx, integration: record, credential, spec }), | 2828 | check({ ctx: runtime.ctx, integration: record, credential, spec }), |
| 2766 | `Health check for connection "${ref.name}" failed.`, | 2829 | `Health check for connection "${ref.name}" failed.`, |
| @@ -316,6 +316,7 @@ export { · 315 unmodified lines | |||
| 316 | type IntegrationConfigureDecl, | 316 | type IntegrationConfigureDecl, |
| 317 | type IntegrationConfigureSchema, | 317 | type IntegrationConfigureSchema, |
| 318 | type IntegrationPreset, | 318 | type IntegrationPreset, |
| 319 | type IntegrationPresetAuthentication, | ||
| 319 | type IntegrationPresetCatalogEntry, | 320 | type IntegrationPresetCatalogEntry, |
| 320 | type ResolveToolsInput, | 321 | type ResolveToolsInput, |
| 321 | type ResolveToolsResult, | 322 | type ResolveToolsResult, |
| @@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines | |||
| 22 | export interface IntegrationDisplayDescriptor { | 22 | export interface IntegrationDisplayDescriptor { |
| 23 | /** Non-secret URL suitable for display metadata such as favicons. */ | 23 | /** Non-secret URL suitable for display metadata such as favicons. */ |
| 24 | readonly url?: string; | 24 | readonly url?: string; |
| 25 | /** Catalog family derived from plugin config for grouped display. */ | ||
| 26 | readonly family?: string; | ||
| 25 | } | 27 | } |
| 26 | 28 | ||
| 27 | /** Where a credential value is carried. `header`/`query` place it on an | 29 | /** Where a credential value is carried. `header`/`query` place it on an |
| @@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines | |||
| 100 | /** Non-secret display URL derived by the owning plugin from opaque config. | 102 | /** Non-secret display URL derived by the owning plugin from opaque config. |
| 101 | * Used for catalog favicons; never includes credentials or plugin config. */ | 103 | * Used for catalog favicons; never includes credentials or plugin config. */ |
| 102 | readonly displayUrl?: string; | 104 | readonly displayUrl?: string; |
| 105 | /** Catalog family derived by the owning plugin from opaque config. */ | ||
| 106 | readonly family?: string; | ||
| 103 | } | 107 | } |
| 104 | 108 | ||
| 105 | /** Plugin-owned, opaque-to-core configuration stored on the integration row. The | 109 | /** Plugin-owned, opaque-to-core configuration stored on the integration row. The |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | } from "./ids"; | 12 | } from "./ids"; |
| 13 | import { decodeOAuthCallbackState } from "./oauth"; | 13 | import { decodeOAuthCallbackState } from "./oauth"; |
| 14 | import { OAuthStartError } from "./oauth-client"; | 14 | import { OAuthStartError } from "./oauth-client"; |
| 15 | import { missingGrantedOAuthScopes } from "./oauth-service"; | ||
| 15 | import { definePlugin } from "./plugin"; | 16 | import { definePlugin } from "./plugin"; |
| 16 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 17 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 18 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| @@ -294,6 +295,153 @@ describe("oauth.start / oauth.complete", () => { · 276 unmodified lines | |||
| 294 | ), | 295 | ), |
| 295 | ); | 296 | ); |
| 296 | 297 | ||
| 298 | it.effect("persists connection identity from id_token claims on authorization-code grant", () => | ||
| 299 | Effect.scoped( | ||
| 300 | Effect.gen(function* () { | ||
| 301 | const server = yield* serveOAuthTestServer({ | ||
| 302 | scopes: ["openid", "email", "profile", "read"], | ||
| 303 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | ||
| 304 | }); | ||
| 305 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 306 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | ||
| 307 | |||
| 308 | yield* executor.oauth.createClient({ | ||
| 309 | owner: "org", | ||
| 310 | slug: CLIENT, | ||
| 311 | authorizationUrl: server.authorizationEndpoint, | ||
| 312 | tokenUrl: server.tokenEndpoint, | ||
| 313 | grant: "authorization_code", | ||
| 314 | clientId: "test-client", | ||
| 315 | clientSecret: "test-secret", | ||
| 316 | }); | ||
| 317 | |||
| 318 | const started = yield* executor.oauth.start({ | ||
| 319 | owner: "org", | ||
| 320 | client: CLIENT, | ||
| 321 | clientOwner: "org", | ||
| 322 | name: ConnectionName.make("main"), | ||
| 323 | integration: INTEG, | ||
| 324 | template: TEMPLATE, | ||
| 325 | }); | ||
| 326 | expect(started.status).toBe("redirect"); | ||
| 327 | if (started.status !== "redirect") return; | ||
| 328 | |||
| 329 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 330 | authorizationUrl: started.authorizationUrl, | ||
| 331 | }); | ||
| 332 | const connection = yield* executor.oauth.complete({ | ||
| 333 | state: started.state, | ||
| 334 | code: callback.code, | ||
| 335 | }); | ||
| 336 | |||
| 337 | expect(connection.identityLabel).toBe("alice@example.com"); | ||
| 338 | }), | ||
| 339 | ), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.effect("keeps an explicit OAuth session identity label over id_token claims", () => | ||
| 343 | Effect.scoped( | ||
| 344 | Effect.gen(function* () { | ||
| 345 | const server = yield* serveOAuthTestServer({ | ||
| 346 | scopes: ["openid", "email", "profile", "read"], | ||
| 347 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | ||
| 348 | }); | ||
| 349 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 350 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | ||
| 351 | |||
| 352 | yield* executor.oauth.createClient({ | ||
| 353 | owner: "org", | ||
| 354 | slug: CLIENT, | ||
| 355 | authorizationUrl: server.authorizationEndpoint, | ||
| 356 | tokenUrl: server.tokenEndpoint, | ||
| 357 | grant: "authorization_code", | ||
| 358 | clientId: "test-client", | ||
| 359 | clientSecret: "test-secret", | ||
| 360 | }); | ||
| 361 | |||
| 362 | const started = yield* executor.oauth.start({ | ||
| 363 | owner: "org", | ||
| 364 | client: CLIENT, | ||
| 365 | clientOwner: "org", | ||
| 366 | name: ConnectionName.make("main"), | ||
| 367 | integration: INTEG, | ||
| 368 | template: TEMPLATE, | ||
| 369 | identityLabel: "Work account", | ||
| 370 | }); | ||
| 371 | expect(started.status).toBe("redirect"); | ||
| 372 | if (started.status !== "redirect") return; | ||
| 373 | |||
| 374 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 375 | authorizationUrl: started.authorizationUrl, | ||
| 376 | }); | ||
| 377 | const connection = yield* executor.oauth.complete({ | ||
| 378 | state: started.state, | ||
| 379 | code: callback.code, | ||
| 380 | }); | ||
| 381 | |||
| 382 | expect(connection.identityLabel).toBe("Work account"); | ||
| 383 | }), | ||
| 384 | ), | ||
| 385 | ); | ||
| 386 | |||
| 387 | it.effect("does not overwrite connection identity from id_token claims on refresh", () => | ||
| 388 | Effect.scoped( | ||
| 389 | Effect.gen(function* () { | ||
| 390 | const server = yield* serveOAuthTestServer({ | ||
| 391 | scopes: ["openid", "email", "profile", "read"], | ||
| 392 | idTokenClaims: { email: "alice@example.com", sub: "user-1" }, | ||
| 393 | refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" }, | ||
| 394 | }); | ||
| 395 | const harness = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 396 | const { executor, config } = harness; | ||
| 397 | yield* executor.acme.seed(["openid", "email", "profile", "read"]); | ||
| 398 | |||
| 399 | yield* executor.oauth.createClient({ | ||
| 400 | owner: "org", | ||
| 401 | slug: CLIENT, | ||
| 402 | authorizationUrl: server.authorizationEndpoint, | ||
| 403 | tokenUrl: server.tokenEndpoint, | ||
| 404 | grant: "authorization_code", | ||
| 405 | clientId: "test-client", | ||
| 406 | clientSecret: "test-secret", | ||
| 407 | }); | ||
| 408 | |||
| 409 | const started = yield* executor.oauth.start({ | ||
| 410 | owner: "org", | ||
| 411 | client: CLIENT, | ||
| 412 | clientOwner: "org", | ||
| 413 | name: ConnectionName.make("main"), | ||
| 414 | integration: INTEG, | ||
| 415 | template: TEMPLATE, | ||
| 416 | }); | ||
| 417 | expect(started.status).toBe("redirect"); | ||
| 418 | if (started.status !== "redirect") return; | ||
| 419 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 420 | authorizationUrl: started.authorizationUrl, | ||
| 421 | }); | ||
| 422 | yield* executor.oauth.complete({ | ||
| 423 | state: started.state, | ||
| 424 | code: callback.code, | ||
| 425 | }); | ||
| 426 | |||
| 427 | yield* Effect.promise(() => | ||
| 428 | config.db.updateMany("connection", { | ||
| 429 | where: (b) => b("name", "=", "main"), | ||
| 430 | set: { expires_at: Date.now() - 60_000 }, | ||
| 431 | }), | ||
| 432 | ); | ||
| 433 | |||
| 434 | yield* executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {}); | ||
| 435 | const refreshed = yield* executor.connections.get({ | ||
| 436 | owner: "org", | ||
| 437 | integration: INTEG, | ||
| 438 | name: ConnectionName.make("main"), | ||
| 439 | }); | ||
| 440 | expect(refreshed?.identityLabel).toBe("alice@example.com"); | ||
| 441 | }), | ||
| 442 | ), | ||
| 443 | ); | ||
| 444 | |||
| 297 | it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () => | 445 | it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () => |
| 298 | Effect.scoped( | 446 | Effect.scoped( |
| 299 | Effect.gen(function* () { | 447 | Effect.gen(function* () { |
| @@ -702,6 +850,170 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 402 unmodified lines | |||
| 702 | }), | 850 | }), |
| 703 | ), | 851 | ), |
| 704 | ); | 852 | ); |
| 853 | |||
| 854 | it.effect( | ||
| 855 | "checkHealth reports healthy from OAuth credential resolution when no probe is configured", | ||
| 856 | () => | ||
| 857 | Effect.scoped( | ||
| 858 | Effect.gen(function* () { | ||
| 859 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 860 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 861 | yield* executor.acme.seed(); | ||
| 862 | |||
| 863 | yield* executor.oauth.createClient({ | ||
| 864 | owner: "org", | ||
| 865 | slug: CLIENT, | ||
| 866 | authorizationUrl: server.authorizationEndpoint, | ||
| 867 | tokenUrl: server.tokenEndpoint, | ||
| 868 | grant: "authorization_code", | ||
| 869 | clientId: "test-client", | ||
| 870 | clientSecret: "test-secret", | ||
| 871 | resource: server.mcpResourceUrl, | ||
| 872 | }); | ||
| 873 | |||
| 874 | const started = yield* executor.oauth.start({ | ||
| 875 | owner: "org", | ||
| 876 | client: CLIENT, | ||
| 877 | clientOwner: "org", | ||
| 878 | name: ConnectionName.make("main"), | ||
| 879 | integration: INTEG, | ||
| 880 | template: TEMPLATE, | ||
| 881 | }); | ||
| 882 | expect(started.status).toBe("redirect"); | ||
| 883 | if (started.status !== "redirect") return; | ||
| 884 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 885 | authorizationUrl: started.authorizationUrl, | ||
| 886 | }); | ||
| 887 | yield* executor.oauth.complete({ | ||
| 888 | state: started.state, | ||
| 889 | code: callback.code, | ||
| 890 | }); | ||
| 891 | yield* server.clearRequests; | ||
| 892 | |||
| 893 | const result = yield* executor.connections.checkHealth({ | ||
| 894 | owner: "org", | ||
| 895 | integration: INTEG, | ||
| 896 | name: ConnectionName.make("main"), | ||
| 897 | }); | ||
| 898 | |||
| 899 | expect(result).toMatchObject({ | ||
| 900 | status: "healthy", | ||
| 901 | detail: "Credential resolved (no probe configured).", | ||
| 902 | }); | ||
| 903 | expect(result.identity).toBeUndefined(); | ||
| 904 | const requests = yield* server.requests; | ||
| 905 | expect(requests).toEqual([]); | ||
| 906 | }), | ||
| 907 | ), | ||
| 908 | ); | ||
| 909 | |||
| 910 | it.effect("records missing authorization-code scopes without blocking the connection", () => | ||
| 911 | Effect.scoped( | ||
| 912 | Effect.gen(function* () { | ||
| 913 | const server = yield* serveOAuthTestServer({ | ||
| 914 | scopes: ["openid", "email", "profile", "offline_access", "read", "write"], | ||
| 915 | omitTokenResponseScopes: ["email", "profile", "write"], | ||
| 916 | }); | ||
| 917 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 918 | yield* executor.acme.seed([ | ||
| 919 | "openid", | ||
| 920 | "email", | ||
| 921 | "profile", | ||
| 922 | "offline_access", | ||
| 923 | "read", | ||
| 924 | "write", | ||
| 925 | ]); | ||
| 926 | |||
| 927 | yield* executor.oauth.createClient({ | ||
| 928 | owner: "org", | ||
| 929 | slug: CLIENT, | ||
| 930 | authorizationUrl: server.authorizationEndpoint, | ||
| 931 | tokenUrl: server.tokenEndpoint, | ||
| 932 | grant: "authorization_code", | ||
| 933 | clientId: "test-client", | ||
| 934 | clientSecret: "test-secret", | ||
| 935 | resource: server.mcpResourceUrl, | ||
| 936 | }); | ||
| 937 | |||
| 938 | const started = yield* executor.oauth.start({ | ||
| 939 | owner: "org", | ||
| 940 | client: CLIENT, | ||
| 941 | clientOwner: "org", | ||
| 942 | name: ConnectionName.make("main"), | ||
| 943 | integration: INTEG, | ||
| 944 | template: TEMPLATE, | ||
| 945 | }); | ||
| 946 | expect(started.status).toBe("redirect"); | ||
| 947 | if (started.status !== "redirect") return; | ||
| 948 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 949 | authorizationUrl: started.authorizationUrl, | ||
| 950 | }); | ||
| 951 | const connection = yield* executor.oauth.complete({ | ||
| 952 | state: started.state, | ||
| 953 | code: callback.code, | ||
| 954 | }); | ||
| 955 | |||
| 956 | expect(connection.missingOAuthScopes).toEqual(["write"]); | ||
| 957 | const row = yield* Effect.promise(() => | ||
| 958 | config.db.findFirst("connection", { | ||
| 959 | where: (b) => b("name", "=", "main"), | ||
| 960 | }), | ||
| 961 | ); | ||
| 962 | expect(row?.provider_state).toEqual({ missingOAuthScopes: ["write"] }); | ||
| 963 | const listed = yield* executor.connections.list({ integration: INTEG }); | ||
| 964 | expect(listed[0]?.missingOAuthScopes).toEqual(["write"]); | ||
| 965 | }), | ||
| 966 | ), | ||
| 967 | ); | ||
| 968 | |||
| 969 | it.effect( | ||
| 970 | "does not persist missing scopes when the authorization-code grant covers requested scopes", | ||
| 971 | () => | ||
| 972 | Effect.scoped( | ||
| 973 | Effect.gen(function* () { | ||
| 974 | const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); | ||
| 975 | const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 976 | yield* executor.acme.seed(["read", "write"]); | ||
| 977 | |||
| 978 | yield* executor.oauth.createClient({ | ||
| 979 | owner: "org", | ||
| 980 | slug: CLIENT, | ||
| 981 | authorizationUrl: server.authorizationEndpoint, | ||
| 982 | tokenUrl: server.tokenEndpoint, | ||
| 983 | grant: "authorization_code", | ||
| 984 | clientId: "test-client", | ||
| 985 | clientSecret: "test-secret", | ||
| 986 | resource: server.mcpResourceUrl, | ||
| 987 | }); | ||
| 988 | |||
| 989 | const started = yield* executor.oauth.start({ | ||
| 990 | owner: "org", | ||
| 991 | client: CLIENT, | ||
| 992 | clientOwner: "org", | ||
| 993 | name: ConnectionName.make("main"), | ||
| 994 | integration: INTEG, | ||
| 995 | template: TEMPLATE, | ||
| 996 | }); | ||
| 997 | expect(started.status).toBe("redirect"); | ||
| 998 | if (started.status !== "redirect") return; | ||
| 999 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1000 | authorizationUrl: started.authorizationUrl, | ||
| 1001 | }); | ||
| 1002 | const connection = yield* executor.oauth.complete({ | ||
| 1003 | state: started.state, | ||
| 1004 | code: callback.code, | ||
| 1005 | }); | ||
| 1006 | |||
| 1007 | expect(connection.missingOAuthScopes).toEqual([]); | ||
| 1008 | const row = yield* Effect.promise(() => | ||
| 1009 | config.db.findFirst("connection", { | ||
| 1010 | where: (b) => b("name", "=", "main"), | ||
| 1011 | }), | ||
| 1012 | ); | ||
| 1013 | expect(row?.provider_state).toBeNull(); | ||
| 1014 | }), | ||
| 1015 | ), | ||
| 1016 | ); | ||
| 705 | }); | 1017 | }); |
| 706 | 1018 | ||
| 707 | // Multi-site providers (Datadog) statically advertise one region's token | 1019 | // Multi-site providers (Datadog) statically advertise one region's token |
| @@ -866,3 +1178,45 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 158 unmodified lines | |||
| 866 | ), | 1178 | ), |
| 867 | ); | 1179 | ); |
| 868 | }); | 1180 | }); |
| 1181 | |||
| 1182 | describe("missingGrantedOAuthScopes canonicalization", () => { | ||
| 1183 | it("treats Microsoft fully-qualified granted scopes as covering short-form requests", () => { | ||
| 1184 | const missing = missingGrantedOAuthScopes( | ||
| 1185 | [ | ||
| 1186 | "offline_access", | ||
| 1187 | "User.Read", | ||
| 1188 | "https://graph.microsoft.com/.default", | ||
| 1189 | "Mail.ReadWrite", | ||
| 1190 | "Mail.Send", | ||
| 1191 | "MailboxSettings.ReadWrite", | ||
| 1192 | ], | ||
| 1193 | [ | ||
| 1194 | "https://graph.microsoft.com/Mail.ReadWrite", | ||
| 1195 | "https://graph.microsoft.com/Mail.Send", | ||
| 1196 | "https://graph.microsoft.com/MailboxSettings.ReadWrite", | ||
| 1197 | "https://graph.microsoft.com/User.Read", | ||
| 1198 | ].join(" "), | ||
| 1199 | ); | ||
| 1200 | expect(missing).toEqual([]); | ||
| 1201 | }); | ||
| 1202 | |||
| 1203 | it("still reports genuinely ungranted scopes under the qualified Microsoft shape", () => { | ||
| 1204 | const missing = missingGrantedOAuthScopes( | ||
| 1205 | ["User.Read", "Mail.ReadWrite", "Mail.Send"], | ||
| 1206 | "https://graph.microsoft.com/User.Read https://graph.microsoft.com/Mail.ReadWrite", | ||
| 1207 | ); | ||
| 1208 | expect(missing).toEqual(["Mail.Send"]); | ||
| 1209 | }); | ||
| 1210 | |||
| 1211 | it("maps Google userinfo aliases and ignores identity meta-scopes", () => { | ||
| 1212 | const missing = missingGrantedOAuthScopes( | ||
| 1213 | ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"], | ||
| 1214 | [ | ||
| 1215 | "https://www.googleapis.com/auth/userinfo.email", | ||
| 1216 | "https://www.googleapis.com/auth/userinfo.profile", | ||
| 1217 | "https://www.googleapis.com/auth/calendar", | ||
| 1218 | ].join(" "), | ||
| 1219 | ); | ||
| 1220 | expect(missing).toEqual([]); | ||
| 1221 | }); | ||
| 1222 | }); | ||
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | |||
| 19 | createPkceCodeVerifier, | 19 | createPkceCodeVerifier, |
| 20 | exchangeAuthorizationCode, | 20 | exchangeAuthorizationCode, |
| 21 | exchangeClientCredentials, | 21 | exchangeClientCredentials, |
| 22 | idTokenIdentityLabel, | ||
| 22 | refreshAccessToken, | 23 | refreshAccessToken, |
| 23 | shouldRefreshToken, | 24 | shouldRefreshToken, |
| 24 | } from "./oauth-helpers"; | 25 | } from "./oauth-helpers"; |
| @@ -362,6 +363,81 @@ describe("exchangeAuthorizationCode", () => { · 337 unmodified lines | |||
| 362 | }); | 363 | }); |
| 363 | expect(result.access_token).toBe("tok"); | 364 | expect(result.access_token).toBe("tok"); |
| 364 | expect(result.refresh_token).toBe("rtok"); | 365 | expect(result.refresh_token).toBe("rtok"); |
| 366 | expect(result.idTokenIdentityLabel).toBe("user-1"); | ||
| 367 | }), | ||
| 368 | ), | ||
| 369 | ); | ||
| 370 | |||
| 371 | it.effect("extracts id_token email as the identity label", () => | ||
| 372 | withTokenEndpoint( | ||
| 373 | tokenResponse({ | ||
| 374 | ...validCodeBody, | ||
| 375 | id_token: unsignedJwt({ | ||
| 376 | email: "alice@example.com", | ||
| 377 | preferred_username: "alice", | ||
| 378 | sub: "user-1", | ||
| 379 | }), | ||
| 380 | }), | ||
| 381 | ({ tokenUrl }) => | ||
| 382 | Effect.gen(function* () { | ||
| 383 | const result = yield* exchangeAuthorizationCode({ | ||
| 384 | tokenUrl, | ||
| 385 | clientId: "cid", | ||
| 386 | redirectUrl: "https://app.example.com/cb", | ||
| 387 | codeVerifier: "verifier", | ||
| 388 | code: "abc", | ||
| 389 | }); | ||
| 390 | expect(result.idTokenIdentityLabel).toBe("alice@example.com"); | ||
| 391 | }), | ||
| 392 | ), | ||
| 393 | ); | ||
| 394 | |||
| 395 | it.effect("falls back from id_token email to preferred_username then sub", () => | ||
| 396 | withTokenEndpoint( | ||
| 397 | tokenResponse({ | ||
| 398 | ...validCodeBody, | ||
| 399 | id_token: unsignedJwt({ | ||
| 400 | preferred_username: "alice", | ||
| 401 | sub: "user-1", | ||
| 402 | }), | ||
| 403 | }), | ||
| 404 | ({ tokenUrl }) => | ||
| 405 | Effect.gen(function* () { | ||
| 406 | const preferred = yield* exchangeAuthorizationCode({ | ||
| 407 | tokenUrl, | ||
| 408 | clientId: "cid", | ||
| 409 | redirectUrl: "https://app.example.com/cb", | ||
| 410 | codeVerifier: "verifier", | ||
| 411 | code: "abc", | ||
| 412 | }); | ||
| 413 | expect(preferred.idTokenIdentityLabel).toBe("alice"); | ||
| 414 | }), | ||
| 415 | ), | ||
| 416 | ); | ||
| 417 | |||
| 418 | it("falls back to sub and ignores malformed id_tokens", () => { | ||
| 419 | expect(idTokenIdentityLabel(unsignedJwt({ sub: "user-1" }))).toBe("user-1"); | ||
| 420 | expect(idTokenIdentityLabel("not-a-jwt")).toBeUndefined(); | ||
| 421 | expect(idTokenIdentityLabel(undefined)).toBeUndefined(); | ||
| 422 | }); | ||
| 423 | |||
| 424 | it.effect("ignores malformed id_tokens without failing the exchange", () => | ||
| 425 | withTokenEndpoint( | ||
| 426 | tokenResponse({ | ||
| 427 | ...validCodeBody, | ||
| 428 | id_token: "not-a-jwt", | ||
| 429 | }), | ||
| 430 | ({ tokenUrl }) => | ||
| 431 | Effect.gen(function* () { | ||
| 432 | const result = yield* exchangeAuthorizationCode({ | ||
| 433 | tokenUrl, | ||
| 434 | clientId: "cid", | ||
| 435 | redirectUrl: "https://app.example.com/cb", | ||
| 436 | codeVerifier: "verifier", | ||
| 437 | code: "abc", | ||
| 438 | }); | ||
| 439 | expect(result.access_token).toBe("tok"); | ||
| 440 | expect(result.idTokenIdentityLabel).toBeUndefined(); | ||
| 365 | }), | 441 | }), |
| 366 | ), | 442 | ), |
| 367 | ); | 443 | ); |
| @@ -406,6 +482,7 @@ describe("exchangeAuthorizationCode", () => { · 38 unmodified lines | |||
| 406 | expect(result.access_token).toBe("tok"); | 482 | expect(result.access_token).toBe("tok"); |
| 407 | expect(result.refresh_token).toBe("rtok"); | 483 | expect(result.refresh_token).toBe("rtok"); |
| 408 | expect(result.expires_in).toBe(3600); | 484 | expect(result.expires_in).toBe(3600); |
| 485 | expect(result.idTokenIdentityLabel).toBeUndefined(); | ||
| 409 | }), | 486 | }), |
| 410 | ), | 487 | ), |
| 411 | ); | 488 | ); |
| @@ -16,7 +16,7 @@ · 15 unmodified lines | |||
| 16 | // construction keeps the call sync and lets callers opt out of PAR | 16 | // construction keeps the call sync and lets callers opt out of PAR |
| 17 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | ||
| 19 | import { Data, Effect, Predicate } from "effect"; | 19 | import { Data, Effect, Option, Predicate, Schema } from "effect"; |
| 20 | import * as oauth from "oauth4webapi"; | 20 | import * as oauth from "oauth4webapi"; |
| 21 | 21 | ||
| 22 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| @@ -45,6 +45,7 @@ export type OAuth2TokenResponse = { · 22 unmodified lines | |||
| 45 | readonly refresh_token?: string; | 45 | readonly refresh_token?: string; |
| 46 | readonly expires_in?: number; | 46 | readonly expires_in?: number; |
| 47 | readonly scope?: string; | 47 | readonly scope?: string; |
| 48 | readonly idTokenIdentityLabel?: string; | ||
| 48 | }; | 49 | }; |
| 49 | 50 | ||
| 50 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| @@ -461,12 +462,56 @@ const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse · 410 unmodified lines | |||
| 461 | scope: r.scope, | 462 | scope: r.scope, |
| 462 | }); | 463 | }); |
| 463 | 464 | ||
| 464 | // MCP source connections are pure OAuth 2.0 — we never request `openid` and | 465 | const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); |
| 465 | // never consume `id_token`. Some providers (PostHog, etc.) front an OIDC | 466 | const decodeJwtClaims = Schema.decodeUnknownOption(Schema.fromJsonString(JwtClaims)); |
| 466 | // backend and emit an `id_token` anyway; oauth4webapi then strict-validates | 467 | |
| 467 | // its claims against the AS metadata and rejects mismatches we don't care | 468 | const stringClaim = ( |
| 468 | // about. Strip the field before delegation. | 469 | claims: Readonly<Record<string, unknown>>, |
| 469 | const stripIdToken = async (response: Response): Promise<Response> => { | 470 | key: string, |
| 471 | ): string | undefined => { | ||
| 472 | const value = claims[key]; | ||
| 473 | if (typeof value !== "string") return undefined; | ||
| 474 | const trimmed = value.trim(); | ||
| 475 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 476 | }; | ||
| 477 | |||
| 478 | const decodeJwtPayload = (token: string): Readonly<Record<string, unknown>> | null => { | ||
| 479 | const payload = token.split(".")[1]; | ||
| 480 | if (!payload) return null; | ||
| 481 | if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) return null; | ||
| 482 | const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); | ||
| 483 | const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); | ||
| 484 | // atob yields latin1 code units; JWT payloads are UTF-8 bytes, so re-decode | ||
| 485 | // them properly or non-ASCII claim values (accented emails, names) garble. | ||
| 486 | const utf8 = new TextDecoder().decode( | ||
| 487 | Uint8Array.from(globalThis.atob(padded), (char) => char.charCodeAt(0)), | ||
| 488 | ); | ||
| 489 | const decoded = decodeJwtClaims(utf8); | ||
| 490 | return Option.isSome(decoded) ? decoded.value : null; | ||
| 491 | }; | ||
| 492 | |||
| 493 | export const idTokenIdentityLabel = (idToken: string | undefined): string | undefined => { | ||
| 494 | if (!idToken) return undefined; | ||
| 495 | const claims = decodeJwtPayload(idToken); | ||
| 496 | if (!claims) return undefined; | ||
| 497 | return ( | ||
| 498 | stringClaim(claims, "email") ?? | ||
| 499 | stringClaim(claims, "preferred_username") ?? | ||
| 500 | stringClaim(claims, "sub") | ||
| 501 | ); | ||
| 502 | }; | ||
| 503 | |||
| 504 | type StrippedTokenResponse = { | ||
| 505 | readonly response: Response; | ||
| 506 | readonly idTokenIdentityLabel?: string; | ||
| 507 | }; | ||
| 508 | |||
| 509 | // MCP source connections are pure OAuth 2.0. Some providers (PostHog, etc.) | ||
| 510 | // front an OIDC backend and emit an `id_token` anyway; oauth4webapi then | ||
| 511 | // strict-validates its claims against the AS metadata and rejects mismatches we | ||
| 512 | // don't care about. Strip the field before delegation, after extracting the | ||
| 513 | // optional display label when the token endpoint returned OIDC account claims. | ||
| 514 | const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> => { | ||
| 470 | const body = await response | 515 | const body = await response |
| 471 | .clone() | 516 | .clone() |
| 472 | .json() | 517 | .json() |
| @@ -475,24 +520,33 @@ const stripIdToken = async (response: Response): Promise<Response> => { · 2 unmodified lines | |||
| 475 | () => null, | 520 | () => null, |
| 476 | ); | 521 | ); |
| 477 | if (!body || typeof body !== "object" || !("id_token" in (body as Record<string, unknown>))) { | 522 | if (!body || typeof body !== "object" || !("id_token" in (body as Record<string, unknown>))) { |
| 478 | return response; | 523 | return { response }; |
| 479 | } | 524 | } |
| 480 | const { id_token: _ignored, ...rest } = body as Record<string, unknown>; | 525 | const { id_token: idToken, ...rest } = body as Record<string, unknown>; |
| 481 | return new Response(JSON.stringify(rest), { | 526 | const label = typeof idToken === "string" ? idTokenIdentityLabel(idToken) : undefined; |
| 482 | status: response.status, | 527 | return { |
| 483 | statusText: response.statusText, | 528 | response: new Response(JSON.stringify(rest), { |
| 484 | headers: response.headers, | 529 | status: response.status, |
| 485 | }); | 530 | statusText: response.statusText, |
| 531 | headers: response.headers, | ||
| 532 | }), | ||
| 533 | ...(label ? { idTokenIdentityLabel: label } : {}), | ||
| 534 | }; | ||
| 486 | }; | 535 | }; |
| 487 | 536 | ||
| 488 | const processTokenEndpointResponse = async ( | 537 | const processTokenEndpointResponse = async ( |
| 489 | as: oauth.AuthorizationServer, | 538 | as: oauth.AuthorizationServer, |
| 490 | client: oauth.Client, | 539 | client: oauth.Client, |
| 491 | response: Response, | 540 | response: Response, |
| 492 | ): Promise<OAuth2TokenResponse> => | 541 | ): Promise<OAuth2TokenResponse> => { |
| 493 | tokenResponseFrom( | 542 | const stripped = await stripIdToken(response); |
| 494 | await oauth.processGenericTokenEndpointResponse(as, client, await stripIdToken(response)), | 543 | const token = tokenResponseFrom( |
| 544 | await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), | ||
| 495 | ); | 545 | ); |
| 546 | return stripped.idTokenIdentityLabel | ||
| 547 | ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel } | ||
| 548 | : token; | ||
| 549 | }; | ||
| 496 | 550 | ||
| 497 | // --------------------------------------------------------------------------- | 551 | // --------------------------------------------------------------------------- |
| 498 | // Exchange authorization code → tokens | 552 | // Exchange authorization code → tokens |
| @@ -681,7 +735,7 @@ export const refreshAccessToken = ( · 182 unmodified lines | |||
| 681 | const result = await oauth.processRefreshTokenResponse( | 735 | const result = await oauth.processRefreshTokenResponse( |
| 682 | as, | 736 | as, |
| 683 | client, | 737 | client, |
| 684 | await stripIdToken(response), | 738 | (await stripIdToken(response)).response, |
| 685 | ); | 739 | ); |
| 686 | return tokenResponseFrom(result); | 740 | return tokenResponseFrom(result); |
| 687 | }, | 741 | }, |
| @@ -91,6 +91,7 @@ export interface MintOAuthConnectionInput { · 90 unmodified lines | |||
| 91 | readonly refreshItemId: string | null; | 91 | readonly refreshItemId: string | null; |
| 92 | readonly expiresAt: number | null; | 92 | readonly expiresAt: number | null; |
| 93 | readonly oauthScope: string | null; | 93 | readonly oauthScope: string | null; |
| 94 | readonly missingOAuthScopes?: readonly string[]; | ||
| 94 | /** Per-connection override for the token endpoint, persisted only when the | 95 | /** Per-connection override for the token endpoint, persisted only when the |
| 95 | * code was redeemed at a region other than the client's configured token | 96 | * code was redeemed at a region other than the client's configured token |
| 96 | * host (Datadog multi-site). Null means refresh uses the client's token URL. */ | 97 | * host (Datadog multi-site). Null means refresh uses the client's token URL. */ |
| @@ -203,6 +204,52 @@ const recordedOAuthScope = ( · 106 unmodified lines | |||
| 203 | return recorded.join(" ") || null; | 204 | return recorded.join(" ") || null; |
| 204 | }; | 205 | }; |
| 205 | 206 | ||
| 207 | const OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = { | ||
| 208 | "https://www.googleapis.com/auth/userinfo.email": "email", | ||
| 209 | "https://www.googleapis.com/auth/userinfo.profile": "profile", | ||
| 210 | }; | ||
| 211 | |||
| 212 | const informationalOAuthScopes = new Set(["openid", "email", "profile", "offline_access"]); | ||
| 213 | |||
| 214 | /** Canonicalize a scope for granted-vs-requested comparison. Microsoft's token | ||
| 215 | * endpoint returns Graph scopes fully qualified | ||
| 216 | * (`https://graph.microsoft.com/Mail.ReadWrite`) even when the request used | ||
| 217 | * the short form, so resource-URI prefixes are stripped down to the scope's | ||
| 218 | * final path segment before comparing. */ | ||
| 219 | const canonicalOAuthScope = (scope: string): string => { | ||
| 220 | const aliased = OAUTH_SCOPE_ALIASES[scope]; | ||
| 221 | if (aliased) return aliased; | ||
| 222 | if (/^https?:\/\/graph\.microsoft\.(com|us|de)\//i.test(scope)) { | ||
| 223 | return scope.slice(scope.lastIndexOf("/") + 1); | ||
| 224 | } | ||
| 225 | return scope; | ||
| 226 | }; | ||
| 227 | |||
| 228 | /** `.default` is a request-time meta-scope (Microsoft expands it server-side | ||
| 229 | * and never echoes it in the granted scope), so it can never be "missing". */ | ||
| 230 | const isMetaOAuthScope = (scope: string): boolean => scope.toLowerCase().endsWith("/.default"); | ||
| 231 | |||
| 232 | const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> => | ||
| 233 | new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean)); | ||
| 234 | |||
| 235 | export const missingGrantedOAuthScopes = ( | ||
| 236 | requestedScopes: readonly string[], | ||
| 237 | recordedScope: string | null, | ||
| 238 | ): readonly string[] => { | ||
| 239 | const granted = normalizedOAuthScopeSet(recordedScope?.split(/\s+/).filter(Boolean) ?? []); | ||
| 240 | const seen = new Set<string>(); | ||
| 241 | const out: string[] = []; | ||
| 242 | for (const raw of requestedScopes) { | ||
| 243 | const trimmed = raw.trim(); | ||
| 244 | if (isMetaOAuthScope(trimmed)) continue; | ||
| 245 | const scope = canonicalOAuthScope(trimmed); | ||
| 246 | if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue; | ||
| 247 | seen.add(scope); | ||
| 248 | if (!granted.has(scope)) out.push(scope); | ||
| 249 | } | ||
| 250 | return out; | ||
| 251 | }; | ||
| 252 | |||
| 206 | const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); | 253 | const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); |
| 207 | 254 | ||
| 208 | /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The | 255 | /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The |
| @@ -1209,7 +1256,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1000 unmodified lines | |||
| 1209 | name: session.name, | 1256 | name: session.name, |
| 1210 | integration: session.integration, | 1257 | integration: session.integration, |
| 1211 | template: session.template, | 1258 | template: session.template, |
| 1212 | identityLabel: session.identityLabel, | 1259 | identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null, |
| 1213 | }, | 1260 | }, |
| 1214 | client, | 1261 | client, |
| 1215 | token, | 1262 | token, |
| @@ -1278,6 +1325,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 62 unmodified lines | |||
| 1278 | yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); | 1325 | yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); |
| 1279 | } | 1326 | } |
| 1280 | 1327 | ||
| 1328 | const oauthScope = recordedOAuthScope(token, requestedScopes); | ||
| 1281 | return yield* deps.mintOAuthConnection({ | 1329 | return yield* deps.mintOAuthConnection({ |
| 1282 | owner: target.owner, | 1330 | owner: target.owner, |
| 1283 | name: target.name, | 1331 | name: target.name, |
| @@ -1294,7 +1342,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines | |||
| 1294 | // Microsoft, issue a refresh token for `offline_access` but omit that | 1342 | // Microsoft, issue a refresh token for `offline_access` but omit that |
| 1295 | // non-resource scope from the token `scope` string, so preserve it when | 1343 | // non-resource scope from the token `scope` string, so preserve it when |
| 1296 | // the refresh token proves it was granted. | 1344 | // the refresh token proves it was granted. |
| 1297 | oauthScope: recordedOAuthScope(token, requestedScopes), | 1345 | oauthScope, |
| 1346 | missingOAuthScopes: | ||
| 1347 | client.grant === "authorization_code" | ||
| 1348 | ? missingGrantedOAuthScopes(requestedScopes, oauthScope) | ||
| 1349 | : [], | ||
| 1298 | oauthTokenUrl, | 1350 | oauthTokenUrl, |
| 1299 | }); | 1351 | }); |
| 1300 | }); | 1352 | }); |
| @@ -505,12 +505,37 @@ export interface IntegrationPreset { · 504 unmodified lines | |||
| 505 | readonly endpoint?: string; | 505 | readonly endpoint?: string; |
| 506 | readonly icon?: string; | 506 | readonly icon?: string; |
| 507 | readonly featured?: boolean; | 507 | readonly featured?: boolean; |
| 508 | readonly family?: string; | ||
| 509 | readonly specFormat?: string; | ||
| 510 | readonly defaultSlug?: string; | ||
| 511 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | ||
| 512 | readonly healthCheck?: HealthCheckSpec; | ||
| 508 | readonly transport?: "remote" | "stdio"; | 513 | readonly transport?: "remote" | "stdio"; |
| 509 | readonly command?: string; | 514 | readonly command?: string; |
| 510 | readonly args?: readonly string[]; | 515 | readonly args?: readonly string[]; |
| 511 | readonly env?: Readonly<Record<string, string>>; | 516 | readonly env?: Readonly<Record<string, string>>; |
| 512 | } | 517 | } |
| 513 | 518 | ||
| 519 | export type IntegrationPresetAuthentication = | ||
| 520 | | { | ||
| 521 | readonly slug: string; | ||
| 522 | readonly kind: "oauth2"; | ||
| 523 | readonly authorizationUrl: string; | ||
| 524 | readonly tokenUrl: string; | ||
| 525 | readonly resource?: string | null; | ||
| 526 | readonly scopes: readonly string[]; | ||
| 527 | readonly supportsClientIdMetadataDocument?: boolean; | ||
| 528 | } | ||
| 529 | | { | ||
| 530 | readonly kind: "apiKey"; | ||
| 531 | readonly slug?: string; | ||
| 532 | readonly name?: string; | ||
| 533 | readonly placements?: readonly unknown[]; | ||
| 534 | readonly headers?: Readonly<Record<string, readonly unknown[]>>; | ||
| 535 | readonly queryParams?: Readonly<Record<string, readonly unknown[]>>; | ||
| 536 | readonly cookies?: Readonly<Record<string, readonly unknown[]>>; | ||
| 537 | }; | ||
| 538 | |||
| 514 | export interface IntegrationPresetCatalogEntry extends IntegrationPreset { | 539 | export interface IntegrationPresetCatalogEntry extends IntegrationPreset { |
| 515 | readonly pluginId: string; | 540 | readonly pluginId: string; |
| 516 | } | 541 | } |
| @@ -55,6 +55,8 @@ export interface OAuthTestServerOptions { · 54 unmodified lines | |||
| 55 | readonly supportRefresh?: boolean; | 55 | readonly supportRefresh?: boolean; |
| 56 | readonly tokenExpiresInSeconds?: number; | 56 | readonly tokenExpiresInSeconds?: number; |
| 57 | readonly invalidRefreshTokenDescription?: string; | 57 | readonly invalidRefreshTokenDescription?: string; |
| 58 | readonly idTokenClaims?: Readonly<Record<string, unknown>>; | ||
| 59 | readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>; | ||
| 58 | /** Gate Dynamic Client Registration on the requested redirect URIs. When set, | 60 | /** Gate Dynamic Client Registration on the requested redirect URIs. When set, |
| 59 | * `/register` returns `400 invalid_redirect_uri` unless every requested | 61 | * `/register` returns `400 invalid_redirect_uri` unless every requested |
| 60 | * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. | 62 | * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. |
| @@ -127,6 +129,7 @@ const TokenResponse = Schema.Struct({ · 66 unmodified lines | |||
| 127 | token_type: Schema.String, | 129 | token_type: Schema.String, |
| 128 | expires_in: Schema.optional(Schema.Number), | 130 | expires_in: Schema.optional(Schema.Number), |
| 129 | scope: Schema.optional(Schema.String), | 131 | scope: Schema.optional(Schema.String), |
| 132 | id_token: Schema.optional(Schema.String), | ||
| 130 | }); | 133 | }); |
| 131 | const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); | 134 | const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); |
| 132 | 135 | ||
| @@ -179,6 +182,12 @@ const decodeBasicAuthorization = ( · 46 unmodified lines | |||
| 179 | const codeChallengeForVerifier = (verifier: string): string => | 182 | const codeChallengeForVerifier = (verifier: string): string => |
| 180 | createHash("sha256").update(verifier).digest("base64url"); | 183 | createHash("sha256").update(verifier).digest("base64url"); |
| 181 | 184 | ||
| 185 | const jwtPart = (value: unknown): string => | ||
| 186 | Buffer.from(JSON.stringify(value)).toString("base64url"); | ||
| 187 | |||
| 188 | const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string => | ||
| 189 | `${jwtPart({ alg: "RS256", typ: "JWT" })}.${jwtPart(claims)}.sig`; | ||
| 190 | |||
| 182 | const oauthError = (status: number, error: string, errorDescription: string) => | 191 | const oauthError = (status: number, error: string, errorDescription: string) => |
| 183 | jsonResponse( | 192 | jsonResponse( |
| 184 | status, | 193 | status, |
| @@ -652,6 +661,7 @@ export const serveOAuthTestServer = ( · 467 unmodified lines | |||
| 652 | token_type: "Bearer", | 661 | token_type: "Bearer", |
| 653 | expires_in: tokenExpiresInSeconds, | 662 | expires_in: tokenExpiresInSeconds, |
| 654 | ...(scope ? { scope } : {}), | 663 | ...(scope ? { scope } : {}), |
| 664 | ...(options.idTokenClaims ? { id_token: unsignedJwt(options.idTokenClaims) } : {}), | ||
| 655 | }, | 665 | }, |
| 656 | { "cache-control": "no-store" }, | 666 | { "cache-control": "no-store" }, |
| 657 | ); | 667 | ); |
| @@ -680,6 +690,9 @@ export const serveOAuthTestServer = ( · 22 unmodified lines | |||
| 680 | token_type: "Bearer", | 690 | token_type: "Bearer", |
| 681 | expires_in: tokenExpiresInSeconds, | 691 | expires_in: tokenExpiresInSeconds, |
| 682 | ...(scope ? { scope } : {}), | 692 | ...(scope ? { scope } : {}), |
| 693 | ...(options.refreshIdTokenClaims | ||
| 694 | ? { id_token: unsignedJwt(options.refreshIdTokenClaims) } | ||
| 695 | : {}), | ||
| 683 | }, | 696 | }, |
| 684 | { "cache-control": "no-store" }, | 697 | { "cache-control": "no-store" }, |
| 685 | ); | 698 | ); |
| @@ -1,143 +0,0 @@ | |||
| 1 | # @executor-js/plugin-google | ||
| 2 | |||
| 3 | ## 1.5.28 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.5.29 | ||
| 9 | - @executor-js/plugin-openapi@1.5.29 | ||
| 10 | - @executor-js/api@1.4.49 | ||
| 11 | - @executor-js/react@1.4.49 | ||
| 12 | |||
| 13 | ## 1.5.27 | ||
| 14 | |||
| 15 | ### Patch Changes | ||
| 16 | |||
| 17 | - Updated dependencies [[`1c48182`](https://github.com/UsefulSoftwareCo/executor/commit/1c4818254e71dc4ee27ff95f489e2c5cf330a450)]: | ||
| 18 | - @executor-js/sdk@1.5.28 | ||
| 19 | - @executor-js/api@1.4.48 | ||
| 20 | - @executor-js/plugin-openapi@1.5.28 | ||
| 21 | - @executor-js/react@1.4.48 | ||
| 22 | |||
| 23 | ## 1.5.26 | ||
| 24 | |||
| 25 | ### Patch Changes | ||
| 26 | |||
| 27 | - Updated dependencies [[`c7ab1e2`](https://github.com/RhysSullivan/executor/commit/c7ab1e2d56884e0453af85f6399fd25a39f04785)]: | ||
| 28 | - @executor-js/api@1.4.47 | ||
| 29 | - @executor-js/plugin-openapi@1.5.27 | ||
| 30 | - @executor-js/react@1.4.47 | ||
| 31 | - @executor-js/sdk@1.5.27 | ||
| 32 | |||
| 33 | ## 1.5.25 | ||
| 34 | |||
| 35 | ### Patch Changes | ||
| 36 | |||
| 37 | - Updated dependencies []: | ||
| 38 | - @executor-js/sdk@1.5.26 | ||
| 39 | - @executor-js/plugin-openapi@1.5.26 | ||
| 40 | - @executor-js/api@1.4.46 | ||
| 41 | - @executor-js/react@1.4.46 | ||
| 42 | |||
| 43 | ## 1.5.24 | ||
| 44 | |||
| 45 | ### Patch Changes | ||
| 46 | |||
| 47 | - Updated dependencies [[`dc9bf71`](https://github.com/RhysSullivan/executor/commit/dc9bf717b81a3b719a137b25d01a8fd28e6cd699)]: | ||
| 48 | - @executor-js/plugin-openapi@1.5.25 | ||
| 49 | - @executor-js/sdk@1.5.25 | ||
| 50 | - @executor-js/api@1.4.45 | ||
| 51 | - @executor-js/react@1.4.45 | ||
| 52 | |||
| 53 | ## 1.5.23 | ||
| 54 | |||
| 55 | ### Patch Changes | ||
| 56 | |||
| 57 | - Updated dependencies []: | ||
| 58 | - @executor-js/sdk@1.5.24 | ||
| 59 | - @executor-js/plugin-openapi@1.5.24 | ||
| 60 | - @executor-js/api@1.4.44 | ||
| 61 | - @executor-js/react@1.4.44 | ||
| 62 | |||
| 63 | ## 1.5.22 | ||
| 64 | |||
| 65 | ### Patch Changes | ||
| 66 | |||
| 67 | - Updated dependencies [[`29936d5`](https://github.com/RhysSullivan/executor/commit/29936d5981256f8f953797d9ce8ce073ac6a0b6a), [`29936d5`](https://github.com/RhysSullivan/executor/commit/29936d5981256f8f953797d9ce8ce073ac6a0b6a)]: | ||
| 68 | - @executor-js/api@1.4.43 | ||
| 69 | - @executor-js/react@1.4.43 | ||
| 70 | - @executor-js/plugin-openapi@1.5.23 | ||
| 71 | - @executor-js/sdk@1.5.23 | ||
| 72 | |||
| 73 | ## 1.5.21 | ||
| 74 | |||
| 75 | ### Patch Changes | ||
| 76 | |||
| 77 | - [#1137](https://github.com/RhysSullivan/executor/pull/1137) [`1a1f9aa`](https://github.com/RhysSullivan/executor/commit/1a1f9aaae4e4d0f73311fd643919cdfaa637c124) Thanks [@zrm625](https://github.com/zrm625)! - Add a Google Photos preset with raw upload support and binary-safe `bodyBase64` handling. | ||
| 78 | |||
| 79 | - Updated dependencies [[`1a1f9aa`](https://github.com/RhysSullivan/executor/commit/1a1f9aaae4e4d0f73311fd643919cdfaa637c124)]: | ||
| 80 | - @executor-js/plugin-openapi@1.5.22 | ||
| 81 | - @executor-js/sdk@1.5.22 | ||
| 82 | - @executor-js/api@1.4.42 | ||
| 83 | - @executor-js/react@1.4.42 | ||
| 84 | |||
| 85 | ## 1.5.20 | ||
| 86 | |||
| 87 | ### Patch Changes | ||
| 88 | |||
| 89 | - Updated dependencies [[`4b361b9`](https://github.com/RhysSullivan/executor/commit/4b361b9f7220f679f582137f5375b29c3b72f919)]: | ||
| 90 | - @executor-js/plugin-openapi@1.5.21 | ||
| 91 | - @executor-js/sdk@1.5.21 | ||
| 92 | - @executor-js/api@1.4.41 | ||
| 93 | - @executor-js/react@1.4.41 | ||
| 94 | |||
| 95 | ## 1.5.19 | ||
| 96 | |||
| 97 | ### Patch Changes | ||
| 98 | |||
| 99 | - Updated dependencies []: | ||
| 100 | - @executor-js/sdk@1.5.20 | ||
| 101 | - @executor-js/plugin-openapi@1.5.20 | ||
| 102 | - @executor-js/api@1.4.40 | ||
| 103 | - @executor-js/react@1.4.40 | ||
| 104 | |||
| 105 | ## 1.5.18 | ||
| 106 | |||
| 107 | ### Patch Changes | ||
| 108 | |||
| 109 | - Updated dependencies []: | ||
| 110 | - @executor-js/sdk@1.5.19 | ||
| 111 | - @executor-js/plugin-openapi@1.5.19 | ||
| 112 | - @executor-js/api@1.4.39 | ||
| 113 | - @executor-js/react@1.4.39 | ||
| 114 | |||
| 115 | ## 1.5.17 | ||
| 116 | |||
| 117 | ### Patch Changes | ||
| 118 | |||
| 119 | - Updated dependencies []: | ||
| 120 | - @executor-js/sdk@1.5.18 | ||
| 121 | - @executor-js/plugin-openapi@1.5.18 | ||
| 122 | - @executor-js/api@1.4.38 | ||
| 123 | - @executor-js/react@1.4.38 | ||
| 124 | |||
| 125 | ## 1.5.16 | ||
| 126 | |||
| 127 | ### Patch Changes | ||
| 128 | |||
| 129 | - Updated dependencies []: | ||
| 130 | - @executor-js/sdk@1.5.17 | ||
| 131 | - @executor-js/plugin-openapi@1.5.17 | ||
| 132 | - @executor-js/api@1.4.37 | ||
| 133 | - @executor-js/react@1.4.37 | ||
| 134 | |||
| 135 | ## 1.5.15 | ||
| 136 | |||
| 137 | ### Patch Changes | ||
| 138 | |||
| 139 | - Updated dependencies []: | ||
| 140 | - @executor-js/sdk@1.5.16 | ||
| 141 | - @executor-js/plugin-openapi@1.5.16 | ||
| 142 | - @executor-js/api@1.4.36 | ||
| 143 | - @executor-js/react@1.4.36 | ||
| @@ -1,98 +0,0 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor-js/plugin-google", | ||
| 3 | "version": "1.5.28", | ||
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/google", | ||
| 5 | "bugs": { | ||
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | ||
| 7 | }, | ||
| 8 | "license": "MIT", | ||
| 9 | "repository": { | ||
| 10 | "type": "git", | ||
| 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | ||
| 12 | "directory": "packages/plugins/google" | ||
| 13 | }, | ||
| 14 | "files": [ | ||
| 15 | "dist" | ||
| 16 | ], | ||
| 17 | "type": "module", | ||
| 18 | "exports": { | ||
| 19 | ".": "./src/sdk/index.ts", | ||
| 20 | "./promise": "./src/promise.ts", | ||
| 21 | "./api": "./src/api/index.ts", | ||
| 22 | "./react": "./src/react/index.ts", | ||
| 23 | "./client": "./src/react/plugin-client.tsx" | ||
| 24 | }, | ||
| 25 | "publishConfig": { | ||
| 26 | "access": "public", | ||
| 27 | "exports": { | ||
| 28 | ".": { | ||
| 29 | "import": { | ||
| 30 | "types": "./dist/promise.d.ts", | ||
| 31 | "default": "./dist/index.js" | ||
| 32 | } | ||
| 33 | }, | ||
| 34 | "./core": { | ||
| 35 | "import": { | ||
| 36 | "types": "./dist/sdk/index.d.ts", | ||
| 37 | "default": "./dist/core.js" | ||
| 38 | } | ||
| 39 | }, | ||
| 40 | "./client": { | ||
| 41 | "import": { | ||
| 42 | "types": "./dist/react/plugin-client.d.ts", | ||
| 43 | "default": "./dist/client.js" | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | }, | ||
| 48 | "scripts": { | ||
| 49 | "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", | ||
| 50 | "typecheck": "tsgo --noEmit", | ||
| 51 | "test": "vitest run", | ||
| 52 | "test:watch": "vitest", | ||
| 53 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | ||
| 54 | }, | ||
| 55 | "dependencies": { | ||
| 56 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 57 | "@executor-js/sdk": "workspace:*", | ||
| 58 | "lucide-react": "^1.7.0" | ||
| 59 | }, | ||
| 60 | "devDependencies": { | ||
| 61 | "@effect/atom-react": "catalog:", | ||
| 62 | "@effect/vitest": "catalog:", | ||
| 63 | "@executor-js/api": "workspace:*", | ||
| 64 | "@executor-js/react": "workspace:*", | ||
| 65 | "@types/node": "catalog:", | ||
| 66 | "@types/react": "catalog:", | ||
| 67 | "bun-types": "catalog:", | ||
| 68 | "effect": "catalog:", | ||
| 69 | "react": "catalog:", | ||
| 70 | "tsup": "catalog:", | ||
| 71 | "vitest": "catalog:" | ||
| 72 | }, | ||
| 73 | "peerDependencies": { | ||
| 74 | "@effect/atom-react": "catalog:", | ||
| 75 | "@executor-js/api": "workspace:*", | ||
| 76 | "@executor-js/react": "workspace:*", | ||
| 77 | "@tanstack/react-router": "catalog:", | ||
| 78 | "effect": "catalog:", | ||
| 79 | "react": "catalog:" | ||
| 80 | }, | ||
| 81 | "peerDependenciesMeta": { | ||
| 82 | "react": { | ||
| 83 | "optional": true | ||
| 84 | }, | ||
| 85 | "@effect/atom-react": { | ||
| 86 | "optional": true | ||
| 87 | }, | ||
| 88 | "@tanstack/react-router": { | ||
| 89 | "optional": true | ||
| 90 | }, | ||
| 91 | "@executor-js/api": { | ||
| 92 | "optional": true | ||
| 93 | }, | ||
| 94 | "@executor-js/react": { | ||
| 95 | "optional": true | ||
| 96 | } | ||
| 97 | } | ||
| 98 | } | ||
| @@ -1,142 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 4 | import { | ||
| 5 | InternalError, | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationNotFoundError, | ||
| 8 | IntegrationSlug, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 10 | import { | ||
| 11 | OpenApiExtractionError, | ||
| 12 | OpenApiOAuthError, | ||
| 13 | OpenApiParseError, | ||
| 14 | } from "@executor-js/plugin-openapi"; | ||
| 15 | |||
| 16 | const DomainErrors = [ | ||
| 17 | InternalError, | ||
| 18 | OpenApiParseError, | ||
| 19 | OpenApiExtractionError, | ||
| 20 | OpenApiOAuthError, | ||
| 21 | IntegrationAlreadyExistsError, | ||
| 22 | ] as const; | ||
| 23 | |||
| 24 | const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | ||
| 25 | |||
| 26 | const UpdateErrors = [ | ||
| 27 | InternalError, | ||
| 28 | OpenApiParseError, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | OpenApiOAuthError, | ||
| 31 | IntegrationNotFound, | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | const SlugParams = { | ||
| 35 | slug: Schema.String, | ||
| 36 | }; | ||
| 37 | |||
| 38 | const OAuthTemplatePayload = Schema.Struct({ | ||
| 39 | slug: Schema.String, | ||
| 40 | kind: Schema.Literal("oauth2"), | ||
| 41 | authorizationUrl: Schema.String, | ||
| 42 | tokenUrl: Schema.String, | ||
| 43 | scopes: Schema.Array(Schema.String), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); | ||
| 47 | const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); | ||
| 48 | |||
| 49 | const AddBundlePayload = Schema.Struct({ | ||
| 50 | urls: Schema.Array(Schema.String), | ||
| 51 | slug: Schema.optional(Schema.String), | ||
| 52 | name: Schema.optional(Schema.String), | ||
| 53 | description: Schema.optional(Schema.String), | ||
| 54 | baseUrl: Schema.optional(Schema.String), | ||
| 55 | }); | ||
| 56 | |||
| 57 | const AddBundleResponse = Schema.Struct({ | ||
| 58 | slug: IntegrationSlug, | ||
| 59 | toolCount: Schema.Number, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const UpdateBundlePayload = Schema.Struct({ | ||
| 63 | urls: Schema.optional(Schema.Array(Schema.String)), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const UpdateBundleResponse = Schema.Struct({ | ||
| 67 | slug: IntegrationSlug, | ||
| 68 | toolCount: Schema.Number, | ||
| 69 | addedTools: Schema.Array(Schema.String), | ||
| 70 | removedTools: Schema.Array(Schema.String), | ||
| 71 | }); | ||
| 72 | |||
| 73 | const IntegrationView = Schema.Struct({ | ||
| 74 | slug: IntegrationSlug, | ||
| 75 | description: Schema.String, | ||
| 76 | kind: Schema.String, | ||
| 77 | canRemove: Schema.Boolean, | ||
| 78 | canRefresh: Schema.Boolean, | ||
| 79 | }); | ||
| 80 | |||
| 81 | const GoogleConfigView = Schema.Struct({ | ||
| 82 | googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), | ||
| 83 | baseUrl: Schema.optional(Schema.String), | ||
| 84 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 85 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 86 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), | ||
| 87 | }); | ||
| 88 | |||
| 89 | const ConfigurePayload = Schema.Struct({ | ||
| 90 | authenticationTemplate: Schema.Array(AuthenticationPayload), | ||
| 91 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const ConfigureResponse = Schema.Struct({ | ||
| 95 | authenticationTemplate: Schema.Array(AuthenticationResponse), | ||
| 96 | }); | ||
| 97 | |||
| 98 | export const GoogleGroup = HttpApiGroup.make("google") | ||
| 99 | .add( | ||
| 100 | HttpApiEndpoint.post("addBundle", "/google/bundles", { | ||
| 101 | payload: AddBundlePayload, | ||
| 102 | success: AddBundleResponse, | ||
| 103 | error: DomainErrors, | ||
| 104 | }), | ||
| 105 | ) | ||
| 106 | .add( | ||
| 107 | HttpApiEndpoint.get("getIntegration", "/google/integrations/:slug", { | ||
| 108 | params: SlugParams, | ||
| 109 | success: Schema.NullOr(IntegrationView), | ||
| 110 | error: DomainErrors, | ||
| 111 | }), | ||
| 112 | ) | ||
| 113 | .add( | ||
| 114 | HttpApiEndpoint.get("getConfig", "/google/integrations/:slug/config", { | ||
| 115 | params: SlugParams, | ||
| 116 | success: Schema.NullOr(GoogleConfigView), | ||
| 117 | error: DomainErrors, | ||
| 118 | }), | ||
| 119 | ) | ||
| 120 | .add( | ||
| 121 | HttpApiEndpoint.post("configure", "/google/integrations/:slug/config", { | ||
| 122 | params: SlugParams, | ||
| 123 | payload: ConfigurePayload, | ||
| 124 | success: ConfigureResponse, | ||
| 125 | error: DomainErrors, | ||
| 126 | }), | ||
| 127 | ) | ||
| 128 | .add( | ||
| 129 | HttpApiEndpoint.post("updateBundle", "/google/integrations/:slug/bundle", { | ||
| 130 | params: SlugParams, | ||
| 131 | payload: UpdateBundlePayload, | ||
| 132 | success: UpdateBundleResponse, | ||
| 133 | error: UpdateErrors, | ||
| 134 | }), | ||
| 135 | ) | ||
| 136 | .add( | ||
| 137 | HttpApiEndpoint.delete("removeBundle", "/google/integrations/:slug", { | ||
| 138 | params: SlugParams, | ||
| 139 | success: Schema.Void, | ||
| 140 | error: DomainErrors, | ||
| 141 | }), | ||
| 142 | ); | ||
| @@ -1,105 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { GooglePluginExtension } from "../sdk/plugin"; | ||
| 6 | import { GoogleGroup } from "./group"; | ||
| 7 | |||
| 8 | export class GoogleExtensionService extends Context.Service< | ||
| 9 | GoogleExtensionService, | ||
| 10 | GooglePluginExtension | ||
| 11 | >()("GoogleExtensionService") {} | ||
| 12 | |||
| 13 | const ExecutorApiWithGoogle = addGroup(GoogleGroup); | ||
| 14 | |||
| 15 | export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) => | ||
| 16 | handlers | ||
| 17 | .handle("addBundle", ({ payload }) => | ||
| 18 | capture( | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const ext = yield* GoogleExtensionService; | ||
| 21 | return yield* ext.addBundle({ | ||
| 22 | urls: payload.urls, | ||
| 23 | slug: payload.slug, | ||
| 24 | name: payload.name, | ||
| 25 | description: payload.description, | ||
| 26 | baseUrl: payload.baseUrl, | ||
| 27 | }); | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | ) | ||
| 31 | .handle("getIntegration", ({ params }) => | ||
| 32 | capture( | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const ext = yield* GoogleExtensionService; | ||
| 35 | const integration = yield* ext.getIntegration(params.slug); | ||
| 36 | return integration | ||
| 37 | ? { | ||
| 38 | slug: integration.slug, | ||
| 39 | description: integration.description, | ||
| 40 | kind: integration.kind, | ||
| 41 | canRemove: integration.canRemove, | ||
| 42 | canRefresh: integration.canRefresh, | ||
| 43 | } | ||
| 44 | : null; | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ) | ||
| 48 | .handle("getConfig", ({ params }) => | ||
| 49 | capture( | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const ext = yield* GoogleExtensionService; | ||
| 52 | const config = yield* ext.getConfig(params.slug); | ||
| 53 | return config | ||
| 54 | ? { | ||
| 55 | googleDiscoveryUrls: config.googleDiscoveryUrls | ||
| 56 | ? [...config.googleDiscoveryUrls] | ||
| 57 | : undefined, | ||
| 58 | baseUrl: config.baseUrl, | ||
| 59 | headers: config.headers ? { ...config.headers } : undefined, | ||
| 60 | queryParams: config.queryParams ? { ...config.queryParams } : undefined, | ||
| 61 | authenticationTemplate: config.authenticationTemplate | ||
| 62 | ? [...config.authenticationTemplate] | ||
| 63 | : undefined, | ||
| 64 | } | ||
| 65 | : null; | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ) | ||
| 69 | .handle("configure", ({ params, payload }) => | ||
| 70 | capture( | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const ext = yield* GoogleExtensionService; | ||
| 73 | const authenticationTemplate = yield* ext.configure(params.slug, { | ||
| 74 | authenticationTemplate: payload.authenticationTemplate, | ||
| 75 | mode: payload.mode ?? "merge", | ||
| 76 | }); | ||
| 77 | return { authenticationTemplate: [...authenticationTemplate] }; | ||
| 78 | }), | ||
| 79 | ), | ||
| 80 | ) | ||
| 81 | .handle("updateBundle", ({ params, payload }) => | ||
| 82 | capture( | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const ext = yield* GoogleExtensionService; | ||
| 85 | const result = yield* ext.updateBundle(params.slug, { | ||
| 86 | ...(payload.urls !== undefined ? { urls: payload.urls } : {}), | ||
| 87 | }); | ||
| 88 | return { | ||
| 89 | slug: result.slug, | ||
| 90 | toolCount: result.toolCount, | ||
| 91 | addedTools: [...result.addedTools], | ||
| 92 | removedTools: [...result.removedTools], | ||
| 93 | }; | ||
| 94 | }), | ||
| 95 | ), | ||
| 96 | ) | ||
| 97 | .handle("removeBundle", ({ params }) => | ||
| 98 | capture( | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const ext = yield* GoogleExtensionService; | ||
| 101 | yield* ext.removeBundle(params.slug); | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ), | ||
| 105 | ); | ||
| @@ -1,15 +0,0 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 3 | import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin"; | ||
| 4 | import { GoogleGroup } from "./group"; | ||
| 5 | import { GoogleHandlers, GoogleExtensionService } from "./handlers"; | ||
| 6 | |||
| 7 | export { GoogleGroup } from "./group"; | ||
| 8 | export { GoogleHandlers, GoogleExtensionService } from "./handlers"; | ||
| 9 | |||
| 10 | export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({ | ||
| 11 | ...googlePlugin(options), | ||
| 12 | routes: () => GoogleGroup, | ||
| 13 | handlers: () => GoogleHandlers, | ||
| 14 | extensionService: GoogleExtensionService, | ||
| 15 | })); | ||
| @@ -1 +0,0 @@ | |||
| 1 | export { googlePlugin } from "./sdk/plugin"; | ||
| @@ -1,178 +0,0 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | ||
| 2 | import { useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | |||
| 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { | ||
| 7 | slugifyNamespace, | ||
| 8 | useIntegrationIdentity, | ||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | ||
| 12 | import { | ||
| 13 | addIntegrationErrorMessage, | ||
| 14 | FormErrorAlert, | ||
| 15 | SlugCollisionAlert, | ||
| 16 | useSlugAlreadyExists, | ||
| 17 | } from "@executor-js/react/lib/integration-add"; | ||
| 18 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | ||
| 19 | |||
| 20 | import { addGoogleBundle } from "./atoms"; | ||
| 21 | import { GoogleProductPicker } from "./GoogleProductPicker"; | ||
| 22 | import { | ||
| 23 | GOOGLE_PHOTOS_PRESET_ID, | ||
| 24 | googleOpenApiPresets, | ||
| 25 | googlePhotosPresetIds, | ||
| 26 | type GoogleOpenApiPreset, | ||
| 27 | } from "../sdk/presets"; | ||
| 28 | |||
| 29 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | ||
| 30 | |||
| 31 | const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( | ||
| 32 | googleOpenApiPresets | ||
| 33 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 34 | .map((preset: GoogleOpenApiPreset) => preset.id), | ||
| 35 | ); | ||
| 36 | |||
| 37 | const googleBundleUrls = ( | ||
| 38 | selectedPresetIds: ReadonlySet<string>, | ||
| 39 | customUrls: readonly string[], | ||
| 40 | ): readonly string[] => { | ||
| 41 | const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => | ||
| 42 | preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], | ||
| 43 | ); | ||
| 44 | return [...new Set([...fromPresets, ...customUrls])]; | ||
| 45 | }; | ||
| 46 | |||
| 47 | export default function AddGoogleSource(props: { | ||
| 48 | onComplete: (slug?: string) => void; | ||
| 49 | onCancel: () => void; | ||
| 50 | initialPreset?: string; | ||
| 51 | initialNamespace?: string; | ||
| 52 | }) { | ||
| 53 | const isGooglePhotosPreset = props.initialPreset === GOOGLE_PHOTOS_PRESET_ID; | ||
| 54 | const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>( | ||
| 55 | isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds, | ||
| 56 | ); | ||
| 57 | const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); | ||
| 58 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 59 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | ||
| 60 | const [adding, setAdding] = useState(false); | ||
| 61 | const [addError, setAddError] = useState<string | null>(null); | ||
| 62 | |||
| 63 | const identity = useIntegrationIdentity({ | ||
| 64 | fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google", | ||
| 65 | fallbackNamespace: | ||
| 66 | props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"), | ||
| 67 | }); | ||
| 68 | |||
| 69 | const bundleDiscoveryUrls = useMemo( | ||
| 70 | () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), | ||
| 71 | [selectedPresetIds, customDiscoveryUrls], | ||
| 72 | ); | ||
| 73 | |||
| 74 | const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { | ||
| 75 | setSelectedPresetIds((current: ReadonlySet<string>) => { | ||
| 76 | const next = new Set(current); | ||
| 77 | if (checked) next.add(presetId); | ||
| 78 | else next.delete(presetId); | ||
| 79 | return next; | ||
| 80 | }); | ||
| 81 | }, []); | ||
| 82 | |||
| 83 | const addCustomDiscoveryUrl = useCallback((url: string) => { | ||
| 84 | setCustomDiscoveryUrls((current: readonly string[]) => | ||
| 85 | current.includes(url) ? current : [...current, url], | ||
| 86 | ); | ||
| 87 | }, []); | ||
| 88 | |||
| 89 | const removeCustomDiscoveryUrl = useCallback((url: string) => { | ||
| 90 | setCustomDiscoveryUrls((current: readonly string[]) => | ||
| 91 | current.filter((entry: string) => entry !== url), | ||
| 92 | ); | ||
| 93 | }, []); | ||
| 94 | |||
| 95 | const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); | ||
| 96 | |||
| 97 | const resolvedSourceId = | ||
| 98 | slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google"); | ||
| 99 | const resolvedDisplayName = | ||
| 100 | identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google"); | ||
| 101 | const resolvedDescription = | ||
| 102 | descriptionDraft ?? | ||
| 103 | (isGooglePhotosPreset | ||
| 104 | ? "Google Photos albums, uploads, app-created media, and selected picker media." | ||
| 105 | : "Google APIs"); | ||
| 106 | const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | ||
| 107 | const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists; | ||
| 108 | |||
| 109 | const handleAdd = async () => { | ||
| 110 | setAdding(true); | ||
| 111 | setAddError(null); | ||
| 112 | const exit = await doAdd({ | ||
| 113 | payload: { | ||
| 114 | urls: [...bundleDiscoveryUrls], | ||
| 115 | slug: resolvedSourceId, | ||
| 116 | name: resolvedDisplayName, | ||
| 117 | ...(resolvedDescription.trim().length > 0 | ||
| 118 | ? { description: resolvedDescription.trim() } | ||
| 119 | : {}), | ||
| 120 | ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), | ||
| 121 | }, | ||
| 122 | reactivityKeys: integrationWriteKeys, | ||
| 123 | }); | ||
| 124 | if (Exit.isFailure(exit)) { | ||
| 125 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); | ||
| 126 | setAdding(false); | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | props.onComplete(String(exit.value.slug)); | ||
| 130 | }; | ||
| 131 | |||
| 132 | return ( | ||
| 133 | <div className="flex flex-1 flex-col gap-6"> | ||
| 134 | <div> | ||
| 135 | <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1> | ||
| 136 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 137 | Bundle Google APIs into one integration with a shared OAuth consent. | ||
| 138 | </p> | ||
| 139 | </div> | ||
| 140 | |||
| 141 | <GoogleProductPicker | ||
| 142 | selectedPresetIds={selectedPresetIds} | ||
| 143 | onToggle={toggleBundlePreset} | ||
| 144 | customUrls={customDiscoveryUrls} | ||
| 145 | onAddCustomUrl={addCustomDiscoveryUrl} | ||
| 146 | onRemoveCustomUrl={removeCustomDiscoveryUrl} | ||
| 147 | /> | ||
| 148 | |||
| 149 | <OpenApiSourceDetailsFields | ||
| 150 | title="Google" | ||
| 151 | subtitle={`${bundleDiscoveryUrls.length} Google API${ | ||
| 152 | bundleDiscoveryUrls.length !== 1 ? "s" : "" | ||
| 153 | } · one shared OAuth consent`} | ||
| 154 | identity={identity} | ||
| 155 | description={resolvedDescription} | ||
| 156 | onDescriptionChange={setDescriptionDraft} | ||
| 157 | baseUrl={baseUrl} | ||
| 158 | onBaseUrlChange={setBaseUrl} | ||
| 159 | baseUrlLabel="Base URL override (optional)" | ||
| 160 | faviconIcon={GOOGLE_BUNDLE_FAVICON} | ||
| 161 | faviconUrl={baseUrl} | ||
| 162 | /> | ||
| 163 | |||
| 164 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | ||
| 165 | |||
| 166 | {addError && <FormErrorAlert message={addError} />} | ||
| 167 | |||
| 168 | <FloatActions> | ||
| 169 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | ||
| 170 | Cancel | ||
| 171 | </Button> | ||
| 172 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | ||
| 173 | Connect Google | ||
| 174 | </Button> | ||
| 175 | </FloatActions> | ||
| 176 | </div> | ||
| 177 | ); | ||
| 178 | } | ||
| @@ -1,148 +0,0 @@ | |||
| 1 | import { useCallback, useMemo } from "react"; | ||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 6 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | ||
| 7 | import { TriangleAlert } from "lucide-react"; | ||
| 8 | |||
| 9 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | ||
| 10 | import { | ||
| 11 | HealthCheckEditor, | ||
| 12 | type HealthCheckLivePreview, | ||
| 13 | } from "@executor-js/react/components/health-check-editor"; | ||
| 14 | import { useOrganizationId } from "@executor-js/react/api/organization-context"; | ||
| 15 | import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner"; | ||
| 16 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | ||
| 17 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 18 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 19 | import { | ||
| 20 | useCustomMethodActions, | ||
| 21 | type AuthMethodsCodec, | ||
| 22 | type ConfigureAuthMethods, | ||
| 23 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 24 | import { | ||
| 25 | authMethodsFromConfig, | ||
| 26 | openApiWireAuthInput, | ||
| 27 | templateFromPlacements, | ||
| 28 | } from "@executor-js/plugin-openapi/react"; | ||
| 29 | import type { Authentication } from "@executor-js/plugin-openapi"; | ||
| 30 | |||
| 31 | import { googleConfigAtom, googleConfigure } from "./atoms"; | ||
| 32 | import { googleAudienceWarningsForUrls } from "../sdk/presets"; | ||
| 33 | |||
| 34 | const GOOGLE_AUDIENCE_WARNING: Readonly<Record<string, string>> = { | ||
| 35 | "workspace-admin": | ||
| 36 | "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.", | ||
| 37 | "unsupported-user": | ||
| 38 | "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.", | ||
| 39 | }; | ||
| 40 | |||
| 41 | const NO_AUTH_METHOD: AuthMethod = { | ||
| 42 | id: "none", | ||
| 43 | label: "No authentication", | ||
| 44 | kind: "none", | ||
| 45 | source: "spec", | ||
| 46 | template: AuthTemplateSlug.make("none"), | ||
| 47 | placements: [], | ||
| 48 | }; | ||
| 49 | |||
| 50 | export default function GoogleAccountsPanel(props: { | ||
| 51 | readonly sourceId: string; | ||
| 52 | readonly integrationName: string; | ||
| 53 | readonly accountHandoff?: IntegrationAccountHandoff | null; | ||
| 54 | }) { | ||
| 55 | const { sourceId, integrationName, accountHandoff } = props; | ||
| 56 | const slug = IntegrationSlug.make(sourceId); | ||
| 57 | const configResult = useAtomValue(googleConfigAtom(slug)); | ||
| 58 | const doConfigure = useAtomSet(googleConfigure, { mode: "promiseExit" }); | ||
| 59 | |||
| 60 | const existingTemplate = useMemo<readonly Authentication[]>(() => { | ||
| 61 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 62 | return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[]; | ||
| 63 | }, [configResult]); | ||
| 64 | |||
| 65 | const methods = useMemo<readonly AuthMethod[]>(() => { | ||
| 66 | const declared = authMethodsFromConfig(existingTemplate); | ||
| 67 | return declared.length > 0 ? declared : [NO_AUTH_METHOD]; | ||
| 68 | }, [existingTemplate]); | ||
| 69 | |||
| 70 | const configure = useCallback<ConfigureAuthMethods<Authentication>>( | ||
| 71 | async (input) => { | ||
| 72 | const exit = await doConfigure({ | ||
| 73 | params: { slug }, | ||
| 74 | payload: { | ||
| 75 | authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), | ||
| 76 | ...(input.mode ? { mode: input.mode } : {}), | ||
| 77 | }, | ||
| 78 | reactivityKeys: integrationWriteKeys, | ||
| 79 | }); | ||
| 80 | return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); | ||
| 81 | }, | ||
| 82 | [doConfigure, slug], | ||
| 83 | ); | ||
| 84 | |||
| 85 | const codec = useMemo<AuthMethodsCodec<Authentication>>( | ||
| 86 | () => ({ | ||
| 87 | toAuthMethods: authMethodsFromConfig, | ||
| 88 | templatesFromPlacements: (placements: readonly Placement[]) => [ | ||
| 89 | templateFromPlacements(placements), | ||
| 90 | ], | ||
| 91 | slugOf: (template: Authentication) => String(template.slug), | ||
| 92 | }), | ||
| 93 | [], | ||
| 94 | ); | ||
| 95 | |||
| 96 | const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | ||
| 97 | existing: existingTemplate, | ||
| 98 | codec, | ||
| 99 | configure, | ||
| 100 | }); | ||
| 101 | |||
| 102 | const audienceWarnings = useMemo<readonly string[]>(() => { | ||
| 103 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 104 | const urls = configResult.value.googleDiscoveryUrls ?? []; | ||
| 105 | return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => { | ||
| 106 | const message = GOOGLE_AUDIENCE_WARNING[audience]; | ||
| 107 | return message ? [message] : []; | ||
| 108 | }); | ||
| 109 | }, [configResult]); | ||
| 110 | |||
| 111 | // Live-preview context for the health-check edit sheet (apiKey methods only; | ||
| 112 | // OAuth methods can't be previewed with a pasted key). | ||
| 113 | const organizationId = useOrganizationId(); | ||
| 114 | const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => { | ||
| 115 | const templates = methods | ||
| 116 | .filter((m) => m.kind === "apikey") | ||
| 117 | .map((m) => ({ template: m.template, label: m.label })); | ||
| 118 | if (templates.length === 0) return undefined; | ||
| 119 | return { owner: defaultConnectionOwnerForHost(organizationId), templates }; | ||
| 120 | }, [methods, organizationId]); | ||
| 121 | |||
| 122 | return ( | ||
| 123 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | ||
| 124 | {audienceWarnings.length > 0 && ( | ||
| 125 | <Alert variant="destructive"> | ||
| 126 | <TriangleAlert /> | ||
| 127 | <AlertTitle>Some Google APIs need special consent</AlertTitle> | ||
| 128 | <AlertDescription> | ||
| 129 | {audienceWarnings.map((message: string) => ( | ||
| 130 | <p key={message}>{message}</p> | ||
| 131 | ))} | ||
| 132 | </AlertDescription> | ||
| 133 | </Alert> | ||
| 134 | )} | ||
| 135 | <AccountsSection | ||
| 136 | integration={slug} | ||
| 137 | integrationName={integrationName} | ||
| 138 | methods={methods} | ||
| 139 | accountHandoff={accountHandoff} | ||
| 140 | createCustomMethod={createCustomMethod} | ||
| 141 | removeCustomMethod={removeCustomMethod} | ||
| 142 | /> | ||
| 143 | {/* Google auto-configures the People identity probe at add time; this is | ||
| 144 | where an admin sees, adjusts, or clears it. */} | ||
| 145 | <HealthCheckEditor integration={slug} livePreview={livePreview} /> | ||
| 146 | </div> | ||
| 147 | ); | ||
| 148 | } | ||
| @@ -1,363 +0,0 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 5 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 8 | import { | ||
| 9 | Collapsible, | ||
| 10 | CollapsibleContent, | ||
| 11 | CollapsibleTrigger, | ||
| 12 | } from "@executor-js/react/components/collapsible"; | ||
| 13 | import { FieldLabel } from "@executor-js/react/components/field"; | ||
| 14 | import { Input } from "@executor-js/react/components/input"; | ||
| 15 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | googleOAuthConsentScopesForPreset, | ||
| 19 | googleOpenApiPresets, | ||
| 20 | type GoogleOpenApiOAuthAudience, | ||
| 21 | type GoogleOpenApiPreset, | ||
| 22 | } from "../sdk/presets"; | ||
| 23 | import { googleOAuthConsentBatches } from "../sdk/oauth-batches"; | ||
| 24 | import { isGoogleDiscoveryUrl } from "../sdk/discovery"; | ||
| 25 | |||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | // GoogleProductPicker - the "customize your Google connection" surface. | ||
| 28 | // | ||
| 29 | // A checkable card grid over `googleOpenApiPresets`, grouped/annotated by | ||
| 30 | // `oauthAudience`. The user picks which Google APIs to bundle into the single | ||
| 31 | // `google` integration; the parent turns the selected discovery URLs into a | ||
| 32 | // `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews | ||
| 33 | // the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting, | ||
| 34 | // and a custom-URL escape hatch lets advanced users paste any Google Discovery | ||
| 35 | // document the preset list doesn't cover. | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | // Audience groups, ordered from least- to most-privileged. The warning tiers | ||
| 39 | // (`workspace-admin`, `unsupported-user`) carry a caution chip so the user sees | ||
| 40 | // the consent risk before selecting. | ||
| 41 | const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [ | ||
| 42 | "standard-user", | ||
| 43 | "advanced-user", | ||
| 44 | "workspace-admin", | ||
| 45 | "unsupported-user", | ||
| 46 | ]; | ||
| 47 | |||
| 48 | const AUDIENCE_LABEL: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = { | ||
| 49 | "standard-user": "Core Google services", | ||
| 50 | "advanced-user": "Advanced services", | ||
| 51 | "workspace-admin": "Workspace admin", | ||
| 52 | "unsupported-user": "Limited user consent", | ||
| 53 | }; | ||
| 54 | |||
| 55 | const AUDIENCE_DESCRIPTION: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = { | ||
| 56 | "standard-user": "Connect with a normal Google account - one consent screen.", | ||
| 57 | "advanced-user": "Broader scopes that may need an unverified-app warning to be accepted.", | ||
| 58 | "workspace-admin": "Requires a Google Workspace admin account; not available on personal Gmail.", | ||
| 59 | "unsupported-user": "Google does not grant these scopes through standard user OAuth consent.", | ||
| 60 | }; | ||
| 61 | |||
| 62 | const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean => | ||
| 63 | audience === "workspace-admin" || audience === "unsupported-user"; | ||
| 64 | |||
| 65 | type GoogleProductPickerProps = { | ||
| 66 | readonly selectedPresetIds: ReadonlySet<string>; | ||
| 67 | readonly onToggle: (presetId: string, checked: boolean) => void; | ||
| 68 | readonly customUrls: readonly string[]; | ||
| 69 | readonly onAddCustomUrl: (url: string) => void; | ||
| 70 | readonly onRemoveCustomUrl: (url: string) => void; | ||
| 71 | }; | ||
| 72 | |||
| 73 | const AudienceWarningChip = ({ audience }: { audience: GoogleOpenApiOAuthAudience }) => | ||
| 74 | audience === "workspace-admin" ? ( | ||
| 75 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> | ||
| 76 | <TriangleAlert className="size-3" /> | ||
| 77 | Admin only | ||
| 78 | </Badge> | ||
| 79 | ) : audience === "unsupported-user" ? ( | ||
| 80 | <Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive"> | ||
| 81 | <TriangleAlert className="size-3" /> | ||
| 82 | Limited consent | ||
| 83 | </Badge> | ||
| 84 | ) : null; | ||
| 85 | |||
| 86 | // A Google API row - borderless and single-line, leaning on hover/selected | ||
| 87 | // fills instead of per-item card chrome (lightest separation that still reads). | ||
| 88 | // Name + truncated summary share one baseline for a dense, scannable two-column | ||
| 89 | // list; the audience warning chip trails on the right. | ||
| 90 | const ProductRow = ({ | ||
| 91 | preset, | ||
| 92 | checked, | ||
| 93 | onToggle, | ||
| 94 | }: { | ||
| 95 | readonly preset: GoogleOpenApiPreset; | ||
| 96 | readonly checked: boolean; | ||
| 97 | readonly onToggle: (checked: boolean) => void; | ||
| 98 | }) => ( | ||
| 99 | <FieldLabel | ||
| 100 | className={cn( | ||
| 101 | // `w-full` overrides FieldLabel's base `w-fit` (which would size the row to | ||
| 102 | // its content and overflow the column); `min-w-0` then lets the cell shrink | ||
| 103 | // to its track so the name/summary truncates instead of spilling over. | ||
| 104 | "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors", | ||
| 105 | checked ? "bg-primary/5" : "hover:bg-muted/40", | ||
| 106 | )} | ||
| 107 | > | ||
| 108 | <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} /> | ||
| 109 | <div className="shrink-0"> | ||
| 110 | <IntegrationFavicon icon={preset.icon} url={preset.url} size={16} /> | ||
| 111 | </div> | ||
| 112 | {/* One truncating line - the name + summary clip to the cell with an | ||
| 113 | ellipsis instead of overflowing into the neighbouring column. */} | ||
| 114 | <div className="min-w-0 flex-1 truncate text-sm"> | ||
| 115 | <span className="font-medium text-foreground">{preset.name}</span>{" "} | ||
| 116 | <span className="text-[11px] text-muted-foreground">{preset.summary}</span> | ||
| 117 | </div> | ||
| 118 | <AudienceWarningChip audience={preset.oauthAudience} /> | ||
| 119 | </FieldLabel> | ||
| 120 | ); | ||
| 121 | |||
| 122 | const CustomUrlEscapeHatch = ({ | ||
| 123 | customUrls, | ||
| 124 | onAddCustomUrl, | ||
| 125 | onRemoveCustomUrl, | ||
| 126 | }: { | ||
| 127 | readonly customUrls: readonly string[]; | ||
| 128 | readonly onAddCustomUrl: (url: string) => void; | ||
| 129 | readonly onRemoveCustomUrl: (url: string) => void; | ||
| 130 | }) => { | ||
| 131 | const [draft, setDraft] = useState(""); | ||
| 132 | const trimmed = draft.trim(); | ||
| 133 | const isValid = isGoogleDiscoveryUrl(trimmed); | ||
| 134 | const isDuplicate = customUrls.includes(trimmed); | ||
| 135 | |||
| 136 | const commit = () => { | ||
| 137 | if (!isValid || isDuplicate) return; | ||
| 138 | onAddCustomUrl(trimmed); | ||
| 139 | setDraft(""); | ||
| 140 | }; | ||
| 141 | |||
| 142 | return ( | ||
| 143 | <div className="space-y-2"> | ||
| 144 | <FieldLabel className="text-[11px] font-medium text-muted-foreground"> | ||
| 145 | Add a custom Google Discovery URL | ||
| 146 | </FieldLabel> | ||
| 147 | <div className="flex items-center gap-2"> | ||
| 148 | <Input | ||
| 149 | value={draft} | ||
| 150 | onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)} | ||
| 151 | onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { | ||
| 152 | if (event.key === "Enter") { | ||
| 153 | event.preventDefault(); | ||
| 154 | commit(); | ||
| 155 | } | ||
| 156 | }} | ||
| 157 | placeholder="https://www.googleapis.com/discovery/v1/apis/<service>/<version>/rest" | ||
| 158 | className="font-mono text-[11px]" | ||
| 159 | /> | ||
| 160 | <Button | ||
| 161 | type="button" | ||
| 162 | variant="outline" | ||
| 163 | size="sm" | ||
| 164 | disabled={!isValid || isDuplicate} | ||
| 165 | onClick={commit} | ||
| 166 | > | ||
| 167 | <PlusIcon className="size-3.5" /> | ||
| 168 | Add | ||
| 169 | </Button> | ||
| 170 | </div> | ||
| 171 | {trimmed.length > 0 && !isValid ? ( | ||
| 172 | <p className="text-[11px] text-destructive"> | ||
| 173 | Enter a Google Discovery document URL (a *.googleapis.com discovery/$discovery endpoint). | ||
| 174 | </p> | ||
| 175 | ) : null} | ||
| 176 | {customUrls.length > 0 ? ( | ||
| 177 | <ul className="space-y-1"> | ||
| 178 | {customUrls.map((url: string) => ( | ||
| 179 | <li | ||
| 180 | key={url} | ||
| 181 | className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5" | ||
| 182 | > | ||
| 183 | <span className="truncate font-mono text-[11px] text-foreground">{url}</span> | ||
| 184 | <Button | ||
| 185 | type="button" | ||
| 186 | variant="ghost" | ||
| 187 | size="icon" | ||
| 188 | className="size-6 shrink-0" | ||
| 189 | onClick={() => onRemoveCustomUrl(url)} | ||
| 190 | aria-label={`Remove ${url}`} | ||
| 191 | > | ||
| 192 | <XIcon className="size-3.5" /> | ||
| 193 | </Button> | ||
| 194 | </li> | ||
| 195 | ))} | ||
| 196 | </ul> | ||
| 197 | ) : null} | ||
| 198 | </div> | ||
| 199 | ); | ||
| 200 | }; | ||
| 201 | |||
| 202 | export function GoogleProductPicker({ | ||
| 203 | selectedPresetIds, | ||
| 204 | onToggle, | ||
| 205 | customUrls, | ||
| 206 | onAddCustomUrl, | ||
| 207 | onRemoveCustomUrl, | ||
| 208 | }: GoogleProductPickerProps) { | ||
| 209 | const [scopesOpen, setScopesOpen] = useState(false); | ||
| 210 | |||
| 211 | const groups = useMemo( | ||
| 212 | () => | ||
| 213 | AUDIENCE_ORDER.flatMap((audience: GoogleOpenApiOAuthAudience) => { | ||
| 214 | const presets = googleOpenApiPresets.filter( | ||
| 215 | (preset: GoogleOpenApiPreset) => preset.oauthAudience === audience, | ||
| 216 | ); | ||
| 217 | return presets.length > 0 ? [{ audience, presets }] : []; | ||
| 218 | }), | ||
| 219 | [], | ||
| 220 | ); | ||
| 221 | |||
| 222 | // The "View scopes" preview unions the selected presets' representative | ||
| 223 | // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned | ||
| 224 | // scopes the bundle converter ultimately stores on the integration. | ||
| 225 | const consentBatches = useMemo( | ||
| 226 | () => | ||
| 227 | googleOAuthConsentBatches( | ||
| 228 | googleOpenApiPresets | ||
| 229 | .filter((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id)) | ||
| 230 | .map((preset: GoogleOpenApiPreset) => ({ | ||
| 231 | id: preset.id, | ||
| 232 | name: preset.name, | ||
| 233 | oauthAudience: preset.oauthAudience, | ||
| 234 | scopes: googleOAuthConsentScopesForPreset(preset.id), | ||
| 235 | })), | ||
| 236 | ), | ||
| 237 | [selectedPresetIds], | ||
| 238 | ); | ||
| 239 | |||
| 240 | const selectedCount = selectedPresetIds.size + customUrls.length; | ||
| 241 | |||
| 242 | return ( | ||
| 243 | <section className="space-y-4"> | ||
| 244 | <div className="space-y-1"> | ||
| 245 | <FieldLabel>Customize your Google connection</FieldLabel> | ||
| 246 | <p className="text-[11px] text-muted-foreground"> | ||
| 247 | Pick the Google APIs to bundle into one connection. They share a single OAuth consent and | ||
| 248 | appear as merged tools under one Google integration. | ||
| 249 | </p> | ||
| 250 | </div> | ||
| 251 | |||
| 252 | {groups.map( | ||
| 253 | ({ | ||
| 254 | audience, | ||
| 255 | presets, | ||
| 256 | }: { | ||
| 257 | readonly audience: GoogleOpenApiOAuthAudience; | ||
| 258 | readonly presets: readonly GoogleOpenApiPreset[]; | ||
| 259 | }) => ( | ||
| 260 | <div key={audience} className="space-y-2"> | ||
| 261 | <div className="flex items-center gap-2"> | ||
| 262 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 263 | {AUDIENCE_LABEL[audience]} | ||
| 264 | </span> | ||
| 265 | {audienceNeedsWarning(audience) ? <AudienceWarningChip audience={audience} /> : null} | ||
| 266 | <Button | ||
| 267 | type="button" | ||
| 268 | variant="ghost" | ||
| 269 | size="sm" | ||
| 270 | className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground" | ||
| 271 | onClick={() => { | ||
| 272 | const allSelected = presets.every((preset: GoogleOpenApiPreset) => | ||
| 273 | selectedPresetIds.has(preset.id), | ||
| 274 | ); | ||
| 275 | presets.forEach((preset: GoogleOpenApiPreset) => | ||
| 276 | onToggle(preset.id, !allSelected), | ||
| 277 | ); | ||
| 278 | }} | ||
| 279 | > | ||
| 280 | {presets.every((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id)) | ||
| 281 | ? "Clear" | ||
| 282 | : "Select all"} | ||
| 283 | </Button> | ||
| 284 | </div> | ||
| 285 | <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p> | ||
| 286 | <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2"> | ||
| 287 | {presets.map((preset: GoogleOpenApiPreset) => ( | ||
| 288 | <ProductRow | ||
| 289 | key={preset.id} | ||
| 290 | preset={preset} | ||
| 291 | checked={selectedPresetIds.has(preset.id)} | ||
| 292 | onToggle={(checked: boolean) => onToggle(preset.id, checked)} | ||
| 293 | /> | ||
| 294 | ))} | ||
| 295 | </div> | ||
| 296 | </div> | ||
| 297 | ), | ||
| 298 | )} | ||
| 299 | |||
| 300 | <CustomUrlEscapeHatch | ||
| 301 | customUrls={customUrls} | ||
| 302 | onAddCustomUrl={onAddCustomUrl} | ||
| 303 | onRemoveCustomUrl={onRemoveCustomUrl} | ||
| 304 | /> | ||
| 305 | |||
| 306 | <div className="space-y-1.5 rounded-lg border border-border bg-muted/10 px-3 py-2.5"> | ||
| 307 | <div className="flex items-center gap-2"> | ||
| 308 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 309 | Authentication | ||
| 310 | </span> | ||
| 311 | <Badge variant="secondary">OAuth</Badge> | ||
| 312 | </div> | ||
| 313 | <p className="text-[11px] text-muted-foreground"> | ||
| 314 | The selected Google APIs share one OAuth consent. Review the scopes below, then connect a | ||
| 315 | Google account from the integration page after adding. | ||
| 316 | </p> | ||
| 317 | </div> | ||
| 318 | |||
| 319 | <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}> | ||
| 320 | <CollapsibleTrigger asChild> | ||
| 321 | <Button type="button" variant="outline" size="sm" disabled={consentBatches.length === 0}> | ||
| 322 | <ChevronDownIcon | ||
| 323 | className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")} | ||
| 324 | /> | ||
| 325 | View scopes | ||
| 326 | {selectedCount > 0 ? ( | ||
| 327 | <Badge variant="secondary" className="ml-1"> | ||
| 328 | {selectedCount} | ||
| 329 | </Badge> | ||
| 330 | ) : null} | ||
| 331 | </Button> | ||
| 332 | </CollapsibleTrigger> | ||
| 333 | <CollapsibleContent className="pt-3"> | ||
| 334 | {consentBatches.length === 0 ? ( | ||
| 335 | <p className="text-[11px] text-muted-foreground"> | ||
| 336 | Select at least one Google API to preview the OAuth consent. | ||
| 337 | </p> | ||
| 338 | ) : ( | ||
| 339 | <div className="space-y-3"> | ||
| 340 | {consentBatches.map((batch) => ( | ||
| 341 | <div key={batch.id} className="space-y-1.5"> | ||
| 342 | <span className="text-[11px] font-semibold text-foreground">{batch.label}</span> | ||
| 343 | <ul className="space-y-1"> | ||
| 344 | {batch.apiScopes.map((scope: string) => ( | ||
| 345 | <li | ||
| 346 | key={scope} | ||
| 347 | className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | ||
| 348 | > | ||
| 349 | {scope} | ||
| 350 | </li> | ||
| 351 | ))} | ||
| 352 | </ul> | ||
| 353 | </div> | ||
| 354 | ))} | ||
| 355 | </div> | ||
| 356 | )} | ||
| 357 | </CollapsibleContent> | ||
| 358 | </Collapsible> | ||
| 359 | </section> | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export default GoogleProductPicker; | ||
| @@ -1,30 +0,0 @@ | |||
| 1 | import type { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 4 | import { GoogleClient } from "./client"; | ||
| 5 | |||
| 6 | export const googleIntegrationAtom = (slug: IntegrationSlug) => | ||
| 7 | GoogleClient.query("google", "getIntegration", { | ||
| 8 | params: { slug }, | ||
| 9 | timeToLive: "15 seconds", | ||
| 10 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 11 | }); | ||
| 12 | |||
| 13 | export const googleConfigAtom = (slug: IntegrationSlug) => | ||
| 14 | GoogleClient.query("google", "getConfig", { | ||
| 15 | params: { slug }, | ||
| 16 | timeToLive: "15 seconds", | ||
| 17 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const addGoogleBundle = GoogleClient.mutation("google", "addBundle"); | ||
| 21 | |||
| 22 | export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle"); | ||
| 23 | |||
| 24 | export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle"); | ||
| 25 | |||
| 26 | export const googleConfigure = GoogleClient.mutation("google", "configure"); | ||
| 27 | |||
| 28 | export const googleIntegrationFamily = Atom.family(googleIntegrationAtom); | ||
| 29 | |||
| 30 | export const googleConfigFamily = Atom.family(googleConfigAtom); | ||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | ||
| 2 | import { | ||
| 3 | getExecutorOrganizationHeaders, | ||
| 4 | getExecutorApiBaseUrl, | ||
| 5 | getExecutorServerAuthorizationHeader, | ||
| 6 | } from "@executor-js/react/api/server-connection"; | ||
| 7 | import { GoogleGroup } from "../api/group"; | ||
| 8 | |||
| 9 | export const GoogleClient = createPluginAtomClient(GoogleGroup, { | ||
| 10 | baseUrl: getExecutorApiBaseUrl, | ||
| 11 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 13 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | export { googleIntegrationPlugin } from "./source-plugin"; | ||
| 2 | export { GoogleClient } from "./client"; | ||
| 3 | export { | ||
| 4 | addGoogleBundle, | ||
| 5 | googleConfigAtom, | ||
| 6 | googleConfigFamily, | ||
| 7 | googleConfigure, | ||
| 8 | googleIntegrationAtom, | ||
| 9 | googleIntegrationFamily, | ||
| 10 | removeGoogleBundle, | ||
| 11 | updateGoogleBundle, | ||
| 12 | } from "./atoms"; | ||
| @@ -1,8 +0,0 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { googleIntegrationPlugin } from "./source-plugin"; | ||
| 4 | |||
| 5 | export default defineClientPlugin({ | ||
| 6 | id: "google" as const, | ||
| 7 | integrationPlugin: googleIntegrationPlugin, | ||
| 8 | }); | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | ||
| 3 | import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets"; | ||
| 4 | |||
| 5 | const importAdd = () => import("./AddGoogleSource"); | ||
| 6 | const importAccounts = () => import("./GoogleAccountsPanel"); | ||
| 7 | |||
| 8 | export const googleIntegrationPlugin: IntegrationPlugin = { | ||
| 9 | key: "google", | ||
| 10 | label: "Google", | ||
| 11 | add: lazy(importAdd), | ||
| 12 | accounts: lazy(importAccounts), | ||
| 13 | presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], | ||
| 14 | preload: () => { | ||
| 15 | void importAdd(); | ||
| 16 | void importAccounts(); | ||
| 17 | }, | ||
| 18 | }; | ||
| @@ -1,98 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { googleOAuthConsentBatches } from "./oauth-batches"; | ||
| 4 | |||
| 5 | it("keeps core Google OAuth in one consent batch", () => { | ||
| 6 | expect( | ||
| 7 | googleOAuthConsentBatches([ | ||
| 8 | { | ||
| 9 | id: "google-gmail", | ||
| 10 | name: "Gmail", | ||
| 11 | oauthAudience: "standard-user", | ||
| 12 | scopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send"], | ||
| 13 | }, | ||
| 14 | { | ||
| 15 | id: "google-calendar", | ||
| 16 | name: "Google Calendar", | ||
| 17 | oauthAudience: "standard-user", | ||
| 18 | scopes: [ | ||
| 19 | "https://www.googleapis.com/auth/calendar", | ||
| 20 | "https://www.googleapis.com/auth/calendar.readonly", | ||
| 21 | ], | ||
| 22 | }, | ||
| 23 | ]), | ||
| 24 | ).toEqual([ | ||
| 25 | { | ||
| 26 | id: "google-core", | ||
| 27 | label: "Core Google services", | ||
| 28 | apiScopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/calendar"], | ||
| 29 | }, | ||
| 30 | ]); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("splits advanced Google OAuth into smaller known-good consent batches", () => { | ||
| 34 | expect( | ||
| 35 | googleOAuthConsentBatches([ | ||
| 36 | { | ||
| 37 | id: "google-youtube-data", | ||
| 38 | name: "YouTube Data", | ||
| 39 | oauthAudience: "advanced-user", | ||
| 40 | scopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | id: "google-classroom", | ||
| 44 | name: "Google Classroom", | ||
| 45 | oauthAudience: "advanced-user", | ||
| 46 | scopes: ["https://www.googleapis.com/auth/classroom.courses"], | ||
| 47 | }, | ||
| 48 | { | ||
| 49 | id: "google-apps-script", | ||
| 50 | name: "Google Apps Script", | ||
| 51 | oauthAudience: "advanced-user", | ||
| 52 | scopes: [ | ||
| 53 | "https://www.googleapis.com/auth/script.projects", | ||
| 54 | "https://www.googleapis.com/auth/drive", | ||
| 55 | ], | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | id: "google-bigquery", | ||
| 59 | name: "Google BigQuery", | ||
| 60 | oauthAudience: "advanced-user", | ||
| 61 | scopes: ["https://www.googleapis.com/auth/bigquery"], | ||
| 62 | }, | ||
| 63 | { | ||
| 64 | id: "google-cloud-resource-manager", | ||
| 65 | name: "Google Cloud Resource Manager", | ||
| 66 | oauthAudience: "advanced-user", | ||
| 67 | scopes: ["https://www.googleapis.com/auth/cloud-platform"], | ||
| 68 | }, | ||
| 69 | ]).map((batch) => ({ id: batch.id, label: batch.label, apiScopes: batch.apiScopes })), | ||
| 70 | ).toEqual([ | ||
| 71 | { | ||
| 72 | id: "google-youtube-data", | ||
| 73 | label: "YouTube Data", | ||
| 74 | apiScopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | id: "google-classroom", | ||
| 78 | label: "Google Classroom", | ||
| 79 | apiScopes: ["https://www.googleapis.com/auth/classroom.courses"], | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | id: "google-apps-script", | ||
| 83 | label: "Google Apps Script", | ||
| 84 | apiScopes: [ | ||
| 85 | "https://www.googleapis.com/auth/script.projects", | ||
| 86 | "https://www.googleapis.com/auth/drive", | ||
| 87 | ], | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | id: "google-cloud", | ||
| 91 | label: "Google Cloud services", | ||
| 92 | apiScopes: [ | ||
| 93 | "https://www.googleapis.com/auth/bigquery", | ||
| 94 | "https://www.googleapis.com/auth/cloud-platform", | ||
| 95 | ], | ||
| 96 | }, | ||
| 97 | ]); | ||
| 98 | }); | ||
| @@ -1,69 +0,0 @@ | |||
| 1 | import type { GoogleOpenApiOAuthAudience } from "./presets"; | ||
| 2 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 3 | |||
| 4 | export type GoogleOAuthBatchInput = { | ||
| 5 | readonly id: string; | ||
| 6 | readonly name: string; | ||
| 7 | readonly oauthAudience: GoogleOpenApiOAuthAudience; | ||
| 8 | readonly scopes: readonly string[]; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type GoogleOAuthConsentBatch = { | ||
| 12 | readonly id: string; | ||
| 13 | readonly label: string; | ||
| 14 | readonly apiScopes: readonly string[]; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]); | ||
| 18 | |||
| 19 | export const googleOAuthConsentBatches = ( | ||
| 20 | items: readonly GoogleOAuthBatchInput[], | ||
| 21 | ): readonly GoogleOAuthConsentBatch[] => { | ||
| 22 | const standardScopes: string[] = []; | ||
| 23 | const cloudScopes: string[] = []; | ||
| 24 | const batches: GoogleOAuthConsentBatch[] = []; | ||
| 25 | |||
| 26 | for (const item of items) { | ||
| 27 | if (item.scopes.length === 0) continue; | ||
| 28 | if (item.oauthAudience === "standard-user") { | ||
| 29 | standardScopes.push(...item.scopes); | ||
| 30 | continue; | ||
| 31 | } | ||
| 32 | if (GOOGLE_CLOUD_BATCH_IDS.has(item.id)) { | ||
| 33 | cloudScopes.push(...item.scopes); | ||
| 34 | continue; | ||
| 35 | } | ||
| 36 | batches.push({ | ||
| 37 | id: item.id, | ||
| 38 | label: item.name, | ||
| 39 | apiScopes: item.scopes, | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | const compactedStandardScopes = compactGoogleOAuthScopes(standardScopes); | ||
| 44 | const compactedCloudScopes = compactGoogleOAuthScopes(cloudScopes); | ||
| 45 | return [ | ||
| 46 | ...(compactedStandardScopes.length > 0 | ||
| 47 | ? [ | ||
| 48 | { | ||
| 49 | id: "google-core", | ||
| 50 | label: "Core Google services", | ||
| 51 | apiScopes: compactedStandardScopes, | ||
| 52 | }, | ||
| 53 | ] | ||
| 54 | : []), | ||
| 55 | ...batches.map((batch) => ({ | ||
| 56 | ...batch, | ||
| 57 | apiScopes: compactGoogleOAuthScopes(batch.apiScopes), | ||
| 58 | })), | ||
| 59 | ...(compactedCloudScopes.length > 0 | ||
| 60 | ? [ | ||
| 61 | { | ||
| 62 | id: "google-cloud", | ||
| 63 | label: "Google Cloud services", | ||
| 64 | apiScopes: compactedCloudScopes, | ||
| 65 | }, | ||
| 66 | ] | ||
| 67 | : []), | ||
| 68 | ].filter((batch) => batch.apiScopes.length > 0); | ||
| 69 | }; | ||
| @@ -1,799 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Google bundle add flow, "customize your Google connection". | ||
| 3 | // | ||
| 4 | // The product picker emits a URL list. The server fetches each Discovery | ||
| 5 | // document, merges them into ONE `google` | ||
| 6 | // integration spec, and stores the unioned `googleOAuth2` auth template. These | ||
| 7 | // tests exercise that path end-to-end against a stubbed Discovery host: | ||
| 8 | // - a 3-API bundle (calendar + gmail + drive) produces a single `google` | ||
| 9 | // integration whose merged tools carry NO name collisions (each method id | ||
| 10 | // is service-prefixed) even when two APIs share a generic method name; | ||
| 11 | // - the stored oauth template carries the UNION of every API's scopes. | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | import { describe, expect, it } from "@effect/vitest"; | ||
| 15 | import { Effect, Exit, Layer } from "effect"; | ||
| 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | ConnectionName, | ||
| 20 | IntegrationSlug, | ||
| 21 | createExecutor, | ||
| 22 | AuthTemplateSlug, | ||
| 23 | } from "@executor-js/sdk"; | ||
| 24 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 25 | |||
| 26 | import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; | ||
| 27 | import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets"; | ||
| 28 | |||
| 29 | // --- Canned Discovery documents ------------------------------------------- | ||
| 30 | // Each carries one method. Calendar and Gmail BOTH expose a generic `list` | ||
| 31 | // method id segment, so a naive merge that keyed tools on the trailing method | ||
| 32 | // name would collide. The bundle converter keys on the full method id | ||
| 33 | // (`calendar.events.list`, `gmail.users.messages.list`, …), so they don't. | ||
| 34 | |||
| 35 | const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"; | ||
| 36 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; | ||
| 37 | const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; | ||
| 38 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; | ||
| 39 | const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest"; | ||
| 40 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; | ||
| 41 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; | ||
| 42 | const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest"; | ||
| 43 | const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; | ||
| 44 | const OAUTH2_USERINFO_SCOPES = [ | ||
| 45 | "openid", | ||
| 46 | "https://www.googleapis.com/auth/userinfo.email", | ||
| 47 | "https://www.googleapis.com/auth/userinfo.profile", | ||
| 48 | ] as const; | ||
| 49 | |||
| 50 | const calendarDoc = { | ||
| 51 | name: "calendar", | ||
| 52 | version: "v3", | ||
| 53 | title: "Calendar API", | ||
| 54 | rootUrl: "https://www.googleapis.com/", | ||
| 55 | servicePath: "calendar/v3/", | ||
| 56 | auth: { | ||
| 57 | oauth2: { | ||
| 58 | scopes: { | ||
| 59 | "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" }, | ||
| 60 | "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" }, | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | }, | ||
| 64 | resources: { | ||
| 65 | events: { | ||
| 66 | methods: { | ||
| 67 | list: { | ||
| 68 | id: "calendar.events.list", | ||
| 69 | httpMethod: "GET", | ||
| 70 | path: "calendars/{calendarId}/events", | ||
| 71 | scopes: ["https://www.googleapis.com/auth/calendar.readonly"], | ||
| 72 | parameters: { | ||
| 73 | calendarId: { location: "path", required: true, type: "string" }, | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | }, | ||
| 77 | }, | ||
| 78 | }, | ||
| 79 | schemas: { | ||
| 80 | Event: { id: "Event", type: "object", properties: { id: { type: "string" } } }, | ||
| 81 | }, | ||
| 82 | }; | ||
| 83 | |||
| 84 | const gmailDoc = { | ||
| 85 | name: "gmail", | ||
| 86 | version: "v1", | ||
| 87 | title: "Gmail API", | ||
| 88 | rootUrl: "https://gmail.googleapis.com/", | ||
| 89 | servicePath: "", | ||
| 90 | auth: { | ||
| 91 | oauth2: { | ||
| 92 | scopes: { | ||
| 93 | "https://mail.google.com/": { description: "Full Gmail access" }, | ||
| 94 | "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" }, | ||
| 95 | }, | ||
| 96 | }, | ||
| 97 | }, | ||
| 98 | resources: { | ||
| 99 | users: { | ||
| 100 | resources: { | ||
| 101 | messages: { | ||
| 102 | methods: { | ||
| 103 | // Same trailing `list` as calendar.events.list - would collide on a | ||
| 104 | // naive merge; service-prefixed method id keeps them distinct. | ||
| 105 | list: { | ||
| 106 | id: "gmail.users.messages.list", | ||
| 107 | httpMethod: "GET", | ||
| 108 | path: "gmail/v1/users/{userId}/messages", | ||
| 109 | scopes: ["https://www.googleapis.com/auth/gmail.readonly"], | ||
| 110 | parameters: { | ||
| 111 | userId: { location: "path", required: true, type: "string" }, | ||
| 112 | }, | ||
| 113 | }, | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | }, | ||
| 118 | }, | ||
| 119 | schemas: { | ||
| 120 | Message: { id: "Message", type: "object", properties: { id: { type: "string" } } }, | ||
| 121 | }, | ||
| 122 | }; | ||
| 123 | |||
| 124 | const driveDoc = { | ||
| 125 | name: "drive", | ||
| 126 | version: "v3", | ||
| 127 | title: "Drive API", | ||
| 128 | rootUrl: "https://www.googleapis.com/", | ||
| 129 | servicePath: "drive/v3/", | ||
| 130 | auth: { | ||
| 131 | oauth2: { | ||
| 132 | scopes: { | ||
| 133 | "https://www.googleapis.com/auth/drive": { description: "Manage Drive" }, | ||
| 134 | }, | ||
| 135 | }, | ||
| 136 | }, | ||
| 137 | resources: { | ||
| 138 | files: { | ||
| 139 | methods: { | ||
| 140 | // A third `list` - three generic method names across three APIs. | ||
| 141 | list: { | ||
| 142 | id: "drive.files.list", | ||
| 143 | httpMethod: "GET", | ||
| 144 | path: "files", | ||
| 145 | scopes: ["https://www.googleapis.com/auth/drive"], | ||
| 146 | parameters: {}, | ||
| 147 | }, | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | }, | ||
| 151 | schemas: { | ||
| 152 | File: { id: "File", type: "object", properties: { id: { type: "string" } } }, | ||
| 153 | }, | ||
| 154 | }; | ||
| 155 | |||
| 156 | const sheetsDoc = { | ||
| 157 | name: "sheets", | ||
| 158 | version: "v4", | ||
| 159 | title: "Google Sheets API", | ||
| 160 | rootUrl: "https://sheets.googleapis.com/", | ||
| 161 | servicePath: "v4/", | ||
| 162 | auth: { | ||
| 163 | oauth2: { | ||
| 164 | scopes: { | ||
| 165 | "https://www.googleapis.com/auth/spreadsheets": { description: "Manage spreadsheets" }, | ||
| 166 | }, | ||
| 167 | }, | ||
| 168 | }, | ||
| 169 | resources: { | ||
| 170 | spreadsheets: { | ||
| 171 | methods: { | ||
| 172 | get: { | ||
| 173 | id: "sheets.spreadsheets.get", | ||
| 174 | httpMethod: "GET", | ||
| 175 | path: "spreadsheets/{spreadsheetId}", | ||
| 176 | scopes: ["https://www.googleapis.com/auth/spreadsheets"], | ||
| 177 | parameters: { | ||
| 178 | spreadsheetId: { location: "path", required: true, type: "string" }, | ||
| 179 | }, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | }, | ||
| 183 | }, | ||
| 184 | schemas: { | ||
| 185 | Spreadsheet: { id: "Spreadsheet", type: "object", properties: { id: { type: "string" } } }, | ||
| 186 | }, | ||
| 187 | }; | ||
| 188 | |||
| 189 | const docsDoc = { | ||
| 190 | name: "docs", | ||
| 191 | version: "v1", | ||
| 192 | title: "Google Docs API", | ||
| 193 | rootUrl: "https://docs.googleapis.com/", | ||
| 194 | servicePath: "v1/", | ||
| 195 | auth: { | ||
| 196 | oauth2: { | ||
| 197 | scopes: { | ||
| 198 | "https://www.googleapis.com/auth/documents": { description: "Manage documents" }, | ||
| 199 | }, | ||
| 200 | }, | ||
| 201 | }, | ||
| 202 | resources: { | ||
| 203 | documents: { | ||
| 204 | methods: { | ||
| 205 | get: { | ||
| 206 | id: "docs.documents.get", | ||
| 207 | httpMethod: "GET", | ||
| 208 | path: "documents/{documentId}", | ||
| 209 | scopes: ["https://www.googleapis.com/auth/documents"], | ||
| 210 | parameters: { | ||
| 211 | documentId: { location: "path", required: true, type: "string" }, | ||
| 212 | }, | ||
| 213 | }, | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | }, | ||
| 217 | schemas: { | ||
| 218 | Document: { id: "Document", type: "object", properties: { documentId: { type: "string" } } }, | ||
| 219 | }, | ||
| 220 | }; | ||
| 221 | |||
| 222 | const photosLibraryDoc = { | ||
| 223 | name: "photoslibrary", | ||
| 224 | version: "v1", | ||
| 225 | title: "Google Photos Library API", | ||
| 226 | rootUrl: "https://photoslibrary.googleapis.com/", | ||
| 227 | servicePath: "v1/", | ||
| 228 | auth: { | ||
| 229 | oauth2: { | ||
| 230 | scopes: { | ||
| 231 | "https://www.googleapis.com/auth/photoslibrary": { | ||
| 232 | description: "Manage the full Google Photos library", | ||
| 233 | }, | ||
| 234 | "https://www.googleapis.com/auth/photoslibrary.appendonly": { | ||
| 235 | description: "Upload to Google Photos", | ||
| 236 | }, | ||
| 237 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": { | ||
| 238 | description: "Read app-created Google Photos media", | ||
| 239 | }, | ||
| 240 | }, | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | resources: { | ||
| 244 | albums: { | ||
| 245 | methods: { | ||
| 246 | list: { | ||
| 247 | id: "photoslibrary.albums.list", | ||
| 248 | httpMethod: "GET", | ||
| 249 | path: "albums", | ||
| 250 | scopes: ["https://www.googleapis.com/auth/photoslibrary"], | ||
| 251 | parameters: {}, | ||
| 252 | }, | ||
| 253 | }, | ||
| 254 | }, | ||
| 255 | mediaItems: { | ||
| 256 | methods: { | ||
| 257 | search: { | ||
| 258 | id: "photoslibrary.mediaItems.search", | ||
| 259 | httpMethod: "POST", | ||
| 260 | path: "mediaItems:search", | ||
| 261 | scopes: ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"], | ||
| 262 | parameters: {}, | ||
| 263 | }, | ||
| 264 | }, | ||
| 265 | }, | ||
| 266 | }, | ||
| 267 | schemas: {}, | ||
| 268 | }; | ||
| 269 | |||
| 270 | const photosPickerDoc = { | ||
| 271 | name: "photospicker", | ||
| 272 | version: "v1", | ||
| 273 | title: "Google Photos Picker API", | ||
| 274 | rootUrl: "https://photospicker.googleapis.com/", | ||
| 275 | servicePath: "v1/", | ||
| 276 | resources: { | ||
| 277 | mediaItems: { | ||
| 278 | methods: { | ||
| 279 | list: { | ||
| 280 | id: "photospicker.mediaItems.list", | ||
| 281 | httpMethod: "GET", | ||
| 282 | path: "mediaItems", | ||
| 283 | parameters: {}, | ||
| 284 | }, | ||
| 285 | }, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | schemas: {}, | ||
| 289 | }; | ||
| 290 | |||
| 291 | const peopleDoc = { | ||
| 292 | name: "people", | ||
| 293 | version: "v1", | ||
| 294 | title: "People API", | ||
| 295 | rootUrl: "https://people.googleapis.com/", | ||
| 296 | servicePath: "", | ||
| 297 | auth: { | ||
| 298 | oauth2: { | ||
| 299 | scopes: { | ||
| 300 | "https://www.googleapis.com/auth/userinfo.email": { description: "See your email" }, | ||
| 301 | }, | ||
| 302 | }, | ||
| 303 | }, | ||
| 304 | resources: { | ||
| 305 | people: { | ||
| 306 | methods: { | ||
| 307 | get: { | ||
| 308 | id: "people.people.get", | ||
| 309 | httpMethod: "GET", | ||
| 310 | path: "v1/{+resourceName}", | ||
| 311 | scopes: ["https://www.googleapis.com/auth/userinfo.email"], | ||
| 312 | parameters: { | ||
| 313 | resourceName: { location: "path", required: true, type: "string" }, | ||
| 314 | personFields: { location: "query", type: "string" }, | ||
| 315 | }, | ||
| 316 | response: { $ref: "Person" }, | ||
| 317 | }, | ||
| 318 | }, | ||
| 319 | }, | ||
| 320 | }, | ||
| 321 | schemas: { | ||
| 322 | Person: { | ||
| 323 | id: "Person", | ||
| 324 | type: "object", | ||
| 325 | properties: { | ||
| 326 | resourceName: { type: "string" }, | ||
| 327 | emailAddresses: { type: "array", items: { $ref: "EmailAddress" } }, | ||
| 328 | }, | ||
| 329 | }, | ||
| 330 | EmailAddress: { id: "EmailAddress", type: "object", properties: { value: { type: "string" } } }, | ||
| 331 | }, | ||
| 332 | }; | ||
| 333 | |||
| 334 | const oauth2Doc = { | ||
| 335 | name: "oauth2", | ||
| 336 | version: "v2", | ||
| 337 | title: "Google OAuth2 API", | ||
| 338 | rootUrl: "https://www.googleapis.com/", | ||
| 339 | servicePath: "", | ||
| 340 | auth: { | ||
| 341 | oauth2: { | ||
| 342 | scopes: { | ||
| 343 | openid: { description: "Associate you with your personal info on Google" }, | ||
| 344 | "https://www.googleapis.com/auth/userinfo.email": { | ||
| 345 | description: "See your primary Google Account email address", | ||
| 346 | }, | ||
| 347 | "https://www.googleapis.com/auth/userinfo.profile": { | ||
| 348 | description: "See your personal info", | ||
| 349 | }, | ||
| 350 | }, | ||
| 351 | }, | ||
| 352 | }, | ||
| 353 | methods: { | ||
| 354 | tokeninfo: { | ||
| 355 | id: "oauth2.tokeninfo", | ||
| 356 | httpMethod: "POST", | ||
| 357 | path: "oauth2/v2/tokeninfo", | ||
| 358 | response: { $ref: "Tokeninfo" }, | ||
| 359 | }, | ||
| 360 | }, | ||
| 361 | resources: { | ||
| 362 | userinfo: { | ||
| 363 | methods: { | ||
| 364 | get: { | ||
| 365 | id: "oauth2.userinfo.get", | ||
| 366 | httpMethod: "GET", | ||
| 367 | path: "oauth2/v2/userinfo", | ||
| 368 | scopes: OAUTH2_USERINFO_SCOPES, | ||
| 369 | response: { $ref: "Userinfo" }, | ||
| 370 | }, | ||
| 371 | }, | ||
| 372 | resources: { | ||
| 373 | v2: { | ||
| 374 | resources: { | ||
| 375 | me: { | ||
| 376 | methods: { | ||
| 377 | get: { | ||
| 378 | id: "oauth2.userinfo.v2.me.get", | ||
| 379 | httpMethod: "GET", | ||
| 380 | path: "userinfo/v2/me", | ||
| 381 | scopes: OAUTH2_USERINFO_SCOPES, | ||
| 382 | response: { $ref: "Userinfo" }, | ||
| 383 | }, | ||
| 384 | }, | ||
| 385 | }, | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | }, | ||
| 389 | }, | ||
| 390 | }, | ||
| 391 | schemas: { | ||
| 392 | Tokeninfo: { | ||
| 393 | id: "Tokeninfo", | ||
| 394 | type: "object", | ||
| 395 | properties: { | ||
| 396 | audience: { type: "string" }, | ||
| 397 | scope: { type: "string" }, | ||
| 398 | }, | ||
| 399 | }, | ||
| 400 | Userinfo: { | ||
| 401 | id: "Userinfo", | ||
| 402 | type: "object", | ||
| 403 | properties: { | ||
| 404 | email: { type: "string" }, | ||
| 405 | family_name: { type: "string" }, | ||
| 406 | gender: { type: "string" }, | ||
| 407 | given_name: { type: "string" }, | ||
| 408 | hd: { type: "string" }, | ||
| 409 | id: { type: "string" }, | ||
| 410 | link: { type: "string" }, | ||
| 411 | locale: { type: "string" }, | ||
| 412 | name: { type: "string" }, | ||
| 413 | picture: { type: "string" }, | ||
| 414 | verified_email: { type: "boolean" }, | ||
| 415 | }, | ||
| 416 | }, | ||
| 417 | }, | ||
| 418 | }; | ||
| 419 | |||
| 420 | const toJson = (value: unknown): string => JSON.stringify(value); | ||
| 421 | |||
| 422 | const DISCOVERY_BODIES: Readonly<Record<string, string>> = { | ||
| 423 | [CALENDAR_URL]: toJson(calendarDoc), | ||
| 424 | [GMAIL_URL]: toJson(gmailDoc), | ||
| 425 | [SHEETS_URL]: toJson(sheetsDoc), | ||
| 426 | [DRIVE_URL]: toJson(driveDoc), | ||
| 427 | [DOCS_URL]: toJson(docsDoc), | ||
| 428 | [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), | ||
| 429 | [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), | ||
| 430 | [PEOPLE_URL]: toJson(peopleDoc), | ||
| 431 | [OAUTH2_URL]: toJson(oauth2Doc), | ||
| 432 | }; | ||
| 433 | |||
| 434 | // A stub HTTP client that serves the canned Discovery document for whichever | ||
| 435 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their | ||
| 436 | // version in the query string, so match the full URL before falling back to the | ||
| 437 | // path-only key used by central Discovery URLs. | ||
| 438 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 439 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 440 | const url = new URL(request.url); | ||
| 441 | const key = `${url.origin}${url.pathname}`; | ||
| 442 | const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | ||
| 443 | return Effect.succeed( | ||
| 444 | HttpClientResponse.fromWeb( | ||
| 445 | request, | ||
| 446 | body === undefined | ||
| 447 | ? new Response("not found", { status: 404 }) | ||
| 448 | : new Response(body, { | ||
| 449 | status: 200, | ||
| 450 | headers: { "content-type": "application/json" }, | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | }), | ||
| 455 | ); | ||
| 456 | |||
| 457 | const bundlePlugins = () => | ||
| 458 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; | ||
| 459 | |||
| 460 | describe("Google bundle add flow", () => { | ||
| 461 | it("SDK catalog includes the Google Photos focused preset", () => { | ||
| 462 | const presetIds = | ||
| 463 | googlePlugin({ httpClientLayer: discoveryHttpClientLayer }).integrationPresets?.map( | ||
| 464 | (preset) => preset.id, | ||
| 465 | ) ?? []; | ||
| 466 | |||
| 467 | expect(presetIds).toContain("google"); | ||
| 468 | expect(presetIds).toContain("google-photos"); | ||
| 469 | }); | ||
| 470 | |||
| 471 | it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => | ||
| 472 | Effect.scoped( | ||
| 473 | Effect.gen(function* () { | ||
| 474 | let requests = 0; | ||
| 475 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 476 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 477 | Effect.sync(() => { | ||
| 478 | requests += 1; | ||
| 479 | return HttpClientResponse.fromWeb( | ||
| 480 | request, | ||
| 481 | new Response("unexpected request", { status: 500 }), | ||
| 482 | ); | ||
| 483 | }), | ||
| 484 | ), | ||
| 485 | ); | ||
| 486 | const executor = yield* createExecutor( | ||
| 487 | makeTestConfig({ | ||
| 488 | plugins: [ | ||
| 489 | googlePlugin({ httpClientLayer: blockedHttpClientLayer }), | ||
| 490 | memoryCredentialsPlugin(), | ||
| 491 | ], | ||
| 492 | }), | ||
| 493 | ); | ||
| 494 | |||
| 495 | const exit = yield* executor.google | ||
| 496 | .addBundle({ | ||
| 497 | urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 498 | slug: "bad_google", | ||
| 499 | }) | ||
| 500 | .pipe(Effect.exit); | ||
| 501 | |||
| 502 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 503 | expect(requests).toBe(0); | ||
| 504 | }), | ||
| 505 | ), | ||
| 506 | ); | ||
| 507 | |||
| 508 | it.effect( | ||
| 509 | "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", | ||
| 510 | () => | ||
| 511 | Effect.scoped( | ||
| 512 | Effect.gen(function* () { | ||
| 513 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 514 | |||
| 515 | const result = yield* executor.google.addBundle({ | ||
| 516 | urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], | ||
| 517 | slug: "google", | ||
| 518 | description: "Google", | ||
| 519 | }); | ||
| 520 | expect(String(result.slug)).toBe("google"); | ||
| 521 | |||
| 522 | // ONE integration, not three. | ||
| 523 | const integration = yield* executor.google.getIntegration("google"); | ||
| 524 | expect(integration?.slug).toBe(IntegrationSlug.make("google")); | ||
| 525 | |||
| 526 | // The stored oauth template carries the COMPACTED union of every API's | ||
| 527 | // scopes plus the hidden OAuth2 identity scopes that `oauth.start` | ||
| 528 | // requests. | ||
| 529 | // `calendar.readonly` collapses under `calendar`, and `gmail.readonly` | ||
| 530 | // collapses under `https://mail.google.com/`, so the requested consent | ||
| 531 | // is clean rather than the raw per-method union. | ||
| 532 | const config = yield* executor.google.getConfig("google"); | ||
| 533 | expect(config?.googleDiscoveryUrls).toEqual([ | ||
| 534 | CALENDAR_URL, | ||
| 535 | GMAIL_URL, | ||
| 536 | DRIVE_URL, | ||
| 537 | OAUTH2_URL, | ||
| 538 | ]); | ||
| 539 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 540 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 541 | [ | ||
| 542 | "email", | ||
| 543 | "https://mail.google.com/", | ||
| 544 | "https://www.googleapis.com/auth/calendar", | ||
| 545 | "https://www.googleapis.com/auth/drive", | ||
| 546 | "openid", | ||
| 547 | "profile", | ||
| 548 | ].sort(), | ||
| 549 | ); | ||
| 550 | |||
| 551 | // A connection stamps the merged tools; assert all three `list`s are | ||
| 552 | // present under distinct service-prefixed names (no collision). | ||
| 553 | yield* executor.connections.create({ | ||
| 554 | owner: "org", | ||
| 555 | name: ConnectionName.make("main"), | ||
| 556 | integration: IntegrationSlug.make("google"), | ||
| 557 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 558 | value: "token-xyz", | ||
| 559 | }); | ||
| 560 | |||
| 561 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 562 | expect(toolNames).toContain("calendar.events.list"); | ||
| 563 | expect(toolNames).toContain("gmail.users.messages.list"); | ||
| 564 | expect(toolNames).toContain("drive.files.list"); | ||
| 565 | |||
| 566 | // No duplicate tool names across the merged surface. | ||
| 567 | const googleTools = toolNames.filter((name) => name.endsWith(".list")); | ||
| 568 | expect(new Set(googleTools).size).toBe(googleTools.length); | ||
| 569 | expect(googleTools.length).toBe(3); | ||
| 570 | }), | ||
| 571 | ), | ||
| 572 | ); | ||
| 573 | |||
| 574 | it.effect("addBundle constrains Google Photos to the preset scopes and upload tool", () => | ||
| 575 | Effect.scoped( | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 578 | |||
| 579 | yield* executor.google.addBundle({ | ||
| 580 | urls: [ | ||
| 581 | PHOTOS_LIBRARY_URL, | ||
| 582 | "https://photospicker.googleapis.com/$discovery/rest?version=v1", | ||
| 583 | ], | ||
| 584 | slug: "google_photos", | ||
| 585 | name: "Google Photos", | ||
| 586 | }); | ||
| 587 | |||
| 588 | const config = yield* executor.google.getConfig("google_photos"); | ||
| 589 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 590 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 591 | [ | ||
| 592 | "email", | ||
| 593 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 594 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 595 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 596 | "openid", | ||
| 597 | "profile", | ||
| 598 | ].sort(), | ||
| 599 | ); | ||
| 600 | |||
| 601 | yield* executor.connections.create({ | ||
| 602 | owner: "org", | ||
| 603 | name: ConnectionName.make("main"), | ||
| 604 | integration: IntegrationSlug.make("google_photos"), | ||
| 605 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 606 | value: "token-xyz", | ||
| 607 | }); | ||
| 608 | |||
| 609 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 610 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 611 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 612 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 613 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | ); | ||
| 617 | |||
| 618 | it.effect("addBundle keeps Google Photos scoped when combined with another API", () => | ||
| 619 | Effect.scoped( | ||
| 620 | Effect.gen(function* () { | ||
| 621 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 622 | |||
| 623 | yield* executor.google.addBundle({ | ||
| 624 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL], | ||
| 625 | slug: "google_photos_calendar", | ||
| 626 | name: "Google Photos and Calendar", | ||
| 627 | }); | ||
| 628 | |||
| 629 | const config = yield* executor.google.getConfig("google_photos_calendar"); | ||
| 630 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 631 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 632 | [ | ||
| 633 | "email", | ||
| 634 | "https://www.googleapis.com/auth/calendar", | ||
| 635 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 636 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 637 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 638 | "openid", | ||
| 639 | "profile", | ||
| 640 | ].sort(), | ||
| 641 | ); | ||
| 642 | |||
| 643 | yield* executor.connections.create({ | ||
| 644 | owner: "org", | ||
| 645 | name: ConnectionName.make("main"), | ||
| 646 | integration: IntegrationSlug.make("google_photos_calendar"), | ||
| 647 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 648 | value: "token-xyz", | ||
| 649 | }); | ||
| 650 | |||
| 651 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 652 | expect(toolNames).toContain("calendar.events.list"); | ||
| 653 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 654 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 655 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 656 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 657 | }), | ||
| 658 | ), | ||
| 659 | ); | ||
| 660 | |||
| 661 | it.effect("addBundle scopes a partial Google Photos bundle when mixed with another API", () => | ||
| 662 | Effect.scoped( | ||
| 663 | Effect.gen(function* () { | ||
| 664 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 665 | |||
| 666 | yield* executor.google.addBundle({ | ||
| 667 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], | ||
| 668 | slug: "google_photos_library_calendar", | ||
| 669 | name: "Google Photos Library and Calendar", | ||
| 670 | }); | ||
| 671 | |||
| 672 | const config = yield* executor.google.getConfig("google_photos_library_calendar"); | ||
| 673 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 674 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 675 | [ | ||
| 676 | "email", | ||
| 677 | "https://www.googleapis.com/auth/calendar", | ||
| 678 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 679 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 680 | "openid", | ||
| 681 | "profile", | ||
| 682 | ].sort(), | ||
| 683 | ); | ||
| 684 | |||
| 685 | yield* executor.connections.create({ | ||
| 686 | owner: "org", | ||
| 687 | name: ConnectionName.make("main"), | ||
| 688 | integration: IntegrationSlug.make("google_photos_library_calendar"), | ||
| 689 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 690 | value: "token-xyz", | ||
| 691 | }); | ||
| 692 | |||
| 693 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 694 | expect(toolNames).toContain("calendar.events.list"); | ||
| 695 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 696 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 697 | expect(toolNames).not.toContain("photospicker.mediaItems.list"); | ||
| 698 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 699 | }), | ||
| 700 | ), | ||
| 701 | ); | ||
| 702 | }); | ||
| 703 | |||
| 704 | describe("Google health-check default", () => { | ||
| 705 | it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () => | ||
| 706 | Effect.scoped( | ||
| 707 | Effect.gen(function* () { | ||
| 708 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 709 | const defaultUrls = googleOpenApiPresets | ||
| 710 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 711 | .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : [])); | ||
| 712 | |||
| 713 | expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]); | ||
| 714 | |||
| 715 | yield* executor.google.addBundle({ | ||
| 716 | urls: defaultUrls, | ||
| 717 | slug: "google_default", | ||
| 718 | description: "Google", | ||
| 719 | }); | ||
| 720 | |||
| 721 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 722 | IntegrationSlug.make("google_default"), | ||
| 723 | ); | ||
| 724 | expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( | ||
| 725 | "oauth2.userinfo.get", | ||
| 726 | ); | ||
| 727 | expect(stored?.args, "userinfo needs no args").toBeUndefined(); | ||
| 728 | expect(stored?.identityField, "the default reads the account email").toBe("email"); | ||
| 729 | |||
| 730 | const config = yield* executor.google.getConfig("google_default"); | ||
| 731 | expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]); | ||
| 732 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 733 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 734 | [ | ||
| 735 | "email", | ||
| 736 | "https://mail.google.com/", | ||
| 737 | "https://www.googleapis.com/auth/calendar", | ||
| 738 | "https://www.googleapis.com/auth/documents", | ||
| 739 | "https://www.googleapis.com/auth/drive", | ||
| 740 | "https://www.googleapis.com/auth/spreadsheets", | ||
| 741 | "openid", | ||
| 742 | "profile", | ||
| 743 | ].sort(), | ||
| 744 | ); | ||
| 745 | |||
| 746 | const candidates = yield* executor.integrations.healthCheck.candidates( | ||
| 747 | IntegrationSlug.make("google_default"), | ||
| 748 | ); | ||
| 749 | const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); | ||
| 750 | expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); | ||
| 751 | expect( | ||
| 752 | (userinfo?.responseFields ?? []).map((field) => field.path), | ||
| 753 | "the email identity field is projected from the response schema", | ||
| 754 | ).toContain("email"); | ||
| 755 | }), | ||
| 756 | ), | ||
| 757 | ); | ||
| 758 | |||
| 759 | it.effect("addBundle with People still prefers OAuth2 userinfo", () => | ||
| 760 | Effect.scoped( | ||
| 761 | Effect.gen(function* () { | ||
| 762 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 763 | |||
| 764 | yield* executor.google.addBundle({ | ||
| 765 | urls: [PEOPLE_URL, CALENDAR_URL], | ||
| 766 | slug: "google_people", | ||
| 767 | description: "Google", | ||
| 768 | }); | ||
| 769 | |||
| 770 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 771 | IntegrationSlug.make("google_people"), | ||
| 772 | ); | ||
| 773 | expect(stored?.operation, "OAuth2 userinfo beats the People identity call").toBe( | ||
| 774 | "oauth2.userinfo.get", | ||
| 775 | ); | ||
| 776 | expect(stored?.args).toBeUndefined(); | ||
| 777 | expect(stored?.identityField).toBe("email"); | ||
| 778 | }), | ||
| 779 | ), | ||
| 780 | ); | ||
| 781 | |||
| 782 | it("falls back to People when OAuth2 userinfo is absent", () => { | ||
| 783 | expect( | ||
| 784 | defaultGoogleHealthCheck( | ||
| 785 | [PEOPLE_URL], | ||
| 786 | [ | ||
| 787 | { | ||
| 788 | toolPath: "people.people.get", | ||
| 789 | operation: { method: "get", pathTemplate: "/v1/{+resourceName}" }, | ||
| 790 | }, | ||
| 791 | ], | ||
| 792 | ), | ||
| 793 | ).toEqual({ | ||
| 794 | operation: "people.people.get", | ||
| 795 | args: { resourceName: "people/me", personFields: "names,emailAddresses" }, | ||
| 796 | identityField: "emailAddresses.0.value", | ||
| 797 | }); | ||
| 798 | }); | ||
| 799 | }); | ||
| @@ -1,469 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type { Layer } from "effect"; | ||
| 3 | import { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationDetectionResult, | ||
| 8 | IntegrationNotFoundError, | ||
| 9 | IntegrationSlug, | ||
| 10 | definePlugin, | ||
| 11 | mergeAuthTemplates, | ||
| 12 | sha256Hex, | ||
| 13 | type AuthMethodDescriptor, | ||
| 14 | type HealthCheckSpec, | ||
| 15 | type Integration, | ||
| 16 | type IntegrationConfig, | ||
| 17 | type IntegrationRecord, | ||
| 18 | type PluginCtx, | ||
| 19 | } from "@executor-js/sdk/core"; | ||
| 20 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | ||
| 21 | import { | ||
| 22 | checkHealthOpenApi, | ||
| 23 | compileOpenApiSpec, | ||
| 24 | invokeOpenApiBackedTool, | ||
| 25 | listHealthCheckCandidatesOpenApi, | ||
| 26 | makeDefaultOpenapiStore, | ||
| 27 | normalizeOpenApiAuthInputs, | ||
| 28 | OpenApiParseError, | ||
| 29 | openApiStoredOperationsFromCompiled, | ||
| 30 | resolveOpenApiBackedAnnotations, | ||
| 31 | resolveOpenApiBackedTools, | ||
| 32 | type Authentication, | ||
| 33 | type AuthenticationInput, | ||
| 34 | type OpenapiStore, | ||
| 35 | } from "@executor-js/plugin-openapi"; | ||
| 36 | |||
| 37 | import { | ||
| 38 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 39 | fetchGoogleDiscoveryDocument, | ||
| 40 | normalizeGoogleDiscoveryUrl, | ||
| 41 | } from "./discovery"; | ||
| 42 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; | ||
| 43 | import { | ||
| 44 | googleOAuthConsentScopesForPreset, | ||
| 45 | googleOpenApiBundlePreset, | ||
| 46 | googlePhotosOpenApiBundlePreset, | ||
| 47 | googlePhotosOpenApiPresets, | ||
| 48 | } from "./presets"; | ||
| 49 | |||
| 50 | const GOOGLE_OAUTH2_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; | ||
| 51 | |||
| 52 | /** The default health check for a Google bundle: prefer the lightweight | ||
| 53 | * OAuth2 userinfo identity call, which every new Google bundle includes. | ||
| 54 | * Older bundles may only have People API, so keep that fallback. */ | ||
| 55 | export const defaultGoogleHealthCheck = ( | ||
| 56 | urls: readonly string[], | ||
| 57 | definitions: readonly { | ||
| 58 | readonly toolPath: string; | ||
| 59 | readonly operation: { readonly method: string; readonly pathTemplate: string }; | ||
| 60 | }[], | ||
| 61 | ): HealthCheckSpec | undefined => { | ||
| 62 | const userinfoGet = definitions.find( | ||
| 63 | (def) => | ||
| 64 | def.operation.method.toLowerCase() === "get" && | ||
| 65 | (def.toolPath === "oauth2.userinfo.get" || | ||
| 66 | def.operation.pathTemplate === "/oauth2/v2/userinfo"), | ||
| 67 | ); | ||
| 68 | if (userinfoGet) { | ||
| 69 | return { | ||
| 70 | operation: userinfoGet.toolPath, | ||
| 71 | identityField: "email", | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | const hasPeopleApi = urls.some((url) => url.includes("/people/")); | ||
| 76 | if (!hasPeopleApi) return undefined; | ||
| 77 | const peopleGet = definitions.find( | ||
| 78 | (def) => | ||
| 79 | def.operation.method.toLowerCase() === "get" && | ||
| 80 | (def.toolPath === "people.people.get" || | ||
| 81 | def.operation.pathTemplate === "/v1/{+resourceName}"), | ||
| 82 | ); | ||
| 83 | return peopleGet | ||
| 84 | ? { | ||
| 85 | operation: peopleGet.toolPath, | ||
| 86 | args: { resourceName: "people/me", personFields: "names,emailAddresses" }, | ||
| 87 | identityField: "emailAddresses.0.value", | ||
| 88 | } | ||
| 89 | : undefined; | ||
| 90 | }; | ||
| 91 | |||
| 92 | export interface GoogleBundleConfig { | ||
| 93 | readonly urls: readonly string[]; | ||
| 94 | readonly slug?: string; | ||
| 95 | readonly name?: string; | ||
| 96 | readonly description?: string; | ||
| 97 | readonly baseUrl?: string; | ||
| 98 | } | ||
| 99 | |||
| 100 | export interface GoogleConfigureInput { | ||
| 101 | readonly authenticationTemplate: readonly AuthenticationInput[]; | ||
| 102 | readonly mode?: "merge" | "replace"; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface GoogleUpdateInput { | ||
| 106 | readonly urls?: readonly string[]; | ||
| 107 | } | ||
| 108 | |||
| 109 | export interface GoogleUpdateResult { | ||
| 110 | readonly slug: IntegrationSlug; | ||
| 111 | readonly toolCount: number; | ||
| 112 | readonly addedTools: readonly string[]; | ||
| 113 | readonly removedTools: readonly string[]; | ||
| 114 | } | ||
| 115 | |||
| 116 | export interface GooglePluginOptions { | ||
| 117 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 118 | } | ||
| 119 | |||
| 120 | const DEFAULT_GOOGLE_SLUG = "google"; | ||
| 121 | |||
| 122 | const googlePhotosBundlePresetIdByUrl = new Map( | ||
| 123 | googlePhotosOpenApiPresets.flatMap((preset) => | ||
| 124 | preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], | ||
| 125 | ), | ||
| 126 | ); | ||
| 127 | |||
| 128 | const googlePhotosBundleConsentScopes = ( | ||
| 129 | urls: readonly string[], | ||
| 130 | ): readonly string[] | undefined => { | ||
| 131 | const normalized = new Set(urls); | ||
| 132 | const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) => | ||
| 133 | normalized.has(url) ? [presetId] : [], | ||
| 134 | ); | ||
| 135 | return presetIds.length > 0 | ||
| 136 | ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId)) | ||
| 137 | : undefined; | ||
| 138 | }; | ||
| 139 | |||
| 140 | const fetchGoogleBundleConversion = ( | ||
| 141 | urls: readonly string[], | ||
| 142 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | ||
| 143 | ) => | ||
| 144 | Effect.forEach( | ||
| 145 | urls, | ||
| 146 | (url) => | ||
| 147 | fetchGoogleDiscoveryDocument(url).pipe( | ||
| 148 | Effect.provide(httpClientLayer), | ||
| 149 | Effect.map((documentText) => ({ discoveryUrl: url, documentText })), | ||
| 150 | ), | ||
| 151 | { concurrency: 4 }, | ||
| 152 | ).pipe( | ||
| 153 | Effect.flatMap((documents) => { | ||
| 154 | const consentScopes = googlePhotosBundleConsentScopes(urls); | ||
| 155 | return convertGoogleDiscoveryBundleToOpenApi({ | ||
| 156 | documents, | ||
| 157 | ...(consentScopes ? { consentScopes } : {}), | ||
| 158 | }); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 162 | const uniqueUrls = (urls: readonly string[]): readonly string[] => [ | ||
| 163 | ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), | ||
| 164 | ]; | ||
| 165 | |||
| 166 | const googleBundleUrlsWithIdentity = ( | ||
| 167 | urls: readonly string[], | ||
| 168 | ): Effect.Effect<readonly string[], OpenApiParseError> => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const normalized: string[] = []; | ||
| 171 | for (const url of urls) { | ||
| 172 | const discoveryUrl = normalizeGoogleDiscoveryUrl(url); | ||
| 173 | if (!discoveryUrl) { | ||
| 174 | return yield* new OpenApiParseError({ | ||
| 175 | message: | ||
| 176 | "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | normalized.push(discoveryUrl); | ||
| 180 | } | ||
| 181 | return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]); | ||
| 182 | }); | ||
| 183 | |||
| 184 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | ||
| 185 | const config = decodeGoogleIntegrationConfig(record.config); | ||
| 186 | if (!config) return []; | ||
| 187 | return (config.authenticationTemplate ?? []).map( | ||
| 188 | (template: Authentication): AuthMethodDescriptor => { | ||
| 189 | if (template.kind === "oauth2") { | ||
| 190 | return { | ||
| 191 | id: String(template.slug), | ||
| 192 | label: "OAuth2", | ||
| 193 | kind: "oauth", | ||
| 194 | template: String(template.slug), | ||
| 195 | oauth: { | ||
| 196 | authorizationUrl: template.authorizationUrl, | ||
| 197 | tokenUrl: template.tokenUrl, | ||
| 198 | scopes: template.scopes, | ||
| 199 | }, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | return describeApiKeyAuthMethod(template); | ||
| 203 | }, | ||
| 204 | ); | ||
| 205 | }; | ||
| 206 | |||
| 207 | const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => { | ||
| 208 | const config = decodeGoogleIntegrationConfig(record.config); | ||
| 209 | return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] }; | ||
| 210 | }; | ||
| 211 | |||
| 212 | const makeGooglePluginExtension = ( | ||
| 213 | options: GooglePluginOptions | undefined, | ||
| 214 | ctx: PluginCtx<OpenapiStore>, | ||
| 215 | ) => { | ||
| 216 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 217 | |||
| 218 | const addBundle = (config: GoogleBundleConfig) => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const urls = yield* googleBundleUrlsWithIdentity(config.urls); | ||
| 221 | const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); | ||
| 222 | const compiled = yield* compileOpenApiSpec(conversion.specText); | ||
| 223 | const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG); | ||
| 224 | |||
| 225 | const existing = yield* ctx.core.integrations.get(slug); | ||
| 226 | if (existing) { | ||
| 227 | return yield* new IntegrationAlreadyExistsError({ slug }); | ||
| 228 | } | ||
| 229 | |||
| 230 | const specHash = yield* sha256Hex(conversion.specText); | ||
| 231 | const integrationConfig: GoogleIntegrationConfig = { | ||
| 232 | specHash, | ||
| 233 | googleDiscoveryUrls: urls, | ||
| 234 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | ||
| 235 | ...(conversion.authenticationTemplate | ||
| 236 | ? { authenticationTemplate: conversion.authenticationTemplate } | ||
| 237 | : {}), | ||
| 238 | }; | ||
| 239 | |||
| 240 | yield* ctx.storage.putSpec(specHash, conversion.specText); | ||
| 241 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 242 | |||
| 243 | yield* ctx.transaction( | ||
| 244 | Effect.gen(function* () { | ||
| 245 | yield* ctx.core.integrations.register({ | ||
| 246 | slug, | ||
| 247 | name: config.name?.trim() || "Google", | ||
| 248 | description: config.description ?? "Google APIs", | ||
| 249 | config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 250 | canRemove: true, | ||
| 251 | canRefresh: true, | ||
| 252 | }); | ||
| 253 | yield* ctx.storage.putOperations( | ||
| 254 | String(slug), | ||
| 255 | openApiStoredOperationsFromCompiled(String(slug), compiled), | ||
| 256 | ); | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | |||
| 260 | // Default the health check to the light OAuth2 userinfo identity call | ||
| 261 | // added to every new bundle. Older bundles without oauth2/v2 can still | ||
| 262 | // fall back to the People API identity operation. | ||
| 263 | const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); | ||
| 264 | if (defaultHealthCheck) { | ||
| 265 | yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); | ||
| 266 | } | ||
| 267 | |||
| 268 | return { slug, toolCount: compiled.definitions.length }; | ||
| 269 | }); | ||
| 270 | |||
| 271 | const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) => | ||
| 272 | Effect.gen(function* () { | ||
| 273 | const slug = IntegrationSlug.make(rawSlug); | ||
| 274 | const record = yield* ctx.core.integrations.get(slug); | ||
| 275 | const current = record ? decodeGoogleIntegrationConfig(record.config) : null; | ||
| 276 | if (!record || !current) { | ||
| 277 | return yield* new IntegrationNotFoundError({ slug }); | ||
| 278 | } | ||
| 279 | |||
| 280 | const urls = yield* googleBundleUrlsWithIdentity( | ||
| 281 | input?.urls ?? current.googleDiscoveryUrls ?? [], | ||
| 282 | ); | ||
| 283 | const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); | ||
| 284 | const compiled = yield* compileOpenApiSpec(conversion.specText); | ||
| 285 | |||
| 286 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | ||
| 287 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | ||
| 288 | const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); | ||
| 289 | |||
| 290 | const specHash = yield* sha256Hex(conversion.specText); | ||
| 291 | yield* ctx.storage.putSpec(specHash, conversion.specText); | ||
| 292 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 293 | |||
| 294 | const nextConfig: GoogleIntegrationConfig = { | ||
| 295 | ...current, | ||
| 296 | specHash, | ||
| 297 | googleDiscoveryUrls: urls, | ||
| 298 | }; | ||
| 299 | |||
| 300 | yield* ctx.transaction( | ||
| 301 | Effect.gen(function* () { | ||
| 302 | yield* ctx.core.integrations.update(slug, { | ||
| 303 | config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 304 | }); | ||
| 305 | yield* ctx.storage.putOperations( | ||
| 306 | rawSlug, | ||
| 307 | openApiStoredOperationsFromCompiled(rawSlug, compiled), | ||
| 308 | ); | ||
| 309 | }), | ||
| 310 | ); | ||
| 311 | |||
| 312 | const connections = yield* ctx.connections.list({ integration: slug }); | ||
| 313 | yield* Effect.forEach( | ||
| 314 | connections, | ||
| 315 | (connection) => | ||
| 316 | ctx.connections | ||
| 317 | .refresh({ | ||
| 318 | owner: connection.owner, | ||
| 319 | integration: connection.integration, | ||
| 320 | name: connection.name, | ||
| 321 | }) | ||
| 322 | .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), | ||
| 323 | { discard: true }, | ||
| 324 | ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void)); | ||
| 325 | |||
| 326 | return { | ||
| 327 | slug, | ||
| 328 | toolCount: compiled.definitions.length, | ||
| 329 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | ||
| 330 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | ||
| 331 | }; | ||
| 332 | }); | ||
| 333 | |||
| 334 | return { | ||
| 335 | addBundle, | ||
| 336 | updateBundle, | ||
| 337 | removeBundle: (slug: string) => | ||
| 338 | ctx.transaction( | ||
| 339 | Effect.gen(function* () { | ||
| 340 | yield* ctx.storage.removeOperations(slug); | ||
| 341 | yield* ctx.core.integrations | ||
| 342 | .remove(IntegrationSlug.make(slug)) | ||
| 343 | .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); | ||
| 344 | }), | ||
| 345 | ), | ||
| 346 | getIntegration: (slug: string) => | ||
| 347 | ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( | ||
| 348 | Effect.map((record) => | ||
| 349 | record | ||
| 350 | ? ({ | ||
| 351 | slug: record.slug, | ||
| 352 | description: record.description, | ||
| 353 | kind: record.kind, | ||
| 354 | canRemove: record.canRemove, | ||
| 355 | canRefresh: record.canRefresh, | ||
| 356 | } as Integration) | ||
| 357 | : null, | ||
| 358 | ), | ||
| 359 | ), | ||
| 360 | getConfig: (slug: string) => | ||
| 361 | ctx.core.integrations | ||
| 362 | .get(IntegrationSlug.make(slug)) | ||
| 363 | .pipe( | ||
| 364 | Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)), | ||
| 365 | ), | ||
| 366 | configure: (slug: string, input: GoogleConfigureInput) => | ||
| 367 | ctx.transaction( | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); | ||
| 370 | if (!record) return [] as readonly Authentication[]; | ||
| 371 | const current = decodeGoogleIntegrationConfig(record.config); | ||
| 372 | if (!current) return [] as readonly Authentication[]; | ||
| 373 | |||
| 374 | const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | ||
| 375 | const merged = | ||
| 376 | input.mode === "replace" | ||
| 377 | ? incoming | ||
| 378 | : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | ||
| 379 | |||
| 380 | const next: GoogleIntegrationConfig = { | ||
| 381 | ...current, | ||
| 382 | authenticationTemplate: merged, | ||
| 383 | }; | ||
| 384 | |||
| 385 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { | ||
| 386 | config: next satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 387 | }); | ||
| 388 | |||
| 389 | return merged; | ||
| 390 | }), | ||
| 391 | ), | ||
| 392 | }; | ||
| 393 | }; | ||
| 394 | |||
| 395 | export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>; | ||
| 396 | |||
| 397 | export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ | ||
| 398 | id: "google" as const, | ||
| 399 | packageName: "@executor-js/plugin-google", | ||
| 400 | integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], | ||
| 401 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | ||
| 402 | |||
| 403 | extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx), | ||
| 404 | |||
| 405 | describeAuthMethods: describeGoogleAuthMethods, | ||
| 406 | describeIntegrationDisplay: describeGoogleIntegrationDisplay, | ||
| 407 | |||
| 408 | resolveTools: ({ integration, config, storage }) => | ||
| 409 | resolveOpenApiBackedTools({ integration, config, storage }), | ||
| 410 | |||
| 411 | invokeTool: ({ ctx, toolRow, credential, args }) => { | ||
| 412 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 413 | return invokeOpenApiBackedTool({ | ||
| 414 | ctx, | ||
| 415 | toolRow, | ||
| 416 | credential, | ||
| 417 | args, | ||
| 418 | httpClientLayer, | ||
| 419 | }); | ||
| 420 | }, | ||
| 421 | |||
| 422 | resolveAnnotations: ({ ctx, integration, toolRows }) => | ||
| 423 | resolveOpenApiBackedAnnotations({ | ||
| 424 | ctx, | ||
| 425 | integration: String(integration), | ||
| 426 | toolRows, | ||
| 427 | }), | ||
| 428 | |||
| 429 | // Health checks reuse the OpenAPI backing (same store). The People API | ||
| 430 | // identity call is auto-defaulted at addBundle when present; core owns the | ||
| 431 | // stored spec, the user adjusts it via the editor. | ||
| 432 | listHealthCheckCandidates: (input) => | ||
| 433 | listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), | ||
| 434 | checkHealth: (input) => | ||
| 435 | checkHealthOpenApi({ | ||
| 436 | ctx: input.ctx, | ||
| 437 | integration: input.integration, | ||
| 438 | credential: input.credential, | ||
| 439 | spec: input.spec, | ||
| 440 | httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, | ||
| 441 | }), | ||
| 442 | |||
| 443 | removeConnection: () => Effect.void, | ||
| 444 | |||
| 445 | detect: ({ ctx, url }) => | ||
| 446 | Effect.gen(function* () { | ||
| 447 | const trimmed = url.trim(); | ||
| 448 | const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); | ||
| 449 | if (!trimmed || !discoveryUrl) return null; | ||
| 450 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 451 | const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( | ||
| 452 | Effect.provide(httpClientLayer), | ||
| 453 | Effect.flatMap((documentText) => | ||
| 454 | convertGoogleDiscoveryBundleToOpenApi({ | ||
| 455 | documents: [{ discoveryUrl, documentText }], | ||
| 456 | }), | ||
| 457 | ), | ||
| 458 | Effect.catch(() => Effect.succeed(null)), | ||
| 459 | ); | ||
| 460 | if (!conversion) return null; | ||
| 461 | return IntegrationDetectionResult.make({ | ||
| 462 | kind: "google", | ||
| 463 | confidence: "high", | ||
| 464 | endpoint: discoveryUrl, | ||
| 465 | name: conversion.title, | ||
| 466 | slug: DEFAULT_GOOGLE_SLUG, | ||
| 467 | }); | ||
| 468 | }), | ||
| 469 | })); | ||
| @@ -1,29 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; | ||
| 4 | |||
| 5 | it("keeps Select all limited to Google services that can use normal user OAuth", () => { | ||
| 6 | const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id)); | ||
| 7 | |||
| 8 | expect(standardIds).toContain("google-calendar"); | ||
| 9 | expect(standardIds).toContain("google-gmail"); | ||
| 10 | expect(standardIds).toContain("google-tasks"); | ||
| 11 | expect(standardIds).toContain("google-people"); | ||
| 12 | expect(standardIds).toContain("google-search-console"); | ||
| 13 | |||
| 14 | expect(standardIds).not.toContain("google-youtube-data"); | ||
| 15 | expect(standardIds).not.toContain("google-cloud-resource-manager"); | ||
| 16 | expect(standardIds).not.toContain("google-chat"); | ||
| 17 | expect(standardIds).not.toContain("google-keep"); | ||
| 18 | expect(standardIds).not.toContain("google-admin-directory"); | ||
| 19 | expect(standardIds).not.toContain("google-admin-reports"); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("classifies every Google service for bundle OAuth UX", () => { | ||
| 23 | expect( | ||
| 24 | googleOpenApiPresets.map((preset) => ({ | ||
| 25 | id: preset.id, | ||
| 26 | oauthAudience: preset.oauthAudience, | ||
| 27 | })), | ||
| 28 | ).toMatchSnapshot(); | ||
| 29 | }); | ||
| @@ -1,122 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // GoogleProductPicker "View scopes" data source. | ||
| 3 | // | ||
| 4 | // The picker previews the OAuth consent BEFORE connecting by feeding each | ||
| 5 | // selected preset's representative scopes through `googleOAuthConsentBatches`. | ||
| 6 | // These tests pin that bridge: the consent-scope map is complete and the | ||
| 7 | // previewed batches match what the user is about to grant (core services | ||
| 8 | // collapsed into one batch; admin/cloud services split out). | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | import { expect, it } from "@effect/vitest"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | googleAudienceWarningsForUrls, | ||
| 15 | googleOAuthConsentScopes, | ||
| 16 | googleOAuthConsentScopesForPreset, | ||
| 17 | googleOpenApiPresets, | ||
| 18 | googlePresetForDiscoveryUrl, | ||
| 19 | type GoogleOpenApiPreset, | ||
| 20 | } from "./presets"; | ||
| 21 | import { googleOAuthConsentBatches } from "./oauth-batches"; | ||
| 22 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 23 | |||
| 24 | const batchInputsFor = (presetIds: readonly string[]) => | ||
| 25 | googleOpenApiPresets | ||
| 26 | .filter((preset: GoogleOpenApiPreset) => presetIds.includes(preset.id)) | ||
| 27 | .map((preset: GoogleOpenApiPreset) => ({ | ||
| 28 | id: preset.id, | ||
| 29 | name: preset.name, | ||
| 30 | oauthAudience: preset.oauthAudience, | ||
| 31 | scopes: googleOAuthConsentScopesForPreset(preset.id), | ||
| 32 | })); | ||
| 33 | |||
| 34 | it("declares representative consent scopes for every Google preset", () => { | ||
| 35 | for (const preset of googleOpenApiPresets) { | ||
| 36 | expect(googleOAuthConsentScopesForPreset(preset.id).length).toBeGreaterThan(0); | ||
| 37 | } | ||
| 38 | // No stray keys for presets that no longer exist. | ||
| 39 | const presetIds = new Set(googleOpenApiPresets.map((preset) => preset.id)); | ||
| 40 | for (const key of Object.keys(googleOAuthConsentScopes)) { | ||
| 41 | expect(presetIds.has(key)).toBe(true); | ||
| 42 | } | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("previews the featured selection as a single core-services consent batch", () => { | ||
| 46 | const featuredIds = googleOpenApiPresets | ||
| 47 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 48 | .map((preset: GoogleOpenApiPreset) => preset.id); | ||
| 49 | |||
| 50 | const batches = googleOAuthConsentBatches(batchInputsFor(featuredIds)); | ||
| 51 | |||
| 52 | // The featured presets are all standard-user, so they collapse into one batch. | ||
| 53 | expect(batches.map((batch) => batch.id)).toEqual(["google-core"]); | ||
| 54 | expect(batches[0]?.apiScopes).toEqual([ | ||
| 55 | "https://www.googleapis.com/auth/calendar", | ||
| 56 | "https://mail.google.com/", | ||
| 57 | "https://www.googleapis.com/auth/spreadsheets", | ||
| 58 | "https://www.googleapis.com/auth/drive", | ||
| 59 | "https://www.googleapis.com/auth/documents", | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("maps stored Discovery URLs back to presets and flags caution-tier audiences", () => { | ||
| 64 | // A normalized stored URL (trailing slash, sorted query) still resolves. | ||
| 65 | expect( | ||
| 66 | googlePresetForDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest/") | ||
| 67 | ?.id, | ||
| 68 | ).toBe("google-calendar"); | ||
| 69 | |||
| 70 | // A bundle of only standard/advanced APIs raises no audience warning. | ||
| 71 | expect( | ||
| 72 | googleAudienceWarningsForUrls([ | ||
| 73 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 74 | "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", | ||
| 75 | ]), | ||
| 76 | ).toEqual([]); | ||
| 77 | |||
| 78 | // Admin-only (Chat) and unsupported-consent (Keep) APIs both flag. | ||
| 79 | expect( | ||
| 80 | [ | ||
| 81 | ...googleAudienceWarningsForUrls([ | ||
| 82 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 83 | "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", | ||
| 84 | "https://keep.googleapis.com/$discovery/rest?version=v1", | ||
| 85 | ]), | ||
| 86 | ].sort(), | ||
| 87 | ).toEqual(["unsupported-user", "workspace-admin"]); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("previews a consent set that matches what the bundle persists (compaction parity)", () => { | ||
| 91 | // The bundle converter runs the unioned Discovery scopes through | ||
| 92 | // `compactGoogleOAuthScopes` before persisting the auth template. The picker | ||
| 93 | // preview must agree: feeding every preview batch's scopes back through the | ||
| 94 | // same compaction is idempotent (already collapsed/filtered), so the previewed | ||
| 95 | // grant is exactly the persisted/requested grant. | ||
| 96 | const allPresetIds = googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => preset.id); | ||
| 97 | const batches = googleOAuthConsentBatches(batchInputsFor(allPresetIds)); | ||
| 98 | for (const batch of batches) { | ||
| 99 | expect(compactGoogleOAuthScopes(batch.apiScopes)).toEqual([...batch.apiScopes]); | ||
| 100 | } | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("splits a mixed selection into core + per-service consent batches", () => { | ||
| 104 | const batches = googleOAuthConsentBatches( | ||
| 105 | batchInputsFor(["google-calendar", "google-youtube-data", "google-bigquery"]), | ||
| 106 | ); | ||
| 107 | |||
| 108 | expect(batches.map((batch) => ({ id: batch.id, apiScopes: batch.apiScopes }))).toEqual([ | ||
| 109 | { | ||
| 110 | id: "google-core", | ||
| 111 | apiScopes: ["https://www.googleapis.com/auth/calendar"], | ||
| 112 | }, | ||
| 113 | { | ||
| 114 | id: "google-youtube-data", | ||
| 115 | apiScopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 116 | }, | ||
| 117 | { | ||
| 118 | id: "google-cloud", | ||
| 119 | apiScopes: ["https://www.googleapis.com/auth/bigquery"], | ||
| 120 | }, | ||
| 121 | ]); | ||
| 122 | }); | ||
| @@ -1,24 +0,0 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "lib": ["ES2022", "DOM"], | ||
| 9 | "types": ["bun-types", "node"], | ||
| 10 | "noUnusedLocals": true, | ||
| 11 | "noImplicitOverride": true, | ||
| 12 | "jsx": "react-jsx", | ||
| 13 | "plugins": [ | ||
| 14 | { | ||
| 15 | "name": "@effect/language-service", | ||
| 16 | "ignoreEffectSuggestionsInTscExitCode": true, | ||
| 17 | "ignoreEffectWarningsInTscExitCode": true, | ||
| 18 | "diagnosticSeverity": {} | ||
| 19 | } | ||
| 20 | ] | ||
| 21 | }, | ||
| 22 | "include": ["src/**/*.ts", "src/**/*.tsx"], | ||
| 23 | "exclude": ["src/promise.ts"] | ||
| 24 | } | ||
| @@ -1,14 +0,0 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | index: "src/promise.ts", | ||
| 6 | core: "src/sdk/index.ts", | ||
| 7 | client: "src/react/plugin-client.tsx", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: false, | ||
| 11 | sourcemap: true, | ||
| 12 | clean: true, | ||
| 13 | external: [/^@executor-js\//, /^effect/, /^@effect\//], | ||
| 14 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | // `.test.ts` only. React component tests are `.test.tsx` and need a DOM | ||
| 6 | // env, so they're excluded; the plugin-side converters are pure `.ts` | ||
| 7 | // logic and run here from `src/react/`. | ||
| 8 | include: ["src/**/*.test.ts"], | ||
| 9 | exclude: ["src/api/**", "src/react/**/*.test.tsx"], | ||
| 10 | testTimeout: 15_000, | ||
| 11 | }, | ||
| 12 | }); | ||
| @@ -23,7 +23,7 @@ import { · 22 unmodified lines | |||
| 23 | useSlugAlreadyExists, | 23 | useSlugAlreadyExists, |
| 24 | } from "@executor-js/react/lib/integration-add"; | 24 | } from "@executor-js/react/lib/integration-add"; |
| 25 | 25 | ||
| 26 | import { addGraphqlIntegrationOptimistic } from "./atoms"; | 26 | import { createGraphqlIntegrationOptimistic } from "./atoms"; |
| 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 29 | import type { GraphqlAuthMethodInput } from "../sdk/types"; | 29 | import type { GraphqlAuthMethodInput } from "../sdk/types"; |
| @@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines | |||
| 52 | const [adding, setAdding] = useState(false); | 52 | const [adding, setAdding] = useState(false); |
| 53 | const [addError, setAddError] = useState<string | null>(null); | 53 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 54 | ||
| 55 | const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, { | 55 | const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, { |
| 56 | mode: "promiseExit", | 56 | mode: "promiseExit", |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| @@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines | |||
| 33 | // Mutation atoms | 33 | // Mutation atoms |
| 34 | // --------------------------------------------------------------------------- | 34 | // --------------------------------------------------------------------------- |
| 35 | 35 | ||
| 36 | export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration"); | 36 | export const createGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration"); |
| 37 | 37 | ||
| 38 | // Merge-append custom auth methods onto an integration's `authenticationTemplate`. | 38 | // Merge-append custom auth methods onto an integration's `authenticationTemplate`. |
| 39 | export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); | 39 | export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); |
| @@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line | |||
| 41 | // Optimistically slot a pending row into the shared integration catalog so the | 41 | // Optimistically slot a pending row into the shared integration catalog so the |
| 42 | // integrations list reflects the new GraphQL integration before the server | 42 | // integrations list reflects the new GraphQL integration before the server |
| 43 | // round-trips. The reducer mirrors the catalog `Integration` shape. | 43 | // round-trips. The reducer mirrors the catalog `Integration` shape. |
| 44 | export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | 44 | export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( |
| 45 | Atom.optimisticFn({ | 45 | Atom.optimisticFn({ |
| 46 | reducer: ( | 46 | reducer: ( |
| 47 | current, | 47 | current, |
| @@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines | |||
| 69 | a.name.localeCompare(b.name), | 69 | a.name.localeCompare(b.name), |
| 70 | ); | 70 | ); |
| 71 | }), | 71 | }), |
| 72 | fn: addGraphqlIntegration, | 72 | fn: createGraphqlIntegration, |
| 73 | }), | 73 | }), |
| 74 | ); | 74 | ); |
| @@ -1,3 +1,3 @@ | |||
| 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; | 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; |
| 2 | export { GraphqlClient } from "./client"; | 2 | export { GraphqlClient } from "./client"; |
| 3 | export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms"; | 3 | export { createGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms"; |
| @@ -1,141 +0,0 @@ | |||
| 1 | # @executor-js/plugin-microsoft | ||
| 2 | |||
| 3 | ## 1.5.28 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.5.29 | ||
| 9 | - @executor-js/plugin-openapi@1.5.29 | ||
| 10 | - @executor-js/api@1.4.49 | ||
| 11 | - @executor-js/react@1.4.49 | ||
| 12 | |||
| 13 | ## 1.5.27 | ||
| 14 | |||
| 15 | ### Patch Changes | ||
| 16 | |||
| 17 | - Updated dependencies [[`1c48182`](https://github.com/UsefulSoftwareCo/executor/commit/1c4818254e71dc4ee27ff95f489e2c5cf330a450)]: | ||
| 18 | - @executor-js/sdk@1.5.28 | ||
| 19 | - @executor-js/api@1.4.48 | ||
| 20 | - @executor-js/plugin-openapi@1.5.28 | ||
| 21 | - @executor-js/react@1.4.48 | ||
| 22 | |||
| 23 | ## 1.5.26 | ||
| 24 | |||
| 25 | ### Patch Changes | ||
| 26 | |||
| 27 | - Updated dependencies [[`c7ab1e2`](https://github.com/RhysSullivan/executor/commit/c7ab1e2d56884e0453af85f6399fd25a39f04785)]: | ||
| 28 | - @executor-js/api@1.4.47 | ||
| 29 | - @executor-js/plugin-openapi@1.5.27 | ||
| 30 | - @executor-js/react@1.4.47 | ||
| 31 | - @executor-js/sdk@1.5.27 | ||
| 32 | |||
| 33 | ## 1.5.25 | ||
| 34 | |||
| 35 | ### Patch Changes | ||
| 36 | |||
| 37 | - Updated dependencies []: | ||
| 38 | - @executor-js/sdk@1.5.26 | ||
| 39 | - @executor-js/plugin-openapi@1.5.26 | ||
| 40 | - @executor-js/api@1.4.46 | ||
| 41 | - @executor-js/react@1.4.46 | ||
| 42 | |||
| 43 | ## 1.5.24 | ||
| 44 | |||
| 45 | ### Patch Changes | ||
| 46 | |||
| 47 | - Updated dependencies [[`dc9bf71`](https://github.com/RhysSullivan/executor/commit/dc9bf717b81a3b719a137b25d01a8fd28e6cd699)]: | ||
| 48 | - @executor-js/plugin-openapi@1.5.25 | ||
| 49 | - @executor-js/sdk@1.5.25 | ||
| 50 | - @executor-js/api@1.4.45 | ||
| 51 | - @executor-js/react@1.4.45 | ||
| 52 | |||
| 53 | ## 1.5.23 | ||
| 54 | |||
| 55 | ### Patch Changes | ||
| 56 | |||
| 57 | - Updated dependencies []: | ||
| 58 | - @executor-js/sdk@1.5.24 | ||
| 59 | - @executor-js/plugin-openapi@1.5.24 | ||
| 60 | - @executor-js/api@1.4.44 | ||
| 61 | - @executor-js/react@1.4.44 | ||
| 62 | |||
| 63 | ## 1.5.22 | ||
| 64 | |||
| 65 | ### Patch Changes | ||
| 66 | |||
| 67 | - Updated dependencies [[`29936d5`](https://github.com/RhysSullivan/executor/commit/29936d5981256f8f953797d9ce8ce073ac6a0b6a), [`29936d5`](https://github.com/RhysSullivan/executor/commit/29936d5981256f8f953797d9ce8ce073ac6a0b6a)]: | ||
| 68 | - @executor-js/api@1.4.43 | ||
| 69 | - @executor-js/react@1.4.43 | ||
| 70 | - @executor-js/plugin-openapi@1.5.23 | ||
| 71 | - @executor-js/sdk@1.5.23 | ||
| 72 | |||
| 73 | ## 1.5.21 | ||
| 74 | |||
| 75 | ### Patch Changes | ||
| 76 | |||
| 77 | - Updated dependencies [[`1a1f9aa`](https://github.com/RhysSullivan/executor/commit/1a1f9aaae4e4d0f73311fd643919cdfaa637c124)]: | ||
| 78 | - @executor-js/plugin-openapi@1.5.22 | ||
| 79 | - @executor-js/sdk@1.5.22 | ||
| 80 | - @executor-js/api@1.4.42 | ||
| 81 | - @executor-js/react@1.4.42 | ||
| 82 | |||
| 83 | ## 1.5.20 | ||
| 84 | |||
| 85 | ### Patch Changes | ||
| 86 | |||
| 87 | - Updated dependencies [[`4b361b9`](https://github.com/RhysSullivan/executor/commit/4b361b9f7220f679f582137f5375b29c3b72f919)]: | ||
| 88 | - @executor-js/plugin-openapi@1.5.21 | ||
| 89 | - @executor-js/sdk@1.5.21 | ||
| 90 | - @executor-js/api@1.4.41 | ||
| 91 | - @executor-js/react@1.4.41 | ||
| 92 | |||
| 93 | ## 1.5.19 | ||
| 94 | |||
| 95 | ### Patch Changes | ||
| 96 | |||
| 97 | - Updated dependencies []: | ||
| 98 | - @executor-js/sdk@1.5.20 | ||
| 99 | - @executor-js/plugin-openapi@1.5.20 | ||
| 100 | - @executor-js/api@1.4.40 | ||
| 101 | - @executor-js/react@1.4.40 | ||
| 102 | |||
| 103 | ## 1.5.18 | ||
| 104 | |||
| 105 | ### Patch Changes | ||
| 106 | |||
| 107 | - Updated dependencies []: | ||
| 108 | - @executor-js/sdk@1.5.19 | ||
| 109 | - @executor-js/plugin-openapi@1.5.19 | ||
| 110 | - @executor-js/api@1.4.39 | ||
| 111 | - @executor-js/react@1.4.39 | ||
| 112 | |||
| 113 | ## 1.5.17 | ||
| 114 | |||
| 115 | ### Patch Changes | ||
| 116 | |||
| 117 | - Updated dependencies []: | ||
| 118 | - @executor-js/sdk@1.5.18 | ||
| 119 | - @executor-js/plugin-openapi@1.5.18 | ||
| 120 | - @executor-js/api@1.4.38 | ||
| 121 | - @executor-js/react@1.4.38 | ||
| 122 | |||
| 123 | ## 1.5.16 | ||
| 124 | |||
| 125 | ### Patch Changes | ||
| 126 | |||
| 127 | - Updated dependencies []: | ||
| 128 | - @executor-js/sdk@1.5.17 | ||
| 129 | - @executor-js/plugin-openapi@1.5.17 | ||
| 130 | - @executor-js/api@1.4.37 | ||
| 131 | - @executor-js/react@1.4.37 | ||
| 132 | |||
| 133 | ## 1.5.15 | ||
| 134 | |||
| 135 | ### Patch Changes | ||
| 136 | |||
| 137 | - Updated dependencies []: | ||
| 138 | - @executor-js/sdk@1.5.16 | ||
| 139 | - @executor-js/plugin-openapi@1.5.16 | ||
| 140 | - @executor-js/api@1.4.36 | ||
| 141 | - @executor-js/react@1.4.36 | ||
| @@ -1,98 +0,0 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor-js/plugin-microsoft", | ||
| 3 | "version": "1.5.28", | ||
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/microsoft", | ||
| 5 | "bugs": { | ||
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | ||
| 7 | }, | ||
| 8 | "license": "MIT", | ||
| 9 | "repository": { | ||
| 10 | "type": "git", | ||
| 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | ||
| 12 | "directory": "packages/plugins/microsoft" | ||
| 13 | }, | ||
| 14 | "files": [ | ||
| 15 | "dist" | ||
| 16 | ], | ||
| 17 | "type": "module", | ||
| 18 | "exports": { | ||
| 19 | ".": "./src/sdk/index.ts", | ||
| 20 | "./promise": "./src/promise.ts", | ||
| 21 | "./api": "./src/api/index.ts", | ||
| 22 | "./react": "./src/react/index.ts", | ||
| 23 | "./client": "./src/react/plugin-client.tsx" | ||
| 24 | }, | ||
| 25 | "publishConfig": { | ||
| 26 | "access": "public", | ||
| 27 | "exports": { | ||
| 28 | ".": { | ||
| 29 | "import": { | ||
| 30 | "types": "./dist/promise.d.ts", | ||
| 31 | "default": "./dist/index.js" | ||
| 32 | } | ||
| 33 | }, | ||
| 34 | "./core": { | ||
| 35 | "import": { | ||
| 36 | "types": "./dist/sdk/index.d.ts", | ||
| 37 | "default": "./dist/core.js" | ||
| 38 | } | ||
| 39 | }, | ||
| 40 | "./client": { | ||
| 41 | "import": { | ||
| 42 | "types": "./dist/react/plugin-client.d.ts", | ||
| 43 | "default": "./dist/client.js" | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | }, | ||
| 48 | "scripts": { | ||
| 49 | "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", | ||
| 50 | "typecheck": "tsgo --noEmit", | ||
| 51 | "test": "vitest run", | ||
| 52 | "test:watch": "vitest", | ||
| 53 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | ||
| 54 | }, | ||
| 55 | "dependencies": { | ||
| 56 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 57 | "@executor-js/sdk": "workspace:*", | ||
| 58 | "lucide-react": "^1.7.0" | ||
| 59 | }, | ||
| 60 | "devDependencies": { | ||
| 61 | "@effect/atom-react": "catalog:", | ||
| 62 | "@effect/vitest": "catalog:", | ||
| 63 | "@executor-js/api": "workspace:*", | ||
| 64 | "@executor-js/react": "workspace:*", | ||
| 65 | "@types/node": "catalog:", | ||
| 66 | "@types/react": "catalog:", | ||
| 67 | "bun-types": "catalog:", | ||
| 68 | "effect": "catalog:", | ||
| 69 | "react": "catalog:", | ||
| 70 | "tsup": "catalog:", | ||
| 71 | "vitest": "catalog:" | ||
| 72 | }, | ||
| 73 | "peerDependencies": { | ||
| 74 | "@effect/atom-react": "catalog:", | ||
| 75 | "@executor-js/api": "workspace:*", | ||
| 76 | "@executor-js/react": "workspace:*", | ||
| 77 | "@tanstack/react-router": "catalog:", | ||
| 78 | "effect": "catalog:", | ||
| 79 | "react": "catalog:" | ||
| 80 | }, | ||
| 81 | "peerDependenciesMeta": { | ||
| 82 | "react": { | ||
| 83 | "optional": true | ||
| 84 | }, | ||
| 85 | "@effect/atom-react": { | ||
| 86 | "optional": true | ||
| 87 | }, | ||
| 88 | "@tanstack/react-router": { | ||
| 89 | "optional": true | ||
| 90 | }, | ||
| 91 | "@executor-js/api": { | ||
| 92 | "optional": true | ||
| 93 | }, | ||
| 94 | "@executor-js/react": { | ||
| 95 | "optional": true | ||
| 96 | } | ||
| 97 | } | ||
| 98 | } | ||
| @@ -1,163 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 4 | import { | ||
| 5 | InternalError, | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationNotFoundError, | ||
| 8 | IntegrationSlug, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 10 | import { | ||
| 11 | OpenApiExtractionError, | ||
| 12 | OpenApiOAuthError, | ||
| 13 | OpenApiParseError, | ||
| 14 | } from "@executor-js/plugin-openapi"; | ||
| 15 | |||
| 16 | const DomainErrors = [ | ||
| 17 | InternalError, | ||
| 18 | OpenApiParseError, | ||
| 19 | OpenApiExtractionError, | ||
| 20 | OpenApiOAuthError, | ||
| 21 | IntegrationAlreadyExistsError, | ||
| 22 | ] as const; | ||
| 23 | |||
| 24 | const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | ||
| 25 | |||
| 26 | const UpdateErrors = [ | ||
| 27 | InternalError, | ||
| 28 | OpenApiParseError, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | OpenApiOAuthError, | ||
| 31 | IntegrationNotFound, | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | const SlugParams = { | ||
| 35 | slug: Schema.String, | ||
| 36 | }; | ||
| 37 | |||
| 38 | const OAuthTemplatePayload = Schema.Struct({ | ||
| 39 | slug: Schema.String, | ||
| 40 | kind: Schema.Literal("oauth2"), | ||
| 41 | authorizationUrl: Schema.String, | ||
| 42 | tokenUrl: Schema.String, | ||
| 43 | scopes: Schema.Array(Schema.String), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); | ||
| 47 | const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); | ||
| 48 | |||
| 49 | const AddGraphPayload = Schema.Struct({ | ||
| 50 | presetIds: Schema.Array(Schema.String), | ||
| 51 | customScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 52 | slug: Schema.optional(Schema.String), | ||
| 53 | name: Schema.optional(Schema.String), | ||
| 54 | description: Schema.optional(Schema.String), | ||
| 55 | baseUrl: Schema.optional(Schema.String), | ||
| 56 | specUrl: Schema.optional(Schema.String), | ||
| 57 | authorizationUrl: Schema.optional(Schema.String), | ||
| 58 | tokenUrl: Schema.optional(Schema.String), | ||
| 59 | clientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 60 | }); | ||
| 61 | |||
| 62 | const AddGraphResponse = Schema.Struct({ | ||
| 63 | slug: IntegrationSlug, | ||
| 64 | toolCount: Schema.Number, | ||
| 65 | }); | ||
| 66 | |||
| 67 | const UpdateGraphPayload = Schema.Struct({ | ||
| 68 | presetIds: Schema.optional(Schema.Array(Schema.String)), | ||
| 69 | customScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 70 | baseUrl: Schema.optional(Schema.String), | ||
| 71 | specUrl: Schema.optional(Schema.String), | ||
| 72 | authorizationUrl: Schema.optional(Schema.String), | ||
| 73 | tokenUrl: Schema.optional(Schema.String), | ||
| 74 | clientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 75 | }); | ||
| 76 | |||
| 77 | const UpdateGraphResponse = Schema.Struct({ | ||
| 78 | slug: IntegrationSlug, | ||
| 79 | toolCount: Schema.Number, | ||
| 80 | addedTools: Schema.Array(Schema.String), | ||
| 81 | removedTools: Schema.Array(Schema.String), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const IntegrationView = Schema.Struct({ | ||
| 85 | slug: IntegrationSlug, | ||
| 86 | description: Schema.String, | ||
| 87 | kind: Schema.String, | ||
| 88 | canRemove: Schema.Boolean, | ||
| 89 | canRefresh: Schema.Boolean, | ||
| 90 | }); | ||
| 91 | |||
| 92 | const MicrosoftConfigView = Schema.Struct({ | ||
| 93 | sourceUrl: Schema.optional(Schema.String), | ||
| 94 | microsoftGraphPresetIds: Schema.optional(Schema.Array(Schema.String)), | ||
| 95 | microsoftGraphCustomScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 96 | microsoftGraphScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 97 | microsoftGraphExactPaths: Schema.optional(Schema.Array(Schema.String)), | ||
| 98 | microsoftGraphPathPrefixes: Schema.optional(Schema.Array(Schema.String)), | ||
| 99 | microsoftGraphTagPrefixes: Schema.optional(Schema.Array(Schema.String)), | ||
| 100 | microsoftGraphCoversFullGraph: Schema.optional(Schema.Boolean), | ||
| 101 | microsoftGraphAuthorizationUrl: Schema.optional(Schema.String), | ||
| 102 | microsoftGraphTokenUrl: Schema.optional(Schema.String), | ||
| 103 | microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 104 | baseUrl: Schema.optional(Schema.String), | ||
| 105 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 106 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 107 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), | ||
| 108 | }); | ||
| 109 | |||
| 110 | const ConfigurePayload = Schema.Struct({ | ||
| 111 | authenticationTemplate: Schema.Array(AuthenticationPayload), | ||
| 112 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 113 | }); | ||
| 114 | |||
| 115 | const ConfigureResponse = Schema.Struct({ | ||
| 116 | authenticationTemplate: Schema.Array(AuthenticationResponse), | ||
| 117 | }); | ||
| 118 | |||
| 119 | export const MicrosoftGroup = HttpApiGroup.make("microsoft") | ||
| 120 | .add( | ||
| 121 | HttpApiEndpoint.post("addGraph", "/microsoft/graph", { | ||
| 122 | payload: AddGraphPayload, | ||
| 123 | success: AddGraphResponse, | ||
| 124 | error: DomainErrors, | ||
| 125 | }), | ||
| 126 | ) | ||
| 127 | .add( | ||
| 128 | HttpApiEndpoint.get("getIntegration", "/microsoft/integrations/:slug", { | ||
| 129 | params: SlugParams, | ||
| 130 | success: Schema.NullOr(IntegrationView), | ||
| 131 | error: DomainErrors, | ||
| 132 | }), | ||
| 133 | ) | ||
| 134 | .add( | ||
| 135 | HttpApiEndpoint.get("getConfig", "/microsoft/integrations/:slug/config", { | ||
| 136 | params: SlugParams, | ||
| 137 | success: Schema.NullOr(MicrosoftConfigView), | ||
| 138 | error: DomainErrors, | ||
| 139 | }), | ||
| 140 | ) | ||
| 141 | .add( | ||
| 142 | HttpApiEndpoint.post("configure", "/microsoft/integrations/:slug/config", { | ||
| 143 | params: SlugParams, | ||
| 144 | payload: ConfigurePayload, | ||
| 145 | success: ConfigureResponse, | ||
| 146 | error: DomainErrors, | ||
| 147 | }), | ||
| 148 | ) | ||
| 149 | .add( | ||
| 150 | HttpApiEndpoint.post("updateGraph", "/microsoft/integrations/:slug/graph", { | ||
| 151 | params: SlugParams, | ||
| 152 | payload: UpdateGraphPayload, | ||
| 153 | success: UpdateGraphResponse, | ||
| 154 | error: UpdateErrors, | ||
| 155 | }), | ||
| 156 | ) | ||
| 157 | .add( | ||
| 158 | HttpApiEndpoint.delete("removeGraph", "/microsoft/integrations/:slug", { | ||
| 159 | params: SlugParams, | ||
| 160 | success: Schema.Void, | ||
| 161 | error: DomainErrors, | ||
| 162 | }), | ||
| 163 | ); | ||
| @@ -1,144 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { MicrosoftPluginExtension } from "../sdk/plugin"; | ||
| 6 | import { MicrosoftGroup } from "./group"; | ||
| 7 | |||
| 8 | export class MicrosoftExtensionService extends Context.Service< | ||
| 9 | MicrosoftExtensionService, | ||
| 10 | MicrosoftPluginExtension | ||
| 11 | >()("MicrosoftExtensionService") {} | ||
| 12 | |||
| 13 | const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup); | ||
| 14 | |||
| 15 | export const MicrosoftHandlers = HttpApiBuilder.group( | ||
| 16 | ExecutorApiWithMicrosoft, | ||
| 17 | "microsoft", | ||
| 18 | (handlers) => | ||
| 19 | handlers | ||
| 20 | .handle("addGraph", ({ payload }) => | ||
| 21 | capture( | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const ext = yield* MicrosoftExtensionService; | ||
| 24 | return yield* ext.addGraph({ | ||
| 25 | presetIds: payload.presetIds, | ||
| 26 | customScopes: payload.customScopes, | ||
| 27 | slug: payload.slug, | ||
| 28 | name: payload.name, | ||
| 29 | description: payload.description, | ||
| 30 | baseUrl: payload.baseUrl, | ||
| 31 | specUrl: payload.specUrl, | ||
| 32 | authorizationUrl: payload.authorizationUrl, | ||
| 33 | tokenUrl: payload.tokenUrl, | ||
| 34 | clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl, | ||
| 35 | }); | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | ) | ||
| 39 | .handle("getIntegration", ({ params }) => | ||
| 40 | capture( | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const ext = yield* MicrosoftExtensionService; | ||
| 43 | const integration = yield* ext.getIntegration(params.slug); | ||
| 44 | return integration | ||
| 45 | ? { | ||
| 46 | slug: integration.slug, | ||
| 47 | description: integration.description, | ||
| 48 | kind: integration.kind, | ||
| 49 | canRemove: integration.canRemove, | ||
| 50 | canRefresh: integration.canRefresh, | ||
| 51 | } | ||
| 52 | : null; | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ) | ||
| 56 | .handle("getConfig", ({ params }) => | ||
| 57 | capture( | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const ext = yield* MicrosoftExtensionService; | ||
| 60 | const config = yield* ext.getConfig(params.slug); | ||
| 61 | return config | ||
| 62 | ? { | ||
| 63 | sourceUrl: config.sourceUrl, | ||
| 64 | microsoftGraphPresetIds: config.microsoftGraphPresetIds | ||
| 65 | ? [...config.microsoftGraphPresetIds] | ||
| 66 | : undefined, | ||
| 67 | microsoftGraphCustomScopes: config.microsoftGraphCustomScopes | ||
| 68 | ? [...config.microsoftGraphCustomScopes] | ||
| 69 | : undefined, | ||
| 70 | microsoftGraphScopes: config.microsoftGraphScopes | ||
| 71 | ? [...config.microsoftGraphScopes] | ||
| 72 | : undefined, | ||
| 73 | microsoftGraphExactPaths: config.microsoftGraphExactPaths | ||
| 74 | ? [...config.microsoftGraphExactPaths] | ||
| 75 | : undefined, | ||
| 76 | microsoftGraphPathPrefixes: config.microsoftGraphPathPrefixes | ||
| 77 | ? [...config.microsoftGraphPathPrefixes] | ||
| 78 | : undefined, | ||
| 79 | microsoftGraphTagPrefixes: config.microsoftGraphTagPrefixes | ||
| 80 | ? [...config.microsoftGraphTagPrefixes] | ||
| 81 | : undefined, | ||
| 82 | microsoftGraphCoversFullGraph: config.microsoftGraphCoversFullGraph, | ||
| 83 | microsoftGraphAuthorizationUrl: config.microsoftGraphAuthorizationUrl, | ||
| 84 | microsoftGraphTokenUrl: config.microsoftGraphTokenUrl, | ||
| 85 | microsoftGraphClientCredentialsTokenUrl: | ||
| 86 | config.microsoftGraphClientCredentialsTokenUrl, | ||
| 87 | baseUrl: config.baseUrl, | ||
| 88 | headers: config.headers ? { ...config.headers } : undefined, | ||
| 89 | queryParams: config.queryParams ? { ...config.queryParams } : undefined, | ||
| 90 | authenticationTemplate: config.authenticationTemplate | ||
| 91 | ? [...config.authenticationTemplate] | ||
| 92 | : undefined, | ||
| 93 | } | ||
| 94 | : null; | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | ) | ||
| 98 | .handle("configure", ({ params, payload }) => | ||
| 99 | capture( | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const ext = yield* MicrosoftExtensionService; | ||
| 102 | const authenticationTemplate = yield* ext.configure(params.slug, { | ||
| 103 | authenticationTemplate: payload.authenticationTemplate, | ||
| 104 | mode: payload.mode ?? "merge", | ||
| 105 | }); | ||
| 106 | return { authenticationTemplate: [...authenticationTemplate] }; | ||
| 107 | }), | ||
| 108 | ), | ||
| 109 | ) | ||
| 110 | .handle("updateGraph", ({ params, payload }) => | ||
| 111 | capture( | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const ext = yield* MicrosoftExtensionService; | ||
| 114 | const result = yield* ext.updateGraph(params.slug, { | ||
| 115 | ...(payload.presetIds !== undefined ? { presetIds: payload.presetIds } : {}), | ||
| 116 | ...(payload.customScopes !== undefined ? { customScopes: payload.customScopes } : {}), | ||
| 117 | ...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}), | ||
| 118 | ...(payload.specUrl !== undefined ? { specUrl: payload.specUrl } : {}), | ||
| 119 | ...(payload.authorizationUrl !== undefined | ||
| 120 | ? { authorizationUrl: payload.authorizationUrl } | ||
| 121 | : {}), | ||
| 122 | ...(payload.tokenUrl !== undefined ? { tokenUrl: payload.tokenUrl } : {}), | ||
| 123 | ...(payload.clientCredentialsTokenUrl !== undefined | ||
| 124 | ? { clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl } | ||
| 125 | : {}), | ||
| 126 | }); | ||
| 127 | return { | ||
| 128 | slug: result.slug, | ||
| 129 | toolCount: result.toolCount, | ||
| 130 | addedTools: [...result.addedTools], | ||
| 131 | removedTools: [...result.removedTools], | ||
| 132 | }; | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | ) | ||
| 136 | .handle("removeGraph", ({ params }) => | ||
| 137 | capture( | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const ext = yield* MicrosoftExtensionService; | ||
| 140 | yield* ext.removeGraph(params.slug); | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | ); | ||
| @@ -1,15 +0,0 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 3 | import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin"; | ||
| 4 | import { MicrosoftGroup } from "./group"; | ||
| 5 | import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers"; | ||
| 6 | |||
| 7 | export { MicrosoftGroup } from "./group"; | ||
| 8 | export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers"; | ||
| 9 | |||
| 10 | export const microsoftHttpPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({ | ||
| 11 | ...microsoftPlugin(options), | ||
| 12 | routes: () => MicrosoftGroup, | ||
| 13 | handlers: () => MicrosoftHandlers, | ||
| 14 | extensionService: MicrosoftExtensionService, | ||
| 15 | })); | ||
| @@ -1 +0,0 @@ | |||
| 1 | export { microsoftPlugin } from "./sdk/plugin"; | ||
| @@ -1,155 +0,0 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | ||
| 2 | import { useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | |||
| 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { | ||
| 7 | slugifyNamespace, | ||
| 8 | useIntegrationIdentity, | ||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | ||
| 12 | import { | ||
| 13 | addIntegrationErrorMessage, | ||
| 14 | FormErrorAlert, | ||
| 15 | SlugCollisionAlert, | ||
| 16 | useSlugAlreadyExists, | ||
| 17 | } from "@executor-js/react/lib/integration-add"; | ||
| 18 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | ||
| 19 | |||
| 20 | import { addMicrosoftGraph } from "./atoms"; | ||
| 21 | import { MicrosoftScopePicker } from "./MicrosoftScopePicker"; | ||
| 22 | import { | ||
| 23 | MICROSOFT_GRAPH_BASE_URL, | ||
| 24 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 25 | microsoftGraphScopesForPresetIds, | ||
| 26 | } from "../sdk/presets"; | ||
| 27 | |||
| 28 | const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico"; | ||
| 29 | |||
| 30 | const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 31 | |||
| 32 | export default function AddMicrosoftSource(props: { | ||
| 33 | onComplete: (slug?: string) => void; | ||
| 34 | onCancel: () => void; | ||
| 35 | initialNamespace?: string; | ||
| 36 | }) { | ||
| 37 | const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(defaultPresetIds); | ||
| 38 | const [customScopes, setCustomScopes] = useState<readonly string[]>([]); | ||
| 39 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 40 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | ||
| 41 | const [adding, setAdding] = useState(false); | ||
| 42 | const [addError, setAddError] = useState<string | null>(null); | ||
| 43 | |||
| 44 | const identity = useIntegrationIdentity({ | ||
| 45 | fallbackName: "Microsoft Graph", | ||
| 46 | fallbackNamespace: props.initialNamespace ?? "microsoft_graph", | ||
| 47 | }); | ||
| 48 | |||
| 49 | const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); | ||
| 50 | const selectedScopes = useMemo( | ||
| 51 | () => microsoftGraphScopesForPresetIds(selectedIds, customScopes), | ||
| 52 | [selectedIds, customScopes], | ||
| 53 | ); | ||
| 54 | |||
| 55 | const togglePreset = useCallback((presetId: string, checked: boolean) => { | ||
| 56 | setSelectedPresetIds((current: ReadonlySet<string>) => { | ||
| 57 | const next = new Set(current); | ||
| 58 | if (checked) next.add(presetId); | ||
| 59 | else next.delete(presetId); | ||
| 60 | return next; | ||
| 61 | }); | ||
| 62 | }, []); | ||
| 63 | |||
| 64 | const addCustomScope = useCallback((scope: string) => { | ||
| 65 | setCustomScopes((current: readonly string[]) => | ||
| 66 | current.includes(scope) ? current : [...current, scope], | ||
| 67 | ); | ||
| 68 | }, []); | ||
| 69 | |||
| 70 | const removeCustomScope = useCallback((scope: string) => { | ||
| 71 | setCustomScopes((current: readonly string[]) => | ||
| 72 | current.filter((entry: string) => entry !== scope), | ||
| 73 | ); | ||
| 74 | }, []); | ||
| 75 | |||
| 76 | const doAdd = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" }); | ||
| 77 | |||
| 78 | const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph"; | ||
| 79 | const resolvedDisplayName = identity.name.trim() || "Microsoft Graph"; | ||
| 80 | const resolvedDescription = descriptionDraft ?? "Selected Microsoft Graph workloads."; | ||
| 81 | const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | ||
| 82 | const canAdd = selectedIds.length > 0 && !slugAlreadyExists; | ||
| 83 | |||
| 84 | const handleAdd = async () => { | ||
| 85 | setAdding(true); | ||
| 86 | setAddError(null); | ||
| 87 | const exit = await doAdd({ | ||
| 88 | payload: { | ||
| 89 | presetIds: selectedIds, | ||
| 90 | customScopes: [...customScopes], | ||
| 91 | slug: resolvedSourceId, | ||
| 92 | name: resolvedDisplayName, | ||
| 93 | ...(resolvedDescription.trim().length > 0 | ||
| 94 | ? { description: resolvedDescription.trim() } | ||
| 95 | : {}), | ||
| 96 | ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), | ||
| 97 | }, | ||
| 98 | reactivityKeys: integrationWriteKeys, | ||
| 99 | }); | ||
| 100 | if (Exit.isFailure(exit)) { | ||
| 101 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft")); | ||
| 102 | setAdding(false); | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | props.onComplete(String(exit.value.slug)); | ||
| 106 | }; | ||
| 107 | |||
| 108 | return ( | ||
| 109 | <div className="flex flex-1 flex-col gap-6"> | ||
| 110 | <div> | ||
| 111 | <h1 className="text-xl font-semibold text-foreground">Add Microsoft integration</h1> | ||
| 112 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 113 | Pick Microsoft 365 workloads and connect them through one delegated OAuth consent. | ||
| 114 | </p> | ||
| 115 | </div> | ||
| 116 | |||
| 117 | <MicrosoftScopePicker | ||
| 118 | selectedPresetIds={selectedPresetIds} | ||
| 119 | onToggle={togglePreset} | ||
| 120 | customScopes={customScopes} | ||
| 121 | onAddCustomScope={addCustomScope} | ||
| 122 | onRemoveCustomScope={removeCustomScope} | ||
| 123 | /> | ||
| 124 | |||
| 125 | <OpenApiSourceDetailsFields | ||
| 126 | title="Microsoft Graph" | ||
| 127 | subtitle={`${selectedIds.length} operation group${ | ||
| 128 | selectedIds.length !== 1 ? "s" : "" | ||
| 129 | } with ${selectedScopes.length} preview scope${selectedScopes.length !== 1 ? "s" : ""}`} | ||
| 130 | identity={identity} | ||
| 131 | description={resolvedDescription} | ||
| 132 | onDescriptionChange={setDescriptionDraft} | ||
| 133 | baseUrl={baseUrl} | ||
| 134 | onBaseUrlChange={setBaseUrl} | ||
| 135 | baseUrlLabel="Base URL override (optional)" | ||
| 136 | baseUrlPlaceholder={MICROSOFT_GRAPH_BASE_URL} | ||
| 137 | faviconIcon={MICROSOFT_FAVICON} | ||
| 138 | faviconUrl={baseUrl || MICROSOFT_GRAPH_BASE_URL} | ||
| 139 | /> | ||
| 140 | |||
| 141 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | ||
| 142 | |||
| 143 | {addError && <FormErrorAlert message={addError} />} | ||
| 144 | |||
| 145 | <FloatActions> | ||
| 146 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | ||
| 147 | Cancel | ||
| 148 | </Button> | ||
| 149 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | ||
| 150 | Connect Microsoft | ||
| 151 | </Button> | ||
| 152 | </FloatActions> | ||
| 153 | </div> | ||
| 154 | ); | ||
| 155 | } | ||
| @@ -1,118 +0,0 @@ | |||
| 1 | import { useCallback, useMemo } from "react"; | ||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 6 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | ||
| 7 | |||
| 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | ||
| 9 | import { | ||
| 10 | HealthCheckEditor, | ||
| 11 | type HealthCheckLivePreview, | ||
| 12 | } from "@executor-js/react/components/health-check-editor"; | ||
| 13 | import { useOrganizationId } from "@executor-js/react/api/organization-context"; | ||
| 14 | import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner"; | ||
| 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 16 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 17 | import { | ||
| 18 | useCustomMethodActions, | ||
| 19 | type AuthMethodsCodec, | ||
| 20 | type ConfigureAuthMethods, | ||
| 21 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 22 | import { | ||
| 23 | authMethodsFromConfig, | ||
| 24 | openApiWireAuthInput, | ||
| 25 | templateFromPlacements, | ||
| 26 | } from "@executor-js/plugin-openapi/react"; | ||
| 27 | import type { Authentication } from "@executor-js/plugin-openapi"; | ||
| 28 | |||
| 29 | import { microsoftConfigAtom, microsoftConfigure } from "./atoms"; | ||
| 30 | |||
| 31 | const NO_AUTH_METHOD: AuthMethod = { | ||
| 32 | id: "none", | ||
| 33 | label: "No authentication", | ||
| 34 | kind: "none", | ||
| 35 | source: "spec", | ||
| 36 | template: AuthTemplateSlug.make("none"), | ||
| 37 | placements: [], | ||
| 38 | }; | ||
| 39 | |||
| 40 | export default function MicrosoftAccountsPanel(props: { | ||
| 41 | readonly sourceId: string; | ||
| 42 | readonly integrationName: string; | ||
| 43 | readonly accountHandoff?: IntegrationAccountHandoff | null; | ||
| 44 | }) { | ||
| 45 | const { sourceId, integrationName, accountHandoff } = props; | ||
| 46 | const slug = IntegrationSlug.make(sourceId); | ||
| 47 | const configResult = useAtomValue(microsoftConfigAtom(slug)); | ||
| 48 | const doConfigure = useAtomSet(microsoftConfigure, { mode: "promiseExit" }); | ||
| 49 | |||
| 50 | const existingTemplate = useMemo<readonly Authentication[]>(() => { | ||
| 51 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 52 | return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[]; | ||
| 53 | }, [configResult]); | ||
| 54 | |||
| 55 | const methods = useMemo<readonly AuthMethod[]>(() => { | ||
| 56 | const declared = authMethodsFromConfig(existingTemplate); | ||
| 57 | return declared.length > 0 ? declared : [NO_AUTH_METHOD]; | ||
| 58 | }, [existingTemplate]); | ||
| 59 | |||
| 60 | const configure = useCallback<ConfigureAuthMethods<Authentication>>( | ||
| 61 | async (input) => { | ||
| 62 | const exit = await doConfigure({ | ||
| 63 | params: { slug }, | ||
| 64 | payload: { | ||
| 65 | authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), | ||
| 66 | ...(input.mode ? { mode: input.mode } : {}), | ||
| 67 | }, | ||
| 68 | reactivityKeys: integrationWriteKeys, | ||
| 69 | }); | ||
| 70 | return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); | ||
| 71 | }, | ||
| 72 | [doConfigure, slug], | ||
| 73 | ); | ||
| 74 | |||
| 75 | const codec = useMemo<AuthMethodsCodec<Authentication>>( | ||
| 76 | () => ({ | ||
| 77 | toAuthMethods: authMethodsFromConfig, | ||
| 78 | templatesFromPlacements: (placements: readonly Placement[]) => [ | ||
| 79 | templateFromPlacements(placements), | ||
| 80 | ], | ||
| 81 | slugOf: (template: Authentication) => String(template.slug), | ||
| 82 | }), | ||
| 83 | [], | ||
| 84 | ); | ||
| 85 | |||
| 86 | const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | ||
| 87 | existing: existingTemplate, | ||
| 88 | codec, | ||
| 89 | configure, | ||
| 90 | }); | ||
| 91 | |||
| 92 | // Live-preview context for the health-check edit sheet (apiKey methods only; | ||
| 93 | // OAuth methods can't be previewed with a pasted key). | ||
| 94 | const organizationId = useOrganizationId(); | ||
| 95 | const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => { | ||
| 96 | const templates = methods | ||
| 97 | .filter((m) => m.kind === "apikey") | ||
| 98 | .map((m) => ({ template: m.template, label: m.label })); | ||
| 99 | if (templates.length === 0) return undefined; | ||
| 100 | return { owner: defaultConnectionOwnerForHost(organizationId), templates }; | ||
| 101 | }, [methods, organizationId]); | ||
| 102 | |||
| 103 | return ( | ||
| 104 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | ||
| 105 | <AccountsSection | ||
| 106 | integration={slug} | ||
| 107 | integrationName={integrationName} | ||
| 108 | methods={methods} | ||
| 109 | accountHandoff={accountHandoff} | ||
| 110 | createCustomMethod={createCustomMethod} | ||
| 111 | removeCustomMethod={removeCustomMethod} | ||
| 112 | /> | ||
| 113 | {/* Graph auto-configures the /me identity probe at add time; this is | ||
| 114 | where an admin sees, adjusts, or clears it. */} | ||
| 115 | <HealthCheckEditor integration={slug} livePreview={livePreview} /> | ||
| 116 | </div> | ||
| 117 | ); | ||
| 118 | } | ||
| @@ -1,286 +0,0 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 5 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 8 | import { | ||
| 9 | Collapsible, | ||
| 10 | CollapsibleContent, | ||
| 11 | CollapsibleTrigger, | ||
| 12 | } from "@executor-js/react/components/collapsible"; | ||
| 13 | import { FieldLabel } from "@executor-js/react/components/field"; | ||
| 14 | import { Input } from "@executor-js/react/components/input"; | ||
| 15 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | microsoftGraphScopePresets, | ||
| 19 | microsoftGraphScopesForPresetIds, | ||
| 20 | type MicrosoftGraphScopeAudience, | ||
| 21 | type MicrosoftGraphScopePreset, | ||
| 22 | } from "../sdk/presets"; | ||
| 23 | |||
| 24 | type MicrosoftScopePickerProps = { | ||
| 25 | readonly selectedPresetIds: ReadonlySet<string>; | ||
| 26 | readonly onToggle: (presetId: string, checked: boolean) => void; | ||
| 27 | readonly customScopes: readonly string[]; | ||
| 28 | readonly onAddCustomScope: (scope: string) => void; | ||
| 29 | readonly onRemoveCustomScope: (scope: string) => void; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [ | ||
| 33 | "productivity", | ||
| 34 | "files-content", | ||
| 35 | "collaboration", | ||
| 36 | "directory-identity", | ||
| 37 | "admin-security", | ||
| 38 | "platform-business", | ||
| 39 | ]; | ||
| 40 | |||
| 41 | const AUDIENCE_LABEL: Readonly<Record<MicrosoftGraphScopeAudience, string>> = { | ||
| 42 | productivity: "Productivity", | ||
| 43 | "files-content": "Files and content", | ||
| 44 | collaboration: "Collaboration", | ||
| 45 | "directory-identity": "Directory and identity", | ||
| 46 | "admin-security": "Admin and security", | ||
| 47 | "platform-business": "Platform and business", | ||
| 48 | }; | ||
| 49 | |||
| 50 | const AUDIENCE_DESCRIPTION: Readonly<Record<MicrosoftGraphScopeAudience, string>> = { | ||
| 51 | productivity: "User-facing Microsoft 365 operations for the connected account.", | ||
| 52 | "files-content": "Drive, SharePoint, OneNote, workbook, and content APIs.", | ||
| 53 | collaboration: "Teams, chats, online meetings, calls, and teamwork APIs.", | ||
| 54 | "directory-identity": "Tenant directory, app, identity, policy, and governance APIs.", | ||
| 55 | "admin-security": "Admin, audit, security, compliance, device, and education APIs.", | ||
| 56 | "platform-business": "Search, connectors, extensions, print, places, and business services.", | ||
| 57 | }; | ||
| 58 | |||
| 59 | const ScopeRow = ({ | ||
| 60 | preset, | ||
| 61 | checked, | ||
| 62 | onToggle, | ||
| 63 | }: { | ||
| 64 | readonly preset: MicrosoftGraphScopePreset; | ||
| 65 | readonly checked: boolean; | ||
| 66 | readonly onToggle: (checked: boolean) => void; | ||
| 67 | }) => ( | ||
| 68 | <FieldLabel | ||
| 69 | className={cn( | ||
| 70 | "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors", | ||
| 71 | checked ? "bg-primary/5" : "hover:bg-muted/40", | ||
| 72 | )} | ||
| 73 | > | ||
| 74 | <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} /> | ||
| 75 | <div className="shrink-0"> | ||
| 76 | <IntegrationFavicon icon={preset.icon} size={16} /> | ||
| 77 | </div> | ||
| 78 | <div className="min-w-0 flex-1"> | ||
| 79 | <div className="flex min-w-0 items-center gap-2"> | ||
| 80 | <span className="truncate text-sm font-medium text-foreground">{preset.name}</span> | ||
| 81 | </div> | ||
| 82 | <p className="truncate text-[11px] text-muted-foreground">{preset.summary}</p> | ||
| 83 | </div> | ||
| 84 | </FieldLabel> | ||
| 85 | ); | ||
| 86 | |||
| 87 | const CustomScopeInput = ({ | ||
| 88 | customScopes, | ||
| 89 | onAddCustomScope, | ||
| 90 | onRemoveCustomScope, | ||
| 91 | }: { | ||
| 92 | readonly customScopes: readonly string[]; | ||
| 93 | readonly onAddCustomScope: (scope: string) => void; | ||
| 94 | readonly onRemoveCustomScope: (scope: string) => void; | ||
| 95 | }) => { | ||
| 96 | const [draft, setDraft] = useState(""); | ||
| 97 | const trimmed = draft.trim(); | ||
| 98 | const isValid = trimmed.length > 0 && !/\s/.test(trimmed); | ||
| 99 | const isDuplicate = customScopes.includes(trimmed); | ||
| 100 | |||
| 101 | const commit = () => { | ||
| 102 | if (!isValid || isDuplicate) return; | ||
| 103 | onAddCustomScope(trimmed); | ||
| 104 | setDraft(""); | ||
| 105 | }; | ||
| 106 | |||
| 107 | return ( | ||
| 108 | <div className="space-y-2"> | ||
| 109 | <FieldLabel className="text-[11px] font-medium text-muted-foreground"> | ||
| 110 | Add custom Graph scope | ||
| 111 | </FieldLabel> | ||
| 112 | <div className="flex items-center gap-2"> | ||
| 113 | <Input | ||
| 114 | value={draft} | ||
| 115 | onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)} | ||
| 116 | onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { | ||
| 117 | if (event.key === "Enter") { | ||
| 118 | event.preventDefault(); | ||
| 119 | commit(); | ||
| 120 | } | ||
| 121 | }} | ||
| 122 | placeholder="Sites.Read.All" | ||
| 123 | className="font-mono text-[11px]" | ||
| 124 | /> | ||
| 125 | <Button | ||
| 126 | type="button" | ||
| 127 | variant="outline" | ||
| 128 | size="sm" | ||
| 129 | disabled={!isValid || isDuplicate} | ||
| 130 | onClick={commit} | ||
| 131 | > | ||
| 132 | <PlusIcon className="size-3.5" /> | ||
| 133 | Add | ||
| 134 | </Button> | ||
| 135 | </div> | ||
| 136 | {trimmed.length > 0 && !isValid ? ( | ||
| 137 | <p className="text-[11px] text-destructive">Scopes cannot contain whitespace.</p> | ||
| 138 | ) : null} | ||
| 139 | {customScopes.length > 0 ? ( | ||
| 140 | <ul className="space-y-1"> | ||
| 141 | {customScopes.map((scope: string) => ( | ||
| 142 | <li | ||
| 143 | key={scope} | ||
| 144 | className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5" | ||
| 145 | > | ||
| 146 | <span className="truncate font-mono text-[11px] text-foreground">{scope}</span> | ||
| 147 | <Button | ||
| 148 | type="button" | ||
| 149 | variant="ghost" | ||
| 150 | size="icon" | ||
| 151 | className="size-6 shrink-0" | ||
| 152 | onClick={() => onRemoveCustomScope(scope)} | ||
| 153 | aria-label={`Remove ${scope}`} | ||
| 154 | > | ||
| 155 | <XIcon className="size-3.5" /> | ||
| 156 | </Button> | ||
| 157 | </li> | ||
| 158 | ))} | ||
| 159 | </ul> | ||
| 160 | ) : null} | ||
| 161 | </div> | ||
| 162 | ); | ||
| 163 | }; | ||
| 164 | |||
| 165 | export function MicrosoftScopePicker({ | ||
| 166 | selectedPresetIds, | ||
| 167 | onToggle, | ||
| 168 | customScopes, | ||
| 169 | onAddCustomScope, | ||
| 170 | onRemoveCustomScope, | ||
| 171 | }: MicrosoftScopePickerProps) { | ||
| 172 | const [scopesOpen, setScopesOpen] = useState(false); | ||
| 173 | |||
| 174 | const groups = useMemo( | ||
| 175 | () => | ||
| 176 | AUDIENCE_ORDER.flatMap((audience: MicrosoftGraphScopeAudience) => { | ||
| 177 | const presets = microsoftGraphScopePresets.filter( | ||
| 178 | (preset: MicrosoftGraphScopePreset) => preset.audience === audience, | ||
| 179 | ); | ||
| 180 | return presets.length > 0 ? [{ audience, presets }] : []; | ||
| 181 | }), | ||
| 182 | [], | ||
| 183 | ); | ||
| 184 | |||
| 185 | const scopes = useMemo( | ||
| 186 | () => microsoftGraphScopesForPresetIds([...selectedPresetIds], customScopes), | ||
| 187 | [selectedPresetIds, customScopes], | ||
| 188 | ); | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <section className="space-y-4"> | ||
| 192 | <div className="space-y-1"> | ||
| 193 | <FieldLabel>Customize Microsoft Graph</FieldLabel> | ||
| 194 | <p className="text-[11px] text-muted-foreground"> | ||
| 195 | Pick the operation groups to expose as tools. They share one Microsoft OAuth consent and | ||
| 196 | one account connection. | ||
| 197 | </p> | ||
| 198 | </div> | ||
| 199 | |||
| 200 | {groups.map( | ||
| 201 | ({ | ||
| 202 | audience, | ||
| 203 | presets, | ||
| 204 | }: { | ||
| 205 | readonly audience: MicrosoftGraphScopeAudience; | ||
| 206 | readonly presets: readonly MicrosoftGraphScopePreset[]; | ||
| 207 | }) => ( | ||
| 208 | <div key={audience} className="space-y-2"> | ||
| 209 | <div className="flex items-center gap-2"> | ||
| 210 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 211 | {AUDIENCE_LABEL[audience]} | ||
| 212 | </span> | ||
| 213 | <Button | ||
| 214 | type="button" | ||
| 215 | variant="ghost" | ||
| 216 | size="sm" | ||
| 217 | className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground" | ||
| 218 | onClick={() => { | ||
| 219 | const allSelected = presets.every((preset: MicrosoftGraphScopePreset) => | ||
| 220 | selectedPresetIds.has(preset.id), | ||
| 221 | ); | ||
| 222 | presets.forEach((preset: MicrosoftGraphScopePreset) => | ||
| 223 | onToggle(preset.id, !allSelected), | ||
| 224 | ); | ||
| 225 | }} | ||
| 226 | > | ||
| 227 | {presets.every((preset: MicrosoftGraphScopePreset) => | ||
| 228 | selectedPresetIds.has(preset.id), | ||
| 229 | ) | ||
| 230 | ? "Clear" | ||
| 231 | : "Select all"} | ||
| 232 | </Button> | ||
| 233 | </div> | ||
| 234 | <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p> | ||
| 235 | <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2"> | ||
| 236 | {presets.map((preset: MicrosoftGraphScopePreset) => ( | ||
| 237 | <ScopeRow | ||
| 238 | key={preset.id} | ||
| 239 | preset={preset} | ||
| 240 | checked={selectedPresetIds.has(preset.id)} | ||
| 241 | onToggle={(checked: boolean) => onToggle(preset.id, checked)} | ||
| 242 | /> | ||
| 243 | ))} | ||
| 244 | </div> | ||
| 245 | </div> | ||
| 246 | ), | ||
| 247 | )} | ||
| 248 | |||
| 249 | <CustomScopeInput | ||
| 250 | customScopes={customScopes} | ||
| 251 | onAddCustomScope={onAddCustomScope} | ||
| 252 | onRemoveCustomScope={onRemoveCustomScope} | ||
| 253 | /> | ||
| 254 | |||
| 255 | <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}> | ||
| 256 | <CollapsibleTrigger asChild> | ||
| 257 | <Button type="button" variant="outline" size="sm" disabled={scopes.length === 0}> | ||
| 258 | <ChevronDownIcon | ||
| 259 | className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")} | ||
| 260 | /> | ||
| 261 | View scopes | ||
| 262 | {scopes.length > 0 ? ( | ||
| 263 | <Badge variant="secondary" className="ml-1"> | ||
| 264 | {scopes.length} | ||
| 265 | </Badge> | ||
| 266 | ) : null} | ||
| 267 | </Button> | ||
| 268 | </CollapsibleTrigger> | ||
| 269 | <CollapsibleContent className="pt-3"> | ||
| 270 | <ul className="space-y-1"> | ||
| 271 | {scopes.map((scope: string) => ( | ||
| 272 | <li | ||
| 273 | key={scope} | ||
| 274 | className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | ||
| 275 | > | ||
| 276 | {scope} | ||
| 277 | </li> | ||
| 278 | ))} | ||
| 279 | </ul> | ||
| 280 | </CollapsibleContent> | ||
| 281 | </Collapsible> | ||
| 282 | </section> | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | export default MicrosoftScopePicker; | ||
| @@ -1,30 +0,0 @@ | |||
| 1 | import type { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 4 | import { MicrosoftClient } from "./client"; | ||
| 5 | |||
| 6 | export const microsoftIntegrationAtom = (slug: IntegrationSlug) => | ||
| 7 | MicrosoftClient.query("microsoft", "getIntegration", { | ||
| 8 | params: { slug }, | ||
| 9 | timeToLive: "15 seconds", | ||
| 10 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 11 | }); | ||
| 12 | |||
| 13 | export const microsoftConfigAtom = (slug: IntegrationSlug) => | ||
| 14 | MicrosoftClient.query("microsoft", "getConfig", { | ||
| 15 | params: { slug }, | ||
| 16 | timeToLive: "15 seconds", | ||
| 17 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph"); | ||
| 21 | |||
| 22 | export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph"); | ||
| 23 | |||
| 24 | export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph"); | ||
| 25 | |||
| 26 | export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure"); | ||
| 27 | |||
| 28 | export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom); | ||
| 29 | |||
| 30 | export const microsoftConfigFamily = Atom.family(microsoftConfigAtom); | ||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | ||
| 2 | import { | ||
| 3 | getExecutorOrganizationHeaders, | ||
| 4 | getExecutorApiBaseUrl, | ||
| 5 | getExecutorServerAuthorizationHeader, | ||
| 6 | } from "@executor-js/react/api/server-connection"; | ||
| 7 | import { MicrosoftGroup } from "../api/group"; | ||
| 8 | |||
| 9 | export const MicrosoftClient = createPluginAtomClient(MicrosoftGroup, { | ||
| 10 | baseUrl: getExecutorApiBaseUrl, | ||
| 11 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 13 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | export { microsoftIntegrationPlugin } from "./source-plugin"; | ||
| 2 | export { MicrosoftClient } from "./client"; | ||
| 3 | export { | ||
| 4 | addMicrosoftGraph, | ||
| 5 | microsoftConfigAtom, | ||
| 6 | microsoftConfigFamily, | ||
| 7 | microsoftConfigure, | ||
| 8 | microsoftIntegrationAtom, | ||
| 9 | microsoftIntegrationFamily, | ||
| 10 | removeMicrosoftGraph, | ||
| 11 | updateMicrosoftGraph, | ||
| 12 | } from "./atoms"; | ||
| @@ -1,8 +0,0 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { microsoftIntegrationPlugin } from "./source-plugin"; | ||
| 4 | |||
| 5 | export default defineClientPlugin({ | ||
| 6 | id: "microsoft" as const, | ||
| 7 | integrationPlugin: microsoftIntegrationPlugin, | ||
| 8 | }); | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | ||
| 3 | import { microsoftGraphPreset } from "../sdk/presets"; | ||
| 4 | |||
| 5 | const importAdd = () => import("./AddMicrosoftSource"); | ||
| 6 | const importAccounts = () => import("./MicrosoftAccountsPanel"); | ||
| 7 | |||
| 8 | export const microsoftIntegrationPlugin: IntegrationPlugin = { | ||
| 9 | key: "microsoft", | ||
| 10 | label: "Microsoft", | ||
| 11 | add: lazy(importAdd), | ||
| 12 | accounts: lazy(importAccounts), | ||
| 13 | presets: [microsoftGraphPreset], | ||
| 14 | preload: () => { | ||
| 15 | void importAdd(); | ||
| 16 | void importAccounts(); | ||
| 17 | }, | ||
| 18 | }; | ||
| @@ -1,692 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Exit, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | AuthTemplateSlug, | ||
| 7 | ConnectionName, | ||
| 8 | IntegrationSlug, | ||
| 9 | createExecutor, | ||
| 10 | } from "@executor-js/sdk"; | ||
| 11 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 12 | |||
| 13 | import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin"; | ||
| 14 | import { | ||
| 15 | MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 16 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 17 | MICROSOFT_GRAPH_ALL_PRESET_IDS, | ||
| 18 | MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES, | ||
| 19 | MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | ||
| 20 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 21 | MICROSOFT_GRAPH_OPENAPI_URL, | ||
| 22 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, | ||
| 23 | } from "./presets"; | ||
| 24 | |||
| 25 | const graphFixture = ` | ||
| 26 | openapi: 3.0.4 | ||
| 27 | info: | ||
| 28 | title: Microsoft Graph Fixture | ||
| 29 | version: v1.0 | ||
| 30 | servers: | ||
| 31 | - url: https://graph.microsoft.com/v1.0 | ||
| 32 | paths: | ||
| 33 | /me: | ||
| 34 | get: | ||
| 35 | operationId: me.GetUser | ||
| 36 | summary: Get the signed-in user profile | ||
| 37 | security: | ||
| 38 | - azureAdDelegated: | ||
| 39 | - User.Read | ||
| 40 | responses: | ||
| 41 | "200": | ||
| 42 | description: OK | ||
| 43 | /me/messages: | ||
| 44 | get: | ||
| 45 | operationId: me.messages.ListMessages | ||
| 46 | security: | ||
| 47 | - azureAdDelegated: | ||
| 48 | - Mail.ReadWrite | ||
| 49 | responses: | ||
| 50 | "200": | ||
| 51 | description: OK | ||
| 52 | /me/events: | ||
| 53 | get: | ||
| 54 | operationId: me.events.ListEvents | ||
| 55 | security: | ||
| 56 | - azureAdDelegated: | ||
| 57 | - Calendars.ReadWrite | ||
| 58 | responses: | ||
| 59 | "200": | ||
| 60 | description: OK | ||
| 61 | /me/onenote/pages: | ||
| 62 | get: | ||
| 63 | operationId: me.onenote.pages.ListPages | ||
| 64 | security: | ||
| 65 | - azureAdDelegated: | ||
| 66 | - Notes.ReadWrite | ||
| 67 | responses: | ||
| 68 | "200": | ||
| 69 | description: OK | ||
| 70 | /sites: | ||
| 71 | get: | ||
| 72 | operationId: sites.ListSites | ||
| 73 | responses: | ||
| 74 | "200": | ||
| 75 | description: OK | ||
| 76 | components: | ||
| 77 | schemas: | ||
| 78 | user: | ||
| 79 | type: object | ||
| 80 | `; | ||
| 81 | |||
| 82 | const permissionsReferenceFixture = ` | ||
| 83 | ### User.Read | ||
| 84 | |||
| 85 | | Category | Application | Delegated | | ||
| 86 | |--|--|--| | ||
| 87 | | Identifier | - | e1fe6dd8-ba31-4d61-89e7-88639da4683d | | ||
| 88 | |||
| 89 | --- | ||
| 90 | |||
| 91 | ### Mail.ReadWrite | ||
| 92 | |||
| 93 | | Category | Application | Delegated | | ||
| 94 | |--|--|--| | ||
| 95 | | Identifier | e2a3a72e-5f79-4c64-b1b1-878b674786c9 | 024d486e-b451-40bb-833d-3e66d98c5c73 | | ||
| 96 | |||
| 97 | --- | ||
| 98 | |||
| 99 | ### Calendars.ReadWrite | ||
| 100 | |||
| 101 | | Category | Application | Delegated | | ||
| 102 | |--|--|--| | ||
| 103 | | Identifier | ef54d2bf-783f-4e0f-bca1-3210c0444d99 | 1ec239c2-d7c9-4623-a91a-a9775856bb36 | | ||
| 104 | |||
| 105 | --- | ||
| 106 | |||
| 107 | ### Notes.ReadWrite | ||
| 108 | |||
| 109 | | Category | Application | Delegated | | ||
| 110 | |--|--|--| | ||
| 111 | | Identifier | 085ca537-6565-41c2-aca7-db852babc212 | 615e82d5-1f7f-4f99-a456-0a0484a820d5 | | ||
| 112 | |||
| 113 | --- | ||
| 114 | |||
| 115 | ### AppCatalog.Read.All | ||
| 116 | |||
| 117 | | Category | Application | Delegated | | ||
| 118 | |--|--|--| | ||
| 119 | | Identifier | e12dae10-5a57-4817-b79d-dfbec5348930 | - | | ||
| 120 | `; | ||
| 121 | |||
| 122 | const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi"; | ||
| 123 | const EMULATOR_BASE_URL = "https://microsoft.emulators.dev"; | ||
| 124 | const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi"; | ||
| 125 | const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123"; | ||
| 126 | const emulatorGraphFixture = ` | ||
| 127 | openapi: 3.0.3 | ||
| 128 | info: | ||
| 129 | title: Microsoft Graph Emulator | ||
| 130 | version: 1.0.0 | ||
| 131 | servers: | ||
| 132 | - url: ${EMULATOR_BASE_URL} | ||
| 133 | paths: | ||
| 134 | /v1.0/users: | ||
| 135 | get: | ||
| 136 | operationId: graphUser_List | ||
| 137 | responses: | ||
| 138 | "200": | ||
| 139 | description: OK | ||
| 140 | /v1.0/me: | ||
| 141 | get: | ||
| 142 | operationId: graphUser_GetMyProfile | ||
| 143 | responses: | ||
| 144 | "200": | ||
| 145 | description: OK | ||
| 146 | components: | ||
| 147 | securitySchemes: | ||
| 148 | azureAdDelegated: | ||
| 149 | type: oauth2 | ||
| 150 | flows: | ||
| 151 | authorizationCode: | ||
| 152 | authorizationUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/authorize | ||
| 153 | tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 154 | scopes: | ||
| 155 | User.Read: User.Read | ||
| 156 | User.Read.All: User.Read.All | ||
| 157 | clientCredentials: | ||
| 158 | tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 159 | scopes: | ||
| 160 | https://graph.microsoft.com/.default: https://graph.microsoft.com/.default | ||
| 161 | `; | ||
| 162 | |||
| 163 | const localEmulatorGraphFixture = ` | ||
| 164 | openapi: 3.0.3 | ||
| 165 | info: | ||
| 166 | title: Microsoft Graph Local Emulator | ||
| 167 | version: 1.0.0 | ||
| 168 | servers: | ||
| 169 | - url: ${LOCAL_EMULATOR_BASE_URL} | ||
| 170 | paths: | ||
| 171 | /v1.0/users: | ||
| 172 | get: | ||
| 173 | operationId: graphUser_List | ||
| 174 | responses: | ||
| 175 | "200": | ||
| 176 | description: OK | ||
| 177 | components: | ||
| 178 | securitySchemes: | ||
| 179 | azureAdDelegated: | ||
| 180 | type: oauth2 | ||
| 181 | flows: | ||
| 182 | clientCredentials: | ||
| 183 | tokenUrl: ${LOCAL_EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 184 | scopes: | ||
| 185 | https://graph.microsoft.com/.default: https://graph.microsoft.com/.default | ||
| 186 | `; | ||
| 187 | |||
| 188 | const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 189 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 190 | Effect.succeed( | ||
| 191 | HttpClientResponse.fromWeb( | ||
| 192 | request, | ||
| 193 | new Response( | ||
| 194 | request.url === MICROSOFT_GRAPH_OPENAPI_URL | ||
| 195 | ? graphFixture | ||
| 196 | : request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL | ||
| 197 | ? permissionsReferenceFixture | ||
| 198 | : request.url === EMULATOR_SPEC_URL | ||
| 199 | ? emulatorGraphFixture | ||
| 200 | : request.url === LOCAL_EMULATOR_SPEC_URL | ||
| 201 | ? localEmulatorGraphFixture | ||
| 202 | : "not found", | ||
| 203 | { | ||
| 204 | status: | ||
| 205 | request.url === MICROSOFT_GRAPH_OPENAPI_URL || | ||
| 206 | request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL || | ||
| 207 | request.url === EMULATOR_SPEC_URL || | ||
| 208 | request.url === LOCAL_EMULATOR_SPEC_URL | ||
| 209 | ? 200 | ||
| 210 | : 404, | ||
| 211 | headers: { | ||
| 212 | "content-type": | ||
| 213 | request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL | ||
| 214 | ? "text/markdown" | ||
| 215 | : "application/yaml", | ||
| 216 | }, | ||
| 217 | }, | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | |||
| 224 | const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) => | ||
| 225 | [ | ||
| 226 | microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }), | ||
| 227 | memoryCredentialsPlugin(), | ||
| 228 | ] as const; | ||
| 229 | |||
| 230 | describe("Microsoft Graph provider", () => { | ||
| 231 | it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () => | ||
| 232 | Effect.scoped( | ||
| 233 | Effect.gen(function* () { | ||
| 234 | let requests = 0; | ||
| 235 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 236 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 237 | Effect.sync(() => { | ||
| 238 | requests += 1; | ||
| 239 | return HttpClientResponse.fromWeb( | ||
| 240 | request, | ||
| 241 | new Response("unexpected request", { status: 500 }), | ||
| 242 | ); | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | const executor = yield* createExecutor( | ||
| 247 | makeTestConfig({ | ||
| 248 | plugins: [ | ||
| 249 | microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }), | ||
| 250 | memoryCredentialsPlugin(), | ||
| 251 | ], | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | const exit = yield* executor.microsoft | ||
| 256 | .addGraph({ | ||
| 257 | slug: "bad_graph", | ||
| 258 | baseUrl: "https://attacker.example/v1.0", | ||
| 259 | specUrl: "https://attacker.example/openapi.yaml", | ||
| 260 | authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize", | ||
| 261 | tokenUrl: "https://attacker.example/oauth2/v2.0/token", | ||
| 262 | clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token", | ||
| 263 | }) | ||
| 264 | .pipe(Effect.exit); | ||
| 265 | |||
| 266 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 267 | expect(requests).toBe(0); | ||
| 268 | }), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("adds a selected Graph workload source with one OAuth template", () => | ||
| 273 | Effect.scoped( | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 276 | |||
| 277 | const result = yield* executor.microsoft.addGraph({ | ||
| 278 | presetIds: ["profile", "mail"], | ||
| 279 | slug: "microsoft_graph", | ||
| 280 | description: "Microsoft Graph", | ||
| 281 | }); | ||
| 282 | |||
| 283 | expect(String(result.slug)).toBe("microsoft_graph"); | ||
| 284 | |||
| 285 | const config = yield* executor.microsoft.getConfig("microsoft_graph"); | ||
| 286 | expect(config?.microsoftGraphPresetIds).toEqual(["profile", "mail"]); | ||
| 287 | expect(config?.microsoftGraphCoversFullGraph).toBe(false); | ||
| 288 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 289 | "offline_access", | ||
| 290 | "User.Read", | ||
| 291 | "Mail.ReadWrite", | ||
| 292 | "Mail.Send", | ||
| 293 | "MailboxSettings.ReadWrite", | ||
| 294 | ]); | ||
| 295 | |||
| 296 | const oauthTemplates = config?.authenticationTemplate?.filter( | ||
| 297 | (entry) => entry.kind === "oauth2", | ||
| 298 | ); | ||
| 299 | const delegated = oauthTemplates?.find( | ||
| 300 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 301 | ); | ||
| 302 | const clientCredentials = oauthTemplates?.find( | ||
| 303 | (entry) => String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 304 | ); | ||
| 305 | expect(delegated?.kind === "oauth2" ? delegated.slug : undefined).toBe( | ||
| 306 | AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 307 | ); | ||
| 308 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([ | ||
| 309 | "offline_access", | ||
| 310 | "User.Read", | ||
| 311 | "Mail.ReadWrite", | ||
| 312 | "Mail.Send", | ||
| 313 | "MailboxSettings.ReadWrite", | ||
| 314 | ]); | ||
| 315 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.slug : undefined).toBe( | ||
| 316 | AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), | ||
| 317 | ); | ||
| 318 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual( | ||
| 319 | [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 320 | ); | ||
| 321 | |||
| 322 | yield* executor.connections.create({ | ||
| 323 | owner: "org", | ||
| 324 | name: ConnectionName.make("main"), | ||
| 325 | integration: IntegrationSlug.make("microsoft_graph"), | ||
| 326 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 327 | value: "token-xyz", | ||
| 328 | }); | ||
| 329 | |||
| 330 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 331 | expect(toolNames).toContain("me.getUser"); | ||
| 332 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 333 | expect(toolNames).not.toContain("sites.listSites"); | ||
| 334 | }), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | |||
| 338 | it.effect("adds common Microsoft Graph workloads by default", () => | ||
| 339 | Effect.scoped( | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 342 | |||
| 343 | yield* executor.microsoft.addGraph({ | ||
| 344 | slug: "microsoft_graph_all", | ||
| 345 | description: "Microsoft Graph", | ||
| 346 | }); | ||
| 347 | |||
| 348 | const config = yield* executor.microsoft.getConfig("microsoft_graph_all"); | ||
| 349 | expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 350 | expect(config?.microsoftGraphCoversFullGraph).toBe(false); | ||
| 351 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 352 | "offline_access", | ||
| 353 | "User.Read", | ||
| 354 | "Mail.ReadWrite", | ||
| 355 | "Mail.Send", | ||
| 356 | "MailboxSettings.ReadWrite", | ||
| 357 | "Calendars.ReadWrite", | ||
| 358 | "Contacts.ReadWrite", | ||
| 359 | "People.Read.All", | ||
| 360 | "Tasks.ReadWrite", | ||
| 361 | "Files.ReadWrite.All", | ||
| 362 | "Sites.ReadWrite.All", | ||
| 363 | "Notes.ReadWrite", | ||
| 364 | "Chat.ReadWrite", | ||
| 365 | "Team.ReadBasic.All", | ||
| 366 | "Channel.ReadBasic.All", | ||
| 367 | "ChannelMessage.Read.All", | ||
| 368 | "ChannelMessage.Send", | ||
| 369 | "OnlineMeetings.ReadWrite", | ||
| 370 | ]); | ||
| 371 | |||
| 372 | const delegated = config?.authenticationTemplate?.find( | ||
| 373 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 374 | ); | ||
| 375 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual( | ||
| 376 | config?.microsoftGraphScopes, | ||
| 377 | ); | ||
| 378 | |||
| 379 | yield* executor.connections.create({ | ||
| 380 | owner: "org", | ||
| 381 | name: ConnectionName.make("all"), | ||
| 382 | integration: IntegrationSlug.make("microsoft_graph_all"), | ||
| 383 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 384 | value: "token-xyz", | ||
| 385 | }); | ||
| 386 | |||
| 387 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 388 | expect(toolNames).toContain("me.getUser"); | ||
| 389 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 390 | expect(toolNames).toContain("me.eventsListEvents"); | ||
| 391 | expect(toolNames).toContain("me.onenotePagesListPages"); | ||
| 392 | expect(toolNames).toContain("sites.listSites"); | ||
| 393 | }), | ||
| 394 | ), | ||
| 395 | ); | ||
| 396 | |||
| 397 | it.effect("uses the app registration default scope when every Graph workload is selected", () => | ||
| 398 | Effect.scoped( | ||
| 399 | Effect.gen(function* () { | ||
| 400 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 401 | |||
| 402 | yield* executor.microsoft.addGraph({ | ||
| 403 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | ||
| 404 | slug: "microsoft_graph_full", | ||
| 405 | description: "Microsoft Graph", | ||
| 406 | }); | ||
| 407 | |||
| 408 | const config = yield* executor.microsoft.getConfig("microsoft_graph_full"); | ||
| 409 | expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_ALL_PRESET_IDS); | ||
| 410 | expect(config?.microsoftGraphCoversFullGraph).toBe(true); | ||
| 411 | expect(config?.microsoftGraphScopes).toEqual(MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES); | ||
| 412 | |||
| 413 | const delegated = config?.authenticationTemplate?.find( | ||
| 414 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 415 | ); | ||
| 416 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([ | ||
| 417 | ...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | ||
| 418 | ]); | ||
| 419 | |||
| 420 | // Full-graph add routes through the streaming compile (the path the | ||
| 421 | // real 37MB spec takes): the source text is structurally split and each | ||
| 422 | // op's binding plus a `description` is persisted, alongside the | ||
| 423 | // content-addressed defs blob, never materializing the whole-document | ||
| 424 | // tree. Read the operations back through the live serve path to prove | ||
| 425 | // they landed in storage AND that the serve fast path rebuilds tools | ||
| 426 | // from the persisted bindings (no spec parse). | ||
| 427 | yield* executor.connections.create({ | ||
| 428 | owner: "org", | ||
| 429 | name: ConnectionName.make("full"), | ||
| 430 | integration: IntegrationSlug.make("microsoft_graph_full"), | ||
| 431 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 432 | value: "token-xyz", | ||
| 433 | }); | ||
| 434 | |||
| 435 | const tools = yield* executor.tools.list(); | ||
| 436 | const toolNames = tools.map((tool) => String(tool.name)); | ||
| 437 | expect(toolNames).toContain("me.getUser"); | ||
| 438 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 439 | expect(toolNames).toContain("sites.listSites"); | ||
| 440 | |||
| 441 | // The serve fast path must rebuild every tool's description from the | ||
| 442 | // persisted operation, not drop it. Each graph tool carries a non-empty | ||
| 443 | // description. | ||
| 444 | for (const tool of tools) { | ||
| 445 | expect(tool.description.length).toBeGreaterThan(0); | ||
| 446 | } | ||
| 447 | |||
| 448 | // `me.getUser`'s spec summary survives the add -> persist -> serve | ||
| 449 | // round-trip. The bare `${METHOD} ${path}` fallback inside the serve | ||
| 450 | // path would be "GET /me", so matching the summary proves the persisted | ||
| 451 | // `description` field is what's served. | ||
| 452 | const getUser = tools.find((tool) => String(tool.name) === "me.getUser"); | ||
| 453 | expect(getUser?.description).toBe("Get the signed-in user profile"); | ||
| 454 | |||
| 455 | // An op without a spec summary falls back to `${METHOD} ${path}`, also | ||
| 456 | // sourced from the persisted binding on the serve fast path. | ||
| 457 | const listSites = tools.find((tool) => String(tool.name) === "sites.listSites"); | ||
| 458 | expect(listSites?.description).toBe("GET /sites"); | ||
| 459 | }), | ||
| 460 | ), | ||
| 461 | ); | ||
| 462 | |||
| 463 | it.effect("uses explicit full Graph scopes when custom dynamic scopes are added", () => | ||
| 464 | Effect.scoped( | ||
| 465 | Effect.gen(function* () { | ||
| 466 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 467 | |||
| 468 | yield* executor.microsoft.addGraph({ | ||
| 469 | slug: "microsoft_graph_all_custom", | ||
| 470 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | ||
| 471 | customScopes: ["Custom.Scope"], | ||
| 472 | }); | ||
| 473 | |||
| 474 | const config = yield* executor.microsoft.getConfig("microsoft_graph_all_custom"); | ||
| 475 | expect(config?.microsoftGraphCoversFullGraph).toBe(true); | ||
| 476 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 477 | "offline_access", | ||
| 478 | "User.Read", | ||
| 479 | "Mail.ReadWrite", | ||
| 480 | "Calendars.ReadWrite", | ||
| 481 | "Notes.ReadWrite", | ||
| 482 | "Custom.Scope", | ||
| 483 | ]); | ||
| 484 | }), | ||
| 485 | ), | ||
| 486 | ); | ||
| 487 | |||
| 488 | it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () => | ||
| 489 | Effect.scoped( | ||
| 490 | Effect.gen(function* () { | ||
| 491 | const executor = yield* createExecutor( | ||
| 492 | makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }), | ||
| 493 | ); | ||
| 494 | |||
| 495 | yield* executor.microsoft.addGraph({ | ||
| 496 | presetIds: ["users"], | ||
| 497 | slug: "microsoft_graph_emulated", | ||
| 498 | baseUrl: EMULATOR_BASE_URL, | ||
| 499 | specUrl: EMULATOR_SPEC_URL, | ||
| 500 | }); | ||
| 501 | |||
| 502 | const config = yield* executor.microsoft.getConfig("microsoft_graph_emulated"); | ||
| 503 | expect(config?.sourceUrl).toBe(EMULATOR_SPEC_URL); | ||
| 504 | expect(config?.baseUrl).toBe(EMULATOR_BASE_URL); | ||
| 505 | expect(config?.microsoftGraphAuthorizationUrl).toBe( | ||
| 506 | `${EMULATOR_BASE_URL}/oauth2/v2.0/authorize`, | ||
| 507 | ); | ||
| 508 | expect(config?.microsoftGraphTokenUrl).toBe(`${EMULATOR_BASE_URL}/oauth2/v2.0/token`); | ||
| 509 | expect(config?.microsoftGraphClientCredentialsTokenUrl).toBe( | ||
| 510 | `${EMULATOR_BASE_URL}/oauth2/v2.0/token`, | ||
| 511 | ); | ||
| 512 | |||
| 513 | const delegated = config?.authenticationTemplate?.find( | ||
| 514 | (entry) => entry.kind === "oauth2" && String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 515 | ); | ||
| 516 | const clientCredentials = config?.authenticationTemplate?.find( | ||
| 517 | (entry) => | ||
| 518 | entry.kind === "oauth2" && | ||
| 519 | String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 520 | ); | ||
| 521 | expect(delegated?.kind === "oauth2" ? delegated.tokenUrl : undefined).toBe( | ||
| 522 | `${EMULATOR_BASE_URL}/oauth2/v2.0/token`, | ||
| 523 | ); | ||
| 524 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual( | ||
| 525 | [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 526 | ); | ||
| 527 | |||
| 528 | yield* executor.connections.create({ | ||
| 529 | owner: "org", | ||
| 530 | name: ConnectionName.make("machine"), | ||
| 531 | integration: IntegrationSlug.make("microsoft_graph_emulated"), | ||
| 532 | template: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), | ||
| 533 | value: "token-xyz", | ||
| 534 | }); | ||
| 535 | |||
| 536 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 537 | expect(toolNames).toContain("users.graphUserList"); | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ); | ||
| 541 | |||
| 542 | it.effect("accepts a loopback http emulator spec only when the override is enabled", () => | ||
| 543 | Effect.scoped( | ||
| 544 | Effect.gen(function* () { | ||
| 545 | const executor = yield* createExecutor( | ||
| 546 | makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }), | ||
| 547 | ); | ||
| 548 | |||
| 549 | yield* executor.microsoft.addGraph({ | ||
| 550 | presetIds: ["users"], | ||
| 551 | slug: "microsoft_graph_local_emulated", | ||
| 552 | baseUrl: LOCAL_EMULATOR_BASE_URL, | ||
| 553 | specUrl: LOCAL_EMULATOR_SPEC_URL, | ||
| 554 | }); | ||
| 555 | |||
| 556 | const config = yield* executor.microsoft.getConfig("microsoft_graph_local_emulated"); | ||
| 557 | expect(config?.sourceUrl).toBe(LOCAL_EMULATOR_SPEC_URL); | ||
| 558 | expect(config?.baseUrl).toBe(LOCAL_EMULATOR_BASE_URL); | ||
| 559 | }), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | |||
| 563 | it.effect("rejects a loopback http spec URL when the override is disabled", () => | ||
| 564 | Effect.scoped( | ||
| 565 | Effect.gen(function* () { | ||
| 566 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 567 | |||
| 568 | const exit = yield* executor.microsoft | ||
| 569 | .addGraph({ | ||
| 570 | slug: "microsoft_graph_local_disabled", | ||
| 571 | baseUrl: LOCAL_EMULATOR_BASE_URL, | ||
| 572 | specUrl: LOCAL_EMULATOR_SPEC_URL, | ||
| 573 | }) | ||
| 574 | .pipe(Effect.exit); | ||
| 575 | |||
| 576 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 577 | }), | ||
| 578 | ), | ||
| 579 | ); | ||
| 580 | |||
| 581 | it.effect("rejects a non-loopback http override even with allowUnsafeUrlOverrides", () => | ||
| 582 | Effect.scoped( | ||
| 583 | Effect.gen(function* () { | ||
| 584 | let requests = 0; | ||
| 585 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 586 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 587 | Effect.sync(() => { | ||
| 588 | requests += 1; | ||
| 589 | return HttpClientResponse.fromWeb( | ||
| 590 | request, | ||
| 591 | new Response("unexpected request", { status: 500 }), | ||
| 592 | ); | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ); | ||
| 596 | const executor = yield* createExecutor( | ||
| 597 | makeTestConfig({ | ||
| 598 | plugins: [ | ||
| 599 | microsoftPlugin({ | ||
| 600 | httpClientLayer: blockedHttpClientLayer, | ||
| 601 | allowUnsafeUrlOverrides: true, | ||
| 602 | }), | ||
| 603 | memoryCredentialsPlugin(), | ||
| 604 | ], | ||
| 605 | }), | ||
| 606 | ); | ||
| 607 | |||
| 608 | const exit = yield* executor.microsoft | ||
| 609 | .addGraph({ | ||
| 610 | slug: "microsoft_graph_http_example", | ||
| 611 | baseUrl: "http://example.com/v1.0", | ||
| 612 | specUrl: "http://example.com/openapi.yaml", | ||
| 613 | }) | ||
| 614 | .pipe(Effect.exit); | ||
| 615 | |||
| 616 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 617 | expect(requests).toBe(0); | ||
| 618 | }), | ||
| 619 | ), | ||
| 620 | ); | ||
| 621 | }); | ||
| 622 | |||
| 623 | describe("Microsoft Graph health-check default", () => { | ||
| 624 | it.effect("addGraph without profile still auto-configures the /me identity check", () => | ||
| 625 | Effect.scoped( | ||
| 626 | Effect.gen(function* () { | ||
| 627 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 628 | |||
| 629 | yield* executor.microsoft.addGraph({ | ||
| 630 | presetIds: ["mail"], | ||
| 631 | slug: "microsoft_graph_mail_hc", | ||
| 632 | description: "Microsoft Graph", | ||
| 633 | }); | ||
| 634 | |||
| 635 | const config = yield* executor.microsoft.getConfig("microsoft_graph_mail_hc"); | ||
| 636 | expect(config?.microsoftGraphExactPaths).toEqual([]); | ||
| 637 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 638 | "offline_access", | ||
| 639 | "User.Read", | ||
| 640 | "Mail.ReadWrite", | ||
| 641 | "Mail.Send", | ||
| 642 | "MailboxSettings.ReadWrite", | ||
| 643 | ]); | ||
| 644 | |||
| 645 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 646 | IntegrationSlug.make("microsoft_graph_mail_hc"), | ||
| 647 | ); | ||
| 648 | expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser"); | ||
| 649 | expect(stored?.identityField, "the default reads the principal name").toBe( | ||
| 650 | "userPrincipalName", | ||
| 651 | ); | ||
| 652 | |||
| 653 | yield* executor.connections.create({ | ||
| 654 | owner: "org", | ||
| 655 | name: ConnectionName.make("mail"), | ||
| 656 | integration: IntegrationSlug.make("microsoft_graph_mail_hc"), | ||
| 657 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 658 | value: "token-xyz", | ||
| 659 | }); | ||
| 660 | |||
| 661 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 662 | expect(toolNames).toContain("me.getUser"); | ||
| 663 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 664 | expect(toolNames).not.toContain("me.eventsListEvents"); | ||
| 665 | }), | ||
| 666 | ), | ||
| 667 | ); | ||
| 668 | |||
| 669 | it.effect("addGraph with a /me workload auto-configures the identity health check", () => | ||
| 670 | Effect.scoped( | ||
| 671 | Effect.gen(function* () { | ||
| 672 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 673 | |||
| 674 | yield* executor.microsoft.addGraph({ | ||
| 675 | presetIds: ["profile", "mail"], | ||
| 676 | slug: "microsoft_graph_hc", | ||
| 677 | description: "Microsoft Graph", | ||
| 678 | }); | ||
| 679 | |||
| 680 | // GET /me is the canonical Graph identity endpoint: the default probe | ||
| 681 | // answers alive/expired + who-am-I with zero configuration. | ||
| 682 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 683 | IntegrationSlug.make("microsoft_graph_hc"), | ||
| 684 | ); | ||
| 685 | expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser"); | ||
| 686 | expect(stored?.identityField, "the default reads the principal name").toBe( | ||
| 687 | "userPrincipalName", | ||
| 688 | ); | ||
| 689 | }), | ||
| 690 | ), | ||
| 691 | ); | ||
| 692 | }); | ||
| @@ -1,436 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type { Layer } from "effect"; | ||
| 3 | import { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationDetectionResult, | ||
| 8 | IntegrationNotFoundError, | ||
| 9 | IntegrationSlug, | ||
| 10 | definePlugin, | ||
| 11 | mergeAuthTemplates, | ||
| 12 | sha256Hex, | ||
| 13 | type AuthMethodDescriptor, | ||
| 14 | type Integration, | ||
| 15 | type IntegrationConfig, | ||
| 16 | type IntegrationRecord, | ||
| 17 | type PluginCtx, | ||
| 18 | type StorageFailure, | ||
| 19 | } from "@executor-js/sdk/core"; | ||
| 20 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | ||
| 21 | import { | ||
| 22 | checkHealthOpenApi, | ||
| 23 | compileAndPersistOpenApiSpecStreaming, | ||
| 24 | listHealthCheckCandidatesOpenApi, | ||
| 25 | decodeOpenApiIntegrationConfig, | ||
| 26 | invokeOpenApiBackedTool, | ||
| 27 | makeDefaultOpenapiStore, | ||
| 28 | normalizeOpenApiAuthInputs, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | resolveOpenApiBackedAnnotations, | ||
| 31 | resolveOpenApiBackedTools, | ||
| 32 | type Authentication, | ||
| 33 | type AuthenticationInput, | ||
| 34 | type OpenApiPersistResult, | ||
| 35 | type OpenapiStore, | ||
| 36 | } from "@executor-js/plugin-openapi"; | ||
| 37 | |||
| 38 | import { | ||
| 39 | buildMicrosoftGraphOpenApiSpec, | ||
| 40 | decodeMicrosoftGraphIntegrationConfig, | ||
| 41 | microsoftGraphKeepPathItem, | ||
| 42 | type MicrosoftGraphUrlPolicy, | ||
| 43 | type MicrosoftGraphIntegrationConfig, | ||
| 44 | type MicrosoftGraphSpecBuild, | ||
| 45 | } from "./graph"; | ||
| 46 | import { | ||
| 47 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 48 | MICROSOFT_GRAPH_BASE_URL, | ||
| 49 | microsoftGraphPreset, | ||
| 50 | } from "./presets"; | ||
| 51 | |||
| 52 | export interface MicrosoftGraphConfig { | ||
| 53 | readonly presetIds?: readonly string[]; | ||
| 54 | readonly customScopes?: readonly string[]; | ||
| 55 | readonly slug?: string; | ||
| 56 | readonly name?: string; | ||
| 57 | readonly description?: string; | ||
| 58 | readonly baseUrl?: string; | ||
| 59 | readonly specUrl?: string; | ||
| 60 | readonly authorizationUrl?: string; | ||
| 61 | readonly tokenUrl?: string; | ||
| 62 | readonly clientCredentialsTokenUrl?: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface MicrosoftConfigureInput { | ||
| 66 | readonly authenticationTemplate: readonly AuthenticationInput[]; | ||
| 67 | readonly mode?: "merge" | "replace"; | ||
| 68 | } | ||
| 69 | |||
| 70 | export interface MicrosoftUpdateInput { | ||
| 71 | readonly presetIds?: readonly string[]; | ||
| 72 | readonly customScopes?: readonly string[]; | ||
| 73 | readonly baseUrl?: string; | ||
| 74 | readonly specUrl?: string; | ||
| 75 | readonly authorizationUrl?: string; | ||
| 76 | readonly tokenUrl?: string; | ||
| 77 | readonly clientCredentialsTokenUrl?: string; | ||
| 78 | } | ||
| 79 | |||
| 80 | export interface MicrosoftUpdateResult { | ||
| 81 | readonly slug: IntegrationSlug; | ||
| 82 | readonly toolCount: number; | ||
| 83 | readonly addedTools: readonly string[]; | ||
| 84 | readonly removedTools: readonly string[]; | ||
| 85 | } | ||
| 86 | |||
| 87 | export interface MicrosoftPluginOptions { | ||
| 88 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 89 | /** | ||
| 90 | * Allows `addGraph` to point spec/base/OAuth URLs at a trusted https host | ||
| 91 | * other than the pinned Microsoft Graph endpoints, or at plain http on | ||
| 92 | * loopback (local Graph emulators). Off by default; hosts wire this to | ||
| 93 | * their own local-network dev posture (e.g. `allowLocalNetwork`), never on | ||
| 94 | * in production. | ||
| 95 | */ | ||
| 96 | readonly allowUnsafeUrlOverrides?: boolean; | ||
| 97 | } | ||
| 98 | |||
| 99 | const DEFAULT_MICROSOFT_SLUG = "microsoft_graph"; | ||
| 100 | |||
| 101 | const describeMicrosoftAuthMethods = ( | ||
| 102 | record: IntegrationRecord, | ||
| 103 | ): readonly AuthMethodDescriptor[] => { | ||
| 104 | const config = decodeOpenApiIntegrationConfig(record.config); | ||
| 105 | if (!config) return []; | ||
| 106 | return (config.authenticationTemplate ?? []).map( | ||
| 107 | (template: Authentication): AuthMethodDescriptor => { | ||
| 108 | if (template.kind === "oauth2") { | ||
| 109 | const machineFlow = | ||
| 110 | String(template.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG; | ||
| 111 | return { | ||
| 112 | id: String(template.slug), | ||
| 113 | label: machineFlow ? "Microsoft OAuth (client credentials)" : "Microsoft OAuth", | ||
| 114 | kind: "oauth", | ||
| 115 | template: String(template.slug), | ||
| 116 | oauth: { | ||
| 117 | authorizationUrl: template.authorizationUrl, | ||
| 118 | tokenUrl: template.tokenUrl, | ||
| 119 | scopes: template.scopes, | ||
| 120 | }, | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | return describeApiKeyAuthMethod(template); | ||
| 124 | }, | ||
| 125 | ); | ||
| 126 | }; | ||
| 127 | |||
| 128 | const describeMicrosoftIntegrationDisplay = ( | ||
| 129 | record: IntegrationRecord, | ||
| 130 | ): { readonly url?: string } => { | ||
| 131 | const config = decodeMicrosoftGraphIntegrationConfig(record.config); | ||
| 132 | return { url: config?.baseUrl ?? MICROSOFT_GRAPH_BASE_URL }; | ||
| 133 | }; | ||
| 134 | |||
| 135 | const makeMicrosoftPluginExtension = ( | ||
| 136 | ctx: PluginCtx<OpenapiStore>, | ||
| 137 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | ||
| 138 | urlPolicy?: MicrosoftGraphUrlPolicy, | ||
| 139 | ) => { | ||
| 140 | const persistGraphOperations = ( | ||
| 141 | graph: MicrosoftGraphSpecBuild, | ||
| 142 | integration: string, | ||
| 143 | specHash: string, | ||
| 144 | ): Effect.Effect<OpenApiPersistResult, OpenApiExtractionError | StorageFailure> => | ||
| 145 | // Stream the (full 37MB) source straight to persisted bindings + a | ||
| 146 | // content-addressed defs blob, never materializing the whole-document tree | ||
| 147 | // that OOMs the 128MB Workers isolate. `keepPathItem` applies the Microsoft | ||
| 148 | // Graph scope selection per path-item during the stream; a full-graph | ||
| 149 | // selection returns `undefined` (keep everything). | ||
| 150 | compileAndPersistOpenApiSpecStreaming({ | ||
| 151 | specText: graph.specText, | ||
| 152 | integration, | ||
| 153 | storage: ctx.storage, | ||
| 154 | specHash, | ||
| 155 | keepPathItem: microsoftGraphKeepPathItem(graph), | ||
| 156 | }); | ||
| 157 | |||
| 158 | const addGraph = (config: MicrosoftGraphConfig) => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy); | ||
| 161 | const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG); | ||
| 162 | |||
| 163 | const existing = yield* ctx.core.integrations.get(slug); | ||
| 164 | if (existing) { | ||
| 165 | return yield* new IntegrationAlreadyExistsError({ slug }); | ||
| 166 | } | ||
| 167 | |||
| 168 | const specHash = yield* sha256Hex(graph.specText); | ||
| 169 | const integrationConfig: MicrosoftGraphIntegrationConfig = { | ||
| 170 | specHash, | ||
| 171 | sourceUrl: graph.specUrl, | ||
| 172 | microsoftGraphPresetIds: graph.presetIds, | ||
| 173 | microsoftGraphCustomScopes: graph.customScopes, | ||
| 174 | microsoftGraphScopes: graph.scopes, | ||
| 175 | microsoftGraphExactPaths: graph.exactPaths, | ||
| 176 | microsoftGraphPathPrefixes: graph.pathPrefixes, | ||
| 177 | microsoftGraphTagPrefixes: graph.tagPrefixes, | ||
| 178 | microsoftGraphCoversFullGraph: graph.coversFullGraph, | ||
| 179 | microsoftGraphAuthorizationUrl: graph.authorizationUrl, | ||
| 180 | microsoftGraphTokenUrl: graph.tokenUrl, | ||
| 181 | microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl, | ||
| 182 | authenticationTemplate: graph.authenticationTemplate, | ||
| 183 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | ||
| 184 | }; | ||
| 185 | |||
| 186 | yield* ctx.storage.putSpec(specHash, graph.specText); | ||
| 187 | |||
| 188 | const persisted = yield* ctx.transaction( | ||
| 189 | Effect.gen(function* () { | ||
| 190 | yield* ctx.core.integrations.register({ | ||
| 191 | slug, | ||
| 192 | name: config.name?.trim() || "Microsoft Graph", | ||
| 193 | description: config.description ?? "Selected Microsoft Graph workloads.", | ||
| 194 | config: | ||
| 195 | integrationConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 196 | canRemove: true, | ||
| 197 | canRefresh: true, | ||
| 198 | }); | ||
| 199 | return yield* persistGraphOperations(graph, String(slug), specHash); | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | // Default the health check to `GET /me` (the canonical Graph identity | ||
| 204 | // endpoint) when the selected workloads include it, so connections report | ||
| 205 | // alive/expired + who-am-I out of the box. The operations were streamed | ||
| 206 | // to storage above, so find the binding there. Best-effort: a Graph | ||
| 207 | // selection without /me just leaves the check unconfigured (the editor | ||
| 208 | // remains available). | ||
| 209 | const meOperation = (yield* ctx.storage.listOperations(String(slug))).find( | ||
| 210 | (op) => op.binding.method.toLowerCase() === "get" && op.binding.pathTemplate === "/me", | ||
| 211 | ); | ||
| 212 | if (meOperation) { | ||
| 213 | yield* ctx.core.integrations.setHealthCheck(slug, { | ||
| 214 | operation: meOperation.toolName, | ||
| 215 | identityField: "userPrincipalName", | ||
| 216 | }); | ||
| 217 | } | ||
| 218 | |||
| 219 | return { slug, toolCount: persisted.toolCount }; | ||
| 220 | }); | ||
| 221 | |||
| 222 | const updateGraph = (rawSlug: string, input?: MicrosoftUpdateInput) => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const slug = IntegrationSlug.make(rawSlug); | ||
| 225 | const record = yield* ctx.core.integrations.get(slug); | ||
| 226 | const current = record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null; | ||
| 227 | if (!record || !current) { | ||
| 228 | return yield* new IntegrationNotFoundError({ slug }); | ||
| 229 | } | ||
| 230 | |||
| 231 | const graph = yield* buildMicrosoftGraphOpenApiSpec( | ||
| 232 | { | ||
| 233 | presetIds: input?.presetIds ?? current.microsoftGraphPresetIds, | ||
| 234 | customScopes: input?.customScopes ?? current.microsoftGraphCustomScopes, | ||
| 235 | baseUrl: input?.baseUrl ?? current.baseUrl, | ||
| 236 | specUrl: input?.specUrl ?? current.sourceUrl, | ||
| 237 | authorizationUrl: input?.authorizationUrl ?? current.microsoftGraphAuthorizationUrl, | ||
| 238 | tokenUrl: input?.tokenUrl ?? current.microsoftGraphTokenUrl, | ||
| 239 | clientCredentialsTokenUrl: | ||
| 240 | input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl, | ||
| 241 | }, | ||
| 242 | httpClientLayer, | ||
| 243 | urlPolicy, | ||
| 244 | ); | ||
| 245 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | ||
| 246 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | ||
| 247 | |||
| 248 | const specHash = yield* sha256Hex(graph.specText); | ||
| 249 | yield* ctx.storage.putSpec(specHash, graph.specText); | ||
| 250 | |||
| 251 | const nextConfig: MicrosoftGraphIntegrationConfig = { | ||
| 252 | ...current, | ||
| 253 | specHash, | ||
| 254 | sourceUrl: graph.specUrl, | ||
| 255 | microsoftGraphPresetIds: graph.presetIds, | ||
| 256 | microsoftGraphCustomScopes: graph.customScopes, | ||
| 257 | microsoftGraphScopes: graph.scopes, | ||
| 258 | microsoftGraphExactPaths: graph.exactPaths, | ||
| 259 | microsoftGraphPathPrefixes: graph.pathPrefixes, | ||
| 260 | microsoftGraphTagPrefixes: graph.tagPrefixes, | ||
| 261 | microsoftGraphCoversFullGraph: graph.coversFullGraph, | ||
| 262 | microsoftGraphAuthorizationUrl: graph.authorizationUrl, | ||
| 263 | microsoftGraphTokenUrl: graph.tokenUrl, | ||
| 264 | microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl, | ||
| 265 | authenticationTemplate: graph.authenticationTemplate, | ||
| 266 | ...(input?.baseUrl ? { baseUrl: input.baseUrl } : {}), | ||
| 267 | }; | ||
| 268 | |||
| 269 | const persisted = yield* ctx.transaction( | ||
| 270 | Effect.gen(function* () { | ||
| 271 | yield* ctx.core.integrations.update(slug, { | ||
| 272 | config: nextConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 273 | }); | ||
| 274 | return yield* persistGraphOperations(graph, rawSlug, specHash); | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | const nextNames = new Set(persisted.toolNames); | ||
| 278 | |||
| 279 | const connections = yield* ctx.connections.list({ integration: slug }); | ||
| 280 | yield* Effect.forEach( | ||
| 281 | connections, | ||
| 282 | (connection) => | ||
| 283 | ctx.connections | ||
| 284 | .refresh({ | ||
| 285 | owner: connection.owner, | ||
| 286 | integration: connection.integration, | ||
| 287 | name: connection.name, | ||
| 288 | }) | ||
| 289 | .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), | ||
| 290 | { discard: true }, | ||
| 291 | ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void)); | ||
| 292 | |||
| 293 | return { | ||
| 294 | slug, | ||
| 295 | toolCount: persisted.toolCount, | ||
| 296 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | ||
| 297 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | ||
| 298 | }; | ||
| 299 | }); | ||
| 300 | |||
| 301 | return { | ||
| 302 | addGraph, | ||
| 303 | updateGraph, | ||
| 304 | removeGraph: (slug: string) => | ||
| 305 | ctx.transaction( | ||
| 306 | Effect.gen(function* () { | ||
| 307 | yield* ctx.storage.removeOperations(slug); | ||
| 308 | yield* ctx.core.integrations | ||
| 309 | .remove(IntegrationSlug.make(slug)) | ||
| 310 | .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | getIntegration: (slug: string) => | ||
| 314 | ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( | ||
| 315 | Effect.map((record) => | ||
| 316 | record | ||
| 317 | ? ({ | ||
| 318 | slug: record.slug, | ||
| 319 | description: record.description, | ||
| 320 | kind: record.kind, | ||
| 321 | canRemove: record.canRemove, | ||
| 322 | canRefresh: record.canRefresh, | ||
| 323 | } as Integration) | ||
| 324 | : null, | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | getConfig: (slug: string) => | ||
| 328 | ctx.core.integrations | ||
| 329 | .get(IntegrationSlug.make(slug)) | ||
| 330 | .pipe( | ||
| 331 | Effect.map((record) => | ||
| 332 | record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null, | ||
| 333 | ), | ||
| 334 | ), | ||
| 335 | configure: (slug: string, input: MicrosoftConfigureInput) => | ||
| 336 | ctx.transaction( | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); | ||
| 339 | if (!record) return [] as readonly Authentication[]; | ||
| 340 | const current = decodeMicrosoftGraphIntegrationConfig(record.config); | ||
| 341 | if (!current) return [] as readonly Authentication[]; | ||
| 342 | |||
| 343 | const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | ||
| 344 | const merged = | ||
| 345 | input.mode === "replace" | ||
| 346 | ? incoming | ||
| 347 | : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | ||
| 348 | |||
| 349 | const next: MicrosoftGraphIntegrationConfig = { | ||
| 350 | ...current, | ||
| 351 | authenticationTemplate: merged, | ||
| 352 | }; | ||
| 353 | |||
| 354 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { | ||
| 355 | config: next satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 356 | }); | ||
| 357 | |||
| 358 | return merged; | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | }; | ||
| 362 | }; | ||
| 363 | |||
| 364 | export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>; | ||
| 365 | |||
| 366 | export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({ | ||
| 367 | id: "microsoft" as const, | ||
| 368 | packageName: "@executor-js/plugin-microsoft", | ||
| 369 | integrationPresets: [microsoftGraphPreset], | ||
| 370 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | ||
| 371 | |||
| 372 | extension: (ctx) => | ||
| 373 | makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, { | ||
| 374 | allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true, | ||
| 375 | }), | ||
| 376 | |||
| 377 | describeAuthMethods: describeMicrosoftAuthMethods, | ||
| 378 | describeIntegrationDisplay: describeMicrosoftIntegrationDisplay, | ||
| 379 | |||
| 380 | resolveTools: ({ integration, config, storage }) => | ||
| 381 | resolveOpenApiBackedTools({ integration, config, storage }), | ||
| 382 | |||
| 383 | invokeTool: ({ ctx, toolRow, credential, args }) => { | ||
| 384 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 385 | return invokeOpenApiBackedTool({ | ||
| 386 | ctx, | ||
| 387 | toolRow, | ||
| 388 | credential, | ||
| 389 | args, | ||
| 390 | httpClientLayer, | ||
| 391 | }); | ||
| 392 | }, | ||
| 393 | |||
| 394 | resolveAnnotations: ({ ctx, integration, toolRows }) => | ||
| 395 | resolveOpenApiBackedAnnotations({ | ||
| 396 | ctx, | ||
| 397 | integration: String(integration), | ||
| 398 | toolRows, | ||
| 399 | }), | ||
| 400 | |||
| 401 | // Health checks reuse the OpenAPI backing (same store). GET /me is | ||
| 402 | // auto-defaulted at addGraph when present; core owns the stored spec. | ||
| 403 | listHealthCheckCandidates: (input) => | ||
| 404 | listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), | ||
| 405 | checkHealth: (input) => | ||
| 406 | checkHealthOpenApi({ | ||
| 407 | ctx: input.ctx, | ||
| 408 | integration: input.integration, | ||
| 409 | credential: input.credential, | ||
| 410 | spec: input.spec, | ||
| 411 | httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, | ||
| 412 | }), | ||
| 413 | |||
| 414 | removeConnection: () => Effect.void, | ||
| 415 | |||
| 416 | detect: ({ url }) => | ||
| 417 | Effect.sync(() => { | ||
| 418 | const trimmed = url.trim(); | ||
| 419 | if (!URL.canParse(trimmed)) return null; | ||
| 420 | const parsed = new URL(trimmed); | ||
| 421 | const host = parsed.hostname.toLowerCase(); | ||
| 422 | const isGraph = | ||
| 423 | host === "graph.microsoft.com" || | ||
| 424 | (host === "learn.microsoft.com" && parsed.pathname.startsWith("/graph/")) || | ||
| 425 | (host === "raw.githubusercontent.com" && | ||
| 426 | parsed.pathname.includes("/microsoftgraph/msgraph-metadata/")); | ||
| 427 | if (!isGraph) return null; | ||
| 428 | return IntegrationDetectionResult.make({ | ||
| 429 | kind: "microsoft", | ||
| 430 | confidence: "high", | ||
| 431 | endpoint: trimmed, | ||
| 432 | name: "Microsoft Graph", | ||
| 433 | slug: DEFAULT_MICROSOFT_SLUG, | ||
| 434 | }); | ||
| 435 | }), | ||
| 436 | })); | ||
| @@ -1,169 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | MICROSOFT_GRAPH_ALL_PRESET_IDS, | ||
| 5 | MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 6 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 7 | microsoftGraphExactPathsForPresetIds, | ||
| 8 | microsoftGraphPathPrefixesForPresetIds, | ||
| 9 | microsoftGraphPresetIdsCoverFullGraph, | ||
| 10 | microsoftGraphScopePresets, | ||
| 11 | microsoftGraphScopesForPresetIds, | ||
| 12 | microsoftGraphTagPrefixesForPresetIds, | ||
| 13 | } from "./presets"; | ||
| 14 | |||
| 15 | describe("Microsoft Graph scope presets", () => { | ||
| 16 | it("keeps default workload ids backed by categorized presets", () => { | ||
| 17 | const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id)); | ||
| 18 | expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.every((id) => ids.has(id))).toBe(true); | ||
| 19 | expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS).toEqual([ | ||
| 20 | "profile", | ||
| 21 | "mail", | ||
| 22 | "calendar", | ||
| 23 | "contacts", | ||
| 24 | "tasks", | ||
| 25 | "files", | ||
| 26 | "excel", | ||
| 27 | "sites", | ||
| 28 | "onenote", | ||
| 29 | "teams-chat", | ||
| 30 | "teams-channels", | ||
| 31 | "meetings-calls", | ||
| 32 | ]); | ||
| 33 | expect(ids.has("all")).toBe(false); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("keeps the full workload ids backed by every categorized preset", () => { | ||
| 37 | const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id)); | ||
| 38 | expect(MICROSOFT_GRAPH_ALL_PRESET_IDS.every((id) => ids.has(id))).toBe(true); | ||
| 39 | expect(MICROSOFT_GRAPH_ALL_PRESET_IDS).toEqual( | ||
| 40 | microsoftGraphScopePresets.map((preset) => preset.id), | ||
| 41 | ); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("detects when the categorized catalog covers full Graph", () => { | ||
| 45 | expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_ALL_PRESET_IDS)).toBe(true); | ||
| 46 | expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS)).toBe(false); | ||
| 47 | expect(microsoftGraphPresetIdsCoverFullGraph(["profile", "mail"])).toBe(false); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("unions selected preset scopes with base and custom scopes", () => { | ||
| 51 | expect(microsoftGraphScopesForPresetIds(["profile", "mail"], ["Sites.Read.All"])).toEqual([ | ||
| 52 | ...MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 53 | "User.Read", | ||
| 54 | "Mail.ReadWrite", | ||
| 55 | "Mail.Send", | ||
| 56 | "MailboxSettings.ReadWrite", | ||
| 57 | "Sites.Read.All", | ||
| 58 | ]); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("includes User.Read for identity when profile is not selected", () => { | ||
| 62 | expect(microsoftGraphScopesForPresetIds(["mail"])).toEqual([ | ||
| 63 | ...MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 64 | "User.Read", | ||
| 65 | "Mail.ReadWrite", | ||
| 66 | "Mail.Send", | ||
| 67 | "MailboxSettings.ReadWrite", | ||
| 68 | ]); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("returns path filters for the selected workloads", () => { | ||
| 72 | expect(microsoftGraphExactPathsForPresetIds(["profile"])).toContain("/me"); | ||
| 73 | expect(microsoftGraphPathPrefixesForPresetIds(["mail"])).toContain("/me/messages"); | ||
| 74 | expect(microsoftGraphTagPrefixesForPresetIds(["mail"])).toEqual([]); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("covers Microsoft Graph root surfaces through category presets", () => { | ||
| 78 | const prefixes = new Set( | ||
| 79 | microsoftGraphPathPrefixesForPresetIds(MICROSOFT_GRAPH_ALL_PRESET_IDS), | ||
| 80 | ); | ||
| 81 | for (const root of [ | ||
| 82 | "/agreementAcceptances", | ||
| 83 | "/agreements", | ||
| 84 | "/admin", | ||
| 85 | "/appCatalogs", | ||
| 86 | "/applicationTemplates", | ||
| 87 | "/applications", | ||
| 88 | "/applications(appId='{appId}')", | ||
| 89 | "/applications(uniqueName='{uniqueName}')", | ||
| 90 | "/authenticationMethodConfigurations", | ||
| 91 | "/authenticationMethodsPolicy", | ||
| 92 | "/auditLogs", | ||
| 93 | "/certificateBasedAuthConfiguration", | ||
| 94 | "/chats", | ||
| 95 | "/communications", | ||
| 96 | "/compliance", | ||
| 97 | "/connections", | ||
| 98 | "/contacts", | ||
| 99 | "/contracts", | ||
| 100 | "/copilot", | ||
| 101 | "/dataPolicyOperations", | ||
| 102 | "/deviceAppManagement", | ||
| 103 | "/deviceManagement", | ||
| 104 | "/devices", | ||
| 105 | "/devices(deviceId='{deviceId}')", | ||
| 106 | "/directory", | ||
| 107 | "/directoryObjects", | ||
| 108 | "/directoryRoleTemplates", | ||
| 109 | "/directoryRoles", | ||
| 110 | "/directoryRoles(roleTemplateId='{roleTemplateId}')", | ||
| 111 | "/domainDnsRecords", | ||
| 112 | "/domains", | ||
| 113 | "/drives", | ||
| 114 | "/education", | ||
| 115 | "/employeeExperience", | ||
| 116 | "/external", | ||
| 117 | "/filterOperators", | ||
| 118 | "/functions", | ||
| 119 | "/groupLifecyclePolicies", | ||
| 120 | "/groupSettingTemplates", | ||
| 121 | "/groupSettings", | ||
| 122 | "/groups", | ||
| 123 | "/groups(uniqueName='{uniqueName}')", | ||
| 124 | "/identity", | ||
| 125 | "/identityGovernance", | ||
| 126 | "/identityProviders", | ||
| 127 | "/identityProtection", | ||
| 128 | "/informationProtection", | ||
| 129 | "/invitations", | ||
| 130 | "/me", | ||
| 131 | "/oauth2PermissionGrants", | ||
| 132 | "/organization", | ||
| 133 | "/permissionGrants", | ||
| 134 | "/places", | ||
| 135 | "/planner", | ||
| 136 | "/policies", | ||
| 137 | "/print", | ||
| 138 | "/privacy", | ||
| 139 | "/reports", | ||
| 140 | "/roleManagement", | ||
| 141 | "/schemaExtensions", | ||
| 142 | "/scopedRoleMemberships", | ||
| 143 | "/search", | ||
| 144 | "/security", | ||
| 145 | "/servicePrincipals", | ||
| 146 | "/servicePrincipals(appId='{appId}')", | ||
| 147 | "/shares", | ||
| 148 | "/sites", | ||
| 149 | "/solutions", | ||
| 150 | "/storage", | ||
| 151 | "/subscribedSkus", | ||
| 152 | "/subscriptions", | ||
| 153 | "/teamwork", | ||
| 154 | "/teams", | ||
| 155 | "/teamsTemplates", | ||
| 156 | "/tenantRelationships", | ||
| 157 | "/users", | ||
| 158 | "/users(userPrincipalName='{userPrincipalName}')", | ||
| 159 | ]) { | ||
| 160 | expect(prefixes.has(root)).toBe(true); | ||
| 161 | } | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("declares product icons for each workload", () => { | ||
| 165 | for (const preset of microsoftGraphScopePresets) { | ||
| 166 | expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/); | ||
| 167 | } | ||
| 168 | }); | ||
| 169 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | // `.test.ts` only. React component tests are `.test.tsx` and need a DOM | ||
| 6 | // env, so they're excluded; the plugin-side converters are pure `.ts` | ||
| 7 | // logic and run here from `src/react/`. | ||
| 8 | include: ["src/**/*.test.ts"], | ||
| 9 | exclude: ["src/api/**", "src/react/**/*.test.tsx"], | ||
| 10 | testTimeout: 15_000, | ||
| 11 | }, | ||
| 12 | }); | ||
| @@ -22,6 +22,8 @@ · 21 unmodified lines | |||
| 22 | "./react": "./src/react/index.ts", | 22 | "./react": "./src/react/index.ts", |
| 23 | "./client": "./src/react/plugin-client.tsx", | 23 | "./client": "./src/react/plugin-client.tsx", |
| 24 | "./presets": "./src/sdk/presets.ts", | 24 | "./presets": "./src/sdk/presets.ts", |
| 25 | "./providers/google": "./src/providers/google/index.ts", | ||
| 26 | "./providers/microsoft": "./src/providers/microsoft/index.ts", | ||
| 25 | "./testing": "./src/testing/index.ts" | 27 | "./testing": "./src/testing/index.ts" |
| 26 | }, | 28 | }, |
| 27 | "publishConfig": { | 29 | "publishConfig": { |
| @@ -45,6 +47,18 @@ · 17 unmodified lines | |||
| 45 | "default": "./dist/client.js" | 47 | "default": "./dist/client.js" |
| 46 | } | 48 | } |
| 47 | }, | 49 | }, |
| 50 | "./providers/google": { | ||
| 51 | "import": { | ||
| 52 | "types": "./dist/providers/google/index.d.ts", | ||
| 53 | "default": "./dist/providers/google.js" | ||
| 54 | } | ||
| 55 | }, | ||
| 56 | "./providers/microsoft": { | ||
| 57 | "import": { | ||
| 58 | "types": "./dist/providers/microsoft/index.d.ts", | ||
| 59 | "default": "./dist/providers/microsoft.js" | ||
| 60 | } | ||
| 61 | }, | ||
| 48 | "./testing": { | 62 | "./testing": { |
| 49 | "import": { | 63 | "import": { |
| 50 | "types": "./dist/testing/index.d.ts", | 64 | "types": "./dist/testing/index.d.ts", |
| @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line | |||
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; |
| 4 | import { | 4 | import { |
| 5 | HealthCheckSpec, | ||
| 5 | InternalError, | 6 | InternalError, |
| 6 | IntegrationAlreadyExistsError, | 7 | IntegrationAlreadyExistsError, |
| 7 | IntegrationNotFoundError, | 8 | IntegrationNotFoundError, |
| @@ -68,25 +69,31 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 60 unmodified lines | |||
| 68 | 69 | ||
| 69 | const AddSpecPayload = Schema.Struct({ | 70 | const AddSpecPayload = Schema.Struct({ |
| 70 | spec: OpenApiSpecInputPayload, | 71 | spec: OpenApiSpecInputPayload, |
| 71 | slug: Schema.String, | 72 | slug: Schema.optional(Schema.String), |
| 72 | name: Schema.optional(Schema.String), | 73 | name: Schema.optional(Schema.String), |
| 73 | description: Schema.optional(Schema.String), | 74 | description: Schema.optional(Schema.String), |
| 74 | baseUrl: Schema.optional(Schema.String), | 75 | baseUrl: Schema.optional(Schema.String), |
| 75 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 76 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 76 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 77 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 78 | specFormat: Schema.optional(Schema.String), | ||
| 79 | family: Schema.optional(Schema.String), | ||
| 80 | healthCheck: Schema.optional(HealthCheckSpec), | ||
| 77 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), | 81 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), |
| 78 | }); | 82 | }); |
| 79 | 83 | ||
| 80 | const PreviewSpecPayload = Schema.Struct({ | 84 | const PreviewSpecPayload = Schema.Struct({ |
| 81 | spec: Schema.String, | 85 | spec: Schema.String, |
| 86 | specFormat: Schema.optional(Schema.String), | ||
| 82 | }); | 87 | }); |
| 83 | 88 | ||
| 84 | // The `configure` payload — the new/updated auth methods to merge onto the | 89 | // The `configure` payload — the new/updated auth methods to merge onto the |
| 85 | // integration's `authenticationTemplate`. Reuses the same `AuthenticationPayload` | 90 | // integration's `authenticationTemplate`, plus request routing metadata. |
| 86 | // schema as `addSpec` so a custom apiKey method round-trips identically. | 91 | // Reuses the same `AuthenticationPayload` schema as `addSpec` so a custom apiKey |
| 92 | // method round-trips identically. | ||
| 87 | const ConfigurePayload = Schema.Struct({ | 93 | const ConfigurePayload = Schema.Struct({ |
| 88 | authenticationTemplate: Schema.Array(AuthenticationPayload), | 94 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), |
| 89 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | 95 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), |
| 96 | baseUrl: Schema.optional(Schema.String), | ||
| 90 | }); | 97 | }); |
| 91 | 98 | ||
| 92 | // --------------------------------------------------------------------------- | 99 | // --------------------------------------------------------------------------- |
| @@ -44,7 +44,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines | |||
| 44 | capture( | 44 | capture( |
| 45 | Effect.gen(function* () { | 45 | Effect.gen(function* () { |
| 46 | const ext = yield* OpenApiExtensionService; | 46 | const ext = yield* OpenApiExtensionService; |
| 47 | const preview = yield* ext.previewSpec({ spec: payload.spec }); | 47 | const preview = yield* ext.previewSpec({ |
| 48 | spec: payload.spec, | ||
| 49 | specFormat: payload.specFormat, | ||
| 50 | }); | ||
| 48 | return specPreviewSummary(preview); | 51 | return specPreviewSummary(preview); |
| 49 | }), | 52 | }), |
| 50 | ), | 53 | ), |
| @@ -61,6 +64,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines | |||
| 61 | baseUrl: payload.baseUrl, | 64 | baseUrl: payload.baseUrl, |
| 62 | headers: payload.headers ? { ...payload.headers } : undefined, | 65 | headers: payload.headers ? { ...payload.headers } : undefined, |
| 63 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, | 66 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 67 | specFormat: payload.specFormat, | ||
| 68 | family: payload.family, | ||
| 69 | healthCheck: payload.healthCheck, | ||
| 64 | authenticationTemplate: payload.authenticationTemplate, | 70 | authenticationTemplate: payload.authenticationTemplate, |
| 65 | }); | 71 | }); |
| 66 | }), | 72 | }), |
| @@ -109,6 +115,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines | |||
| 109 | const authenticationTemplate = yield* ext.configure(params.slug, { | 115 | const authenticationTemplate = yield* ext.configure(params.slug, { |
| 110 | authenticationTemplate: payload.authenticationTemplate, | 116 | authenticationTemplate: payload.authenticationTemplate, |
| 111 | mode: payload.mode ?? "merge", | 117 | mode: payload.mode ?? "merge", |
| 118 | baseUrl: payload.baseUrl, | ||
| 112 | }); | 119 | }); |
| 113 | return { authenticationTemplate: [...authenticationTemplate] }; | 120 | return { authenticationTemplate: [...authenticationTemplate] }; |
| 114 | }), | 121 | }), |
File renamed without changes.
| @@ -1,5 +1,7 @@ | |||
| 1 | import { Option, Schema } from "effect"; | 1 | import { Option, Schema } from "effect"; |
| 2 | import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi"; | 2 | |
| 3 | import { AuthenticationSchema } from "../../sdk/config"; | ||
| 4 | import type { Authentication } from "../../sdk/types"; | ||
| 3 | 5 | ||
| 4 | export const GoogleIntegrationConfigSchema = Schema.Struct({ | 6 | export const GoogleIntegrationConfigSchema = Schema.Struct({ |
| 5 | specHash: Schema.optional(Schema.String), | 7 | specHash: Schema.optional(Schema.String), |
File renamed without changes.
| @@ -5,7 +5,8 @@ · 4 unmodified lines | |||
| 5 | import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; | 5 | import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; |
| 6 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 6 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 7 | 7 | ||
| 8 | import { OpenApiParseError, type Authentication } from "@executor-js/plugin-openapi"; | 8 | import { OpenApiParseError } from "../../sdk/errors"; |
| 9 | import type { Authentication } from "../../sdk/types"; | ||
| 9 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | 10 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; |
| 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 11 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | 12 | ||
| @@ -7,6 +7,7 @@ export { · 6 unmodified lines | |||
| 7 | } from "./discovery"; | 7 | } from "./discovery"; |
| 8 | export { | 8 | export { |
| 9 | googleOpenApiBundlePreset, | 9 | googleOpenApiBundlePreset, |
| 10 | googleCatalog, | ||
| 10 | googleOpenApiPresets, | 11 | googleOpenApiPresets, |
| 11 | googlePhotosOpenApiBundlePreset, | 12 | googlePhotosOpenApiBundlePreset, |
| 12 | googlePhotosOpenApiPresets, | 13 | googlePhotosOpenApiPresets, |
| @@ -16,6 +17,7 @@ export { · 3 unmodified lines | |||
| 16 | googleStandardUserOAuthPresets, | 17 | googleStandardUserOAuthPresets, |
| 17 | googleOAuthConsentScopes, | 18 | googleOAuthConsentScopes, |
| 18 | googleOAuthConsentScopesForPreset, | 19 | googleOAuthConsentScopesForPreset, |
| 20 | googleServiceSlug, | ||
| 19 | googleAudienceWarningsForUrls, | 21 | googleAudienceWarningsForUrls, |
| 20 | googlePresetForDiscoveryUrl, | 22 | googlePresetForDiscoveryUrl, |
| 21 | type GoogleOpenApiOAuthAudience, | 23 | type GoogleOpenApiOAuthAudience, |
| @@ -27,20 +29,7 @@ export { · 5 unmodified lines | |||
| 27 | filterGoogleUserConsentOAuthScopes, | 29 | filterGoogleUserConsentOAuthScopes, |
| 28 | isGoogleUserConsentOAuthScope, | 30 | isGoogleUserConsentOAuthScope, |
| 29 | } from "./oauth-scopes"; | 31 | } from "./oauth-scopes"; |
| 30 | export { | 32 | export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 31 | googleOAuthConsentBatches, | ||
| 32 | type GoogleOAuthBatchInput, | ||
| 33 | type GoogleOAuthConsentBatch, | ||
| 34 | } from "./oauth-batches"; | ||
| 35 | export { | ||
| 36 | googlePlugin, | ||
| 37 | type GoogleBundleConfig, | ||
| 38 | type GoogleConfigureInput, | ||
| 39 | type GooglePluginExtension, | ||
| 40 | type GooglePluginOptions, | ||
| 41 | type GoogleUpdateInput, | ||
| 42 | type GoogleUpdateResult, | ||
| 43 | } from "./plugin"; | ||
| 44 | export { | 33 | export { |
| 45 | googleOpenApiOwnershipDataMigration, | 34 | googleOpenApiOwnershipDataMigration, |
| 46 | runSqliteGoogleOpenApiOwnershipMigration, | 35 | runSqliteGoogleOpenApiOwnershipMigration, |
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
| @@ -0,0 +1,283 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { compileOpenApiSpec } from "@executor-js/plugin-openapi"; | ||
| 4 | |||
| 5 | import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery"; | ||
| 6 | import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; | ||
| 7 | |||
| 8 | const googleHealthCheckDiscoveryFixtures = { | ||
| 9 | "google-calendar": { | ||
| 10 | url: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 11 | document: { | ||
| 12 | name: "calendar", | ||
| 13 | version: "v3", | ||
| 14 | title: "Calendar API", | ||
| 15 | rootUrl: "https://www.googleapis.com/", | ||
| 16 | servicePath: "calendar/v3/", | ||
| 17 | resources: { | ||
| 18 | calendarList: { | ||
| 19 | methods: { | ||
| 20 | list: { | ||
| 21 | id: "calendar.calendarList.list", | ||
| 22 | path: "users/me/calendarList", | ||
| 23 | httpMethod: "GET", | ||
| 24 | response: { $ref: "CalendarList" }, | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | }, | ||
| 29 | schemas: { | ||
| 30 | CalendarList: { | ||
| 31 | type: "object", | ||
| 32 | properties: { | ||
| 33 | items: { | ||
| 34 | type: "array", | ||
| 35 | items: { $ref: "CalendarListEntry" }, | ||
| 36 | }, | ||
| 37 | }, | ||
| 38 | }, | ||
| 39 | CalendarListEntry: { | ||
| 40 | type: "object", | ||
| 41 | properties: { | ||
| 42 | id: { type: "string" }, | ||
| 43 | summary: { type: "string" }, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | "google-gmail": { | ||
| 50 | url: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 51 | document: { | ||
| 52 | name: "gmail", | ||
| 53 | version: "v1", | ||
| 54 | title: "Gmail API", | ||
| 55 | rootUrl: "https://gmail.googleapis.com/", | ||
| 56 | servicePath: "", | ||
| 57 | resources: { | ||
| 58 | users: { | ||
| 59 | methods: { | ||
| 60 | labelsList: { | ||
| 61 | id: "gmail.users.labels.list", | ||
| 62 | path: "gmail/v1/users/{userId}/labels", | ||
| 63 | httpMethod: "GET", | ||
| 64 | parameters: { | ||
| 65 | userId: { | ||
| 66 | type: "string", | ||
| 67 | location: "path", | ||
| 68 | required: true, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | response: { $ref: "ListLabelsResponse" }, | ||
| 72 | }, | ||
| 73 | }, | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | schemas: { | ||
| 77 | ListLabelsResponse: { | ||
| 78 | type: "object", | ||
| 79 | properties: { | ||
| 80 | labels: { | ||
| 81 | type: "array", | ||
| 82 | items: { $ref: "Label" }, | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | Label: { | ||
| 87 | type: "object", | ||
| 88 | properties: { | ||
| 89 | id: { type: "string" }, | ||
| 90 | name: { type: "string" }, | ||
| 91 | }, | ||
| 92 | }, | ||
| 93 | }, | ||
| 94 | }, | ||
| 95 | }, | ||
| 96 | "google-drive": { | ||
| 97 | url: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", | ||
| 98 | document: { | ||
| 99 | name: "drive", | ||
| 100 | version: "v3", | ||
| 101 | title: "Google Drive API", | ||
| 102 | rootUrl: "https://www.googleapis.com/", | ||
| 103 | servicePath: "drive/v3/", | ||
| 104 | resources: { | ||
| 105 | about: { | ||
| 106 | methods: { | ||
| 107 | get: { | ||
| 108 | id: "drive.about.get", | ||
| 109 | path: "about", | ||
| 110 | httpMethod: "GET", | ||
| 111 | response: { $ref: "About" }, | ||
| 112 | }, | ||
| 113 | }, | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | schemas: { | ||
| 117 | About: { | ||
| 118 | type: "object", | ||
| 119 | properties: { | ||
| 120 | user: { $ref: "User" }, | ||
| 121 | }, | ||
| 122 | }, | ||
| 123 | User: { | ||
| 124 | type: "object", | ||
| 125 | properties: { | ||
| 126 | emailAddress: { type: "string" }, | ||
| 127 | displayName: { type: "string" }, | ||
| 128 | }, | ||
| 129 | }, | ||
| 130 | }, | ||
| 131 | }, | ||
| 132 | }, | ||
| 133 | "google-tasks": { | ||
| 134 | url: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest", | ||
| 135 | document: { | ||
| 136 | name: "tasks", | ||
| 137 | version: "v1", | ||
| 138 | title: "Google Tasks API", | ||
| 139 | rootUrl: "https://tasks.googleapis.com/", | ||
| 140 | servicePath: "", | ||
| 141 | resources: { | ||
| 142 | tasklists: { | ||
| 143 | methods: { | ||
| 144 | list: { | ||
| 145 | id: "tasks.tasklists.list", | ||
| 146 | path: "tasks/v1/users/@me/lists", | ||
| 147 | httpMethod: "GET", | ||
| 148 | response: { $ref: "TaskLists" }, | ||
| 149 | }, | ||
| 150 | }, | ||
| 151 | }, | ||
| 152 | }, | ||
| 153 | schemas: { | ||
| 154 | TaskLists: { | ||
| 155 | type: "object", | ||
| 156 | properties: { | ||
| 157 | items: { | ||
| 158 | type: "array", | ||
| 159 | items: { $ref: "TaskList" }, | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | }, | ||
| 163 | TaskList: { | ||
| 164 | type: "object", | ||
| 165 | properties: { | ||
| 166 | id: { type: "string" }, | ||
| 167 | title: { type: "string" }, | ||
| 168 | }, | ||
| 169 | }, | ||
| 170 | }, | ||
| 171 | }, | ||
| 172 | }, | ||
| 173 | } as const; | ||
| 174 | |||
| 175 | const FROZEN_GOOGLE_SLUGS = [ | ||
| 176 | "google_calendar", | ||
| 177 | "google_gmail", | ||
| 178 | "google_sheets", | ||
| 179 | "google_drive", | ||
| 180 | "google_docs", | ||
| 181 | "google_slides", | ||
| 182 | "google_forms", | ||
| 183 | "google_tasks", | ||
| 184 | "google_people", | ||
| 185 | "google_photos_library", | ||
| 186 | "google_photos_picker", | ||
| 187 | "google_chat", | ||
| 188 | "google_keep", | ||
| 189 | "google_youtube_data", | ||
| 190 | "google_search_console", | ||
| 191 | "google_classroom", | ||
| 192 | "google_admin_directory", | ||
| 193 | "google_admin_reports", | ||
| 194 | "google_apps_script", | ||
| 195 | "google_bigquery", | ||
| 196 | "google_cloud_resource_manager", | ||
| 197 | ] as const; | ||
| 198 | |||
| 199 | it("keeps Select all limited to Google services that can use normal user OAuth", () => { | ||
| 200 | const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id)); | ||
| 201 | |||
| 202 | expect(standardIds).toContain("google-calendar"); | ||
| 203 | expect(standardIds).toContain("google-gmail"); | ||
| 204 | expect(standardIds).toContain("google-tasks"); | ||
| 205 | expect(standardIds).toContain("google-people"); | ||
| 206 | expect(standardIds).toContain("google-search-console"); | ||
| 207 | |||
| 208 | expect(standardIds).not.toContain("google-youtube-data"); | ||
| 209 | expect(standardIds).not.toContain("google-cloud-resource-manager"); | ||
| 210 | expect(standardIds).not.toContain("google-chat"); | ||
| 211 | expect(standardIds).not.toContain("google-keep"); | ||
| 212 | expect(standardIds).not.toContain("google-admin-directory"); | ||
| 213 | expect(standardIds).not.toContain("google-admin-reports"); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("classifies every Google service for bundle OAuth UX", () => { | ||
| 217 | expect( | ||
| 218 | googleOpenApiPresets.map((preset) => ({ | ||
| 219 | id: preset.id, | ||
| 220 | oauthAudience: preset.oauthAudience, | ||
| 221 | })), | ||
| 222 | ).toMatchSnapshot(); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("exports one catalog row per Google service with stable slugs and per-service OAuth", () => { | ||
| 226 | expect(googleCatalog.map((preset) => preset.defaultSlug)).toEqual([...FROZEN_GOOGLE_SLUGS]); | ||
| 227 | for (const preset of googleCatalog) { | ||
| 228 | expect(preset.family).toBe("google"); | ||
| 229 | expect(preset.specFormat).toBe("google-discovery"); | ||
| 230 | expect(preset.defaultSlug).toBeTruthy(); | ||
| 231 | expect(preset.authTemplate).toHaveLength(1); | ||
| 232 | const oauthTemplates = (preset.authTemplate ?? []).filter( | ||
| 233 | (template) => template.kind === "oauth2", | ||
| 234 | ); | ||
| 235 | expect(oauthTemplates).toHaveLength(1); | ||
| 236 | expect(oauthTemplates[0]?.scopes).toEqual( | ||
| 237 | expect.arrayContaining(["openid", "email", "profile"]), | ||
| 238 | ); | ||
| 239 | } | ||
| 240 | }); | ||
| 241 | |||
| 242 | it.effect("resolves fixture-backed Google catalog health checks in converted specs", () => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | for (const [presetId, fixture] of Object.entries(googleHealthCheckDiscoveryFixtures)) { | ||
| 245 | const preset = googleCatalog.find((candidate) => candidate.id === presetId); | ||
| 246 | expect(preset, `${presetId} catalog row exists`).toBeTruthy(); | ||
| 247 | expect(preset?.healthCheck, `${presetId} declares a health check`).toBeTruthy(); | ||
| 248 | |||
| 249 | const converted = yield* convertGoogleDiscoveryBundleToOpenApi({ | ||
| 250 | documents: [{ discoveryUrl: fixture.url, documentText: JSON.stringify(fixture.document) }], | ||
| 251 | }); | ||
| 252 | const compiled = yield* compileOpenApiSpec(converted.specText); | ||
| 253 | expect(compiled.definitions.map((definition) => definition.toolPath)).toContain( | ||
| 254 | preset!.healthCheck!.operation, | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | |||
| 260 | it("keeps Google catalog health checks as liveness probes without identity fields", () => { | ||
| 261 | for (const preset of googleCatalog) { | ||
| 262 | expect( | ||
| 263 | preset.healthCheck?.identityField, | ||
| 264 | `${preset.id} should not declare health identity extraction`, | ||
| 265 | ).toBeUndefined(); | ||
| 266 | } | ||
| 267 | }); | ||
| 268 | |||
| 269 | it("omits Google health checks when the service spec has no stable cheap read", () => { | ||
| 270 | const omitted = [ | ||
| 271 | "google-sheets", | ||
| 272 | "google-docs", | ||
| 273 | "google-slides", | ||
| 274 | "google-forms", | ||
| 275 | "google-photos-picker", | ||
| 276 | "google-admin-reports", | ||
| 277 | ]; | ||
| 278 | |||
| 279 | for (const presetId of omitted) { | ||
| 280 | const preset = googleCatalog.find((candidate) => candidate.id === presetId); | ||
| 281 | expect(preset?.healthCheck, `${presetId} should not declare a health check`).toBeUndefined(); | ||
| 282 | } | ||
| 283 | }); | ||
| @@ -1,4 +1,6 @@ | |||
| 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; | 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; |
| 2 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 3 | import type { HealthCheckSpec, IntegrationPreset } from "@executor-js/sdk/core"; | ||
| 2 | 4 | ||
| 3 | export interface GooglePreset { | 5 | export interface GooglePreset { |
| 4 | readonly id: string; | 6 | readonly id: string; |
| @@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines | |||
| 238 | // The picker shows the OAuth consent a user is about to grant BEFORE connecting | 240 | // The picker shows the OAuth consent a user is about to grant BEFORE connecting |
| 239 | // (the "View scopes" panel), but the authoritative scope list only exists in | 241 | // (the "View scopes" panel), but the authoritative scope list only exists in |
| 240 | // each API's live Discovery document, which the add flow fetches lazily at | 242 | // each API's live Discovery document, which the add flow fetches lazily at |
| 241 | // `addBundle` time. To preview consent without N network round-trips, each preset | 243 | // provider add time. To preview consent without N network round-trips, each preset |
| 242 | // declares the broad top-level scope(s) a full integration grants. These flow | 244 | // declares the broad top-level scope(s) a full integration grants. These flow |
| 243 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their | 245 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their |
| 244 | // broad parent), so the previewed grant matches the unioned scopes the bundle | 246 | // broad parent), so the previewed grant matches the unioned scopes the bundle |
| @@ -276,6 +278,72 @@ export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[] · 31 unmodified lines | |||
| 276 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => | 278 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => |
| 277 | googleOAuthConsentScopes[presetId] ?? []; | 279 | googleOAuthConsentScopes[presetId] ?? []; |
| 278 | 280 | ||
| 281 | export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_"); | ||
| 282 | |||
| 283 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; | ||
| 284 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; | ||
| 285 | const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; | ||
| 286 | const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"]; | ||
| 287 | const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = { | ||
| 288 | "google-calendar": { operation: "calendar.calendarList.list" }, | ||
| 289 | "google-gmail": { | ||
| 290 | operation: "gmail.users.labels.list", | ||
| 291 | args: { userId: "me" }, | ||
| 292 | }, | ||
| 293 | "google-drive": { | ||
| 294 | operation: "drive.about.get", | ||
| 295 | args: { fields: "user" }, | ||
| 296 | }, | ||
| 297 | "google-tasks": { operation: "tasks.tasklists.list" }, | ||
| 298 | "google-people": { | ||
| 299 | operation: "people.people.get", | ||
| 300 | args: { resourceName: "people/me", personFields: "emailAddresses" }, | ||
| 301 | }, | ||
| 302 | "google-photos-library": { operation: "photoslibrary.albums.list" }, | ||
| 303 | "google-chat": { operation: "chat.spaces.list" }, | ||
| 304 | "google-keep": { operation: "keep.notes.list" }, | ||
| 305 | "google-youtube-data": { | ||
| 306 | operation: "youtube.channels.list", | ||
| 307 | args: { part: "id", mine: true }, | ||
| 308 | }, | ||
| 309 | "google-search-console": { operation: "webmasters.sites.list" }, | ||
| 310 | "google-classroom": { operation: "classroom.courses.list" }, | ||
| 311 | "google-admin-directory": { | ||
| 312 | operation: "directory.users.list", | ||
| 313 | args: { customer: "my_customer", maxResults: 1 }, | ||
| 314 | }, | ||
| 315 | "google-apps-script": { operation: "script.processes.list" }, | ||
| 316 | "google-bigquery": { operation: "bigquery.projects.list" }, | ||
| 317 | "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" }, | ||
| 318 | }; | ||
| 319 | |||
| 320 | const googleCatalogAuthTemplate = (presetId: string) => [ | ||
| 321 | { | ||
| 322 | slug: GOOGLE_OAUTH_SECURITY_SCHEME, | ||
| 323 | kind: "oauth2" as const, | ||
| 324 | authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, | ||
| 325 | tokenUrl: GOOGLE_OAUTH_TOKEN_URL, | ||
| 326 | scopes: compactGoogleOAuthScopes([ | ||
| 327 | ...GOOGLE_IDENTITY_SCOPES, | ||
| 328 | ...googleOAuthConsentScopesForPreset(presetId), | ||
| 329 | ]), | ||
| 330 | }, | ||
| 331 | ]; | ||
| 332 | |||
| 333 | export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({ | ||
| 334 | id: preset.id, | ||
| 335 | name: preset.name, | ||
| 336 | summary: preset.summary, | ||
| 337 | ...(preset.url ? { url: preset.url } : {}), | ||
| 338 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 339 | ...(preset.featured ? { featured: preset.featured } : {}), | ||
| 340 | family: "google", | ||
| 341 | specFormat: "google-discovery", | ||
| 342 | defaultSlug: googleServiceSlug(preset.id), | ||
| 343 | authTemplate: googleCatalogAuthTemplate(preset.id), | ||
| 344 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), | ||
| 345 | })); | ||
| 346 | |||
| 279 | // --------------------------------------------------------------------------- | 347 | // --------------------------------------------------------------------------- |
| 280 | // Resolve a stored/normalized Discovery URL back to its preset, so a bundled | 348 | // Resolve a stored/normalized Discovery URL back to its preset, so a bundled |
| 281 | // `google` integration can surface each selected API's `oauthAudience` (e.g. a | 349 | // `google` integration can surface each selected API's `oauthAudience` (e.g. a |
| @@ -0,0 +1,167 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; | ||
| 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; | ||
| 7 | import type { AuthenticationInput } from "@executor-js/plugin-openapi"; | ||
| 8 | |||
| 9 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | ||
| 10 | import { googleCatalog } from "./presets"; | ||
| 11 | |||
| 12 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; | ||
| 13 | |||
| 14 | const tasksDiscoveryDoc = { | ||
| 15 | name: "tasks", | ||
| 16 | version: "v1", | ||
| 17 | title: "Google Tasks API", | ||
| 18 | description: "Manage your tasks and task lists.", | ||
| 19 | rootUrl: "https://tasks.googleapis.com/", | ||
| 20 | servicePath: "", | ||
| 21 | auth: { | ||
| 22 | oauth2: { | ||
| 23 | scopes: { | ||
| 24 | "https://www.googleapis.com/auth/tasks": { | ||
| 25 | description: "Create, edit, organize, and delete all your tasks.", | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | }, | ||
| 29 | }, | ||
| 30 | methods: { | ||
| 31 | tasklistsList: { | ||
| 32 | id: "tasks.tasklists.list", | ||
| 33 | httpMethod: "GET", | ||
| 34 | path: "tasks/v1/users/@me/lists", | ||
| 35 | scopes: ["https://www.googleapis.com/auth/tasks"], | ||
| 36 | response: { $ref: "TaskLists" }, | ||
| 37 | }, | ||
| 38 | }, | ||
| 39 | schemas: { | ||
| 40 | TaskLists: { | ||
| 41 | type: "object", | ||
| 42 | properties: { | ||
| 43 | items: { | ||
| 44 | type: "array", | ||
| 45 | items: { $ref: "TaskList" }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | TaskList: { | ||
| 50 | type: "object", | ||
| 51 | properties: { | ||
| 52 | id: { type: "string" }, | ||
| 53 | title: { type: "string" }, | ||
| 54 | }, | ||
| 55 | }, | ||
| 56 | }, | ||
| 57 | }; | ||
| 58 | |||
| 59 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 60 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 61 | Effect.succeed( | ||
| 62 | HttpClientResponse.fromWeb( | ||
| 63 | request, | ||
| 64 | new Response(JSON.stringify(tasksDiscoveryDoc), { | ||
| 65 | status: request.url === TASKS_URL ? 200 : 404, | ||
| 66 | headers: { "content-type": "application/json" }, | ||
| 67 | }), | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | ), | ||
| 71 | ); | ||
| 72 | |||
| 73 | it.effect("fetches and converts a Google Discovery document", () => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const converted = yield* googleDiscoveryAdapter.fetch({ | ||
| 76 | urls: [TASKS_URL], | ||
| 77 | httpClientLayer: discoveryHttpClientLayer, | ||
| 78 | }); | ||
| 79 | const parsed = yield* parse(converted.specText); | ||
| 80 | |||
| 81 | expect(parsed.info.title).toBe("Google"); | ||
| 82 | expect(Object.keys(parsed.paths ?? {})).toContain("/tasks/v1/users/@me/lists"); | ||
| 83 | expect(converted.authenticationTemplate?.[0]?.kind).toBe("oauth2"); | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | |||
| 87 | it("derives Google Discovery identity from the raw document", () => { | ||
| 88 | expect(deriveGoogleDiscoveryIdentity(tasksDiscoveryDoc)).toEqual({ | ||
| 89 | slug: "google_tasks", | ||
| 90 | name: "Google Tasks API", | ||
| 91 | description: "Manage your tasks and task lists.", | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived identity", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const executor = yield* createExecutor( | ||
| 98 | makeTestConfig({ | ||
| 99 | plugins: [ | ||
| 100 | openApiPlugin({ | ||
| 101 | httpClientLayer: discoveryHttpClientLayer, | ||
| 102 | specFormats: [googleDiscoveryAdapter], | ||
| 103 | }), | ||
| 104 | memoryCredentialsPlugin(), | ||
| 105 | ], | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 109 | const added = yield* executor.openapi.addSpec({ | ||
| 110 | spec: { kind: "url", url: TASKS_URL }, | ||
| 111 | specFormat: "google-discovery", | ||
| 112 | }); | ||
| 113 | const integration = yield* executor.openapi.getIntegration("google_tasks"); | ||
| 114 | |||
| 115 | expect(String(added.slug)).toBe("google_tasks"); | ||
| 116 | expect(integration?.slug).toEqual(IntegrationSlug.make("google_tasks")); | ||
| 117 | expect(added.toolCount).toBe(1); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect( | ||
| 122 | "adds a Google catalog preset through OpenAPI with family, format, and default slug", | ||
| 123 | () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const tasksPreset = googleCatalog.find((preset) => preset.defaultSlug === "google_tasks")!; | ||
| 126 | const authTemplate: readonly AuthenticationInput[] = (tasksPreset.authTemplate ?? []).flatMap( | ||
| 127 | (template) => (template.kind === "oauth2" ? [template] : []), | ||
| 128 | ); | ||
| 129 | const executor = yield* createExecutor( | ||
| 130 | makeTestConfig({ | ||
| 131 | plugins: [ | ||
| 132 | openApiPlugin({ | ||
| 133 | httpClientLayer: discoveryHttpClientLayer, | ||
| 134 | presets: [tasksPreset], | ||
| 135 | specFormats: [googleDiscoveryAdapter], | ||
| 136 | }), | ||
| 137 | memoryCredentialsPlugin(), | ||
| 138 | ], | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | const added = yield* executor.openapi.addSpec({ | ||
| 143 | spec: { kind: "url", url: tasksPreset.url! }, | ||
| 144 | slug: tasksPreset.defaultSlug, | ||
| 145 | specFormat: tasksPreset.specFormat, | ||
| 146 | family: tasksPreset.family, | ||
| 147 | authenticationTemplate: authTemplate, | ||
| 148 | }); | ||
| 149 | const config = yield* executor.openapi.getConfig("google_tasks"); | ||
| 150 | |||
| 151 | expect(String(added.slug)).toBe("google_tasks"); | ||
| 152 | expect(config?.family).toBe("google"); | ||
| 153 | expect(config?.specFormat).toBe("google-discovery"); | ||
| 154 | expect(config?.authenticationTemplate?.[0]?.kind).toBe("oauth2"); | ||
| 155 | const storedOAuthTemplates = (config?.authenticationTemplate ?? []).filter( | ||
| 156 | (template) => template.kind === "oauth2", | ||
| 157 | ); | ||
| 158 | expect(storedOAuthTemplates[0]?.scopes).toEqual( | ||
| 159 | expect.arrayContaining([ | ||
| 160 | "openid", | ||
| 161 | "email", | ||
| 162 | "profile", | ||
| 163 | "https://www.googleapis.com/auth/tasks", | ||
| 164 | ]), | ||
| 165 | ); | ||
| 166 | }), | ||
| 167 | ); | ||
| @@ -0,0 +1,85 @@ | |||
| 1 | import { Effect, Schema } from "effect"; | ||
| 2 | |||
| 3 | import { OpenApiParseError } from "../../sdk/errors"; | ||
| 4 | import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-format"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 8 | fetchGoogleDiscoveryDocument, | ||
| 9 | normalizeGoogleDiscoveryUrl, | ||
| 10 | } from "./discovery"; | ||
| 11 | |||
| 12 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 13 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 14 | |||
| 15 | const parseJson = (text: string) => | ||
| 16 | Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( | ||
| 17 | Effect.mapError( | ||
| 18 | () => | ||
| 19 | new OpenApiParseError({ | ||
| 20 | message: "Failed to parse Google Discovery document", | ||
| 21 | }), | ||
| 22 | ), | ||
| 23 | ); | ||
| 24 | |||
| 25 | const googleDiscoverySlug = (service: string): string => | ||
| 26 | `google_${service | ||
| 27 | .trim() | ||
| 28 | .toLowerCase() | ||
| 29 | .replace(/[^a-z0-9]+/g, "_") | ||
| 30 | .replace(/^_+|_+$/g, "")}`; | ||
| 31 | |||
| 32 | export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | null => { | ||
| 33 | if (!isRecord(doc)) return null; | ||
| 34 | const rawName = typeof doc.name === "string" ? doc.name.trim() : ""; | ||
| 35 | if (!rawName) return null; | ||
| 36 | const title = typeof doc.title === "string" && doc.title.trim() ? doc.title.trim() : rawName; | ||
| 37 | const description = | ||
| 38 | typeof doc.description === "string" && doc.description.trim() | ||
| 39 | ? doc.description.trim() | ||
| 40 | : undefined; | ||
| 41 | return { | ||
| 42 | slug: googleDiscoverySlug(rawName), | ||
| 43 | name: title, | ||
| 44 | ...(description ? { description } : {}), | ||
| 45 | }; | ||
| 46 | }; | ||
| 47 | |||
| 48 | export const googleDiscoveryAdapter: SpecFormatAdapter = { | ||
| 49 | id: "google-discovery", | ||
| 50 | fetch: (input) => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const documents = yield* Effect.forEach( | ||
| 53 | input.urls, | ||
| 54 | (url) => | ||
| 55 | fetchGoogleDiscoveryDocument(url, input.credentials).pipe( | ||
| 56 | Effect.provide(input.httpClientLayer), | ||
| 57 | Effect.map((documentText) => ({ | ||
| 58 | discoveryUrl: normalizeGoogleDiscoveryUrl(url) ?? url, | ||
| 59 | documentText, | ||
| 60 | })), | ||
| 61 | ), | ||
| 62 | { concurrency: 4 }, | ||
| 63 | ); | ||
| 64 | const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents }); | ||
| 65 | const document = | ||
| 66 | documents.length === 1 | ||
| 67 | ? yield* parseJson(documents[0]!.documentText) | ||
| 68 | : yield* Effect.forEach(documents, (item) => | ||
| 69 | parseJson(item.documentText).pipe( | ||
| 70 | Effect.map((parsed) => ({ | ||
| 71 | discoveryUrl: item.discoveryUrl, | ||
| 72 | document: parsed, | ||
| 73 | })), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | return { | ||
| 77 | specText: conversion.specText, | ||
| 78 | sourceUrl: documents[0]?.discoveryUrl, | ||
| 79 | baseUrl: conversion.baseUrl, | ||
| 80 | authenticationTemplate: conversion.authenticationTemplate, | ||
| 81 | document, | ||
| 82 | }; | ||
| 83 | }), | ||
| 84 | deriveIdentity: deriveGoogleDiscoveryIdentity, | ||
| 85 | }; | ||
| @@ -8,6 +8,11 @@ import { · 7 unmodified lines | |||
| 8 | } from "@executor-js/plugin-openapi"; | 8 | } from "@executor-js/plugin-openapi"; |
| 9 | 9 | ||
| 10 | import { microsoftGraphKeepPathItem } from "./graph"; | 10 | import { microsoftGraphKeepPathItem } from "./graph"; |
| 11 | import { | ||
| 12 | microsoftGraphExactPathsForPresetIds, | ||
| 13 | microsoftGraphPathPrefixesForPresetIds, | ||
| 14 | microsoftGraphTagPrefixesForPresetIds, | ||
| 15 | } from "./presets"; | ||
| 11 | 16 | ||
| 12 | // Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every | 17 | // Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every |
| 13 | // success response uses the OpenAPI wildcard status key `2XX` (the real spec | 18 | // success response uses the OpenAPI wildcard status key `2XX` (the real spec |
| @@ -220,6 +225,29 @@ paths: · 206 unmodified lines | |||
| 220 | components: {} | 225 | components: {} |
| 221 | `; | 226 | `; |
| 222 | 227 | ||
| 228 | const driveAndWorkbookFixture = ` | ||
| 229 | openapi: 3.0.4 | ||
| 230 | info: | ||
| 231 | title: Microsoft Graph Fixture | ||
| 232 | version: v1.0 | ||
| 233 | servers: | ||
| 234 | - url: https://graph.microsoft.com/v1.0 | ||
| 235 | paths: | ||
| 236 | /drives/{drive-id}/items/{driveItem-id}: | ||
| 237 | get: | ||
| 238 | operationId: drives.GetItems | ||
| 239 | responses: | ||
| 240 | "200": | ||
| 241 | description: OK | ||
| 242 | /drives/{drive-id}/items/{driveItem-id}/workbook/worksheets: | ||
| 243 | get: | ||
| 244 | operationId: drives.GetItemsWorkbookWorksheets | ||
| 245 | responses: | ||
| 246 | "200": | ||
| 247 | description: OK | ||
| 248 | components: {} | ||
| 249 | `; | ||
| 250 | |||
| 223 | const fullGraphSelection = { | 251 | const fullGraphSelection = { |
| 224 | coversFullGraph: true, | 252 | coversFullGraph: true, |
| 225 | presetIds: [], | 253 | presetIds: [], |
| @@ -268,6 +296,36 @@ const keptIdentityFixturePaths = ( · 42 unmodified lines | |||
| 268 | return kept; | 296 | return kept; |
| 269 | }; | 297 | }; |
| 270 | 298 | ||
| 299 | const presetSelection = (presetIds: readonly string[]): MicrosoftGraphKeepPathItemSelection => ({ | ||
| 300 | coversFullGraph: false, | ||
| 301 | presetIds, | ||
| 302 | customScopes: [], | ||
| 303 | exactPaths: microsoftGraphExactPathsForPresetIds(presetIds), | ||
| 304 | pathPrefixes: microsoftGraphPathPrefixesForPresetIds(presetIds), | ||
| 305 | tagPrefixes: microsoftGraphTagPrefixesForPresetIds(presetIds), | ||
| 306 | }); | ||
| 307 | |||
| 308 | const keptOperationIds = ( | ||
| 309 | fixture: string, | ||
| 310 | selection: MicrosoftGraphKeepPathItemSelection, | ||
| 311 | ): readonly string[] => { | ||
| 312 | const structure = structuralSplit(fixture); | ||
| 313 | expect(structure).not.toBeNull(); | ||
| 314 | const keepPathItem = microsoftGraphKeepPathItem(selection); | ||
| 315 | |||
| 316 | return structure!.pathItems.flatMap((range) => { | ||
| 317 | const entry = parseEntry(structure!.text, range, 2); | ||
| 318 | expect(entry).not.toBeNull(); | ||
| 319 | const [path, rawPathItem] = entry!; | ||
| 320 | const pathItem = keepPathItem(path, rawPathItem as Record<string, unknown>); | ||
| 321 | if (!pathItem) return []; | ||
| 322 | return Object.values(pathItem as Record<string, unknown>).flatMap((operation) => { | ||
| 323 | const operationId = (operation as { readonly operationId?: unknown }).operationId; | ||
| 324 | return typeof operationId === "string" ? [operationId] : []; | ||
| 325 | }); | ||
| 326 | }); | ||
| 327 | }; | ||
| 328 | |||
| 271 | const keptPathItem = (fixture: string): Record<string, unknown> => { | 329 | const keptPathItem = (fixture: string): Record<string, unknown> => { |
| 272 | const structure = structuralSplit(fixture); | 330 | const structure = structuralSplit(fixture); |
| 273 | expect(structure).not.toBeNull(); | 331 | expect(structure).not.toBeNull(); |
| @@ -338,6 +396,16 @@ it("keeps all /me operations when profile selects the path", () => { · 64 unmodified lines | |||
| 338 | expect(kept.get("/me")?.patch).toMatchObject({ operationId: "me.UpdateUser" }); | 396 | expect(kept.get("/me")?.patch).toMatchObject({ operationId: "me.UpdateUser" }); |
| 339 | }); | 397 | }); |
| 340 | 398 | ||
| 399 | it("matches fresh-add ownership for overlapping files and excel drive paths", () => { | ||
| 400 | expect(keptOperationIds(driveAndWorkbookFixture, presetSelection(["files"]))).toEqual([ | ||
| 401 | "drives.GetItems", | ||
| 402 | "drives.GetItemsWorkbookWorksheets", | ||
| 403 | ]); | ||
| 404 | expect(keptOperationIds(driveAndWorkbookFixture, presetSelection(["excel"]))).toEqual([ | ||
| 405 | "drives.GetItemsWorkbookWorksheets", | ||
| 406 | ]); | ||
| 407 | }); | ||
| 408 | |||
| 341 | it("keeps already-binary drive content responses untouched", () => { | 409 | it("keeps already-binary drive content responses untouched", () => { |
| 342 | const pathItem = keptPathItem(driveContentFixture); | 410 | const pathItem = keptPathItem(driveContentFixture); |
| 343 | 411 | ||
| @@ -3,18 +3,17 @@ import type { Layer } from "effect"; · 2 unmodified lines | |||
| 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { AuthTemplateSlug } from "@executor-js/sdk/core"; | 5 | import { AuthTemplateSlug } from "@executor-js/sdk/core"; |
| 6 | import { AuthenticationSchema, type OpenApiIntegrationConfig } from "../../sdk/config"; | ||
| 7 | import { OpenApiParseError } from "../../sdk/errors"; | ||
| 6 | import { | 8 | import { |
| 7 | AuthenticationSchema, | ||
| 8 | OpenApiParseError, | ||
| 9 | parseEntry, | 9 | parseEntry, |
| 10 | parseHead, | 10 | parseHead, |
| 11 | parseSmallComponents, | 11 | parseSmallComponents, |
| 12 | structuralSplit, | 12 | structuralSplit, |
| 13 | type Authentication, | ||
| 14 | type KeepPathItem, | 13 | type KeepPathItem, |
| 15 | type OpenApiIntegrationConfig, | ||
| 16 | type SpecStructure, | 14 | type SpecStructure, |
| 17 | } from "@executor-js/plugin-openapi"; | 15 | } from "../../sdk/split"; |
| 16 | import type { Authentication } from "../../sdk/types"; | ||
| 18 | 17 | ||
| 19 | import { | 18 | import { |
| 20 | MICROSOFT_AUTHORIZATION_URL, | 19 | MICROSOFT_AUTHORIZATION_URL, |
| @@ -13,6 +13,7 @@ export { · 12 unmodified lines | |||
| 13 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, | 13 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, |
| 14 | MICROSOFT_GRAPH_PRESET_ID, | 14 | MICROSOFT_GRAPH_PRESET_ID, |
| 15 | MICROSOFT_TOKEN_URL, | 15 | MICROSOFT_TOKEN_URL, |
| 16 | microsoftCatalog, | ||
| 16 | microsoftGraphExactPathsForPresetIds, | 17 | microsoftGraphExactPathsForPresetIds, |
| 17 | microsoftGraphPathPrefixesForPresetIds, | 18 | microsoftGraphPathPrefixesForPresetIds, |
| 18 | microsoftGraphPreset, | 19 | microsoftGraphPreset, |
| @@ -21,6 +22,7 @@ export { · 2 unmodified lines | |||
| 21 | microsoftGraphScopePresets, | 22 | microsoftGraphScopePresets, |
| 22 | microsoftGraphScopesForPresetIds, | 23 | microsoftGraphScopesForPresetIds, |
| 23 | microsoftGraphTagPrefixesForPresetIds, | 24 | microsoftGraphTagPrefixesForPresetIds, |
| 25 | microsoftServiceSlug, | ||
| 24 | type MicrosoftGraphPreset, | 26 | type MicrosoftGraphPreset, |
| 25 | type MicrosoftGraphScopeAudience, | 27 | type MicrosoftGraphScopeAudience, |
| 26 | type MicrosoftGraphScopePreset, | 28 | type MicrosoftGraphScopePreset, |
| @@ -36,12 +38,4 @@ export { · 9 unmodified lines | |||
| 36 | type MicrosoftGraphSelectionInput, | 38 | type MicrosoftGraphSelectionInput, |
| 37 | type MicrosoftGraphSpecBuild, | 39 | type MicrosoftGraphSpecBuild, |
| 38 | } from "./graph"; | 40 | } from "./graph"; |
| 39 | export { | 41 | export { microsoftGraphAdapter } from "./spec-format-adapter"; |
| 40 | microsoftPlugin, | ||
| 41 | type MicrosoftConfigureInput, | ||
| 42 | type MicrosoftGraphConfig, | ||
| 43 | type MicrosoftPluginExtension, | ||
| 44 | type MicrosoftPluginOptions, | ||
| 45 | type MicrosoftUpdateInput, | ||
| 46 | type MicrosoftUpdateResult, | ||
| 47 | } from "./plugin"; | ||
| @@ -0,0 +1,332 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | import { parseEntry, structuralSplit, type KeepPathItem } from "@executor-js/plugin-openapi"; | ||
| 5 | |||
| 6 | import { microsoftGraphAdapter } from "./spec-format-adapter"; | ||
| 7 | import { | ||
| 8 | MICROSOFT_GRAPH_ALL_PRESET_IDS, | ||
| 9 | MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 10 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 11 | MICROSOFT_GRAPH_OPENAPI_URL, | ||
| 12 | microsoftCatalog, | ||
| 13 | microsoftGraphExactPathsForPresetIds, | ||
| 14 | microsoftGraphPathPrefixesForPresetIds, | ||
| 15 | microsoftGraphPresetIdsCoverFullGraph, | ||
| 16 | microsoftGraphScopePresets, | ||
| 17 | microsoftGraphScopesForPresetIds, | ||
| 18 | microsoftGraphTagPrefixesForPresetIds, | ||
| 19 | } from "./presets"; | ||
| 20 | |||
| 21 | const graphHealthCheckFixture = ` | ||
| 22 | openapi: 3.0.4 | ||
| 23 | info: | ||
| 24 | title: Microsoft Graph Fixture | ||
| 25 | version: v1.0 | ||
| 26 | servers: | ||
| 27 | - url: https://graph.microsoft.com/v1.0 | ||
| 28 | paths: | ||
| 29 | /me: | ||
| 30 | get: | ||
| 31 | operationId: me.GetUser | ||
| 32 | security: | ||
| 33 | - azureAdDelegated: | ||
| 34 | - User.Read | ||
| 35 | responses: | ||
| 36 | "200": | ||
| 37 | description: OK | ||
| 38 | content: | ||
| 39 | application/json: | ||
| 40 | schema: | ||
| 41 | $ref: '#/components/schemas/microsoft.graph.user' | ||
| 42 | /me/messages: | ||
| 43 | get: | ||
| 44 | operationId: me.messages.ListMessages | ||
| 45 | security: | ||
| 46 | - azureAdDelegated: | ||
| 47 | - Mail.ReadWrite | ||
| 48 | responses: | ||
| 49 | "200": | ||
| 50 | description: OK | ||
| 51 | components: | ||
| 52 | schemas: | ||
| 53 | microsoft.graph.user: | ||
| 54 | type: object | ||
| 55 | properties: | ||
| 56 | mail: | ||
| 57 | type: string | ||
| 58 | `; | ||
| 59 | |||
| 60 | const graphHealthCheckHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 61 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 62 | Effect.succeed( | ||
| 63 | HttpClientResponse.fromWeb( | ||
| 64 | request, | ||
| 65 | new Response( | ||
| 66 | request.url === MICROSOFT_GRAPH_OPENAPI_URL ? graphHealthCheckFixture : "not found", | ||
| 67 | { | ||
| 68 | status: request.url === MICROSOFT_GRAPH_OPENAPI_URL ? 200 : 404, | ||
| 69 | headers: { "content-type": "application/yaml" }, | ||
| 70 | }, | ||
| 71 | ), | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | |||
| 77 | const operationIdsFromStructure = ( | ||
| 78 | specText: string, | ||
| 79 | keepPathItem: KeepPathItem, | ||
| 80 | ): readonly string[] => { | ||
| 81 | const structure = structuralSplit(specText); | ||
| 82 | expect(structure, "fixture structurally splits").not.toBeNull(); | ||
| 83 | return structure!.pathItems.flatMap((range) => { | ||
| 84 | const entry = parseEntry(structure!.text, range, 2); | ||
| 85 | expect(entry, "fixture path item parses").not.toBeNull(); | ||
| 86 | const [path, rawPathItem] = entry!; | ||
| 87 | const pathItem = keepPathItem(path, rawPathItem as Record<string, unknown>); | ||
| 88 | if (!pathItem) return []; | ||
| 89 | return Object.values(pathItem as Record<string, unknown>).flatMap((operation) => { | ||
| 90 | const operationId = (operation as { readonly operationId?: unknown }).operationId; | ||
| 91 | return typeof operationId === "string" ? [operationId] : []; | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | }; | ||
| 95 | |||
| 96 | const FROZEN_MICROSOFT_SLUGS = [ | ||
| 97 | "microsoft_profile", | ||
| 98 | "microsoft_me_surface", | ||
| 99 | "microsoft_mail", | ||
| 100 | "microsoft_calendar", | ||
| 101 | "microsoft_contacts", | ||
| 102 | "microsoft_tasks", | ||
| 103 | "microsoft_planner", | ||
| 104 | "microsoft_files", | ||
| 105 | "microsoft_excel", | ||
| 106 | "microsoft_sites", | ||
| 107 | "microsoft_onenote", | ||
| 108 | "microsoft_teams_chat", | ||
| 109 | "microsoft_teams_channels", | ||
| 110 | "microsoft_meetings_calls", | ||
| 111 | "microsoft_users", | ||
| 112 | "microsoft_groups", | ||
| 113 | "microsoft_directory", | ||
| 114 | "microsoft_applications", | ||
| 115 | "microsoft_identity", | ||
| 116 | "microsoft_admin_reports", | ||
| 117 | "microsoft_security_compliance", | ||
| 118 | "microsoft_devices", | ||
| 119 | "microsoft_education", | ||
| 120 | "microsoft_search", | ||
| 121 | "microsoft_external_connections", | ||
| 122 | "microsoft_solutions", | ||
| 123 | "microsoft_platform_services", | ||
| 124 | ] as const; | ||
| 125 | |||
| 126 | describe("Microsoft Graph scope presets", () => { | ||
| 127 | it("keeps default workload ids backed by categorized presets", () => { | ||
| 128 | const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id)); | ||
| 129 | expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.every((id) => ids.has(id))).toBe(true); | ||
| 130 | expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS).toEqual([ | ||
| 131 | "profile", | ||
| 132 | "mail", | ||
| 133 | "calendar", | ||
| 134 | "contacts", | ||
| 135 | "tasks", | ||
| 136 | "files", | ||
| 137 | "excel", | ||
| 138 | "sites", | ||
| 139 | "onenote", | ||
| 140 | "teams-chat", | ||
| 141 | "teams-channels", | ||
| 142 | "meetings-calls", | ||
| 143 | ]); | ||
| 144 | expect(ids.has("all")).toBe(false); | ||
| 145 | }); | ||
| 146 | |||
| 147 | it("keeps the full workload ids backed by every categorized preset", () => { | ||
| 148 | const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id)); | ||
| 149 | expect(MICROSOFT_GRAPH_ALL_PRESET_IDS.every((id) => ids.has(id))).toBe(true); | ||
| 150 | expect(MICROSOFT_GRAPH_ALL_PRESET_IDS).toEqual( | ||
| 151 | microsoftGraphScopePresets.map((preset) => preset.id), | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("detects when the categorized catalog covers full Graph", () => { | ||
| 156 | expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_ALL_PRESET_IDS)).toBe(true); | ||
| 157 | expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS)).toBe(false); | ||
| 158 | expect(microsoftGraphPresetIdsCoverFullGraph(["profile", "mail"])).toBe(false); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("unions selected preset scopes with base and custom scopes", () => { | ||
| 162 | expect(microsoftGraphScopesForPresetIds(["profile", "mail"], ["Sites.Read.All"])).toEqual([ | ||
| 163 | ...MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 164 | "User.Read", | ||
| 165 | "Mail.ReadWrite", | ||
| 166 | "Mail.Send", | ||
| 167 | "MailboxSettings.ReadWrite", | ||
| 168 | "Sites.Read.All", | ||
| 169 | ]); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("includes User.Read for identity when profile is not selected", () => { | ||
| 173 | expect(microsoftGraphScopesForPresetIds(["mail"])).toEqual([ | ||
| 174 | ...MICROSOFT_GRAPH_BASE_SCOPES, | ||
| 175 | "User.Read", | ||
| 176 | "Mail.ReadWrite", | ||
| 177 | "Mail.Send", | ||
| 178 | "MailboxSettings.ReadWrite", | ||
| 179 | ]); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("returns path filters for the selected workloads", () => { | ||
| 183 | expect(microsoftGraphExactPathsForPresetIds(["profile"])).toContain("/me"); | ||
| 184 | expect(microsoftGraphPathPrefixesForPresetIds(["mail"])).toContain("/me/messages"); | ||
| 185 | expect(microsoftGraphTagPrefixesForPresetIds(["mail"])).toEqual([]); | ||
| 186 | }); | ||
| 187 | |||
| 188 | it("covers Microsoft Graph root surfaces through category presets", () => { | ||
| 189 | const prefixes = new Set( | ||
| 190 | microsoftGraphPathPrefixesForPresetIds(MICROSOFT_GRAPH_ALL_PRESET_IDS), | ||
| 191 | ); | ||
| 192 | for (const root of [ | ||
| 193 | "/agreementAcceptances", | ||
| 194 | "/agreements", | ||
| 195 | "/admin", | ||
| 196 | "/appCatalogs", | ||
| 197 | "/applicationTemplates", | ||
| 198 | "/applications", | ||
| 199 | "/applications(appId='{appId}')", | ||
| 200 | "/applications(uniqueName='{uniqueName}')", | ||
| 201 | "/authenticationMethodConfigurations", | ||
| 202 | "/authenticationMethodsPolicy", | ||
| 203 | "/auditLogs", | ||
| 204 | "/certificateBasedAuthConfiguration", | ||
| 205 | "/chats", | ||
| 206 | "/communications", | ||
| 207 | "/compliance", | ||
| 208 | "/connections", | ||
| 209 | "/contacts", | ||
| 210 | "/contracts", | ||
| 211 | "/copilot", | ||
| 212 | "/dataPolicyOperations", | ||
| 213 | "/deviceAppManagement", | ||
| 214 | "/deviceManagement", | ||
| 215 | "/devices", | ||
| 216 | "/devices(deviceId='{deviceId}')", | ||
| 217 | "/directory", | ||
| 218 | "/directoryObjects", | ||
| 219 | "/directoryRoleTemplates", | ||
| 220 | "/directoryRoles", | ||
| 221 | "/directoryRoles(roleTemplateId='{roleTemplateId}')", | ||
| 222 | "/domainDnsRecords", | ||
| 223 | "/domains", | ||
| 224 | "/drives", | ||
| 225 | "/education", | ||
| 226 | "/employeeExperience", | ||
| 227 | "/external", | ||
| 228 | "/filterOperators", | ||
| 229 | "/functions", | ||
| 230 | "/groupLifecyclePolicies", | ||
| 231 | "/groupSettingTemplates", | ||
| 232 | "/groupSettings", | ||
| 233 | "/groups", | ||
| 234 | "/groups(uniqueName='{uniqueName}')", | ||
| 235 | "/identity", | ||
| 236 | "/identityGovernance", | ||
| 237 | "/identityProviders", | ||
| 238 | "/identityProtection", | ||
| 239 | "/informationProtection", | ||
| 240 | "/invitations", | ||
| 241 | "/me", | ||
| 242 | "/oauth2PermissionGrants", | ||
| 243 | "/organization", | ||
| 244 | "/permissionGrants", | ||
| 245 | "/places", | ||
| 246 | "/planner", | ||
| 247 | "/policies", | ||
| 248 | "/print", | ||
| 249 | "/privacy", | ||
| 250 | "/reports", | ||
| 251 | "/roleManagement", | ||
| 252 | "/schemaExtensions", | ||
| 253 | "/scopedRoleMemberships", | ||
| 254 | "/search", | ||
| 255 | "/security", | ||
| 256 | "/servicePrincipals", | ||
| 257 | "/servicePrincipals(appId='{appId}')", | ||
| 258 | "/shares", | ||
| 259 | "/sites", | ||
| 260 | "/solutions", | ||
| 261 | "/storage", | ||
| 262 | "/subscribedSkus", | ||
| 263 | "/subscriptions", | ||
| 264 | "/teamwork", | ||
| 265 | "/teams", | ||
| 266 | "/teamsTemplates", | ||
| 267 | "/tenantRelationships", | ||
| 268 | "/users", | ||
| 269 | "/users(userPrincipalName='{userPrincipalName}')", | ||
| 270 | ]) { | ||
| 271 | expect(prefixes.has(root)).toBe(true); | ||
| 272 | } | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("declares product icons for each workload", () => { | ||
| 276 | for (const preset of microsoftGraphScopePresets) { | ||
| 277 | expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/); | ||
| 278 | } | ||
| 279 | }); | ||
| 280 | |||
| 281 | it("exports one catalog row per workload with stable slugs and Graph auth templates", () => { | ||
| 282 | expect(microsoftCatalog.map((preset) => preset.defaultSlug)).toEqual([ | ||
| 283 | ...FROZEN_MICROSOFT_SLUGS, | ||
| 284 | ]); | ||
| 285 | for (const preset of microsoftCatalog) { | ||
| 286 | expect(preset.family).toBe("microsoft"); | ||
| 287 | expect(preset.specFormat).toBe("microsoft-graph"); | ||
| 288 | expect(preset.defaultSlug).toBeTruthy(); | ||
| 289 | expect(preset.authTemplate).toHaveLength(2); | ||
| 290 | expect(preset.authTemplate?.map((template) => template.kind)).toEqual(["oauth2", "oauth2"]); | ||
| 291 | } | ||
| 292 | }); | ||
| 293 | |||
| 294 | it.effect("resolves fixture-backed Microsoft catalog health checks in workload splits", () => | ||
| 295 | Effect.gen(function* () { | ||
| 296 | const fixtureCases = [ | ||
| 297 | { presetId: "profile", expectedOperation: "me.GetUser" }, | ||
| 298 | { presetId: "mail", expectedOperation: undefined }, | ||
| 299 | ] as const; | ||
| 300 | |||
| 301 | for (const { presetId, expectedOperation } of fixtureCases) { | ||
| 302 | const preset = microsoftCatalog.find( | ||
| 303 | (candidate) => candidate.id === `microsoft-${presetId}`, | ||
| 304 | ); | ||
| 305 | expect(preset, `${presetId} catalog row exists`).toBeTruthy(); | ||
| 306 | expect(preset?.healthCheck?.operation, `${presetId} catalog health check`).toBe( | ||
| 307 | expectedOperation, | ||
| 308 | ); | ||
| 309 | |||
| 310 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 311 | urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${presetId}`], | ||
| 312 | httpClientLayer: graphHealthCheckHttpClientLayer, | ||
| 313 | }); | ||
| 314 | expect(converted.keepPathItem, `${presetId} keeps a workload split`).toBeTruthy(); | ||
| 315 | const operationIds = operationIdsFromStructure(converted.specText, converted.keepPathItem!); | ||
| 316 | expect( | ||
| 317 | expectedOperation === undefined || operationIds.includes(expectedOperation), | ||
| 318 | `${presetId} fixture resolves declared health check`, | ||
| 319 | ).toBe(true); | ||
| 320 | } | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | |||
| 324 | it("keeps Microsoft catalog health checks as liveness probes without identity fields", () => { | ||
| 325 | for (const preset of microsoftCatalog) { | ||
| 326 | expect( | ||
| 327 | preset.healthCheck?.identityField, | ||
| 328 | `${preset.id} should not declare health identity extraction`, | ||
| 329 | ).toBeUndefined(); | ||
| 330 | } | ||
| 331 | }); | ||
| 332 | }); | ||
| @@ -1,3 +1,5 @@ | |||
| 1 | import type { IntegrationPreset } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 1 | export interface MicrosoftGraphPreset { | 3 | export interface MicrosoftGraphPreset { |
| 2 | readonly id: string; | 4 | readonly id: string; |
| 3 | readonly name: string; | 5 | readonly name: string; |
| @@ -522,3 +524,42 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines | |||
| 522 | orderedUnique( | 524 | orderedUnique( |
| 523 | [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []), | 525 | [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []), |
| 524 | ); | 526 | ); |
| 527 | |||
| 528 | export const microsoftServiceSlug = (presetId: string): string => | ||
| 529 | `microsoft_${presetId.replaceAll("-", "_")}`; | ||
| 530 | |||
| 531 | const microsoftGraphCatalogUrl = (presetId: string): string => | ||
| 532 | `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`; | ||
| 533 | |||
| 534 | const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => [ | ||
| 535 | { | ||
| 536 | slug: MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 537 | kind: "oauth2" as const, | ||
| 538 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | ||
| 539 | tokenUrl: MICROSOFT_TOKEN_URL, | ||
| 540 | scopes: microsoftGraphScopesForPresetIds([preset.id]), | ||
| 541 | }, | ||
| 542 | { | ||
| 543 | slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 544 | kind: "oauth2" as const, | ||
| 545 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | ||
| 546 | tokenUrl: MICROSOFT_TOKEN_URL, | ||
| 547 | scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 548 | }, | ||
| 549 | ]; | ||
| 550 | |||
| 551 | export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScopePresets.map( | ||
| 552 | (preset) => ({ | ||
| 553 | id: `microsoft-${preset.id}`, | ||
| 554 | name: preset.name, | ||
| 555 | summary: preset.summary, | ||
| 556 | url: microsoftGraphCatalogUrl(preset.id), | ||
| 557 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 558 | ...(preset.featured ? { featured: preset.featured } : {}), | ||
| 559 | family: "microsoft", | ||
| 560 | specFormat: "microsoft-graph", | ||
| 561 | defaultSlug: microsoftServiceSlug(preset.id), | ||
| 562 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), | ||
| 563 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), | ||
| 564 | }), | ||
| 565 | ); | ||
| @@ -0,0 +1,92 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | import { structuralSplit } from "@executor-js/plugin-openapi"; | ||
| 5 | |||
| 6 | import { microsoftGraphAdapter } from "./spec-format-adapter"; | ||
| 7 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; | ||
| 8 | |||
| 9 | const graphFixture = ` | ||
| 10 | openapi: 3.0.4 | ||
| 11 | info: | ||
| 12 | title: Microsoft Graph Fixture | ||
| 13 | version: v1.0 | ||
| 14 | servers: | ||
| 15 | - url: https://graph.microsoft.com/v1.0 | ||
| 16 | paths: | ||
| 17 | /me: | ||
| 18 | get: | ||
| 19 | operationId: me.GetUser | ||
| 20 | security: | ||
| 21 | - azureAdDelegated: | ||
| 22 | - User.Read | ||
| 23 | responses: | ||
| 24 | "200": | ||
| 25 | description: OK | ||
| 26 | /irrelevant: | ||
| 27 | get: | ||
| 28 | operationId: irrelevant.Get | ||
| 29 | security: | ||
| 30 | - azureAdDelegated: | ||
| 31 | - Directory.Read.All | ||
| 32 | responses: | ||
| 33 | "200": | ||
| 34 | description: OK | ||
| 35 | components: | ||
| 36 | securitySchemes: | ||
| 37 | azureAdDelegated: | ||
| 38 | type: oauth2 | ||
| 39 | flows: | ||
| 40 | authorizationCode: | ||
| 41 | authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize | ||
| 42 | tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token | ||
| 43 | scopes: | ||
| 44 | User.Read: Read user profile | ||
| 45 | `; | ||
| 46 | |||
| 47 | const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 48 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 49 | Effect.succeed( | ||
| 50 | HttpClientResponse.fromWeb( | ||
| 51 | request, | ||
| 52 | new Response(request.url === MICROSOFT_GRAPH_OPENAPI_URL ? graphFixture : "not found", { | ||
| 53 | status: request.url === MICROSOFT_GRAPH_OPENAPI_URL ? 200 : 404, | ||
| 54 | headers: { "content-type": "application/yaml" }, | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | |||
| 61 | it.effect("wraps Microsoft Graph structural split with a streaming keep filter", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 64 | urls: [MICROSOFT_GRAPH_OPENAPI_URL], | ||
| 65 | httpClientLayer: graphHttpClientLayer, | ||
| 66 | }); | ||
| 67 | const structure = structuralSplit(converted.specText); | ||
| 68 | expect(structure).not.toBeNull(); | ||
| 69 | const keepPathItem = converted.keepPathItem!; | ||
| 70 | |||
| 71 | expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({ | ||
| 72 | get: { operationId: "me.GetUser" }, | ||
| 73 | }); | ||
| 74 | expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull(); | ||
| 75 | expect(structure!.pathItems.length).toBe(2); | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("uses catalog URL fragments to select one Graph workload", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 82 | urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | ||
| 83 | httpClientLayer: graphHttpClientLayer, | ||
| 84 | }); | ||
| 85 | const keepPathItem = converted.keepPathItem!; | ||
| 86 | |||
| 87 | expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({ | ||
| 88 | get: { operationId: "me.GetUser" }, | ||
| 89 | }); | ||
| 90 | expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull(); | ||
| 91 | }), | ||
| 92 | ); | ||
| @@ -0,0 +1,52 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import type { SpecFormatAdapter } from "../../sdk/spec-format"; | ||
| 4 | |||
| 5 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; | ||
| 6 | |||
| 7 | const graphCatalogSelection = ( | ||
| 8 | rawUrl: string | undefined, | ||
| 9 | ): { readonly specUrl?: string; readonly presetIds?: readonly string[] } => { | ||
| 10 | if (!rawUrl || !URL.canParse(rawUrl)) return rawUrl ? { specUrl: rawUrl } : {}; | ||
| 11 | const parsed = new URL(rawUrl); | ||
| 12 | const preset = parsed.hash.startsWith("#preset=") | ||
| 13 | ? decodeURIComponent(parsed.hash.slice("#preset=".length)) | ||
| 14 | : ""; | ||
| 15 | parsed.hash = ""; | ||
| 16 | return { | ||
| 17 | specUrl: parsed.toString(), | ||
| 18 | ...(preset.length > 0 ? { presetIds: [preset] } : {}), | ||
| 19 | }; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export const microsoftGraphAdapter: SpecFormatAdapter = { | ||
| 23 | id: "microsoft-graph", | ||
| 24 | fetch: (input) => | ||
| 25 | buildMicrosoftGraphOpenApiSpec( | ||
| 26 | graphCatalogSelection(input.urls[0]), | ||
| 27 | input.httpClientLayer, | ||
| 28 | ).pipe( | ||
| 29 | Effect.map((graphSpec) => ({ | ||
| 30 | specText: graphSpec.specText, | ||
| 31 | sourceUrl: graphSpec.specUrl, | ||
| 32 | baseUrl: graphSpec.baseUrl, | ||
| 33 | authenticationTemplate: graphSpec.authenticationTemplate, | ||
| 34 | // Stream the full Graph source straight to persisted bindings. This is | ||
| 35 | // the measured Workers contention/OOM path from the Microsoft plugin: | ||
| 36 | // structural split stays serial and avoids materializing the 37MB tree. | ||
| 37 | keepPathItem: microsoftGraphKeepPathItem(graphSpec), | ||
| 38 | config: { | ||
| 39 | microsoftGraphPresetIds: graphSpec.presetIds, | ||
| 40 | microsoftGraphCustomScopes: graphSpec.customScopes, | ||
| 41 | microsoftGraphScopes: graphSpec.scopes, | ||
| 42 | microsoftGraphExactPaths: graphSpec.exactPaths, | ||
| 43 | microsoftGraphPathPrefixes: graphSpec.pathPrefixes, | ||
| 44 | microsoftGraphTagPrefixes: graphSpec.tagPrefixes, | ||
| 45 | microsoftGraphCoversFullGraph: graphSpec.coversFullGraph, | ||
| 46 | microsoftGraphAuthorizationUrl: graphSpec.authorizationUrl, | ||
| 47 | microsoftGraphTokenUrl: graphSpec.tokenUrl, | ||
| 48 | microsoftGraphClientCredentialsTokenUrl: graphSpec.clientCredentialsTokenUrl, | ||
| 49 | }, | ||
| 50 | })), | ||
| 51 | ), | ||
| 52 | }; | ||
| @@ -1,6 +1,20 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as React from "react"; | ||
| 2 | 3 | ||
| 3 | import { baseUrlFromSpecInput, openApiPreviewFailureMessage } from "./AddOpenApiSource"; | 4 | import { |
| 5 | AddOpenApiHealthCheckSection, | ||
| 6 | baseUrlFromSpecInput, | ||
| 7 | openApiPreviewFailureMessage, | ||
| 8 | } from "./AddOpenApiSource"; | ||
| 9 | |||
| 10 | const visibleText = (node: React.ReactNode): string => { | ||
| 11 | if (node === null || node === undefined || typeof node === "boolean") return ""; | ||
| 12 | if (typeof node === "string" || typeof node === "number") return String(node); | ||
| 13 | if (Array.isArray(node)) return node.map(visibleText).join(""); | ||
| 14 | if (!React.isValidElement(node)) return ""; | ||
| 15 | const props = node.props as { readonly children?: React.ReactNode }; | ||
| 16 | return visibleText(props.children); | ||
| 17 | }; | ||
| 4 | 18 | ||
| 5 | describe("baseUrlFromSpecInput", () => { | 19 | describe("baseUrlFromSpecInput", () => { |
| 6 | it("defaults URL-hosted specs to their origin", () => { | 20 | it("defaults URL-hosted specs to their origin", () => { |
| @@ -27,3 +41,56 @@ describe("openApiPreviewFailureMessage", () => { · 20 unmodified lines | |||
| 27 | ); | 41 | ); |
| 28 | }); | 42 | }); |
| 29 | }); | 43 | }); |
| 44 | |||
| 45 | describe("AddOpenApiHealthCheckSection", () => { | ||
| 46 | const candidate = { | ||
| 47 | operation: "getCurrentUser", | ||
| 48 | method: "get", | ||
| 49 | requiredArgCount: 0, | ||
| 50 | destructive: false, | ||
| 51 | summary: "Get current user", | ||
| 52 | parameters: [], | ||
| 53 | responseFields: [{ path: "email", type: "string" }], | ||
| 54 | }; | ||
| 55 | |||
| 56 | it("renders a read-only catalog line when the preset has a health check", () => { | ||
| 57 | const text = visibleText( | ||
| 58 | AddOpenApiHealthCheckSection({ | ||
| 59 | presetHealthCheck: { operation: "getCurrentUser" }, | ||
| 60 | candidates: [candidate], | ||
| 61 | selected: candidate, | ||
| 62 | operation: "", | ||
| 63 | onOperationChange: () => {}, | ||
| 64 | identityField: "", | ||
| 65 | onIdentityFieldChange: () => {}, | ||
| 66 | args: {}, | ||
| 67 | onArgChange: () => {}, | ||
| 68 | disabled: false, | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | |||
| 72 | expect(text).toContain("getCurrentUser"); | ||
| 73 | expect(text).toContain("From the catalog. Change it later from the integration page."); | ||
| 74 | expect(text).not.toContain("Health check (optional)"); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("renders the full optional editor when there is no preset health check", () => { | ||
| 78 | const text = visibleText( | ||
| 79 | AddOpenApiHealthCheckSection({ | ||
| 80 | candidates: [candidate], | ||
| 81 | selected: candidate, | ||
| 82 | operation: "getCurrentUser", | ||
| 83 | onOperationChange: () => {}, | ||
| 84 | identityField: "", | ||
| 85 | onIdentityFieldChange: () => {}, | ||
| 86 | args: {}, | ||
| 87 | onArgChange: () => {}, | ||
| 88 | disabled: false, | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 92 | expect(text).toContain("Health check (optional)"); | ||
| 93 | expect(text).toContain("OAuth connections, validity and identity come from the OAuth grant"); | ||
| 94 | expect(text).not.toContain("From the catalog."); | ||
| 95 | }); | ||
| 96 | }); | ||
| @@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines | |||
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | AuthTemplateSlug, | ||
| 8 | IntegrationSlug, | 9 | IntegrationSlug, |
| 9 | type HealthCheckCandidate, | 10 | type HealthCheckCandidate, |
| 10 | type HealthCheckSpec, | 11 | type HealthCheckSpec, |
| 11 | } from "@executor-js/sdk/shared"; | 12 | } from "@executor-js/sdk/shared"; |
| 13 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | ||
| 12 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 14 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; | 15 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; |
| 14 | import { | 16 | import { |
| @@ -43,7 +45,6 @@ import { · 28 unmodified lines | |||
| 43 | } from "./auth-method-config"; | 45 | } from "./auth-method-config"; |
| 44 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | 46 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 45 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | 47 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 46 | import { openApiPresets } from "../sdk/presets"; | ||
| 47 | import type { SpecPreviewSummary } from "../sdk/preview"; | 48 | import type { SpecPreviewSummary } from "../sdk/preview"; |
| 48 | import { type Authentication } from "../sdk/types"; | 49 | import { type Authentication } from "../sdk/types"; |
| 49 | import { resolveServerUrl } from "../sdk/openapi-utils"; | 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| @@ -82,6 +83,58 @@ export const baseUrlFromSpecInput = (input: string): string => { · 32 unmodified lines | |||
| 82 | export const openApiPreviewFailureMessage = (message: string | null | undefined): string => | 83 | export const openApiPreviewFailureMessage = (message: string | null | undefined): string => |
| 83 | `Couldn't load or parse this spec: ${message && message.trim().length > 0 ? message : "unknown error"}`; | 84 | `Couldn't load or parse this spec: ${message && message.trim().length > 0 ? message : "unknown error"}`; |
| 84 | 85 | ||
| 86 | export function AddOpenApiHealthCheckSection(props: { | ||
| 87 | readonly presetHealthCheck?: HealthCheckSpec; | ||
| 88 | readonly candidates: readonly HealthCheckCandidate[]; | ||
| 89 | readonly selected: HealthCheckCandidate | null; | ||
| 90 | readonly operation: string; | ||
| 91 | readonly onOperationChange: (operation: string) => void; | ||
| 92 | readonly identityField: string; | ||
| 93 | readonly onIdentityFieldChange: (field: string) => void; | ||
| 94 | readonly args: Record<string, string>; | ||
| 95 | readonly onArgChange: (name: string, value: string) => void; | ||
| 96 | readonly disabled: boolean; | ||
| 97 | }) { | ||
| 98 | if (props.presetHealthCheck) { | ||
| 99 | return ( | ||
| 100 | <section className="space-y-2"> | ||
| 101 | <h3 className="text-sm font-medium text-foreground">Health check</h3> | ||
| 102 | <p className="text-[11px] text-muted-foreground"> | ||
| 103 | <span className="font-mono text-foreground">{props.presetHealthCheck.operation}</span>{" "} | ||
| 104 | From the catalog. Change it later from the integration page. | ||
| 105 | </p> | ||
| 106 | </section> | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | if (props.candidates.length === 0) return null; | ||
| 111 | |||
| 112 | return ( | ||
| 113 | <section className="space-y-4"> | ||
| 114 | <div className="space-y-1"> | ||
| 115 | <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3> | ||
| 116 | <p className="text-[11px] text-muted-foreground"> | ||
| 117 | An optional read-only probe adds live upstream depth. For OAuth connections, validity and | ||
| 118 | identity come from the OAuth grant itself. For non-OAuth methods, the probe can validate | ||
| 119 | the credential and identify the account. | ||
| 120 | </p> | ||
| 121 | </div> | ||
| 122 | <HealthCheckConfigFields | ||
| 123 | candidates={props.candidates} | ||
| 124 | selected={props.selected} | ||
| 125 | operation={props.operation} | ||
| 126 | onOperationChange={props.onOperationChange} | ||
| 127 | identityField={props.identityField} | ||
| 128 | onIdentityFieldChange={props.onIdentityFieldChange} | ||
| 129 | args={props.args} | ||
| 130 | onArgChange={props.onArgChange} | ||
| 131 | disabled={props.disabled} | ||
| 132 | idPrefix="add-health-check" | ||
| 133 | /> | ||
| 134 | </section> | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 85 | // --------------------------------------------------------------------------- | 138 | // --------------------------------------------------------------------------- |
| 86 | // Component: single progressive form. Post-redesign: preview -> addSpec | 139 | // Component: single progressive form. Post-redesign: preview -> addSpec |
| 87 | // (register the integration catalog entry with ALL detected auth methods) → | 140 | // (register the integration catalog entry with ALL detected auth methods) → |
| @@ -96,6 +149,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | |||
| 96 | initialPreset?: string; | 149 | initialPreset?: string; |
| 97 | initialNamespace?: string; | 150 | initialNamespace?: string; |
| 98 | }) { | 151 | }) { |
| 152 | const integrationPlugins = useIntegrationPlugins(); | ||
| 153 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); | ||
| 154 | const openApiPresets = openApiPlugin?.presets; | ||
| 155 | const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null; | ||
| 99 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); | 156 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); |
| 100 | const [analyzing, setAnalyzing] = useState(false); | 157 | const [analyzing, setAnalyzing] = useState(false); |
| 101 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); | 158 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| @@ -106,7 +163,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines | |||
| 106 | // Agent-visible description: prefilled from the spec's `info.description` | 163 | // Agent-visible description: prefilled from the spec's `info.description` |
| 107 | // until the user types (null = untouched, keep deriving from the preview). | 164 | // until the user types (null = untouched, keep deriving from the preview). |
| 108 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | 165 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); |
| 109 | const identityFallbackName = preview ? Option.getOrElse(preview.title, () => "") : ""; | 166 | const identityFallbackName = |
| 167 | initialPreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : ""); | ||
| 110 | const identity = useIntegrationIdentity({ | 168 | const identity = useIntegrationIdentity({ |
| 111 | fallbackName: identityFallbackName, | 169 | fallbackName: identityFallbackName, |
| 112 | fallbackNamespace: props.initialNamespace, | 170 | fallbackNamespace: props.initialNamespace, |
| @@ -159,12 +217,13 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines | |||
| 159 | ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) | 217 | ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) |
| 160 | : ""; | 218 | : ""; |
| 161 | const previewPresetIcon = | 219 | const previewPresetIcon = |
| 162 | openApiPresets.find( | 220 | openApiPresets?.find( |
| 163 | (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), | 221 | (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), |
| 164 | )?.icon ?? null; | 222 | )?.icon ?? null; |
| 165 | 223 | ||
| 166 | const resolvedBaseUrl = baseUrl.trim(); | 224 | const resolvedBaseUrl = baseUrl.trim(); |
| 167 | const resolvedSourceId = | 225 | const resolvedSourceId = |
| 226 | initialPreset?.defaultSlug || | ||
| 168 | slugifyNamespace(identity.namespace) || | 227 | slugifyNamespace(identity.namespace) || |
| 169 | (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); | 228 | (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); |
| 170 | const resolvedDisplayName = | 229 | const resolvedDisplayName = |
| @@ -176,15 +235,26 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | |||
| 176 | // Register EVERY spec-detected auth method, not just a single selected one. | 235 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 177 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the | 236 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the |
| 178 | // freshly-allocated `?? []` fallback arrays. | 237 | // freshly-allocated `?? []` fallback arrays. |
| 179 | const authenticationTemplate: readonly Authentication[] = useMemo( | 238 | const authenticationTemplate: readonly Authentication[] = useMemo(() => { |
| 180 | () => | 239 | if (initialPreset?.authTemplate) { |
| 181 | detectedAuthenticationTemplates( | 240 | return initialPreset.authTemplate.flatMap((template): readonly Authentication[] => |
| 182 | preview?.headerPresets ?? [], | 241 | template.kind === "oauth2" |
| 183 | preview?.oauth2Presets ?? [], | 242 | ? [ |
| 184 | resolvedBaseUrl, | 243 | { |
| 185 | ), | 244 | ...template, |
| 186 | [preview, resolvedBaseUrl], | 245 | slug: AuthTemplateSlug.make(template.slug), |
| 187 | ); | 246 | resource: template.resource ?? undefined, |
| 247 | }, | ||
| 248 | ] | ||
| 249 | : [], | ||
| 250 | ); | ||
| 251 | } | ||
| 252 | return detectedAuthenticationTemplates( | ||
| 253 | preview?.headerPresets ?? [], | ||
| 254 | preview?.oauth2Presets ?? [], | ||
| 255 | resolvedBaseUrl, | ||
| 256 | ); | ||
| 257 | }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]); | ||
| 188 | 258 | ||
| 189 | // Editable auth methods, seeded from the spec-detected templates. The add flow | 259 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 190 | // registers EVERY method (P6), so this is a LIST, preserving multi-method | 260 | // registers EVERY method (P6), so this is a LIST, preserving multi-method |
| @@ -271,7 +341,9 @@ export default function AddOpenApiSource(props: { · 80 unmodified lines | |||
| 271 | setAnalyzing(true); | 341 | setAnalyzing(true); |
| 272 | setAnalyzeError(null); | 342 | setAnalyzeError(null); |
| 273 | setAddError(null); | 343 | setAddError(null); |
| 274 | const exit = await doPreview({ payload: { spec: specUrl } }); | 344 | const exit = await doPreview({ |
| 345 | payload: { spec: specUrl, specFormat: initialPreset?.specFormat }, | ||
| 346 | }); | ||
| 275 | if (Exit.isFailure(exit)) { | 347 | if (Exit.isFailure(exit)) { |
| 276 | setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); | 348 | setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); |
| 277 | setAnalyzing(false); | 349 | setAnalyzing(false); |
| @@ -298,6 +370,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines | |||
| 298 | ? { description: resolvedDescription.trim() } | 370 | ? { description: resolvedDescription.trim() } |
| 299 | : {}), | 371 | : {}), |
| 300 | baseUrl: resolvedBaseUrl, | 372 | baseUrl: resolvedBaseUrl, |
| 373 | specFormat: initialPreset?.specFormat, | ||
| 374 | family: initialPreset?.family, | ||
| 375 | healthCheck: initialPreset?.healthCheck, | ||
| 301 | // Always send the edited method list (even empty) when the user has | 376 | // Always send the edited method list (even empty) when the user has |
| 302 | // inspected a preview: an explicit [] means "no auth methods", while | 377 | // inspected a preview: an explicit [] means "no auth methods", while |
| 303 | // OMITTING the field tells the server to derive defaults from the | 378 | // OMITTING the field tells the server to derive defaults from the |
| @@ -320,6 +395,9 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | |||
| 320 | resolvedDescription, | 395 | resolvedDescription, |
| 321 | resolvedBaseUrl, | 396 | resolvedBaseUrl, |
| 322 | editedAuthenticationTemplate, | 397 | editedAuthenticationTemplate, |
| 398 | initialPreset?.family, | ||
| 399 | initialPreset?.healthCheck, | ||
| 400 | initialPreset?.specFormat, | ||
| 323 | ]); | 401 | ]); |
| 324 | 402 | ||
| 325 | const handleAdd = async () => { | 403 | const handleAdd = async () => { |
| @@ -465,30 +543,20 @@ export default function AddOpenApiSource(props: { · 139 unmodified lines | |||
| 465 | /> | 543 | /> |
| 466 | )} | 544 | )} |
| 467 | 545 | ||
| 468 | {preview && healthCheckCandidates.length > 0 && ( | 546 | {preview ? ( |
| 469 | <section className="space-y-4"> | 547 | <AddOpenApiHealthCheckSection |
| 470 | <div className="space-y-1"> | 548 | presetHealthCheck={initialPreset?.healthCheck} |
| 471 | <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3> | 549 | candidates={healthCheckCandidates} |
| 472 | <p className="text-[11px] text-muted-foreground"> | 550 | selected={hcSelected} |
| 473 | One read-only call Executor runs to tell whether a connection's credential is still | 551 | operation={hcOperation} |
| 474 | alive and, optionally, whose account it is. You can change this later, and run a live | 552 | onOperationChange={onHcOperationChange} |
| 475 | preview against a key, from the integration page. | 553 | identityField={hcIdentityField} |
| 476 | </p> | 554 | onIdentityFieldChange={setHcIdentityField} |
| 477 | </div> | 555 | args={hcArgs} |
| 478 | <HealthCheckConfigFields | 556 | onArgChange={onHcArgChange} |
| 479 | candidates={healthCheckCandidates} | 557 | disabled={adding} |
| 480 | selected={hcSelected} | 558 | /> |
| 481 | operation={hcOperation} | 559 | ) : null} |
| 482 | onOperationChange={onHcOperationChange} | ||
| 483 | identityField={hcIdentityField} | ||
| 484 | onIdentityFieldChange={setHcIdentityField} | ||
| 485 | args={hcArgs} | ||
| 486 | onArgChange={onHcArgChange} | ||
| 487 | disabled={adding} | ||
| 488 | idPrefix="add-health-check" | ||
| 489 | /> | ||
| 490 | </section> | ||
| 491 | )} | ||
| 492 | 560 | ||
| 493 | {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | 561 | {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} |
| 494 | 562 | ||
| @@ -12,9 +12,11 @@ · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import { defineClientPlugin } from "@executor-js/sdk/client"; | 13 | import { defineClientPlugin } from "@executor-js/sdk/client"; |
| 14 | 14 | ||
| 15 | import { openApiIntegrationPlugin } from "./source-plugin"; | 15 | import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin"; |
| 16 | 16 | ||
| 17 | export default defineClientPlugin({ | 17 | export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) { |
| 18 | id: "openapi" as const, | 18 | return defineClientPlugin({ |
| 19 | integrationPlugin: openApiIntegrationPlugin, | 19 | id: "openapi" as const, |
| 20 | }); | 20 | integrationPlugin: createOpenApiIntegrationPlugin(config), |
| 21 | }); | ||
| 22 | } | ||
| @@ -1,21 +1,29 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; |
| 3 | import { openApiPresets } from "../sdk/presets"; | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | ||
| 5 | const importAdd = () => import("./AddOpenApiSource"); | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | const importEditSheet = () => import("./UpdateSpecSection"); | 6 | const importEditSheet = () => import("./UpdateSpecSection"); |
| 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); | 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); |
| 8 | 8 | ||
| 9 | export const openApiIntegrationPlugin: IntegrationPlugin = { | 9 | export interface OpenApiClientConfig { |
| 10 | readonly presets?: readonly IntegrationPreset[]; | ||
| 11 | } | ||
| 12 | |||
| 13 | export const createOpenApiIntegrationPlugin = ( | ||
| 14 | config?: OpenApiClientConfig, | ||
| 15 | ): IntegrationPlugin => ({ | ||
| 10 | key: "openapi", | 16 | key: "openapi", |
| 11 | label: "OpenAPI", | 17 | label: "OpenAPI", |
| 12 | add: lazy(importAdd), | 18 | add: lazy(importAdd), |
| 13 | editSheet: lazy(importEditSheet), | 19 | editSheet: lazy(importEditSheet), |
| 14 | accounts: lazy(importAccounts), | 20 | accounts: lazy(importAccounts), |
| 15 | presets: openApiPresets, | 21 | presets: [...openApiPresets, ...(config?.presets ?? [])], |
| 16 | preload: () => { | 22 | preload: () => { |
| 17 | void importAdd(); | 23 | void importAdd(); |
| 18 | void importEditSheet(); | 24 | void importEditSheet(); |
| 19 | void importAccounts(); | 25 | void importAccounts(); |
| 20 | }, | 26 | }, |
| 21 | }; | 27 | }); |
| 28 | |||
| 29 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | ||
| @@ -51,6 +51,10 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 50 unmodified lines | |||
| 51 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 51 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 52 | /** Static query params applied to every request (no secret material). */ | 52 | /** Static query params applied to every request (no secret material). */ |
| 53 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 53 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 54 | /** Adapter id used to fetch/convert the source format, when not plain OpenAPI. */ | ||
| 55 | specFormat: Schema.optional(Schema.String), | ||
| 56 | /** Catalog family for grouped integration display. */ | ||
| 57 | family: Schema.optional(Schema.String), | ||
| 54 | /** The auth methods a connection's value can be applied through. */ | 58 | /** The auth methods a connection's value can be applied through. */ |
| 55 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), | 59 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 56 | }); | 60 | }); |
| @@ -51,6 +51,14 @@ export { · 50 unmodified lines | |||
| 51 | type OpenApiPluginExtension, | 51 | type OpenApiPluginExtension, |
| 52 | type OpenApiPluginOptions, | 52 | type OpenApiPluginOptions, |
| 53 | } from "./plugin"; | 53 | } from "./plugin"; |
| 54 | export { | ||
| 55 | resolveSpecFormatAdapter, | ||
| 56 | type ConvertedSpec, | ||
| 57 | type DerivedIdentity, | ||
| 58 | type SpecFetchCredentials, | ||
| 59 | type SpecFetchInput, | ||
| 60 | type SpecFormatAdapter, | ||
| 61 | } from "./spec-format"; | ||
| 54 | export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store"; | 62 | export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store"; |
| 55 | export { | 63 | export { |
| 56 | decodeOpenApiIntegrationConfig, | 64 | decodeOpenApiIntegrationConfig, |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | IntegrationSlug, | 9 | IntegrationSlug, |
| 10 | ToolResult, | 10 | ToolResult, |
| 11 | definePlugin, | 11 | definePlugin, |
| 12 | HealthCheckSpec, | ||
| 12 | mergeAuthTemplates, | 13 | mergeAuthTemplates, |
| 13 | sha256Hex, | 14 | sha256Hex, |
| 14 | tool, | 15 | tool, |
| @@ -33,13 +34,19 @@ import { · 18 unmodified lines | |||
| 33 | type SpecPreview, | 34 | type SpecPreview, |
| 34 | } from "./preview"; | 35 | } from "./preview"; |
| 35 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; | 36 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 36 | import { openApiPresets } from "./presets"; | 37 | import { openApiPresets, type OpenApiPreset } from "./presets"; |
| 37 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; | 38 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| 39 | import { | ||
| 40 | resolveSpecFormatAdapter, | ||
| 41 | type ConvertedSpec, | ||
| 42 | type SpecFormatAdapter, | ||
| 43 | } from "./spec-format"; | ||
| 38 | import type { Authentication } from "./types"; | 44 | import type { Authentication } from "./types"; |
| 39 | import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; | 45 | import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; |
| 40 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | 46 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; |
| 41 | import { | 47 | import { |
| 42 | checkHealthOpenApi, | 48 | checkHealthOpenApi, |
| 49 | compileAndPersistOpenApiSpecStreaming, | ||
| 43 | compileOpenApiSpec, | 50 | compileOpenApiSpec, |
| 44 | invokeOpenApiBackedTool, | 51 | invokeOpenApiBackedTool, |
| 45 | listHealthCheckCandidatesOpenApi, | 52 | listHealthCheckCandidatesOpenApi, |
| @@ -59,6 +66,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 13 unmodified lines | |||
| 59 | 66 | ||
| 60 | export interface OpenApiPreviewInput { | 67 | export interface OpenApiPreviewInput { |
| 61 | readonly spec: string; | 68 | readonly spec: string; |
| 69 | readonly specFormat?: string; | ||
| 62 | } | 70 | } |
| 63 | 71 | ||
| 64 | /** Add an OpenAPI integration to the catalog. The integration is the API | 72 | /** Add an OpenAPI integration to the catalog. The integration is the API |
| @@ -67,7 +75,7 @@ export interface OpenApiPreviewInput { · 2 unmodified lines | |||
| 67 | export interface OpenApiSpecConfig { | 75 | export interface OpenApiSpecConfig { |
| 68 | readonly spec: OpenApiSpecInput; | 76 | readonly spec: OpenApiSpecInput; |
| 69 | /** The catalog slug for the new integration (the `<integration>` segment). */ | 77 | /** The catalog slug for the new integration (the `<integration>` segment). */ |
| 70 | readonly slug: string; | 78 | readonly slug?: string; |
| 71 | /** Display name (defaults to the spec title). */ | 79 | /** Display name (defaults to the spec title). */ |
| 72 | readonly name?: string; | 80 | readonly name?: string; |
| 73 | /** Agent-visible description (defaults to the spec's `info.description`, | 81 | /** Agent-visible description (defaults to the spec's `info.description`, |
| @@ -78,6 +86,9 @@ export interface OpenApiSpecConfig { · 4 unmodified lines | |||
| 78 | readonly headers?: Record<string, string>; | 86 | readonly headers?: Record<string, string>; |
| 79 | /** Static query params applied to every request. */ | 87 | /** Static query params applied to every request. */ |
| 80 | readonly queryParams?: Record<string, string>; | 88 | readonly queryParams?: Record<string, string>; |
| 89 | readonly specFormat?: string; | ||
| 90 | readonly family?: string; | ||
| 91 | readonly healthCheck?: HealthCheckSpec; | ||
| 81 | /** Auth methods a connection's value renders through - canonical | 92 | /** Auth methods a connection's value renders through - canonical |
| 82 | * placements or the request-shaped authoring dialect. */ | 93 | * placements or the request-shaped authoring dialect. */ |
| 83 | readonly authenticationTemplate?: readonly AuthenticationInput[]; | 94 | readonly authenticationTemplate?: readonly AuthenticationInput[]; |
| @@ -88,14 +99,16 @@ export interface OpenApiExtensionFailure { · 4 unmodified lines | |||
| 88 | } | 99 | } |
| 89 | 100 | ||
| 90 | /** Add / merge custom auth methods onto an existing OpenAPI integration's | 101 | /** Add / merge custom auth methods onto an existing OpenAPI integration's |
| 91 | * `authenticationTemplate`. Mirrors the GraphQL plugin's `configure`. */ | 102 | * `authenticationTemplate`, and update request routing metadata. Mirrors the |
| 103 | * GraphQL plugin's `configure`. */ | ||
| 92 | export interface OpenApiConfigureInput { | 104 | export interface OpenApiConfigureInput { |
| 93 | /** The auth methods to add. Each entry is appended to (or, when its `slug` | 105 | /** The auth methods to add. Each entry is appended to (or, when its `slug` |
| 94 | * already exists, replaces) the integration's existing template array. A | 106 | * already exists, replaces) the integration's existing template array. A |
| 95 | * custom apiKey method with no `slug` is assigned a generated `custom_<id>` | 107 | * custom apiKey method with no `slug` is assigned a generated `custom_<id>` |
| 96 | * slug that is collision-checked against the existing template. */ | 108 | * slug that is collision-checked against the existing template. */ |
| 97 | readonly authenticationTemplate: readonly AuthenticationInput[]; | 109 | readonly authenticationTemplate?: readonly AuthenticationInput[]; |
| 98 | readonly mode?: "merge" | "replace"; | 110 | readonly mode?: "merge" | "replace"; |
| 111 | readonly baseUrl?: string; | ||
| 99 | } | 112 | } |
| 100 | 113 | ||
| 101 | /** What changed in the tool catalog when a spec was updated in place. Tool | 114 | /** What changed in the tool catalog when a spec was updated in place. Tool |
| @@ -165,6 +178,7 @@ export interface OpenApiPluginExtension { · 63 unmodified lines | |||
| 165 | 178 | ||
| 166 | const PreviewSpecInputSchema = Schema.Struct({ | 179 | const PreviewSpecInputSchema = Schema.Struct({ |
| 167 | spec: Schema.String, | 180 | spec: Schema.String, |
| 181 | specFormat: Schema.optional(Schema.String), | ||
| 168 | }); | 182 | }); |
| 169 | 183 | ||
| 170 | const StaticPreviewServerVariableSchema = Schema.Struct({ | 184 | const StaticPreviewServerVariableSchema = Schema.Struct({ |
| @@ -261,11 +275,15 @@ const AuthenticationSchema = Schema.Union([ · 90 unmodified lines | |||
| 261 | 275 | ||
| 262 | const AddSourceInputSchema = Schema.Struct({ | 276 | const AddSourceInputSchema = Schema.Struct({ |
| 263 | spec: OpenApiSpecInputSchema, | 277 | spec: OpenApiSpecInputSchema, |
| 264 | slug: Schema.String, | 278 | slug: Schema.optional(Schema.String), |
| 279 | name: Schema.optional(Schema.String), | ||
| 265 | description: Schema.optional(Schema.String), | 280 | description: Schema.optional(Schema.String), |
| 266 | baseUrl: Schema.optional(Schema.String), | 281 | baseUrl: Schema.optional(Schema.String), |
| 267 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 282 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 268 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 283 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 284 | specFormat: Schema.optional(Schema.String), | ||
| 285 | family: Schema.optional(Schema.String), | ||
| 286 | healthCheck: Schema.optional(HealthCheckSpec), | ||
| 269 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), | 287 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 270 | }); | 288 | }); |
| 271 | 289 | ||
| @@ -553,9 +571,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines | |||
| 553 | 571 | ||
| 554 | export const describeOpenApiIntegrationDisplay = ( | 572 | export const describeOpenApiIntegrationDisplay = ( |
| 555 | record: IntegrationRecord, | 573 | record: IntegrationRecord, |
| 556 | ): { readonly url?: string } => { | 574 | ): { readonly url?: string; readonly family?: string } => { |
| 557 | const config = decodeOpenApiIntegrationConfig(record.config); | 575 | const config = decodeOpenApiIntegrationConfig(record.config); |
| 558 | return { url: config?.baseUrl ?? config?.sourceUrl }; | 576 | return { |
| 577 | url: config?.baseUrl ?? config?.sourceUrl, | ||
| 578 | ...(config?.family ? { family: config.family } : {}), | ||
| 579 | }; | ||
| 559 | }; | 580 | }; |
| 560 | 581 | ||
| 561 | // --------------------------------------------------------------------------- | 582 | // --------------------------------------------------------------------------- |
| @@ -565,36 +586,65 @@ export const describeOpenApiIntegrationDisplay = ( · 3 unmodified lines | |||
| 565 | export interface OpenApiPluginOptions { | 586 | export interface OpenApiPluginOptions { |
| 566 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 587 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 567 | readonly invokeOptions?: InvokeOptions; | 588 | readonly invokeOptions?: InvokeOptions; |
| 589 | readonly specFormats?: readonly SpecFormatAdapter[]; | ||
| 590 | readonly presets?: readonly OpenApiPreset[]; | ||
| 568 | } | 591 | } |
| 569 | 592 | ||
| 570 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { | 593 | export const openApiPlugin = definePlugin< |
| 594 | "openapi", | ||
| 595 | OpenApiPluginExtension, | ||
| 596 | OpenapiStore, | ||
| 597 | OpenApiPluginOptions | ||
| 598 | >((options?: OpenApiPluginOptions) => { | ||
| 571 | const resolveSpecForInput = ( | 599 | const resolveSpecForInput = ( |
| 572 | spec: OpenApiSpecInput, | 600 | config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">, |
| 573 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | 601 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 574 | ): Effect.Effect< | 602 | ): Effect.Effect<ConvertedSpec, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError> => |
| 575 | { | ||
| 576 | readonly specText: string; | ||
| 577 | }, | ||
| 578 | OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | ||
| 579 | > => | ||
| 580 | Effect.gen(function* () { | 603 | Effect.gen(function* () { |
| 581 | if (spec.kind === "url") { | 604 | const adapter = yield* resolveSpecFormatAdapter( |
| 582 | const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer)); | 605 | options?.specFormats ?? [], |
| 583 | return { specText }; | 606 | config.specFormat, |
| 607 | ); | ||
| 608 | if (adapter) { | ||
| 609 | if (config.spec.kind !== "url") { | ||
| 610 | return yield* new OpenApiParseError({ | ||
| 611 | message: "Spec format adapters require a URL spec input", | ||
| 612 | }); | ||
| 613 | } | ||
| 614 | return yield* adapter.fetch({ | ||
| 615 | urls: [config.spec.url], | ||
| 616 | credentials: { | ||
| 617 | ...(config.headers ? { headers: config.headers } : {}), | ||
| 618 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), | ||
| 619 | }, | ||
| 620 | httpClientLayer, | ||
| 621 | }); | ||
| 622 | } | ||
| 623 | if (config.spec.kind === "url") { | ||
| 624 | const specText = yield* resolveSpecText(config.spec.url).pipe( | ||
| 625 | Effect.provide(httpClientLayer), | ||
| 626 | ); | ||
| 627 | return { specText, sourceUrl: config.spec.url }; | ||
| 584 | } | 628 | } |
| 585 | return { specText: spec.value }; | 629 | return { specText: config.spec.value }; |
| 586 | }); | 630 | }); |
| 587 | 631 | ||
| 588 | return { | 632 | return { |
| 589 | id: "openapi" as const, | 633 | id: "openapi" as const, |
| 590 | packageName: "@executor-js/plugin-openapi", | 634 | packageName: "@executor-js/plugin-openapi", |
| 591 | integrationPresets: openApiPresets.map((preset) => ({ | 635 | clientConfig: options?.presets ? { presets: options.presets } : undefined, |
| 636 | integrationPresets: [...openApiPresets, ...(options?.presets ?? [])].map((preset) => ({ | ||
| 592 | id: preset.id, | 637 | id: preset.id, |
| 593 | name: preset.name, | 638 | name: preset.name, |
| 594 | summary: preset.summary, | 639 | summary: preset.summary, |
| 595 | ...(preset.url ? { url: preset.url } : {}), | 640 | ...(preset.url ? { url: preset.url } : {}), |
| 596 | ...(preset.icon ? { icon: preset.icon } : {}), | 641 | ...(preset.icon ? { icon: preset.icon } : {}), |
| 597 | ...(preset.featured ? { featured: preset.featured } : {}), | 642 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 643 | ...(preset.family ? { family: preset.family } : {}), | ||
| 644 | ...(preset.specFormat ? { specFormat: preset.specFormat } : {}), | ||
| 645 | ...(preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}), | ||
| 646 | ...(preset.authTemplate ? { authTemplate: preset.authTemplate } : {}), | ||
| 647 | ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}), | ||
| 598 | })), | 648 | })), |
| 599 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | 649 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 600 | 650 | ||
| @@ -650,8 +700,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 49 unmodified lines | |||
| 650 | Effect.gen(function* () { | 700 | Effect.gen(function* () { |
| 651 | // Resolve URL → text and parse BEFORE opening a transaction. Holding | 701 | // Resolve URL → text and parse BEFORE opening a transaction. Holding |
| 652 | // `BEGIN` across a network fetch is the Hyperdrive deadlock path. | 702 | // `BEGIN` across a network fetch is the Hyperdrive deadlock path. |
| 653 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); | 703 | const resolved = yield* resolveSpecForInput(config, httpClientLayer); |
| 654 | const compiled = yield* compileOpenApiSpec(resolved.specText); | 704 | const compiled = resolved.keepPathItem |
| 705 | ? undefined | ||
| 706 | : yield* compileOpenApiSpec(resolved.specText); | ||
| 707 | const adapter = yield* resolveSpecFormatAdapter( | ||
| 708 | options?.specFormats ?? [], | ||
| 709 | config.specFormat, | ||
| 710 | ); | ||
| 711 | const derivedIdentity = | ||
| 712 | adapter?.deriveIdentity && resolved.document | ||
| 713 | ? adapter.deriveIdentity(resolved.document) | ||
| 714 | : null; | ||
| 715 | const resolvedSlug = config.slug?.trim() || derivedIdentity?.slug; | ||
| 716 | if (!resolvedSlug) { | ||
| 717 | return yield* new OpenApiParseError({ | ||
| 718 | message: "OpenAPI integration slug is required", | ||
| 719 | }); | ||
| 720 | } | ||
| 655 | 721 | ||
| 656 | // Defaults the add page derives from its preview, applied here so | 722 | // Defaults the add page derives from its preview, applied here so |
| 657 | // headless callers (MCP, API) get the same integration the UI's | 723 | // headless callers (MCP, API) get the same integration the UI's |
| @@ -665,7 +731,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines | |||
| 665 | // end with nowhere to paste a credential) | 731 | // end with nowhere to paste a credential) |
| 666 | // An explicit input always wins; for auth, an explicit EMPTY array | 732 | // An explicit input always wins; for auth, an explicit EMPTY array |
| 667 | // means "no auth methods" and suppresses the derivation. | 733 | // means "no auth methods" and suppresses the derivation. |
| 668 | const explicitBaseUrl = config.baseUrl; | 734 | const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl; |
| 669 | const needsDerivedBaseUrl = explicitBaseUrl == null; | 735 | const needsDerivedBaseUrl = explicitBaseUrl == null; |
| 670 | const needsDerivedAuth = config.authenticationTemplate == null; | 736 | const needsDerivedAuth = config.authenticationTemplate == null; |
| 671 | const preview = | 737 | const preview = |
| @@ -675,8 +741,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines | |||
| 675 | enrichPreviewWithDiscoveredOAuth({ | 741 | enrichPreviewWithDiscoveredOAuth({ |
| 676 | specText: resolved.specText, | 742 | specText: resolved.specText, |
| 677 | preview: rawPreview, | 743 | preview: rawPreview, |
| 678 | sourceUrl: specInputToSourceUrl(config.spec), | 744 | sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec), |
| 679 | baseUrl: config.baseUrl, | 745 | baseUrl: explicitBaseUrl, |
| 680 | }), | 746 | }), |
| 681 | ), | 747 | ), |
| 682 | ) | 748 | ) |
| @@ -689,7 +755,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 6 unmodified lines | |||
| 689 | ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl) | 755 | ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl) |
| 690 | : undefined; | 756 | : undefined; |
| 691 | 757 | ||
| 692 | const slug = IntegrationSlug.make(config.slug); | 758 | const slug = IntegrationSlug.make(resolvedSlug); |
| 693 | 759 | ||
| 694 | // Block re-adding an existing slug. The core `integrations.register` | 760 | // Block re-adding an existing slug. The core `integrations.register` |
| 695 | // primitive upserts (so boot re-registration is idempotent), but an | 761 | // primitive upserts (so boot re-registration is idempotent), but an |
| @@ -704,9 +770,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 8 unmodified lines | |||
| 704 | const specHash = yield* sha256Hex(resolved.specText); | 770 | const specHash = yield* sha256Hex(resolved.specText); |
| 705 | 771 | ||
| 706 | const integrationConfig: OpenApiIntegrationConfig = { | 772 | const integrationConfig: OpenApiIntegrationConfig = { |
| 773 | ...(resolved.config ?? {}), | ||
| 707 | specHash, | 774 | specHash, |
| 708 | ...(specInputToSourceUrl(config.spec) !== undefined | 775 | ...((resolved.sourceUrl ?? specInputToSourceUrl(config.spec)) !== undefined |
| 709 | ? { sourceUrl: specInputToSourceUrl(config.spec) } | 776 | ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec) } |
| 710 | : {}), | 777 | : {}), |
| 711 | // baseUrl is an optional override only. The host is otherwise | 778 | // baseUrl is an optional override only. The host is otherwise |
| 712 | // resolved per call from the operation's `servers` (extracted from | 779 | // resolved per call from the operation's `servers` (extracted from |
| @@ -714,15 +781,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line | |||
| 714 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | 781 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), |
| 715 | ...(config.headers ? { headers: config.headers } : {}), | 782 | ...(config.headers ? { headers: config.headers } : {}), |
| 716 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), | 783 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 784 | ...(config.specFormat ? { specFormat: config.specFormat } : {}), | ||
| 785 | ...(config.family ? { family: config.family } : {}), | ||
| 717 | // Prefer the caller's explicit template; otherwise derive from the | 786 | // Prefer the caller's explicit template; otherwise derive from the |
| 718 | // spec's declared security schemes. | 787 | // spec's declared security schemes. |
| 719 | ...(config.authenticationTemplate | 788 | ...(config.authenticationTemplate |
| 720 | ? { | 789 | ? { |
| 721 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), | 790 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), |
| 722 | } | 791 | } |
| 723 | : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 | 792 | : resolved.authenticationTemplate && resolved.authenticationTemplate.length > 0 |
| 724 | ? { authenticationTemplate: derivedAuthenticationTemplate } | 793 | ? { authenticationTemplate: resolved.authenticationTemplate } |
| 725 | : {}), | 794 | : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 |
| 795 | ? { authenticationTemplate: derivedAuthenticationTemplate } | ||
| 796 | : {}), | ||
| 726 | }; | 797 | }; |
| 727 | 798 | ||
| 728 | // The spec blob is written OUTSIDE the transaction: it's | 799 | // The spec blob is written OUTSIDE the transaction: it's |
| @@ -733,27 +804,53 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines | |||
| 733 | // The content-addressed defs blob lets the serve path resolve the | 804 | // The content-addressed defs blob lets the serve path resolve the |
| 734 | // shared `definitions` without re-parsing the spec. Same idempotent, | 805 | // shared `definitions` without re-parsing the spec. Same idempotent, |
| 735 | // outside-the-transaction rationale as the spec blob. | 806 | // outside-the-transaction rationale as the spec blob. |
| 736 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | 807 | if (compiled) { |
| 808 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 809 | } | ||
| 737 | 810 | ||
| 738 | yield* ctx.transaction( | 811 | yield* ctx.transaction( |
| 739 | Effect.gen(function* () { | 812 | Effect.gen(function* () { |
| 740 | yield* ctx.core.integrations.register({ | 813 | yield* ctx.core.integrations.register({ |
| 741 | slug, | 814 | slug, |
| 742 | name: config.name?.trim() || compiled.title || config.slug, | 815 | name: |
| 816 | config.name?.trim() || derivedIdentity?.name || compiled?.title || resolvedSlug, | ||
| 743 | description: | 817 | description: |
| 744 | config.description ?? compiled.description ?? compiled.title ?? config.slug, | 818 | config.description ?? |
| 819 | derivedIdentity?.description ?? | ||
| 820 | compiled?.description ?? | ||
| 821 | compiled?.title ?? | ||
| 822 | resolvedSlug, | ||
| 745 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, | 823 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 746 | canRemove: true, | 824 | canRemove: true, |
| 747 | canRefresh: specInputToSourceUrl(config.spec) != null, | 825 | canRefresh: integrationConfig.sourceUrl != null, |
| 826 | }); | ||
| 827 | if (config.healthCheck) { | ||
| 828 | yield* ctx.core.integrations.setHealthCheck(slug, config.healthCheck); | ||
| 829 | } | ||
| 830 | if (compiled) { | ||
| 831 | yield* ctx.storage.putOperations( | ||
| 832 | resolvedSlug, | ||
| 833 | openApiStoredOperationsFromCompiled(resolvedSlug, compiled), | ||
| 834 | ); | ||
| 835 | return compiled.definitions.length; | ||
| 836 | } | ||
| 837 | const persisted = yield* compileAndPersistOpenApiSpecStreaming({ | ||
| 838 | specText: resolved.specText, | ||
| 839 | integration: resolvedSlug, | ||
| 840 | storage: ctx.storage, | ||
| 841 | specHash, | ||
| 842 | keepPathItem: resolved.keepPathItem, | ||
| 748 | }); | 843 | }); |
| 749 | yield* ctx.storage.putOperations( | 844 | return persisted.toolCount; |
| 750 | config.slug, | ||
| 751 | openApiStoredOperationsFromCompiled(config.slug, compiled), | ||
| 752 | ); | ||
| 753 | }), | 845 | }), |
| 754 | ); | 846 | ); |
| 755 | 847 | ||
| 756 | return { slug, toolCount: compiled.definitions.length }; | 848 | const toolCount = compiled |
| 849 | ? compiled.definitions.length | ||
| 850 | : yield* ctx.storage | ||
| 851 | .listOperations(resolvedSlug) | ||
| 852 | .pipe(Effect.map((operations) => operations.length)); | ||
| 853 | return { slug, toolCount }; | ||
| 757 | }); | 854 | }); |
| 758 | 855 | ||
| 759 | // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a | 856 | // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a |
| @@ -784,12 +881,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 24 unmodified lines | |||
| 784 | 881 | ||
| 785 | // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock | 882 | // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock |
| 786 | // rule as addSpec: never hold BEGIN across a network fetch). | 883 | // rule as addSpec: never hold BEGIN across a network fetch). |
| 787 | const resolved = yield* resolveSpecForInput(specInput, httpClientLayer); | 884 | const resolved = yield* resolveSpecForInput( |
| 788 | const compiled = yield* compileOpenApiSpec(resolved.specText); | 885 | { |
| 886 | spec: specInput, | ||
| 887 | specFormat: current.specFormat, | ||
| 888 | headers: current.headers, | ||
| 889 | queryParams: current.queryParams, | ||
| 890 | baseUrl: current.baseUrl, | ||
| 891 | }, | ||
| 892 | httpClientLayer, | ||
| 893 | ); | ||
| 894 | const compiled = resolved.keepPathItem | ||
| 895 | ? undefined | ||
| 896 | : yield* compileOpenApiSpec(resolved.specText); | ||
| 789 | 897 | ||
| 790 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | 898 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); |
| 791 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | 899 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); |
| 792 | const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); | ||
| 793 | 900 | ||
| 794 | // The resolved spec text lives in the plugin blob store keyed by its | 901 | // The resolved spec text lives in the plugin blob store keyed by its |
| 795 | // content hash (`spec/<hash>`); the config carries only the hash. Put | 902 | // content hash (`spec/<hash>`); the config carries only the hash. Put |
| @@ -797,13 +904,15 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line | |||
| 797 | // aborted config update just leaves an unreferenced blob. | 904 | // aborted config update just leaves an unreferenced blob. |
| 798 | const specHash = yield* sha256Hex(resolved.specText); | 905 | const specHash = yield* sha256Hex(resolved.specText); |
| 799 | yield* ctx.storage.putSpec(specHash, resolved.specText); | 906 | yield* ctx.storage.putSpec(specHash, resolved.specText); |
| 800 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | 907 | if (compiled) { |
| 908 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 909 | } | ||
| 801 | 910 | ||
| 802 | const nextConfig: OpenApiIntegrationConfig = { | 911 | const nextConfig: OpenApiIntegrationConfig = { |
| 803 | ...current, | 912 | ...current, |
| 804 | specHash, | 913 | specHash, |
| 805 | ...(specInputToSourceUrl(specInput) !== undefined | 914 | ...((resolved.sourceUrl ?? specInputToSourceUrl(specInput)) !== undefined |
| 806 | ? { sourceUrl: specInputToSourceUrl(specInput) } | 915 | ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(specInput) } |
| 807 | : {}), | 916 | : {}), |
| 808 | }; | 917 | }; |
| 809 | 918 | ||
| @@ -812,13 +921,28 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines | |||
| 812 | yield* ctx.core.integrations.update(slug, { | 921 | yield* ctx.core.integrations.update(slug, { |
| 813 | config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, | 922 | config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 814 | }); | 923 | }); |
| 815 | yield* ctx.storage.putOperations( | 924 | if (compiled) { |
| 816 | rawSlug, | 925 | yield* ctx.storage.putOperations( |
| 817 | openApiStoredOperationsFromCompiled(rawSlug, compiled), | 926 | rawSlug, |
| 818 | ); | 927 | openApiStoredOperationsFromCompiled(rawSlug, compiled), |
| 928 | ); | ||
| 929 | } else { | ||
| 930 | yield* compileAndPersistOpenApiSpecStreaming({ | ||
| 931 | specText: resolved.specText, | ||
| 932 | integration: rawSlug, | ||
| 933 | storage: ctx.storage, | ||
| 934 | specHash, | ||
| 935 | keepPathItem: resolved.keepPathItem, | ||
| 936 | }); | ||
| 937 | } | ||
| 819 | }), | 938 | }), |
| 820 | ); | 939 | ); |
| 821 | 940 | ||
| 941 | const nextOperations = compiled | ||
| 942 | ? openApiStoredOperationsFromCompiled(rawSlug, compiled) | ||
| 943 | : yield* ctx.storage.listOperations(rawSlug); | ||
| 944 | const nextNames = new Set(nextOperations.map((op) => op.toolName)); | ||
| 945 | |||
| 822 | // Rebuild each connection's tool rows from the new spec. Outside the | 946 | // Rebuild each connection's tool rows from the new spec. Outside the |
| 823 | // transaction: refresh opens its own, and a half-refreshed catalog | 947 | // transaction: refresh opens its own, and a half-refreshed catalog |
| 824 | // self-heals on the next refresh anyway. | 948 | // self-heals on the next refresh anyway. |
| @@ -843,7 +967,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 18 unmodified lines | |||
| 843 | 967 | ||
| 844 | return { | 968 | return { |
| 845 | slug, | 969 | slug, |
| 846 | toolCount: compiled.definitions.length, | 970 | toolCount: nextNames.size, |
| 847 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | 971 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), |
| 848 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | 972 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), |
| 849 | } satisfies UpdateSpecResult; | 973 | } satisfies UpdateSpecResult; |
| @@ -857,14 +981,18 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines | |||
| 857 | previewSpec: (input: string | OpenApiPreviewInput) => | 981 | previewSpec: (input: string | OpenApiPreviewInput) => |
| 858 | Effect.gen(function* () { | 982 | Effect.gen(function* () { |
| 859 | const previewInput = typeof input === "string" ? { spec: input } : input; | 983 | const previewInput = typeof input === "string" ? { spec: input } : input; |
| 860 | const specText = yield* resolveSpecText(previewInput.spec).pipe( | 984 | const spec = maybeUrl(previewInput.spec.trim()) |
| 861 | Effect.provide(httpClientLayer), | 985 | ? { kind: "url" as const, url: previewInput.spec.trim() } |
| 986 | : { kind: "blob" as const, value: previewInput.spec }; | ||
| 987 | const resolved = yield* resolveSpecForInput( | ||
| 988 | { spec, specFormat: previewInput.specFormat }, | ||
| 989 | httpClientLayer, | ||
| 862 | ); | 990 | ); |
| 863 | const preview = yield* previewSpecText(specText); | 991 | const preview = yield* previewSpecText(resolved.specText); |
| 864 | return yield* enrichPreviewWithDiscoveredOAuth({ | 992 | return yield* enrichPreviewWithDiscoveredOAuth({ |
| 865 | specText, | 993 | specText: resolved.specText, |
| 866 | preview, | 994 | preview, |
| 867 | sourceUrl: maybeUrl(previewInput.spec.trim()) ? previewInput.spec.trim() : undefined, | 995 | sourceUrl: resolved.sourceUrl ?? (spec.kind === "url" ? spec.url : undefined), |
| 868 | }); | 996 | }); |
| 869 | }), | 997 | }), |
| 870 | 998 | ||
| @@ -917,14 +1045,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 46 unmodified lines | |||
| 917 | const current = decodeOpenApiIntegrationConfig(record.config); | 1045 | const current = decodeOpenApiIntegrationConfig(record.config); |
| 918 | if (!current) return [] as readonly Authentication[]; | 1046 | if (!current) return [] as readonly Authentication[]; |
| 919 | 1047 | ||
| 920 | const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | ||
| 921 | const merged = | 1048 | const merged = |
| 922 | input.mode === "replace" | 1049 | input.authenticationTemplate === undefined |
| 923 | ? incoming | 1050 | ? (current.authenticationTemplate ?? []) |
| 924 | : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | 1051 | : input.mode === "replace" |
| 1052 | ? normalizeOpenApiAuthInputs(input.authenticationTemplate) | ||
| 1053 | : mergeAuthTemplates( | ||
| 1054 | current.authenticationTemplate ?? [], | ||
| 1055 | normalizeOpenApiAuthInputs(input.authenticationTemplate), | ||
| 1056 | ); | ||
| 925 | 1057 | ||
| 926 | const next: OpenApiIntegrationConfig = { | 1058 | const next: OpenApiIntegrationConfig = { |
| 927 | ...current, | 1059 | ...current, |
| 1060 | ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), | ||
| 928 | authenticationTemplate: merged, | 1061 | authenticationTemplate: merged, |
| 929 | }; | 1062 | }; |
| 930 | 1063 | ||
| @@ -978,10 +1111,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 47 unmodified lines | |||
| 978 | .addSpec({ | 1111 | .addSpec({ |
| 979 | spec: input.spec, | 1112 | spec: input.spec, |
| 980 | slug: input.slug, | 1113 | slug: input.slug, |
| 1114 | name: input.name, | ||
| 981 | description: input.description, | 1115 | description: input.description, |
| 982 | baseUrl: input.baseUrl, | 1116 | baseUrl: input.baseUrl, |
| 983 | headers: input.headers, | 1117 | headers: input.headers, |
| 984 | queryParams: input.queryParams, | 1118 | queryParams: input.queryParams, |
| 1119 | specFormat: input.specFormat, | ||
| 1120 | family: input.family, | ||
| 1121 | healthCheck: input.healthCheck, | ||
| 985 | authenticationTemplate: input.authenticationTemplate as | 1122 | authenticationTemplate: input.authenticationTemplate as |
| 986 | | readonly AuthenticationInput[] | 1123 | | readonly AuthenticationInput[] |
| 987 | | undefined, | 1124 | | undefined, |
| @@ -1,3 +1,5 @@ | |||
| 1 | import type { HealthCheckSpec, IntegrationPresetAuthentication } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 1 | export interface OpenApiPreset { | 3 | export interface OpenApiPreset { |
| 2 | readonly id: string; | 4 | readonly id: string; |
| 3 | readonly name: string; | 5 | readonly name: string; |
| @@ -5,6 +7,11 @@ export interface OpenApiPreset { · 1 unmodified line | |||
| 5 | readonly url?: string; | 7 | readonly url?: string; |
| 6 | readonly icon?: string; | 8 | readonly icon?: string; |
| 7 | readonly featured?: boolean; | 9 | readonly featured?: boolean; |
| 10 | readonly family?: string; | ||
| 11 | readonly specFormat?: string; | ||
| 12 | readonly defaultSlug?: string; | ||
| 13 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | ||
| 14 | readonly healthCheck?: HealthCheckSpec; | ||
| 8 | } | 15 | } |
| 9 | 16 | ||
| 10 | const openApiOnlyPresets: readonly OpenApiPreset[] = [ | 17 | const openApiOnlyPresets: readonly OpenApiPreset[] = [ |
| @@ -0,0 +1,32 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { OpenApiParseError } from "./errors"; | ||
| 5 | import { resolveSpecFormatAdapter, type SpecFormatAdapter } from "./spec-format"; | ||
| 6 | |||
| 7 | const adapter: SpecFormatAdapter = { | ||
| 8 | id: "known", | ||
| 9 | fetch: () => Effect.succeed({ specText: "{}" }), | ||
| 10 | }; | ||
| 11 | |||
| 12 | it.effect("resolves a registered spec format adapter by id", () => | ||
| 13 | Effect.gen(function* () { | ||
| 14 | const resolved = yield* resolveSpecFormatAdapter([adapter], "known"); | ||
| 15 | expect(resolved).toBe(adapter); | ||
| 16 | }), | ||
| 17 | ); | ||
| 18 | |||
| 19 | it.effect("returns null when no spec format id is supplied", () => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const resolved = yield* resolveSpecFormatAdapter([adapter], undefined); | ||
| 22 | expect(resolved).toBeNull(); | ||
| 23 | }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | it.effect("fails with a typed parse error for an unknown spec format id", () => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const error = yield* resolveSpecFormatAdapter([adapter], "missing").pipe(Effect.flip); | ||
| 29 | expect(error).toBeInstanceOf(OpenApiParseError); | ||
| 30 | expect(error).toMatchObject({ message: "Unknown OpenAPI spec format: missing" }); | ||
| 31 | }), | ||
| 32 | ); | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | import type { Layer } from "effect"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import type { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import type { Authentication } from "./types"; | ||
| 6 | import type { OpenApiIntegrationConfig } from "./config"; | ||
| 7 | import type { KeepPathItem } from "./split"; | ||
| 8 | import { OpenApiParseError } from "./errors"; | ||
| 9 | |||
| 10 | export interface SpecFetchCredentials { | ||
| 11 | readonly headers?: Record<string, string>; | ||
| 12 | readonly queryParams?: Record<string, string>; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface SpecFetchInput { | ||
| 16 | readonly urls: readonly string[]; | ||
| 17 | readonly credentials?: SpecFetchCredentials; | ||
| 18 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface DerivedIdentity { | ||
| 22 | readonly slug: string; | ||
| 23 | readonly name: string; | ||
| 24 | readonly description?: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface ConvertedSpec { | ||
| 28 | readonly specText: string; | ||
| 29 | readonly sourceUrl?: string; | ||
| 30 | readonly baseUrl?: string; | ||
| 31 | readonly authenticationTemplate?: readonly Authentication[]; | ||
| 32 | readonly config?: Partial<OpenApiIntegrationConfig> & Readonly<Record<string, unknown>>; | ||
| 33 | readonly keepPathItem?: KeepPathItem; | ||
| 34 | readonly document?: unknown; | ||
| 35 | } | ||
| 36 | |||
| 37 | export interface SpecFormatAdapter { | ||
| 38 | readonly id: string; | ||
| 39 | readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>; | ||
| 40 | readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; | ||
| 41 | } | ||
| 42 | |||
| 43 | export const resolveSpecFormatAdapter = ( | ||
| 44 | adapters: readonly SpecFormatAdapter[], | ||
| 45 | id: string | undefined, | ||
| 46 | ): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => { | ||
| 47 | if (!id) return Effect.succeed(null); | ||
| 48 | const adapter = adapters.find((candidate) => candidate.id === id); | ||
| 49 | if (adapter) return Effect.succeed(adapter); | ||
| 50 | return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` })); | ||
| 51 | }; | ||
| @@ -5,6 +5,8 @@ export default defineConfig({ · 4 unmodified lines | |||
| 5 | index: "src/promise.ts", | 5 | index: "src/promise.ts", |
| 6 | core: "src/sdk/index.ts", | 6 | core: "src/sdk/index.ts", |
| 7 | client: "src/react/plugin-client.tsx", | 7 | client: "src/react/plugin-client.tsx", |
| 8 | "providers/google": "src/providers/google/index.ts", | ||
| 9 | "providers/microsoft": "src/providers/microsoft/index.ts", | ||
| 8 | testing: "src/testing/index.ts", | 10 | testing: "src/testing/index.ts", |
| 9 | }, | 11 | }, |
| 10 | format: ["esm"], | 12 | format: ["esm"], |
| @@ -0,0 +1,7 @@ | |||
| 1 | # @executor-js/plugin-provider-service-split | ||
| 2 | |||
| 3 | ## 0.0.0 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Initial internal package. | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor-js/plugin-provider-service-split", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "exports": { | ||
| 7 | ".": "./src/index.ts", | ||
| 8 | "./planner": "./src/planner.ts", | ||
| 9 | "./sqlite": "./src/sqlite.ts" | ||
| 10 | }, | ||
| 11 | "scripts": { | ||
| 12 | "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", | ||
| 13 | "typecheck": "tsgo --noEmit", | ||
| 14 | "test": "vitest run", | ||
| 15 | "test:watch": "vitest", | ||
| 16 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" | ||
| 17 | }, | ||
| 18 | "dependencies": { | ||
| 19 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 20 | "@executor-js/sdk": "workspace:*", | ||
| 21 | "effect": "catalog:" | ||
| 22 | }, | ||
| 23 | "devDependencies": { | ||
| 24 | "@effect/vitest": "catalog:", | ||
| 25 | "@types/node": "catalog:", | ||
| 26 | "bun-types": "catalog:", | ||
| 27 | "tsup": "catalog:", | ||
| 28 | "typescript": "catalog:", | ||
| 29 | "vitest": "catalog:" | ||
| 30 | } | ||
| 31 | } | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export * from "./planner"; | ||
| 2 | export * from "./sqlite"; | ||
| @@ -0,0 +1,1119 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { streamOperationBindingsFromStructure, structuralSplit } from "@executor-js/plugin-openapi"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | googlePresetIdForTool, | ||
| 7 | microsoftPresetIdsForTool, | ||
| 8 | microsoftToolFirstSegmentPresetIds, | ||
| 9 | operationStorageKey, | ||
| 10 | planMigration, | ||
| 11 | verifyPolicyRewriteNeverWidens, | ||
| 12 | type BlobRow, | ||
| 13 | type ConnectionRow, | ||
| 14 | type IntegrationRow, | ||
| 15 | type MigrationInput, | ||
| 16 | type PluginStorageRow, | ||
| 17 | type ToolPolicyRow, | ||
| 18 | type ToolRow, | ||
| 19 | } from "./planner"; | ||
| 20 | import { googleCatalog } from "@executor-js/plugin-openapi/providers/google"; | ||
| 21 | |||
| 22 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 23 | |||
| 24 | const integration = (overrides: Partial<IntegrationRow> = {}): IntegrationRow => ({ | ||
| 25 | tenant: "org_1", | ||
| 26 | slug: "google", | ||
| 27 | plugin_id: "google", | ||
| 28 | name: "Google", | ||
| 29 | description: "Google APIs", | ||
| 30 | config: { | ||
| 31 | googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 32 | specHash: "mono-hash", | ||
| 33 | authenticationTemplate: [{ slug: "googleOAuth", kind: "oauth2" }], | ||
| 34 | }, | ||
| 35 | health_check: null, | ||
| 36 | config_revised_at: null, | ||
| 37 | can_remove: true, | ||
| 38 | can_refresh: true, | ||
| 39 | created_at: now, | ||
| 40 | updated_at: now, | ||
| 41 | row_id: "int_1", | ||
| 42 | ...overrides, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const connection = (overrides: Partial<ConnectionRow> = {}): ConnectionRow => ({ | ||
| 46 | tenant: "org_1", | ||
| 47 | owner: "org", | ||
| 48 | subject: "", | ||
| 49 | integration: "google", | ||
| 50 | name: "main", | ||
| 51 | template: "googleOAuth", | ||
| 52 | provider: "vault", | ||
| 53 | item_ids: { access: "access_item", refresh: "refresh_item" }, | ||
| 54 | identity_label: "person@example.test", | ||
| 55 | description: null, | ||
| 56 | last_health: null, | ||
| 57 | tools_synced_at: 1, | ||
| 58 | oauth_client: "google", | ||
| 59 | oauth_client_owner: "org", | ||
| 60 | refresh_item_id: "refresh_item", | ||
| 61 | expires_at: 2, | ||
| 62 | oauth_scope: "calendar gmail", | ||
| 63 | oauth_token_url: "https://oauth2.googleapis.com/token", | ||
| 64 | provider_state: { token: "metadata" }, | ||
| 65 | created_at: now, | ||
| 66 | updated_at: now, | ||
| 67 | row_id: "conn_1", | ||
| 68 | ...overrides, | ||
| 69 | }); | ||
| 70 | |||
| 71 | const tool = (name: string, overrides: Partial<ToolRow> = {}): ToolRow => ({ | ||
| 72 | tenant: "org_1", | ||
| 73 | owner: "org", | ||
| 74 | subject: "", | ||
| 75 | integration: "google", | ||
| 76 | connection: "main", | ||
| 77 | plugin_id: "google", | ||
| 78 | name, | ||
| 79 | description: "tool", | ||
| 80 | input_schema: {}, | ||
| 81 | output_schema: {}, | ||
| 82 | annotations: {}, | ||
| 83 | created_at: now, | ||
| 84 | updated_at: now, | ||
| 85 | row_id: `tool_${name}`, | ||
| 86 | ...overrides, | ||
| 87 | }); | ||
| 88 | |||
| 89 | const operation = (name: string, overrides: Partial<PluginStorageRow> = {}): PluginStorageRow => ({ | ||
| 90 | tenant: "org_1", | ||
| 91 | owner: "org", | ||
| 92 | subject: "", | ||
| 93 | plugin_id: "google", | ||
| 94 | collection: "operation", | ||
| 95 | key: operationStorageKey("google", name), | ||
| 96 | data: { | ||
| 97 | integration: "google", | ||
| 98 | toolName: name, | ||
| 99 | binding: { | ||
| 100 | method: "get", | ||
| 101 | servers: [], | ||
| 102 | pathTemplate: `/${name}`, | ||
| 103 | parameters: [], | ||
| 104 | }, | ||
| 105 | description: name, | ||
| 106 | }, | ||
| 107 | created_at: now, | ||
| 108 | updated_at: now, | ||
| 109 | row_id: `op_${name}`, | ||
| 110 | ...overrides, | ||
| 111 | }); | ||
| 112 | |||
| 113 | const blob = (key: string, overrides: Partial<BlobRow> = {}): BlobRow => ({ | ||
| 114 | id: `blob_${key}`, | ||
| 115 | namespace: "o:org_1/google", | ||
| 116 | key, | ||
| 117 | ...overrides, | ||
| 118 | }); | ||
| 119 | |||
| 120 | const policy = (pattern: string, overrides: Partial<ToolPolicyRow> = {}): ToolPolicyRow => ({ | ||
| 121 | tenant: "org_1", | ||
| 122 | owner: "org", | ||
| 123 | subject: "", | ||
| 124 | id: `pol_${pattern.replaceAll(".", "_").replaceAll("*", "star")}`, | ||
| 125 | pattern, | ||
| 126 | action: "block", | ||
| 127 | position: "a0", | ||
| 128 | created_at: now, | ||
| 129 | updated_at: now, | ||
| 130 | row_id: `row_${pattern}`, | ||
| 131 | ...overrides, | ||
| 132 | }); | ||
| 133 | |||
| 134 | const googleDiscoveryConfig = (urls: readonly string[], specHash = "mono-hash") => ({ | ||
| 135 | googleDiscoveryUrls: urls, | ||
| 136 | specHash, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const youtubeDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest"; | ||
| 140 | |||
| 141 | const googleCatalogMethodPrefixFixtures: ReadonlyMap<string, readonly string[]> = new Map([ | ||
| 142 | ["google-calendar", ["calendar.events.list"]], | ||
| 143 | ["google-gmail", ["gmail.users.messages.list"]], | ||
| 144 | ["google-sheets", ["sheets.spreadsheets.get"]], | ||
| 145 | ["google-drive", ["drive.files.list"]], | ||
| 146 | ["google-docs", ["docs.documents.get"]], | ||
| 147 | ["google-slides", ["slides.presentations.get"]], | ||
| 148 | ["google-forms", ["forms.forms.get"]], | ||
| 149 | ["google-tasks", ["tasks.tasks.list"]], | ||
| 150 | ["google-people", ["people.people.get"]], | ||
| 151 | ["google-photos-library", ["photoslibrary.albums.list"]], | ||
| 152 | ["google-photos-picker", ["photospicker.sessions.create"]], | ||
| 153 | ["google-chat", ["chat.spaces.list"]], | ||
| 154 | ["google-keep", ["keep.notes.list"]], | ||
| 155 | ["google-youtube-data", ["youtube.channels.list"]], | ||
| 156 | ["google-search-console", ["searchconsole.sites.list", "webmasters.sites.list"]], | ||
| 157 | ["google-classroom", ["classroom.courses.list"]], | ||
| 158 | [ | ||
| 159 | "google-admin-directory", | ||
| 160 | ["directory.users.list", "admin.customer.devices.chromeos.list", "admin.customers.get"], | ||
| 161 | ], | ||
| 162 | ["google-admin-reports", ["reports.activities.list", "admin.channels.stop"]], | ||
| 163 | ["google-apps-script", ["script.projects.get"]], | ||
| 164 | ["google-bigquery", ["bigquery.datasets.list"]], | ||
| 165 | ["google-cloud-resource-manager", ["cloudresourcemanager.projects.list"]], | ||
| 166 | ]); | ||
| 167 | |||
| 168 | const microsoftDefaultPresetIds = [ | ||
| 169 | "profile", | ||
| 170 | "mail", | ||
| 171 | "calendar", | ||
| 172 | "contacts", | ||
| 173 | "tasks", | ||
| 174 | "files", | ||
| 175 | "excel", | ||
| 176 | "sites", | ||
| 177 | "onenote", | ||
| 178 | "teams-chat", | ||
| 179 | "teams-channels", | ||
| 180 | "meetings-calls", | ||
| 181 | ] as const; | ||
| 182 | |||
| 183 | const microsoftFixtureTagForFirstSegment = (firstSegment: string): string => | ||
| 184 | firstSegment.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); | ||
| 185 | |||
| 186 | const microsoftToolFirstSegmentsFromSplitterFixture = () => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const paths = [...microsoftToolFirstSegmentPresetIds.keys()] | ||
| 189 | .map( | ||
| 190 | (firstSegment) => ` /fixture/${firstSegment}: | ||
| 191 | get: | ||
| 192 | tags: | ||
| 193 | - ${microsoftFixtureTagForFirstSegment(firstSegment)} | ||
| 194 | operationId: ${firstSegment}.Get | ||
| 195 | responses: | ||
| 196 | "200": | ||
| 197 | description: OK`, | ||
| 198 | ) | ||
| 199 | .join("\n"); | ||
| 200 | const structure = structuralSplit(`openapi: 3.0.4 | ||
| 201 | info: | ||
| 202 | title: Microsoft Graph Tool Ownership Fixture | ||
| 203 | version: v1.0 | ||
| 204 | paths: | ||
| 205 | ${paths} | ||
| 206 | components: {} | ||
| 207 | `); | ||
| 208 | expect(structure).not.toBeNull(); | ||
| 209 | const toolNames: string[] = []; | ||
| 210 | yield* streamOperationBindingsFromStructure(structure!, { chunkSize: 100 }, (chunk) => | ||
| 211 | Effect.sync(() => { | ||
| 212 | toolNames.push(...chunk.map((row) => row.toolName)); | ||
| 213 | }), | ||
| 214 | ); | ||
| 215 | return new Set(toolNames.map((toolName) => toolName.split(".")[0] ?? "")); | ||
| 216 | }); | ||
| 217 | |||
| 218 | const input = (overrides: Partial<MigrationInput> = {}): MigrationInput => ({ | ||
| 219 | integrations: [integration()], | ||
| 220 | connections: [connection()], | ||
| 221 | tools: [tool("calendar.events.list")], | ||
| 222 | pluginStorage: [operation("calendar.events.list")], | ||
| 223 | blobs: [blob("spec/mono-hash"), blob("defs/mono-hash")], | ||
| 224 | policies: [], | ||
| 225 | ...overrides, | ||
| 226 | }); | ||
| 227 | |||
| 228 | describe("provider service split migration planner", () => { | ||
| 229 | it("resolves Google reports, directory, and admin.channels tool prefixes", () => { | ||
| 230 | expect(googlePresetIdForTool("reports.activities.list")).toBe("google-admin-reports"); | ||
| 231 | expect(googlePresetIdForTool("directory.users.list")).toBe("google-admin-directory"); | ||
| 232 | expect(googlePresetIdForTool("admin.channels.stop")).toBe("google-admin-reports"); | ||
| 233 | expect(googlePresetIdForTool("admin.channels.stopPost0019dmcn")).toBe("google-admin-reports"); | ||
| 234 | expect(googlePresetIdForTool("admin.customers.chrome.printers.list")).toBe( | ||
| 235 | "google-admin-directory", | ||
| 236 | ); | ||
| 237 | expect(googlePresetIdForTool("admin.customer.devices.chromeos.list")).toBe( | ||
| 238 | "google-admin-directory", | ||
| 239 | ); | ||
| 240 | }); | ||
| 241 | |||
| 242 | it("keeps Google catalog method prefixes mapped to their owning presets", () => { | ||
| 243 | expect([...googleCatalogMethodPrefixFixtures.keys()]).toEqual( | ||
| 244 | googleCatalog.map((preset) => preset.id), | ||
| 245 | ); | ||
| 246 | for (const [presetId, methods] of googleCatalogMethodPrefixFixtures) { | ||
| 247 | for (const method of methods) { | ||
| 248 | expect(googlePresetIdForTool(method), method).toBe(presetId); | ||
| 249 | } | ||
| 250 | } | ||
| 251 | }); | ||
| 252 | |||
| 253 | it.effect("keeps Microsoft planner ownership table aligned with splitter tool names", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const splitterFirstSegments = yield* microsoftToolFirstSegmentsFromSplitterFixture(); | ||
| 256 | |||
| 257 | expect([...splitterFirstSegments].sort()).toEqual( | ||
| 258 | [...microsoftToolFirstSegmentPresetIds.keys()].sort(), | ||
| 259 | ); | ||
| 260 | for (const [firstSegment, presetIds] of microsoftToolFirstSegmentPresetIds) { | ||
| 261 | expect(microsoftPresetIdsForTool(`${firstSegment}.get`), firstSegment).toEqual(presetIds); | ||
| 262 | } | ||
| 263 | expect(microsoftPresetIdsForTool("drivesDriveItem.workbook.worksheets.list")).toEqual([ | ||
| 264 | "files", | ||
| 265 | "excel", | ||
| 266 | ]); | ||
| 267 | }), | ||
| 268 | ); | ||
| 269 | |||
| 270 | it("plans a single-service monolith split", () => { | ||
| 271 | const plan = planMigration(input()); | ||
| 272 | |||
| 273 | expect(plan.summary.integrationsCreate).toBe(1); | ||
| 274 | expect(plan.summary.connectionsClone).toBe(1); | ||
| 275 | expect(plan.orgs[0]?.integrations.map((row) => row.target.pluginId)).toEqual(["openapi"]); | ||
| 276 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual(["google_calendar"]); | ||
| 277 | expect(plan.orgs[0]?.deleteMonoliths.map((row) => row.slug)).toEqual(["google"]); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("plans a multi-service monolith split from stored Discovery URLs", () => { | ||
| 281 | const plan = planMigration( | ||
| 282 | input({ | ||
| 283 | integrations: [ | ||
| 284 | integration({ | ||
| 285 | config: { | ||
| 286 | googleDiscoveryUrls: [ | ||
| 287 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 288 | "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 289 | ], | ||
| 290 | specHash: "mono-hash", | ||
| 291 | }, | ||
| 292 | }), | ||
| 293 | ], | ||
| 294 | tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")], | ||
| 295 | pluginStorage: [ | ||
| 296 | operation("calendar.events.list"), | ||
| 297 | operation("gmail.users.messages.send"), | ||
| 298 | operation("oauth2.userinfo.get"), | ||
| 299 | ], | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 303 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([ | ||
| 304 | "google_calendar", | ||
| 305 | "google_gmail", | ||
| 306 | ]); | ||
| 307 | expect(plan.summary.connectionsClone).toBe(2); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it("models migrated integration serving storage under the new slug", () => { | ||
| 311 | const plan = planMigration(input()); | ||
| 312 | const planned = plan.orgs[0]?.integrations[0]; | ||
| 313 | |||
| 314 | expect(planned?.config).toEqual({ | ||
| 315 | specHash: "mono-hash", | ||
| 316 | sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 317 | specFormat: "google-discovery", | ||
| 318 | family: "google", | ||
| 319 | authenticationTemplate: [ | ||
| 320 | { | ||
| 321 | slug: "googleOAuth2", | ||
| 322 | kind: "oauth2", | ||
| 323 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 324 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 325 | scopes: expect.arrayContaining([ | ||
| 326 | "openid", | ||
| 327 | "email", | ||
| 328 | "profile", | ||
| 329 | "https://www.googleapis.com/auth/calendar", | ||
| 330 | ]), | ||
| 331 | }, | ||
| 332 | ], | ||
| 333 | }); | ||
| 334 | expect(planned?.healthCheck).toEqual({ | ||
| 335 | operation: "calendar.calendarList.list", | ||
| 336 | }); | ||
| 337 | expect(planned?.servingState).toMatchObject({ | ||
| 338 | specHash: "mono-hash", | ||
| 339 | specSource: "google/google", | ||
| 340 | specBlobPresent: true, | ||
| 341 | defsBlobPresent: true, | ||
| 342 | operationsToBuild: 1, | ||
| 343 | operationToolNames: ["calendar.events.list"], | ||
| 344 | }); | ||
| 345 | expect(plan.orgs[0]?.blobCopies).toEqual([ | ||
| 346 | expect.objectContaining({ | ||
| 347 | sourceNamespace: "o:org_1/google", | ||
| 348 | targetNamespace: "o:org_1/openapi", | ||
| 349 | key: "spec/mono-hash", | ||
| 350 | sourcePresent: true, | ||
| 351 | }), | ||
| 352 | expect.objectContaining({ | ||
| 353 | sourceNamespace: "o:org_1/google", | ||
| 354 | targetNamespace: "o:org_1/openapi", | ||
| 355 | key: "defs/mono-hash", | ||
| 356 | sourcePresent: true, | ||
| 357 | }), | ||
| 358 | ]); | ||
| 359 | expect(operationStorageKey("google_calendar", "calendar.events.list")).toMatch(/^op\./); | ||
| 360 | }); | ||
| 361 | |||
| 362 | it("keeps only the service discovery URL and drops the oauth2 document URL", () => { | ||
| 363 | const plan = planMigration( | ||
| 364 | input({ | ||
| 365 | integrations: [ | ||
| 366 | integration({ | ||
| 367 | config: { | ||
| 368 | googleDiscoveryUrls: [ | ||
| 369 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 370 | "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest", | ||
| 371 | ], | ||
| 372 | specHash: "mono-hash", | ||
| 373 | }, | ||
| 374 | }), | ||
| 375 | ], | ||
| 376 | pluginStorage: [operation("calendar.events.list"), operation("oauth2.userinfo.get")], | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 380 | const planned = plan.orgs[0]?.integrations[0]; | ||
| 381 | |||
| 382 | expect(planned?.config).toMatchObject({ | ||
| 383 | sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 384 | specFormat: "google-discovery", | ||
| 385 | family: "google", | ||
| 386 | }); | ||
| 387 | expect( | ||
| 388 | (planned?.config as { readonly sourceUrl?: string } | undefined)?.sourceUrl, | ||
| 389 | ).not.toContain("oauth2"); | ||
| 390 | expect(planned?.servingState.operationToolNames).toEqual(["calendar.events.list"]); | ||
| 391 | }); | ||
| 392 | |||
| 393 | it("fans out wildcard policies by matched service without widening inventory matches", () => { | ||
| 394 | const migrationInput = input({ | ||
| 395 | integrations: [ | ||
| 396 | integration({ | ||
| 397 | config: { | ||
| 398 | googleDiscoveryUrls: [ | ||
| 399 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 400 | "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 401 | ], | ||
| 402 | specHash: "mono-hash", | ||
| 403 | }, | ||
| 404 | }), | ||
| 405 | ], | ||
| 406 | tools: [tool("calendar.events.delete"), tool("gmail.users.messages.delete")], | ||
| 407 | pluginStorage: [ | ||
| 408 | operation("calendar.events.delete"), | ||
| 409 | operation("gmail.users.messages.delete"), | ||
| 410 | ], | ||
| 411 | policies: [policy("google.*")], | ||
| 412 | }); | ||
| 413 | const plan = planMigration(migrationInput); | ||
| 414 | |||
| 415 | expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([ | ||
| 416 | "google_calendar.*", | ||
| 417 | "google_gmail.*", | ||
| 418 | ]); | ||
| 419 | expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({ | ||
| 420 | ok: true, | ||
| 421 | checkedPolicies: 1, | ||
| 422 | }); | ||
| 423 | }); | ||
| 424 | |||
| 425 | it("fans restrictive wildcard policies out to every monolith service, not only currently matching tools", () => { | ||
| 426 | const migrationInput = input({ | ||
| 427 | integrations: [ | ||
| 428 | integration({ | ||
| 429 | config: { | ||
| 430 | googleDiscoveryUrls: [ | ||
| 431 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 432 | "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 433 | "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 434 | ], | ||
| 435 | specHash: "mono-hash", | ||
| 436 | }, | ||
| 437 | }), | ||
| 438 | ], | ||
| 439 | tools: [tool("calendar.events.update")], | ||
| 440 | pluginStorage: [ | ||
| 441 | operation("calendar.events.update"), | ||
| 442 | operation("gmail.users.messages.update"), | ||
| 443 | ], | ||
| 444 | policies: [policy("google.*.*.*.*.update")], | ||
| 445 | }); | ||
| 446 | const plan = planMigration(migrationInput); | ||
| 447 | |||
| 448 | expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([ | ||
| 449 | "google_calendar.*.*.*.*.update", | ||
| 450 | "google_gmail.*.*.*.*.update", | ||
| 451 | "google_sheets.*.*.*.*.update", | ||
| 452 | ]); | ||
| 453 | expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({ | ||
| 454 | ok: true, | ||
| 455 | checkedPolicies: 1, | ||
| 456 | narrowed: [], | ||
| 457 | }); | ||
| 458 | }); | ||
| 459 | |||
| 460 | it("hard-errors when a policy would be dropped instead of silently skipped", () => { | ||
| 461 | expect(() => | ||
| 462 | planMigration( | ||
| 463 | input({ | ||
| 464 | integrations: [ | ||
| 465 | integration({ | ||
| 466 | config: { | ||
| 467 | googleDiscoveryUrls: [ | ||
| 468 | "https://www.googleapis.com/discovery/v1/apis/searchconsole/v1/rest", | ||
| 469 | ], | ||
| 470 | specHash: "mono-hash", | ||
| 471 | }, | ||
| 472 | }), | ||
| 473 | ], | ||
| 474 | tools: [tool("searchconsole.sites.list")], | ||
| 475 | pluginStorage: [operation("searchconsole.sites.list")], | ||
| 476 | policies: [policy("google.*.*.gmail.users.messages.send")], | ||
| 477 | }), | ||
| 478 | ), | ||
| 479 | ).toThrow(/would be dropped/); | ||
| 480 | }); | ||
| 481 | |||
| 482 | it("hard-errors when config omits a tool-implied service", () => { | ||
| 483 | expect(() => | ||
| 484 | planMigration( | ||
| 485 | input({ | ||
| 486 | tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")], | ||
| 487 | pluginStorage: [ | ||
| 488 | operation("calendar.events.list"), | ||
| 489 | operation("gmail.users.messages.send"), | ||
| 490 | ], | ||
| 491 | }), | ||
| 492 | ), | ||
| 493 | ).toThrow(/config omits tool-implied service preset\(s\): google-gmail/); | ||
| 494 | }); | ||
| 495 | |||
| 496 | it("boot-rail mode creates a catalog-backed tool-implied service", () => { | ||
| 497 | const plan = planMigration( | ||
| 498 | input({ | ||
| 499 | tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")], | ||
| 500 | pluginStorage: [operation("calendar.events.list"), operation("gmail.users.messages.send")], | ||
| 501 | bootRailCreateToolImpliedServices: true, | ||
| 502 | }), | ||
| 503 | ); | ||
| 504 | |||
| 505 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([ | ||
| 506 | "google_calendar", | ||
| 507 | "google_gmail", | ||
| 508 | ]); | ||
| 509 | }); | ||
| 510 | |||
| 511 | it("records a hard error when a source blob is missing", () => { | ||
| 512 | const plan = planMigration( | ||
| 513 | input({ blobs: [blob("spec/mono-hash")], collectPolicyErrors: true }), | ||
| 514 | ); | ||
| 515 | |||
| 516 | expect(plan.orgs[0]?.hardErrors).toEqual([ | ||
| 517 | "Missing source blob o:org_1/google/defs/mono-hash for migrated OpenAPI service namespace o:org_1/openapi", | ||
| 518 | ]); | ||
| 519 | expect(plan.summary.hardErrorOrgs).toBe(1); | ||
| 520 | }); | ||
| 521 | |||
| 522 | it("retargets orphan block policies in boot-rail mode", () => { | ||
| 523 | const plan = planMigration( | ||
| 524 | input({ | ||
| 525 | integrations: [ | ||
| 526 | integration({ | ||
| 527 | config: { | ||
| 528 | googleDiscoveryUrls: [ | ||
| 529 | "https://www.googleapis.com/discovery/v1/apis/searchconsole/v1/rest", | ||
| 530 | ], | ||
| 531 | specHash: "mono-hash", | ||
| 532 | }, | ||
| 533 | }), | ||
| 534 | ], | ||
| 535 | tools: [tool("searchconsole.sites.list")], | ||
| 536 | pluginStorage: [operation("searchconsole.sites.list")], | ||
| 537 | policies: [policy("google.*.*.gmail.users.messages.send")], | ||
| 538 | orphanPolicyMode: "retarget_all", | ||
| 539 | }), | ||
| 540 | ); | ||
| 541 | |||
| 542 | expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([ | ||
| 543 | "google_search_console.*.*.gmail.users.messages.send", | ||
| 544 | ]); | ||
| 545 | }); | ||
| 546 | |||
| 547 | it("skips openapi lookalike google slugs because plugin ownership is not google", () => { | ||
| 548 | const plan = planMigration( | ||
| 549 | input({ | ||
| 550 | integrations: [integration({ plugin_id: "openapi", slug: "google" })], | ||
| 551 | tools: [tool("calendar.events.list", { plugin_id: "openapi" })], | ||
| 552 | policies: [policy("google.*")], | ||
| 553 | }), | ||
| 554 | ); | ||
| 555 | |||
| 556 | expect(plan.summary.orgs).toBe(0); | ||
| 557 | expect(plan.summary.policiesRewrite).toBe(0); | ||
| 558 | }); | ||
| 559 | |||
| 560 | it("includes non-canonical provider slugs and fans out their policies", () => { | ||
| 561 | const migrationInput = input({ | ||
| 562 | integrations: [ | ||
| 563 | integration({ | ||
| 564 | slug: "google_photos_youtube", | ||
| 565 | config: googleDiscoveryConfig([youtubeDiscoveryUrl]), | ||
| 566 | }), | ||
| 567 | ], | ||
| 568 | connections: [connection({ integration: "google_photos_youtube" })], | ||
| 569 | tools: [tool("youtube.channels.list", { integration: "google_photos_youtube" })], | ||
| 570 | pluginStorage: [ | ||
| 571 | operation("youtube.channels.list", { | ||
| 572 | key: operationStorageKey("google_photos_youtube", "youtube.channels.list"), | ||
| 573 | data: { | ||
| 574 | integration: "google_photos_youtube", | ||
| 575 | toolName: "youtube.channels.list", | ||
| 576 | }, | ||
| 577 | }), | ||
| 578 | ], | ||
| 579 | policies: [policy("google_photos_youtube.*.*.youtube.*")], | ||
| 580 | }); | ||
| 581 | const plan = planMigration(migrationInput); | ||
| 582 | |||
| 583 | expect(plan.summary.orgs).toBe(1); | ||
| 584 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([ | ||
| 585 | "google_youtube_data", | ||
| 586 | ]); | ||
| 587 | expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual(["google_youtube_data.*.*.youtube.*"]); | ||
| 588 | expect(verifyPolicyRewriteNeverWidens(plan, migrationInput).ok).toBe(true); | ||
| 589 | }); | ||
| 590 | |||
| 591 | it("merges same-tenant monoliths that derive the same target service", () => { | ||
| 592 | const migrationInput = input({ | ||
| 593 | integrations: [ | ||
| 594 | integration({ | ||
| 595 | slug: "google", | ||
| 596 | config: googleDiscoveryConfig([youtubeDiscoveryUrl], "google-hash"), | ||
| 597 | }), | ||
| 598 | integration({ | ||
| 599 | slug: "google_photos_youtube", | ||
| 600 | name: "Google Photos YouTube", | ||
| 601 | config: googleDiscoveryConfig([youtubeDiscoveryUrl], "photos-hash"), | ||
| 602 | row_id: "int_google_photos_youtube", | ||
| 603 | }), | ||
| 604 | ], | ||
| 605 | connections: [ | ||
| 606 | connection({ integration: "google", row_id: "conn_google" }), | ||
| 607 | connection({ | ||
| 608 | integration: "google_photos_youtube", | ||
| 609 | row_id: "conn_google_photos_youtube", | ||
| 610 | }), | ||
| 611 | ], | ||
| 612 | tools: [ | ||
| 613 | tool("youtube.channels.list", { integration: "google" }), | ||
| 614 | tool("youtube.videos.list", { integration: "google" }), | ||
| 615 | tool("youtube.channels.list", { | ||
| 616 | integration: "google_photos_youtube", | ||
| 617 | row_id: "tool_photos_youtube_channels_list", | ||
| 618 | }), | ||
| 619 | ], | ||
| 620 | pluginStorage: [ | ||
| 621 | operation("youtube.channels.list", { | ||
| 622 | key: operationStorageKey("google", "youtube.channels.list"), | ||
| 623 | data: { integration: "google", toolName: "youtube.channels.list" }, | ||
| 624 | }), | ||
| 625 | operation("youtube.videos.list", { | ||
| 626 | key: operationStorageKey("google", "youtube.videos.list"), | ||
| 627 | data: { integration: "google", toolName: "youtube.videos.list" }, | ||
| 628 | }), | ||
| 629 | operation("youtube.channels.list", { | ||
| 630 | key: operationStorageKey("google_photos_youtube", "youtube.channels.list"), | ||
| 631 | data: { | ||
| 632 | integration: "google_photos_youtube", | ||
| 633 | toolName: "youtube.channels.list", | ||
| 634 | }, | ||
| 635 | row_id: "op_photos_youtube_channels_list", | ||
| 636 | }), | ||
| 637 | ], | ||
| 638 | blobs: [ | ||
| 639 | blob("spec/google-hash"), | ||
| 640 | blob("defs/google-hash"), | ||
| 641 | blob("spec/photos-hash"), | ||
| 642 | blob("defs/photos-hash"), | ||
| 643 | ], | ||
| 644 | policies: [policy("google.*"), policy("google_photos_youtube.*", { id: "pol_photos" })], | ||
| 645 | }); | ||
| 646 | const plan = planMigration(migrationInput); | ||
| 647 | const org = plan.orgs[0]; | ||
| 648 | const planned = org?.integrations[0]; | ||
| 649 | |||
| 650 | expect(org?.integrations).toHaveLength(1); | ||
| 651 | expect(planned?.target.slug).toBe("google_youtube_data"); | ||
| 652 | expect(planned?.servingState.specHash).toBe("google-hash"); | ||
| 653 | expect(planned?.sourceContributions.map((item) => item.source.slug)).toEqual([ | ||
| 654 | "google", | ||
| 655 | "google_photos_youtube", | ||
| 656 | ]); | ||
| 657 | expect(planned?.sourceContributions.map((item) => item.operationsToBuild)).toEqual([2, 1]); | ||
| 658 | expect(org?.connections.map((row) => [row.source.integration, row.action])).toEqual([ | ||
| 659 | ["google", "clone"], | ||
| 660 | ["google_photos_youtube", "skip_existing"], | ||
| 661 | ]); | ||
| 662 | expect(org?.policies.map((row) => row.afterPatterns)).toEqual([ | ||
| 663 | ["google_youtube_data.*"], | ||
| 664 | ["google_youtube_data.*"], | ||
| 665 | ]); | ||
| 666 | expect(org?.blobCopies.map((row) => row.key)).toEqual([ | ||
| 667 | "spec/google-hash", | ||
| 668 | "defs/google-hash", | ||
| 669 | "spec/photos-hash", | ||
| 670 | "defs/photos-hash", | ||
| 671 | ]); | ||
| 672 | expect(plan.summary.integrationsCreate).toBe(1); | ||
| 673 | expect(plan.summary.operationsToBuild).toBe(3); | ||
| 674 | }); | ||
| 675 | |||
| 676 | it("deletes config-only monoliths without creating specHash-less service rows", () => { | ||
| 677 | const plan = planMigration( | ||
| 678 | input({ | ||
| 679 | integrations: [ | ||
| 680 | integration({ | ||
| 681 | slug: "google_mark_life", | ||
| 682 | config: { | ||
| 683 | googleDiscoveryUrls: [ | ||
| 684 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 685 | "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 686 | ], | ||
| 687 | }, | ||
| 688 | }), | ||
| 689 | ], | ||
| 690 | connections: [], | ||
| 691 | tools: [], | ||
| 692 | pluginStorage: [], | ||
| 693 | blobs: [], | ||
| 694 | }), | ||
| 695 | ); | ||
| 696 | |||
| 697 | expect(plan.orgs[0]?.integrations).toEqual([]); | ||
| 698 | expect(plan.orgs[0]?.blobCopies).toEqual([]); | ||
| 699 | expect(plan.orgs[0]?.deleteMonoliths.map((row) => row.slug)).toEqual(["google_mark_life"]); | ||
| 700 | expect(plan.summary.monolithDeletes).toBe(1); | ||
| 701 | expect(plan.summary.integrationsCreate).toBe(0); | ||
| 702 | }); | ||
| 703 | |||
| 704 | it("reads microsoft preset ids from the legacy scope preset config key", () => { | ||
| 705 | const plan = planMigration( | ||
| 706 | input({ | ||
| 707 | integrations: [ | ||
| 708 | integration({ | ||
| 709 | plugin_id: "microsoft", | ||
| 710 | slug: "microsoft_graph_api", | ||
| 711 | name: "Microsoft Graph API", | ||
| 712 | config: { | ||
| 713 | microsoftGraphScopePresetIds: ["mail"], | ||
| 714 | specHash: "graph-hash", | ||
| 715 | }, | ||
| 716 | }), | ||
| 717 | ], | ||
| 718 | connections: [ | ||
| 719 | connection({ | ||
| 720 | integration: "microsoft_graph_api", | ||
| 721 | template: "microsoftOAuth", | ||
| 722 | oauth_client: "microsoft", | ||
| 723 | }), | ||
| 724 | ], | ||
| 725 | tools: [ | ||
| 726 | tool("meMessagesList", { | ||
| 727 | integration: "microsoft_graph_api", | ||
| 728 | plugin_id: "microsoft", | ||
| 729 | }), | ||
| 730 | ], | ||
| 731 | pluginStorage: [ | ||
| 732 | operation("meMessagesList", { | ||
| 733 | plugin_id: "microsoft", | ||
| 734 | key: operationStorageKey("microsoft_graph_api", "meMessagesList"), | ||
| 735 | data: { | ||
| 736 | integration: "microsoft_graph_api", | ||
| 737 | toolName: "meMessagesList", | ||
| 738 | }, | ||
| 739 | }), | ||
| 740 | ], | ||
| 741 | blobs: [ | ||
| 742 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 743 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 744 | ], | ||
| 745 | }), | ||
| 746 | ); | ||
| 747 | |||
| 748 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual(["microsoft_mail"]); | ||
| 749 | }); | ||
| 750 | |||
| 751 | it("plans Microsoft workbook operations into files and excel to match fresh adds", () => { | ||
| 752 | const migrationInput = input({ | ||
| 753 | integrations: [ | ||
| 754 | integration({ | ||
| 755 | plugin_id: "microsoft", | ||
| 756 | slug: "microsoft_graph_api", | ||
| 757 | name: "Microsoft Graph API", | ||
| 758 | config: { | ||
| 759 | microsoftGraphPresetIds: ["files", "excel"], | ||
| 760 | specHash: "graph-hash", | ||
| 761 | }, | ||
| 762 | }), | ||
| 763 | ], | ||
| 764 | connections: [ | ||
| 765 | connection({ | ||
| 766 | integration: "microsoft_graph_api", | ||
| 767 | template: "microsoftOAuth", | ||
| 768 | oauth_client: "microsoft", | ||
| 769 | }), | ||
| 770 | ], | ||
| 771 | tools: [ | ||
| 772 | tool("drivesDriveItem.get", { | ||
| 773 | integration: "microsoft_graph_api", | ||
| 774 | plugin_id: "microsoft", | ||
| 775 | }), | ||
| 776 | tool("drivesDriveItem.workbook.worksheets.list", { | ||
| 777 | integration: "microsoft_graph_api", | ||
| 778 | plugin_id: "microsoft", | ||
| 779 | }), | ||
| 780 | ], | ||
| 781 | pluginStorage: [ | ||
| 782 | operation("drivesDriveItem.get", { | ||
| 783 | plugin_id: "microsoft", | ||
| 784 | key: operationStorageKey("microsoft_graph_api", "drivesDriveItem.get"), | ||
| 785 | data: { | ||
| 786 | integration: "microsoft_graph_api", | ||
| 787 | toolName: "drivesDriveItem.get", | ||
| 788 | }, | ||
| 789 | }), | ||
| 790 | operation("drivesDriveItem.workbook.worksheets.list", { | ||
| 791 | plugin_id: "microsoft", | ||
| 792 | key: operationStorageKey( | ||
| 793 | "microsoft_graph_api", | ||
| 794 | "drivesDriveItem.workbook.worksheets.list", | ||
| 795 | ), | ||
| 796 | data: { | ||
| 797 | integration: "microsoft_graph_api", | ||
| 798 | toolName: "drivesDriveItem.workbook.worksheets.list", | ||
| 799 | }, | ||
| 800 | }), | ||
| 801 | ], | ||
| 802 | blobs: [ | ||
| 803 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 804 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 805 | ], | ||
| 806 | }); | ||
| 807 | const plan = planMigration(migrationInput); | ||
| 808 | const bySlug = new Map(plan.orgs[0]?.integrations.map((row) => [row.target.slug, row])); | ||
| 809 | |||
| 810 | expect(bySlug.get("microsoft_files")?.servingState.operationToolNames).toEqual([ | ||
| 811 | "drivesDriveItem.get", | ||
| 812 | "drivesDriveItem.workbook.worksheets.list", | ||
| 813 | ]); | ||
| 814 | expect(bySlug.get("microsoft_excel")?.servingState.operationToolNames).toEqual([ | ||
| 815 | "drivesDriveItem.workbook.worksheets.list", | ||
| 816 | ]); | ||
| 817 | expect(bySlug.get("microsoft_excel")?.servingState.operationsToBuild).toBe(1); | ||
| 818 | expect(plan.summary.hardErrorOrgs).toBe(0); | ||
| 819 | }); | ||
| 820 | |||
| 821 | it("maps Microsoft me-surface tools to only configured specific presets", () => { | ||
| 822 | const toolNames = [ | ||
| 823 | "meInferenceClassification", | ||
| 824 | "meMailboxSettings", | ||
| 825 | "meOutlookUser", | ||
| 826 | "mePerson", | ||
| 827 | "meSite", | ||
| 828 | "meDrive", | ||
| 829 | "meTeam", | ||
| 830 | "meUser", | ||
| 831 | "meUserActions", | ||
| 832 | "meUserFunctions", | ||
| 833 | "meChat", | ||
| 834 | "meOnlineMeeting", | ||
| 835 | ]; | ||
| 836 | const migrationInput = input({ | ||
| 837 | integrations: [ | ||
| 838 | integration({ | ||
| 839 | plugin_id: "microsoft", | ||
| 840 | slug: "microsoft_graph_api", | ||
| 841 | name: "Microsoft Graph API", | ||
| 842 | config: { | ||
| 843 | microsoftGraphPresetIds: microsoftDefaultPresetIds, | ||
| 844 | specHash: "graph-hash", | ||
| 845 | }, | ||
| 846 | }), | ||
| 847 | ], | ||
| 848 | connections: [ | ||
| 849 | connection({ | ||
| 850 | integration: "microsoft_graph_api", | ||
| 851 | template: "microsoftOAuth", | ||
| 852 | oauth_client: "microsoft", | ||
| 853 | }), | ||
| 854 | ], | ||
| 855 | tools: toolNames.map((name) => | ||
| 856 | tool(name, { | ||
| 857 | integration: "microsoft_graph_api", | ||
| 858 | plugin_id: "microsoft", | ||
| 859 | }), | ||
| 860 | ), | ||
| 861 | pluginStorage: toolNames.map((name) => | ||
| 862 | operation(name, { | ||
| 863 | plugin_id: "microsoft", | ||
| 864 | key: operationStorageKey("microsoft_graph_api", name), | ||
| 865 | data: { | ||
| 866 | integration: "microsoft_graph_api", | ||
| 867 | toolName: name, | ||
| 868 | }, | ||
| 869 | }), | ||
| 870 | ), | ||
| 871 | blobs: [ | ||
| 872 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 873 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 874 | ], | ||
| 875 | }); | ||
| 876 | const plan = planMigration(migrationInput); | ||
| 877 | const bySlug = new Map(plan.orgs[0]?.integrations.map((row) => [row.target.slug, row])); | ||
| 878 | |||
| 879 | expect(microsoftPresetIdsForTool("meInferenceClassification")).toEqual(["mail"]); | ||
| 880 | expect(microsoftPresetIdsForTool("meMailboxSettings")).toEqual(["mail"]); | ||
| 881 | expect(microsoftPresetIdsForTool("meOutlookUser")).toEqual(["mail"]); | ||
| 882 | expect(microsoftPresetIdsForTool("mePerson")).toEqual(["contacts"]); | ||
| 883 | expect(microsoftPresetIdsForTool("meSite")).toEqual(["files"]); | ||
| 884 | expect(microsoftPresetIdsForTool("meDrive")).toEqual(["files"]); | ||
| 885 | expect(microsoftPresetIdsForTool("meTeam")).toEqual(["teams-channels"]); | ||
| 886 | expect(microsoftPresetIdsForTool("meUser")).toEqual(["profile"]); | ||
| 887 | expect(microsoftPresetIdsForTool("meUserActions")).toEqual(["profile"]); | ||
| 888 | expect(microsoftPresetIdsForTool("meUserFunctions")).toEqual(["profile"]); | ||
| 889 | expect(microsoftPresetIdsForTool("meChat")).toEqual(["teams-chat"]); | ||
| 890 | expect(microsoftPresetIdsForTool("meOnlineMeeting")).toEqual(["meetings-calls"]); | ||
| 891 | expect(bySlug.get("microsoft_mail")?.servingState.operationToolNames).toEqual([ | ||
| 892 | "meInferenceClassification", | ||
| 893 | "meMailboxSettings", | ||
| 894 | "meOutlookUser", | ||
| 895 | ]); | ||
| 896 | expect(bySlug.get("microsoft_contacts")?.servingState.operationToolNames).toEqual(["mePerson"]); | ||
| 897 | expect(bySlug.get("microsoft_files")?.servingState.operationToolNames).toEqual([ | ||
| 898 | "meSite", | ||
| 899 | "meDrive", | ||
| 900 | ]); | ||
| 901 | expect(bySlug.get("microsoft_teams_channels")?.servingState.operationToolNames).toEqual([ | ||
| 902 | "meTeam", | ||
| 903 | ]); | ||
| 904 | expect(bySlug.get("microsoft_profile")?.servingState.operationToolNames).toEqual([ | ||
| 905 | "meUser", | ||
| 906 | "meUserActions", | ||
| 907 | "meUserFunctions", | ||
| 908 | ]); | ||
| 909 | expect(bySlug.get("microsoft_teams_chat")?.servingState.operationToolNames).toEqual(["meChat"]); | ||
| 910 | expect(bySlug.get("microsoft_meetings_calls")?.servingState.operationToolNames).toEqual([ | ||
| 911 | "meOnlineMeeting", | ||
| 912 | ]); | ||
| 913 | expect(plan.summary.hardErrorOrgs).toBe(0); | ||
| 914 | }); | ||
| 915 | |||
| 916 | it("hard-errors when a Microsoft operation row maps to no target service", () => { | ||
| 917 | expect(() => | ||
| 918 | planMigration( | ||
| 919 | input({ | ||
| 920 | integrations: [ | ||
| 921 | integration({ | ||
| 922 | plugin_id: "microsoft", | ||
| 923 | slug: "microsoft_graph_api", | ||
| 924 | name: "Microsoft Graph API", | ||
| 925 | config: { | ||
| 926 | microsoftGraphPresetIds: ["mail"], | ||
| 927 | specHash: "graph-hash", | ||
| 928 | }, | ||
| 929 | }), | ||
| 930 | ], | ||
| 931 | connections: [ | ||
| 932 | connection({ | ||
| 933 | integration: "microsoft_graph_api", | ||
| 934 | template: "microsoftOAuth", | ||
| 935 | oauth_client: "microsoft", | ||
| 936 | }), | ||
| 937 | ], | ||
| 938 | tools: [ | ||
| 939 | tool("meMessagesList", { | ||
| 940 | integration: "microsoft_graph_api", | ||
| 941 | plugin_id: "microsoft", | ||
| 942 | }), | ||
| 943 | ], | ||
| 944 | pluginStorage: [ | ||
| 945 | operation("unknownGraphThing", { | ||
| 946 | plugin_id: "microsoft", | ||
| 947 | key: operationStorageKey("microsoft_graph_api", "unknownGraphThing"), | ||
| 948 | data: { | ||
| 949 | integration: "microsoft_graph_api", | ||
| 950 | toolName: "unknownGraphThing", | ||
| 951 | }, | ||
| 952 | }), | ||
| 953 | ], | ||
| 954 | blobs: [ | ||
| 955 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 956 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 957 | ], | ||
| 958 | }), | ||
| 959 | ), | ||
| 960 | ).toThrow(/operation row\(s\) with no target service: unknownGraphThing/); | ||
| 961 | }); | ||
| 962 | |||
| 963 | it("hard-errors when a Microsoft tool row maps to no target service", () => { | ||
| 964 | expect(() => | ||
| 965 | planMigration( | ||
| 966 | input({ | ||
| 967 | integrations: [ | ||
| 968 | integration({ | ||
| 969 | plugin_id: "microsoft", | ||
| 970 | slug: "microsoft_graph_api", | ||
| 971 | name: "Microsoft Graph API", | ||
| 972 | config: { | ||
| 973 | microsoftGraphPresetIds: ["mail"], | ||
| 974 | specHash: "graph-hash", | ||
| 975 | }, | ||
| 976 | }), | ||
| 977 | ], | ||
| 978 | connections: [ | ||
| 979 | connection({ | ||
| 980 | integration: "microsoft_graph_api", | ||
| 981 | template: "microsoftOAuth", | ||
| 982 | oauth_client: "microsoft", | ||
| 983 | }), | ||
| 984 | ], | ||
| 985 | tools: [ | ||
| 986 | tool("unknownGraphThing", { | ||
| 987 | integration: "microsoft_graph_api", | ||
| 988 | plugin_id: "microsoft", | ||
| 989 | }), | ||
| 990 | ], | ||
| 991 | pluginStorage: [], | ||
| 992 | blobs: [ | ||
| 993 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 994 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 995 | ], | ||
| 996 | }), | ||
| 997 | ), | ||
| 998 | ).toThrow(/tool row\(s\) with no target service: unknownGraphThing/); | ||
| 999 | }); | ||
| 1000 | |||
| 1001 | it("hard-errors when Microsoft config lists an unmigratable umbrella preset", () => { | ||
| 1002 | expect(() => | ||
| 1003 | planMigration( | ||
| 1004 | input({ | ||
| 1005 | integrations: [ | ||
| 1006 | integration({ | ||
| 1007 | plugin_id: "microsoft", | ||
| 1008 | slug: "microsoft_graph_api", | ||
| 1009 | name: "Microsoft Graph API", | ||
| 1010 | config: { | ||
| 1011 | microsoftGraphPresetIds: ["me-surface"], | ||
| 1012 | specHash: "graph-hash", | ||
| 1013 | }, | ||
| 1014 | }), | ||
| 1015 | ], | ||
| 1016 | connections: [], | ||
| 1017 | tools: [], | ||
| 1018 | pluginStorage: [], | ||
| 1019 | }), | ||
| 1020 | ), | ||
| 1021 | ).toThrow(/unmigratable-umbrella Microsoft Graph preset\(s\): me-surface/); | ||
| 1022 | }); | ||
| 1023 | |||
| 1024 | it("fans Microsoft workbook policies to files and excel without widening", () => { | ||
| 1025 | const migrationInput = input({ | ||
| 1026 | integrations: [ | ||
| 1027 | integration({ | ||
| 1028 | plugin_id: "microsoft", | ||
| 1029 | slug: "microsoft_graph_api", | ||
| 1030 | name: "Microsoft Graph API", | ||
| 1031 | config: { | ||
| 1032 | microsoftGraphPresetIds: ["files", "excel"], | ||
| 1033 | specHash: "graph-hash", | ||
| 1034 | }, | ||
| 1035 | }), | ||
| 1036 | ], | ||
| 1037 | connections: [ | ||
| 1038 | connection({ | ||
| 1039 | integration: "microsoft_graph_api", | ||
| 1040 | template: "microsoftOAuth", | ||
| 1041 | oauth_client: "microsoft", | ||
| 1042 | }), | ||
| 1043 | ], | ||
| 1044 | tools: [ | ||
| 1045 | tool("drivesDriveItem.workbook.worksheets.list", { | ||
| 1046 | integration: "microsoft_graph_api", | ||
| 1047 | plugin_id: "microsoft", | ||
| 1048 | }), | ||
| 1049 | ], | ||
| 1050 | pluginStorage: [ | ||
| 1051 | operation("drivesDriveItem.workbook.worksheets.list", { | ||
| 1052 | plugin_id: "microsoft", | ||
| 1053 | key: operationStorageKey( | ||
| 1054 | "microsoft_graph_api", | ||
| 1055 | "drivesDriveItem.workbook.worksheets.list", | ||
| 1056 | ), | ||
| 1057 | data: { | ||
| 1058 | integration: "microsoft_graph_api", | ||
| 1059 | toolName: "drivesDriveItem.workbook.worksheets.list", | ||
| 1060 | }, | ||
| 1061 | }), | ||
| 1062 | ], | ||
| 1063 | blobs: [ | ||
| 1064 | blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 1065 | blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }), | ||
| 1066 | ], | ||
| 1067 | policies: [policy("microsoft_graph_api.*.*.drivesDriveItem.*workbook*")], | ||
| 1068 | }); | ||
| 1069 | const plan = planMigration(migrationInput); | ||
| 1070 | |||
| 1071 | expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([ | ||
| 1072 | "microsoft_files.*.*.drivesDriveItem.*workbook*", | ||
| 1073 | "microsoft_excel.*.*.drivesDriveItem.*workbook*", | ||
| 1074 | ]); | ||
| 1075 | expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({ | ||
| 1076 | ok: true, | ||
| 1077 | checkedPolicies: 1, | ||
| 1078 | widened: [], | ||
| 1079 | narrowed: [], | ||
| 1080 | }); | ||
| 1081 | }); | ||
| 1082 | |||
| 1083 | it("is idempotent when target integration and connection already exist", () => { | ||
| 1084 | const plan = planMigration( | ||
| 1085 | input({ | ||
| 1086 | integrations: [ | ||
| 1087 | integration(), | ||
| 1088 | integration({ | ||
| 1089 | slug: "google_calendar", | ||
| 1090 | plugin_id: "openapi", | ||
| 1091 | name: "Google Calendar", | ||
| 1092 | row_id: "int_calendar", | ||
| 1093 | }), | ||
| 1094 | ], | ||
| 1095 | connections: [ | ||
| 1096 | connection(), | ||
| 1097 | connection({ | ||
| 1098 | integration: "google_calendar", | ||
| 1099 | row_id: "conn_calendar", | ||
| 1100 | }), | ||
| 1101 | ], | ||
| 1102 | }), | ||
| 1103 | ); | ||
| 1104 | |||
| 1105 | expect(plan.summary.integrationsCreate).toBe(0); | ||
| 1106 | expect(plan.summary.integrationsSkipExisting).toBe(1); | ||
| 1107 | expect(plan.summary.connectionsClone).toBe(0); | ||
| 1108 | expect(plan.summary.connectionsSkipExisting).toBe(1); | ||
| 1109 | }); | ||
| 1110 | |||
| 1111 | it("skips completed orgs for resume-after-partial", () => { | ||
| 1112 | const plan = planMigration(input({ completedTenants: ["org_1"] })); | ||
| 1113 | |||
| 1114 | expect(plan.orgs[0]?.completed).toBe(true); | ||
| 1115 | expect(plan.summary.completedOrgs).toBe(1); | ||
| 1116 | expect(plan.summary.integrationsCreate).toBe(0); | ||
| 1117 | expect(plan.summary.monolithDeletes).toBe(0); | ||
| 1118 | }); | ||
| 1119 | }); | ||
| @@ -0,0 +1,1335 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-json-parse -- boundary: one-shot provider service split planner preserves the throwing dry-run contract used by migration tooling */ | ||
| 2 | import { createHash } from "node:crypto"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | googleCatalog, | ||
| 6 | googlePresetForDiscoveryUrl, | ||
| 7 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 8 | import { | ||
| 9 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 10 | microsoftCatalog, | ||
| 11 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 12 | import type { OpenApiPreset } from "@executor-js/plugin-openapi/presets"; | ||
| 13 | |||
| 14 | type MonolithPluginId = "google" | "microsoft"; | ||
| 15 | |||
| 16 | const normalizeUrl = (url: string): string => { | ||
| 17 | const trimmed = url.trim(); | ||
| 18 | if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); | ||
| 19 | const parsed = new URL(trimmed); | ||
| 20 | parsed.hash = ""; | ||
| 21 | parsed.searchParams.sort(); | ||
| 22 | return parsed.toString().replace(/\/$/, ""); | ||
| 23 | }; | ||
| 24 | |||
| 25 | const googleCatalogByDiscoveryUrl = new Map( | ||
| 26 | googleCatalog.flatMap((preset) => | ||
| 27 | preset.url ? [[normalizeUrl(preset.url), preset] as const] : [], | ||
| 28 | ), | ||
| 29 | ); | ||
| 30 | |||
| 31 | const matchPattern = (pattern: string, toolId: string): boolean => { | ||
| 32 | if (pattern === "*") return true; | ||
| 33 | const patternSegments = pattern.split("."); | ||
| 34 | const toolSegments = toolId.split("."); | ||
| 35 | for (let index = 0; index < patternSegments.length; index += 1) { | ||
| 36 | const segment = patternSegments[index]!; | ||
| 37 | if (segment === "*") { | ||
| 38 | if (index === patternSegments.length - 1) return toolSegments.length >= index; | ||
| 39 | if (index >= toolSegments.length) return false; | ||
| 40 | continue; | ||
| 41 | } | ||
| 42 | if (index >= toolSegments.length || toolSegments[index] !== segment) return false; | ||
| 43 | } | ||
| 44 | return patternSegments.length === toolSegments.length; | ||
| 45 | }; | ||
| 46 | |||
| 47 | export type PluginId = "openapi"; | ||
| 48 | |||
| 49 | export interface IntegrationRow { | ||
| 50 | readonly tenant: string; | ||
| 51 | readonly slug: string; | ||
| 52 | readonly plugin_id: string; | ||
| 53 | readonly name: string | null; | ||
| 54 | readonly description: string | null; | ||
| 55 | readonly config: unknown; | ||
| 56 | readonly health_check?: unknown; | ||
| 57 | readonly config_revised_at?: string | number | bigint | null; | ||
| 58 | readonly can_remove: boolean; | ||
| 59 | readonly can_refresh: boolean; | ||
| 60 | readonly created_at: string; | ||
| 61 | readonly updated_at: string; | ||
| 62 | readonly row_id: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface ConnectionRow { | ||
| 66 | readonly tenant: string; | ||
| 67 | readonly owner: string; | ||
| 68 | readonly subject: string; | ||
| 69 | readonly integration: string; | ||
| 70 | readonly name: string; | ||
| 71 | readonly template: string; | ||
| 72 | readonly provider: string; | ||
| 73 | readonly item_ids: unknown; | ||
| 74 | readonly identity_label: string | null; | ||
| 75 | readonly description?: string | null; | ||
| 76 | readonly last_health?: unknown; | ||
| 77 | readonly tools_synced_at?: string | number | bigint | null; | ||
| 78 | readonly oauth_client: string | null; | ||
| 79 | readonly oauth_client_owner: string | null; | ||
| 80 | readonly refresh_item_id: string | null; | ||
| 81 | readonly expires_at: string | number | bigint | null; | ||
| 82 | readonly oauth_scope: string | null; | ||
| 83 | readonly oauth_token_url?: string | null; | ||
| 84 | readonly provider_state: unknown; | ||
| 85 | readonly created_at: string; | ||
| 86 | readonly updated_at: string; | ||
| 87 | readonly row_id: string; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface ToolRow { | ||
| 91 | readonly tenant: string; | ||
| 92 | readonly owner: string; | ||
| 93 | readonly subject: string; | ||
| 94 | readonly integration: string; | ||
| 95 | readonly connection: string; | ||
| 96 | readonly plugin_id: string; | ||
| 97 | readonly name: string; | ||
| 98 | readonly description?: string; | ||
| 99 | readonly input_schema?: unknown; | ||
| 100 | readonly output_schema?: unknown; | ||
| 101 | readonly annotations?: unknown; | ||
| 102 | readonly created_at?: string; | ||
| 103 | readonly updated_at?: string; | ||
| 104 | readonly row_id: string; | ||
| 105 | } | ||
| 106 | |||
| 107 | export interface PluginStorageRow { | ||
| 108 | readonly tenant: string; | ||
| 109 | readonly owner: string; | ||
| 110 | readonly subject: string; | ||
| 111 | readonly plugin_id: string; | ||
| 112 | readonly collection: string; | ||
| 113 | readonly key: string; | ||
| 114 | readonly data: unknown; | ||
| 115 | readonly created_at: string; | ||
| 116 | readonly updated_at: string; | ||
| 117 | readonly row_id: string; | ||
| 118 | } | ||
| 119 | |||
| 120 | export interface BlobRow { | ||
| 121 | readonly id: string; | ||
| 122 | readonly namespace: string; | ||
| 123 | readonly key: string; | ||
| 124 | } | ||
| 125 | |||
| 126 | export interface ToolPolicyRow { | ||
| 127 | readonly tenant: string; | ||
| 128 | readonly owner: string; | ||
| 129 | readonly subject: string; | ||
| 130 | readonly id: string; | ||
| 131 | readonly pattern: string; | ||
| 132 | readonly action: string; | ||
| 133 | readonly position: string; | ||
| 134 | readonly created_at: string; | ||
| 135 | readonly updated_at: string; | ||
| 136 | readonly row_id: string; | ||
| 137 | } | ||
| 138 | |||
| 139 | export interface MigrationInput { | ||
| 140 | readonly integrations: readonly IntegrationRow[]; | ||
| 141 | readonly connections: readonly ConnectionRow[]; | ||
| 142 | readonly tools: readonly ToolRow[]; | ||
| 143 | readonly pluginStorage?: readonly PluginStorageRow[]; | ||
| 144 | readonly blobs?: readonly BlobRow[]; | ||
| 145 | readonly policies: readonly ToolPolicyRow[]; | ||
| 146 | readonly completedTenants?: readonly string[]; | ||
| 147 | readonly trafficLastTenant?: string; | ||
| 148 | readonly collectPolicyErrors?: boolean; | ||
| 149 | readonly orphanPolicyMode?: "hard_error" | "retarget_all"; | ||
| 150 | readonly blobBackend?: "database" | "external"; | ||
| 151 | readonly assumeExternalBlobSourcePresent?: boolean; | ||
| 152 | readonly bootRailCreateToolImpliedServices?: boolean; | ||
| 153 | } | ||
| 154 | |||
| 155 | export interface ServiceTarget { | ||
| 156 | readonly family: MonolithPluginId; | ||
| 157 | readonly pluginId: PluginId; | ||
| 158 | readonly presetId: string; | ||
| 159 | readonly slug: string; | ||
| 160 | readonly name: string; | ||
| 161 | readonly description: string; | ||
| 162 | readonly sourceUrl?: string; | ||
| 163 | readonly specFormat: "google-discovery" | "microsoft-graph"; | ||
| 164 | readonly authenticationTemplate?: readonly unknown[]; | ||
| 165 | readonly healthCheck?: unknown; | ||
| 166 | } | ||
| 167 | |||
| 168 | export interface PlannedIntegration { | ||
| 169 | readonly source: Pick<IntegrationRow, "tenant" | "slug" | "plugin_id" | "name">; | ||
| 170 | readonly sourceContributions: readonly { | ||
| 171 | readonly source: IntegrationRow; | ||
| 172 | readonly specHash?: string; | ||
| 173 | readonly operationsToBuild: number; | ||
| 174 | readonly operationToolNames: readonly string[]; | ||
| 175 | readonly specBlobPresent: boolean; | ||
| 176 | readonly defsBlobPresent: boolean; | ||
| 177 | }[]; | ||
| 178 | readonly target: ServiceTarget; | ||
| 179 | readonly action: "create" | "skip_existing"; | ||
| 180 | readonly config: unknown; | ||
| 181 | readonly healthCheck?: unknown; | ||
| 182 | readonly servingState: { | ||
| 183 | readonly specHash?: string; | ||
| 184 | readonly specSource: string; | ||
| 185 | readonly blobBackend: "database" | "external"; | ||
| 186 | readonly specBlobPresent: boolean; | ||
| 187 | readonly defsBlobPresent: boolean; | ||
| 188 | readonly operationsToBuild: number; | ||
| 189 | readonly operationToolNames: readonly string[]; | ||
| 190 | readonly expectedZeroOperations: boolean; | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | export interface PlannedBlobCopy { | ||
| 195 | readonly source: Pick<IntegrationRow, "tenant" | "slug" | "plugin_id">; | ||
| 196 | readonly specHash: string; | ||
| 197 | readonly key: string; | ||
| 198 | readonly sourceNamespace: string; | ||
| 199 | readonly targetNamespace: string; | ||
| 200 | readonly backend: "database" | "external"; | ||
| 201 | readonly sourcePresent: boolean; | ||
| 202 | readonly targetPresent: boolean; | ||
| 203 | readonly sourceObjectName: string; | ||
| 204 | readonly targetObjectName: string; | ||
| 205 | } | ||
| 206 | |||
| 207 | export interface PlannedConnection { | ||
| 208 | readonly source: Pick<ConnectionRow, "tenant" | "owner" | "subject" | "integration" | "name">; | ||
| 209 | readonly targetIntegration: string; | ||
| 210 | readonly action: "clone" | "skip_existing"; | ||
| 211 | readonly tokenReuse: "copy_item_ids_and_oauth_columns"; | ||
| 212 | } | ||
| 213 | |||
| 214 | export interface PlannedPolicyRewrite { | ||
| 215 | readonly policy: Pick< | ||
| 216 | ToolPolicyRow, | ||
| 217 | "tenant" | "owner" | "subject" | "id" | "pattern" | "action" | "position" | ||
| 218 | >; | ||
| 219 | readonly action: "rewrite"; | ||
| 220 | readonly afterPatterns: readonly string[]; | ||
| 221 | readonly matchedServices: readonly string[]; | ||
| 222 | } | ||
| 223 | |||
| 224 | export interface OrgPlan { | ||
| 225 | readonly tenant: string; | ||
| 226 | readonly tenantHash: string; | ||
| 227 | readonly completed: boolean; | ||
| 228 | readonly integrations: readonly PlannedIntegration[]; | ||
| 229 | readonly connections: readonly PlannedConnection[]; | ||
| 230 | readonly policies: readonly PlannedPolicyRewrite[]; | ||
| 231 | readonly blobCopies: readonly PlannedBlobCopy[]; | ||
| 232 | readonly deleteMonoliths: readonly Pick< | ||
| 233 | IntegrationRow, | ||
| 234 | "tenant" | "slug" | "plugin_id" | "name" | ||
| 235 | >[]; | ||
| 236 | readonly clonedToolRows: number; | ||
| 237 | readonly operationsToBuild: number; | ||
| 238 | readonly hardErrors: readonly string[]; | ||
| 239 | } | ||
| 240 | |||
| 241 | export interface MigrationPlan { | ||
| 242 | readonly orgs: readonly OrgPlan[]; | ||
| 243 | readonly summary: { | ||
| 244 | readonly orgs: number; | ||
| 245 | readonly completedOrgs: number; | ||
| 246 | readonly integrationsCreate: number; | ||
| 247 | readonly integrationsSkipExisting: number; | ||
| 248 | readonly connectionsClone: number; | ||
| 249 | readonly connectionsSkipExisting: number; | ||
| 250 | readonly policiesRewrite: number; | ||
| 251 | readonly policiesSkip: number; | ||
| 252 | readonly policyRowsAfter: number; | ||
| 253 | readonly monolithDeletes: number; | ||
| 254 | readonly clonedToolRows: number; | ||
| 255 | readonly operationsToBuild: number; | ||
| 256 | readonly integrationsMissingSpecBlob: number; | ||
| 257 | readonly integrationsMissingDefsBlob: number; | ||
| 258 | readonly hardErrorOrgs: number; | ||
| 259 | readonly policyHardErrors: number; | ||
| 260 | }; | ||
| 261 | } | ||
| 262 | |||
| 263 | const GOOGLE_IDENTITY_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; | ||
| 264 | |||
| 265 | const GOOGLE_TOOL_PREFIX_TO_PRESET_ID: ReadonlyMap<string, string> = new Map([ | ||
| 266 | ["calendar", "google-calendar"], | ||
| 267 | ["gmail", "google-gmail"], | ||
| 268 | ["sheets", "google-sheets"], | ||
| 269 | ["drive", "google-drive"], | ||
| 270 | ["docs", "google-docs"], | ||
| 271 | ["slides", "google-slides"], | ||
| 272 | ["forms", "google-forms"], | ||
| 273 | ["tasks", "google-tasks"], | ||
| 274 | ["people", "google-people"], | ||
| 275 | ["photoslibrary", "google-photos-library"], | ||
| 276 | ["photospicker", "google-photos-picker"], | ||
| 277 | ["chat", "google-chat"], | ||
| 278 | ["keep", "google-keep"], | ||
| 279 | ["youtube", "google-youtube-data"], | ||
| 280 | ["searchconsole", "google-search-console"], | ||
| 281 | ["webmasters", "google-search-console"], | ||
| 282 | ["classroom", "google-classroom"], | ||
| 283 | ["directory", "google-admin-directory"], | ||
| 284 | ["reports", "google-admin-reports"], | ||
| 285 | ["script", "google-apps-script"], | ||
| 286 | ["bigquery", "google-bigquery"], | ||
| 287 | ["cloudresourcemanager", "google-cloud-resource-manager"], | ||
| 288 | ]); | ||
| 289 | |||
| 290 | const unique = <T>(values: Iterable<T>): readonly T[] => [...new Set(values)]; | ||
| 291 | |||
| 292 | export const tenantHash = (tenant: string): string => | ||
| 293 | createHash("sha256").update(tenant).digest("hex").slice(0, 12); | ||
| 294 | |||
| 295 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 296 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 297 | |||
| 298 | const recordFromJsonLike = (value: unknown): Record<string, unknown> => { | ||
| 299 | if (isRecord(value)) return value; | ||
| 300 | if (typeof value !== "string") return {}; | ||
| 301 | try { | ||
| 302 | const parsed = JSON.parse(value) as unknown; | ||
| 303 | return isRecord(parsed) ? parsed : {}; | ||
| 304 | } catch { | ||
| 305 | return {}; | ||
| 306 | } | ||
| 307 | }; | ||
| 308 | |||
| 309 | const stringArray = (value: unknown): readonly string[] => | ||
| 310 | Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; | ||
| 311 | |||
| 312 | const configRecord = (integration: IntegrationRow): Record<string, unknown> => | ||
| 313 | recordFromJsonLike(integration.config); | ||
| 314 | |||
| 315 | const toolAddress = ( | ||
| 316 | tool: Pick<ToolRow, "integration" | "owner" | "connection" | "name">, | ||
| 317 | ): string => `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`; | ||
| 318 | |||
| 319 | const withoutToolsPrefix = (pattern: string): string => | ||
| 320 | pattern.startsWith("tools.") ? pattern.slice("tools.".length) : pattern; | ||
| 321 | |||
| 322 | const withOriginalToolsPrefix = (original: string, rewrittenTail: string): string => | ||
| 323 | original.startsWith("tools.") ? `tools.${rewrittenTail}` : rewrittenTail; | ||
| 324 | |||
| 325 | const integrationPatternSegment = ( | ||
| 326 | pattern: string, | ||
| 327 | ): { readonly prefix: boolean; readonly integration: string } => { | ||
| 328 | const prefix = pattern.startsWith("tools."); | ||
| 329 | const tail = prefix ? pattern.slice("tools.".length) : pattern; | ||
| 330 | return { prefix, integration: tail.split(".")[0] ?? "" }; | ||
| 331 | }; | ||
| 332 | |||
| 333 | const googleCatalogById: ReadonlyMap<string, OpenApiPreset> = new Map( | ||
| 334 | googleCatalog.map((preset) => [preset.id, preset]), | ||
| 335 | ); | ||
| 336 | |||
| 337 | const microsoftCatalogByMonolithPresetId: ReadonlyMap<string, OpenApiPreset> = new Map( | ||
| 338 | microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]), | ||
| 339 | ); | ||
| 340 | |||
| 341 | const MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS = new Set(["me-surface", "users", "groups"]); | ||
| 342 | const MICROSOFT_TOOL_IMPLIED_PRESET_IDS = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 343 | const microsoftCatalogBackedPresetIds = new Set( | ||
| 344 | microsoftCatalog | ||
| 345 | .filter((preset) => preset.defaultSlug && preset.defaultSlug.length > 0) | ||
| 346 | .map((preset) => preset.id.replace(/^microsoft-/, "")), | ||
| 347 | ); | ||
| 348 | |||
| 349 | export const microsoftToolFirstSegmentPresetIds: ReadonlyMap<string, readonly string[]> = new Map([ | ||
| 350 | ["chats", ["teams-chat"]], | ||
| 351 | ["chatsChat", ["teams-chat"]], | ||
| 352 | ["communications", ["meetings-calls"]], | ||
| 353 | ["communicationsCall", ["meetings-calls"]], | ||
| 354 | ["communicationsOnlineMeeting", ["meetings-calls"]], | ||
| 355 | ["communicationsPresence", ["meetings-calls"]], | ||
| 356 | ["drives", ["files"]], | ||
| 357 | ["drivesDrive", ["files"]], | ||
| 358 | ["drivesDriveItem", ["files"]], | ||
| 359 | ["groupsDrive", ["files"]], | ||
| 360 | ["groupsDrives", ["files"]], | ||
| 361 | ["groupsOnenote", ["onenote"]], | ||
| 362 | ["groupsTeam", ["teams-channels"]], | ||
| 363 | ["meCalendar", ["calendar"]], | ||
| 364 | ["meCalendarGroups", ["calendar"]], | ||
| 365 | ["meCalendars", ["calendar"]], | ||
| 366 | ["meCalendarView", ["calendar"]], | ||
| 367 | ["meChat", ["teams-chat"]], | ||
| 368 | ["meChats", ["teams-chat"]], | ||
| 369 | ["meContact", ["contacts"]], | ||
| 370 | ["meContactFolder", ["contacts"]], | ||
| 371 | ["meContactFolders", ["contacts"]], | ||
| 372 | ["meContacts", ["contacts"]], | ||
| 373 | ["meDrive", ["files"]], | ||
| 374 | ["meDrives", ["files"]], | ||
| 375 | ["meEvent", ["calendar"]], | ||
| 376 | ["meEvents", ["calendar"]], | ||
| 377 | ["meFindMeetingTimes", ["calendar"]], | ||
| 378 | ["meFollowedSites", ["files"]], | ||
| 379 | ["meGetMailTips", ["mail"]], | ||
| 380 | ["meInferenceClassification", ["mail"]], | ||
| 381 | ["meJoinedTeams", ["teams-channels"]], | ||
| 382 | ["meMailFolder", ["mail"]], | ||
| 383 | ["meMailFolders", ["mail"]], | ||
| 384 | ["meMailboxSettings", ["mail"]], | ||
| 385 | ["meMessage", ["mail"]], | ||
| 386 | ["meMessages", ["mail"]], | ||
| 387 | ["meOnlineMeeting", ["meetings-calls"]], | ||
| 388 | ["meOnlineMeetings", ["meetings-calls"]], | ||
| 389 | ["meOnenote", ["onenote"]], | ||
| 390 | ["meOutlook", ["mail"]], | ||
| 391 | ["meOutlookUser", ["mail"]], | ||
| 392 | ["mePeople", ["contacts"]], | ||
| 393 | ["mePerson", ["contacts"]], | ||
| 394 | ["mePhoto", ["profile"]], | ||
| 395 | ["meProfilePhoto", ["profile"]], | ||
| 396 | ["meReminderView", ["calendar"]], | ||
| 397 | ["meSendMail", ["mail"]], | ||
| 398 | ["meSite", ["files"]], | ||
| 399 | ["meTeam", ["teams-channels"]], | ||
| 400 | ["meTodo", ["tasks"]], | ||
| 401 | ["meUser", ["profile"]], | ||
| 402 | ["meUserActions", ["profile"]], | ||
| 403 | ["meUserFunctions", ["profile"]], | ||
| 404 | ["shares", ["files"]], | ||
| 405 | ["sites", ["sites"]], | ||
| 406 | ["sitesOnenote", ["onenote"]], | ||
| 407 | ["sitesSite", ["sites"]], | ||
| 408 | ["teams", ["teams-channels"]], | ||
| 409 | ["teamsChannel", ["teams-channels"]], | ||
| 410 | ["teamsTeam", ["teams-channels"]], | ||
| 411 | ["teamsTemplates", ["teams-channels"]], | ||
| 412 | ["teamwork", ["teams-channels"]], | ||
| 413 | ["usersCalendar", ["calendar"]], | ||
| 414 | ["usersCalendarGroups", ["calendar"]], | ||
| 415 | ["usersCalendars", ["calendar"]], | ||
| 416 | ["usersCalendarView", ["calendar"]], | ||
| 417 | ["usersContact", ["contacts"]], | ||
| 418 | ["usersContactFolder", ["contacts"]], | ||
| 419 | ["usersContactFolders", ["contacts"]], | ||
| 420 | ["usersContacts", ["contacts"]], | ||
| 421 | ["usersDrive", ["files"]], | ||
| 422 | ["usersDrives", ["files"]], | ||
| 423 | ["usersEvent", ["calendar"]], | ||
| 424 | ["usersEvents", ["calendar"]], | ||
| 425 | ["usersFindMeetingTimes", ["calendar"]], | ||
| 426 | ["usersMailFolder", ["mail"]], | ||
| 427 | ["usersMailFolders", ["mail"]], | ||
| 428 | ["usersMessage", ["mail"]], | ||
| 429 | ["usersMessages", ["mail"]], | ||
| 430 | ["usersOnlineMeeting", ["meetings-calls"]], | ||
| 431 | ["usersOnlineMeetings", ["meetings-calls"]], | ||
| 432 | ["usersOnenote", ["onenote"]], | ||
| 433 | ["usersOutlook", ["mail"]], | ||
| 434 | ["usersPeople", ["contacts"]], | ||
| 435 | ["usersPerson", ["contacts"]], | ||
| 436 | ["usersReminderView", ["calendar"]], | ||
| 437 | ["usersSendMail", ["mail"]], | ||
| 438 | ["usersTodo", ["tasks"]], | ||
| 439 | ["usersUser", ["profile"]], | ||
| 440 | ["usersUserActions", ["profile"]], | ||
| 441 | ["usersUserFunctions", ["profile"]], | ||
| 442 | ]); | ||
| 443 | |||
| 444 | const MICROSOFT_WORKBOOK_FIRST_SEGMENTS = new Set([ | ||
| 445 | "drivesDriveItem", | ||
| 446 | "groupsDrive", | ||
| 447 | "meDrive", | ||
| 448 | "usersDrive", | ||
| 449 | ]); | ||
| 450 | |||
| 451 | const microsoftFirstSegmentOwnershipKey = (firstSegment: string): string | undefined => | ||
| 452 | [...microsoftToolFirstSegmentPresetIds.keys()] | ||
| 453 | .filter( | ||
| 454 | (key) => | ||
| 455 | firstSegment === key || | ||
| 456 | (firstSegment.startsWith(key) && | ||
| 457 | firstSegment[key.length]?.toUpperCase() === firstSegment[key.length]), | ||
| 458 | ) | ||
| 459 | .sort((left, right) => right.length - left.length)[0]; | ||
| 460 | |||
| 461 | const serviceSlugForPreset = (family: MonolithPluginId, presetId: string): string | undefined => { | ||
| 462 | const preset = | ||
| 463 | family === "google" | ||
| 464 | ? googleCatalogById.get(presetId) | ||
| 465 | : microsoftCatalogByMonolithPresetId.get(presetId); | ||
| 466 | return preset?.defaultSlug; | ||
| 467 | }; | ||
| 468 | |||
| 469 | const serviceTargetForPreset = ( | ||
| 470 | family: MonolithPluginId, | ||
| 471 | presetId: string, | ||
| 472 | ): ServiceTarget | undefined => { | ||
| 473 | if (family === "google") { | ||
| 474 | const preset = googleCatalogById.get(presetId); | ||
| 475 | if (!preset) return undefined; | ||
| 476 | return { | ||
| 477 | family, | ||
| 478 | pluginId: "openapi", | ||
| 479 | presetId, | ||
| 480 | slug: preset.defaultSlug ?? preset.id, | ||
| 481 | name: preset.name, | ||
| 482 | description: preset.summary, | ||
| 483 | ...(preset.url ? { sourceUrl: preset.url } : {}), | ||
| 484 | specFormat: "google-discovery", | ||
| 485 | ...(preset.authTemplate ? { authenticationTemplate: preset.authTemplate } : {}), | ||
| 486 | ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}), | ||
| 487 | }; | ||
| 488 | } | ||
| 489 | const preset = microsoftCatalogByMonolithPresetId.get(presetId); | ||
| 490 | if (!preset) return undefined; | ||
| 491 | return { | ||
| 492 | family, | ||
| 493 | pluginId: "openapi", | ||
| 494 | presetId, | ||
| 495 | slug: preset.defaultSlug ?? preset.id, | ||
| 496 | name: preset.name, | ||
| 497 | description: preset.summary, | ||
| 498 | ...(preset.url ? { sourceUrl: preset.url } : {}), | ||
| 499 | specFormat: "microsoft-graph", | ||
| 500 | ...(preset.authTemplate ? { authenticationTemplate: preset.authTemplate } : {}), | ||
| 501 | ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}), | ||
| 502 | }; | ||
| 503 | }; | ||
| 504 | |||
| 505 | const googlePresetIdsFromConfig = (integration: IntegrationRow): readonly string[] => { | ||
| 506 | const urls = stringArray(configRecord(integration).googleDiscoveryUrls); | ||
| 507 | return unique( | ||
| 508 | urls.flatMap((url) => { | ||
| 509 | if (url === GOOGLE_IDENTITY_DISCOVERY_URL || url.includes("/oauth2/")) return []; | ||
| 510 | const preset = | ||
| 511 | googleCatalogByDiscoveryUrl.get(normalizeUrl(url)) ?? googlePresetForDiscoveryUrl(url); | ||
| 512 | return preset ? [preset.id] : []; | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | }; | ||
| 516 | |||
| 517 | const microsoftPresetIdsFromConfig = (integration: IntegrationRow): readonly string[] => { | ||
| 518 | const config = configRecord(integration); | ||
| 519 | const configured = [ | ||
| 520 | ...stringArray(config.microsoftGraphPresetIds), | ||
| 521 | ...stringArray(config.microsoftGraphScopePresetIds), | ||
| 522 | ]; | ||
| 523 | if (configured.length > 0) return unique(configured); | ||
| 524 | throw new Error( | ||
| 525 | `Microsoft monolith ${tenantHash(integration.tenant)}/${integration.slug} has no stored microsoftGraphPresetIds; refusing to fabricate ${MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.length} default workloads`, | ||
| 526 | ); | ||
| 527 | }; | ||
| 528 | |||
| 529 | const microsoftUnmigratablePresetIds = (presetIds: readonly string[]): readonly string[] => | ||
| 530 | presetIds.filter( | ||
| 531 | (presetId) => | ||
| 532 | MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS.has(presetId) || | ||
| 533 | !microsoftCatalogBackedPresetIds.has(presetId), | ||
| 534 | ); | ||
| 535 | |||
| 536 | export const googlePresetIdForTool = (toolName: string): string | undefined => { | ||
| 537 | const [first, second] = toolName.split("."); | ||
| 538 | if (first === "admin") { | ||
| 539 | return second === "channels" ? "google-admin-reports" : "google-admin-directory"; | ||
| 540 | } | ||
| 541 | return GOOGLE_TOOL_PREFIX_TO_PRESET_ID.get(first ?? ""); | ||
| 542 | }; | ||
| 543 | |||
| 544 | export const microsoftPresetIdsForTool = (toolName: string): readonly string[] => { | ||
| 545 | const segments = toolName.split("."); | ||
| 546 | const first = segments[0] ?? ""; | ||
| 547 | const ownershipKey = microsoftFirstSegmentOwnershipKey(first); | ||
| 548 | const fromFirstSegment = ownershipKey | ||
| 549 | ? (microsoftToolFirstSegmentPresetIds.get(ownershipKey) ?? []) | ||
| 550 | : []; | ||
| 551 | const workbookOwned = | ||
| 552 | ownershipKey !== undefined && | ||
| 553 | MICROSOFT_WORKBOOK_FIRST_SEGMENTS.has(ownershipKey) && | ||
| 554 | segments.some((segment) => segment.toLowerCase().includes("workbook")); | ||
| 555 | return unique([...fromFirstSegment, ...(workbookOwned ? ["excel"] : [])]).filter((presetId) => | ||
| 556 | MICROSOFT_TOOL_IMPLIED_PRESET_IDS.has(presetId), | ||
| 557 | ); | ||
| 558 | }; | ||
| 559 | |||
| 560 | const presetIdsForTool = (pluginId: MonolithPluginId, toolName: string): readonly string[] => { | ||
| 561 | const presetId = pluginId === "google" ? googlePresetIdForTool(toolName) : undefined; | ||
| 562 | return pluginId === "google" ? (presetId ? [presetId] : []) : microsoftPresetIdsForTool(toolName); | ||
| 563 | }; | ||
| 564 | |||
| 565 | const deriveServices = ( | ||
| 566 | integration: IntegrationRow, | ||
| 567 | tools: readonly ToolRow[], | ||
| 568 | bootRailCreateToolImpliedServices: boolean, | ||
| 569 | ): readonly ServiceTarget[] => { | ||
| 570 | const pluginId = integration.plugin_id as MonolithPluginId; | ||
| 571 | const fromConfig = | ||
| 572 | pluginId === "google" | ||
| 573 | ? googlePresetIdsFromConfig(integration) | ||
| 574 | : microsoftPresetIdsFromConfig(integration); | ||
| 575 | if (pluginId === "microsoft") { | ||
| 576 | const unmigratablePresetIds = microsoftUnmigratablePresetIds(fromConfig); | ||
| 577 | if (unmigratablePresetIds.length > 0) { | ||
| 578 | throw new Error( | ||
| 579 | `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} references unmigratable-umbrella Microsoft Graph preset(s): ${unmigratablePresetIds.join(", ")}; org needs manual handling`, | ||
| 580 | ); | ||
| 581 | } | ||
| 582 | } | ||
| 583 | const fromTools = unique( | ||
| 584 | tools.flatMap((tool) => { | ||
| 585 | const presetIds = presetIdsForTool(pluginId, tool.name); | ||
| 586 | if ( | ||
| 587 | pluginId === "microsoft" && | ||
| 588 | fromConfig.length > 0 && | ||
| 589 | presetIds.some((presetId) => fromConfig.includes(presetId)) | ||
| 590 | ) { | ||
| 591 | return []; | ||
| 592 | } | ||
| 593 | return presetIds; | ||
| 594 | }), | ||
| 595 | ); | ||
| 596 | const missingToolPresetIds = fromTools.filter((presetId) => !fromConfig.includes(presetId)); | ||
| 597 | if ( | ||
| 598 | fromConfig.length > 0 && | ||
| 599 | missingToolPresetIds.length > 0 && | ||
| 600 | !bootRailCreateToolImpliedServices | ||
| 601 | ) { | ||
| 602 | throw new Error( | ||
| 603 | `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} config omits tool-implied service preset(s): ${missingToolPresetIds.join(", ")}`, | ||
| 604 | ); | ||
| 605 | } | ||
| 606 | const presetIds = | ||
| 607 | bootRailCreateToolImpliedServices && fromConfig.length > 0 | ||
| 608 | ? unique([...fromConfig, ...fromTools]) | ||
| 609 | : fromConfig.length > 0 | ||
| 610 | ? fromConfig | ||
| 611 | : fromTools; | ||
| 612 | const services = presetIds.flatMap( | ||
| 613 | (presetId) => serviceTargetForPreset(pluginId, presetId) ?? [], | ||
| 614 | ); | ||
| 615 | const missingCatalogPresetIds = presetIds.filter( | ||
| 616 | (presetId) => !serviceTargetForPreset(pluginId, presetId), | ||
| 617 | ); | ||
| 618 | if (missingCatalogPresetIds.length > 0) { | ||
| 619 | throw new Error( | ||
| 620 | `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} references unknown service preset(s): ${missingCatalogPresetIds.join(", ")}`, | ||
| 621 | ); | ||
| 622 | } | ||
| 623 | if (services.length === 0) { | ||
| 624 | throw new Error( | ||
| 625 | `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} has no derivable services`, | ||
| 626 | ); | ||
| 627 | } | ||
| 628 | return services; | ||
| 629 | }; | ||
| 630 | |||
| 631 | const configForService = (source: IntegrationRow, target: ServiceTarget): unknown => { | ||
| 632 | const config = configRecord(source); | ||
| 633 | const specHash = | ||
| 634 | typeof config.specHash === "string" && config.specHash.length > 0 ? config.specHash : undefined; | ||
| 635 | return { | ||
| 636 | ...(specHash ? { specHash } : {}), | ||
| 637 | ...(target.sourceUrl ? { sourceUrl: target.sourceUrl } : {}), | ||
| 638 | specFormat: target.specFormat, | ||
| 639 | family: target.family, | ||
| 640 | ...(target.authenticationTemplate | ||
| 641 | ? { authenticationTemplate: target.authenticationTemplate } | ||
| 642 | : {}), | ||
| 643 | }; | ||
| 644 | }; | ||
| 645 | |||
| 646 | const rowKey = (...parts: readonly string[]): string => parts.join("\u0000"); | ||
| 647 | |||
| 648 | const stableKeyHash = (value: string): string => { | ||
| 649 | let hash = 0xcbf29ce484222325n; | ||
| 650 | const prime = 0x100000001b3n; | ||
| 651 | const mask = 0xffffffffffffffffn; | ||
| 652 | for (let index = 0; index < value.length; index += 1) { | ||
| 653 | hash ^= BigInt(value.charCodeAt(index)); | ||
| 654 | hash = (hash * prime) & mask; | ||
| 655 | } | ||
| 656 | return hash.toString(36).padStart(13, "0"); | ||
| 657 | }; | ||
| 658 | |||
| 659 | export const operationStorageKey = (integration: string, toolName: string): string => | ||
| 660 | `op.${stableKeyHash(integration)}.${stableKeyHash(toolName)}`; | ||
| 661 | |||
| 662 | export const storageDataRecord = (row: Pick<PluginStorageRow, "data">): Record<string, unknown> => | ||
| 663 | recordFromJsonLike(row.data); | ||
| 664 | |||
| 665 | const operationToolName = (row: PluginStorageRow): string | undefined => { | ||
| 666 | const value = storageDataRecord(row).toolName; | ||
| 667 | return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| 668 | }; | ||
| 669 | |||
| 670 | const operationIntegration = (row: PluginStorageRow): string | undefined => { | ||
| 671 | const value = storageDataRecord(row).integration; | ||
| 672 | return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| 673 | }; | ||
| 674 | |||
| 675 | const specHashFor = (integration: IntegrationRow): string => { | ||
| 676 | const value = configRecord(integration).specHash; | ||
| 677 | if (typeof value === "string" && value.length > 0) return value; | ||
| 678 | throw new Error( | ||
| 679 | `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} has no specHash; serving state cannot be migrated`, | ||
| 680 | ); | ||
| 681 | }; | ||
| 682 | |||
| 683 | const pluginBlobNamespace = (tenant: string, pluginId: string): string => `o:${tenant}/${pluginId}`; | ||
| 684 | |||
| 685 | const blobObjectName = (namespace: string, key: string): string => `${namespace}/${key}`; | ||
| 686 | |||
| 687 | const operationRowsForService = ( | ||
| 688 | monolith: IntegrationRow, | ||
| 689 | target: ServiceTarget, | ||
| 690 | rows: readonly PluginStorageRow[], | ||
| 691 | ): readonly PluginStorageRow[] => | ||
| 692 | rows.filter((row) => { | ||
| 693 | if (row.tenant !== monolith.tenant) return false; | ||
| 694 | if (row.plugin_id !== monolith.plugin_id) return false; | ||
| 695 | if (row.collection !== "operation") return false; | ||
| 696 | if (operationIntegration(row) !== monolith.slug) return false; | ||
| 697 | const toolName = operationToolName(row); | ||
| 698 | if (!toolName) return false; | ||
| 699 | if (isIntentionallyDroppedTool(monolith.plugin_id as MonolithPluginId, toolName)) return false; | ||
| 700 | return serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, toolName, [ | ||
| 701 | target, | ||
| 702 | ]).includes(target.slug); | ||
| 703 | }); | ||
| 704 | |||
| 705 | const operationRowsForMonolith = ( | ||
| 706 | monolith: IntegrationRow, | ||
| 707 | rows: readonly PluginStorageRow[], | ||
| 708 | ): readonly PluginStorageRow[] => | ||
| 709 | rows.filter((row) => { | ||
| 710 | if (row.tenant !== monolith.tenant) return false; | ||
| 711 | if (row.plugin_id !== monolith.plugin_id) return false; | ||
| 712 | if (row.collection !== "operation") return false; | ||
| 713 | return operationIntegration(row) === monolith.slug; | ||
| 714 | }); | ||
| 715 | |||
| 716 | const isConfigOnlyMonolith = (input: { | ||
| 717 | readonly tools: readonly ToolRow[]; | ||
| 718 | readonly connections: readonly ConnectionRow[]; | ||
| 719 | readonly operations: readonly PluginStorageRow[]; | ||
| 720 | }): boolean => | ||
| 721 | input.tools.length === 0 && input.connections.length === 0 && input.operations.length === 0; | ||
| 722 | |||
| 723 | const isIntentionallyDroppedTool = (pluginId: MonolithPluginId, toolName: string): boolean => | ||
| 724 | pluginId === "google" && toolName.startsWith("oauth2."); | ||
| 725 | |||
| 726 | const serviceForMatchedTool = ( | ||
| 727 | pluginId: MonolithPluginId, | ||
| 728 | toolName: string, | ||
| 729 | services: readonly ServiceTarget[], | ||
| 730 | ): readonly string[] => { | ||
| 731 | const presetIds = presetIdsForTool(pluginId, toolName); | ||
| 732 | return presetIds.flatMap((presetId) => { | ||
| 733 | const slug = serviceSlugForPreset(pluginId, presetId); | ||
| 734 | return slug && services.some((service) => service.slug === slug) ? [slug] : []; | ||
| 735 | }); | ||
| 736 | }; | ||
| 737 | |||
| 738 | const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] => | ||
| 739 | services.map((service) => service.slug); | ||
| 740 | |||
| 741 | const unassignedToolNames = ( | ||
| 742 | pluginId: MonolithPluginId, | ||
| 743 | tools: readonly { readonly name: string }[], | ||
| 744 | services: readonly ServiceTarget[], | ||
| 745 | ): readonly string[] => | ||
| 746 | unique( | ||
| 747 | tools.flatMap((tool) => { | ||
| 748 | if (isIntentionallyDroppedTool(pluginId, tool.name)) return []; | ||
| 749 | return serviceForMatchedTool(pluginId, tool.name, services).length === 0 ? [tool.name] : []; | ||
| 750 | }), | ||
| 751 | ); | ||
| 752 | |||
| 753 | const assertNoUnassignedRows = ( | ||
| 754 | monolith: IntegrationRow, | ||
| 755 | tools: readonly ToolRow[], | ||
| 756 | operations: readonly PluginStorageRow[], | ||
| 757 | services: readonly ServiceTarget[], | ||
| 758 | ): void => { | ||
| 759 | const pluginId = monolith.plugin_id as MonolithPluginId; | ||
| 760 | const unassignedTools = unassignedToolNames(pluginId, tools, services); | ||
| 761 | if (unassignedTools.length > 0) { | ||
| 762 | throw new Error( | ||
| 763 | `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has tool row(s) with no target service: ${unassignedTools.slice(0, 20).join(", ")}`, | ||
| 764 | ); | ||
| 765 | } | ||
| 766 | const operationTools = operations.flatMap((row) => { | ||
| 767 | const toolName = operationToolName(row); | ||
| 768 | return toolName ? [{ name: toolName }] : []; | ||
| 769 | }); | ||
| 770 | const unassignedOperations = unassignedToolNames(pluginId, operationTools, services); | ||
| 771 | if (unassignedOperations.length > 0) { | ||
| 772 | throw new Error( | ||
| 773 | `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has operation row(s) with no target service: ${unassignedOperations.slice(0, 20).join(", ")}`, | ||
| 774 | ); | ||
| 775 | } | ||
| 776 | }; | ||
| 777 | |||
| 778 | const serviceSlugsForToolPattern = ( | ||
| 779 | pluginId: MonolithPluginId, | ||
| 780 | toolSegments: readonly string[], | ||
| 781 | services: readonly ServiceTarget[], | ||
| 782 | ): readonly string[] => { | ||
| 783 | const firstToolSegment = toolSegments[0]; | ||
| 784 | if (!firstToolSegment || firstToolSegment === "*") return allServiceSlugs(services); | ||
| 785 | const presetIds = presetIdsForTool(pluginId, toolSegments.join(".")); | ||
| 786 | return presetIds.flatMap((presetId) => { | ||
| 787 | const slug = serviceSlugForPreset(pluginId, presetId); | ||
| 788 | return slug && services.some((service) => service.slug === slug) ? [slug] : []; | ||
| 789 | }); | ||
| 790 | }; | ||
| 791 | |||
| 792 | const serviceSlugsForPolicyPattern = ( | ||
| 793 | policy: ToolPolicyRow, | ||
| 794 | monolith: IntegrationRow, | ||
| 795 | services: readonly ServiceTarget[], | ||
| 796 | ): readonly string[] => { | ||
| 797 | const tail = withoutToolsPrefix(policy.pattern); | ||
| 798 | const segments = tail.split("."); | ||
| 799 | const rest = segments.slice(1); | ||
| 800 | const pluginId = monolith.plugin_id as MonolithPluginId; | ||
| 801 | if (rest.length === 0) return allServiceSlugs(services); | ||
| 802 | |||
| 803 | if (rest[0] === "org" || rest[0] === "user") { | ||
| 804 | return serviceSlugsForToolPattern(pluginId, rest.slice(2), services); | ||
| 805 | } | ||
| 806 | |||
| 807 | if (rest[0] === "*") { | ||
| 808 | if (rest.length <= 2) return allServiceSlugs(services); | ||
| 809 | return serviceSlugsForToolPattern(pluginId, rest.slice(2), services); | ||
| 810 | } | ||
| 811 | |||
| 812 | return serviceSlugsForToolPattern(pluginId, rest, services); | ||
| 813 | }; | ||
| 814 | |||
| 815 | const rewritePatternIntegration = (pattern: string, targetSlug: string): string => { | ||
| 816 | const hasTools = pattern.startsWith("tools."); | ||
| 817 | const tail = hasTools ? pattern.slice("tools.".length) : pattern; | ||
| 818 | const segments = tail.split("."); | ||
| 819 | segments[0] = targetSlug; | ||
| 820 | return withOriginalToolsPrefix(pattern, segments.join(".")); | ||
| 821 | }; | ||
| 822 | |||
| 823 | const policyMatches = (pattern: string, tool: ToolRow): boolean => { | ||
| 824 | const normalized = withoutToolsPrefix(pattern); | ||
| 825 | return matchPattern(normalized, toolAddress(tool)); | ||
| 826 | }; | ||
| 827 | |||
| 828 | const rewritePolicy = ( | ||
| 829 | policy: ToolPolicyRow, | ||
| 830 | monolith: IntegrationRow, | ||
| 831 | services: readonly ServiceTarget[], | ||
| 832 | orphanPolicyMode: MigrationInput["orphanPolicyMode"], | ||
| 833 | ): PlannedPolicyRewrite => { | ||
| 834 | const patternIntegration = integrationPatternSegment(policy.pattern).integration; | ||
| 835 | if (patternIntegration !== monolith.slug) { | ||
| 836 | throw new Error( | ||
| 837 | `Policy ${policy.id} for org ${tenantHash(policy.tenant)} does not target ${monolith.slug}`, | ||
| 838 | ); | ||
| 839 | } | ||
| 840 | |||
| 841 | // Orphan policies (pattern targets a service the org never derived) retarget | ||
| 842 | // to every derived service as dormant rows: block/require_approval so the | ||
| 843 | // guardrail intent survives a later add of that service, and approve because | ||
| 844 | // dropping it is the only alternative and a dormant row is equally inert. | ||
| 845 | const serviceSlugs = unique(serviceSlugsForPolicyPattern(policy, monolith, services)); | ||
| 846 | const orphanPolicyRetarget = orphanPolicyMode === "retarget_all" ? allServiceSlugs(services) : []; | ||
| 847 | |||
| 848 | if (serviceSlugs.length === 0 && orphanPolicyRetarget.length === 0) { | ||
| 849 | throw new Error( | ||
| 850 | `Policy ${policy.id} (${policy.pattern}) for org ${tenantHash(policy.tenant)} would be dropped; no target service could be derived`, | ||
| 851 | ); | ||
| 852 | } | ||
| 853 | const matchedServices = serviceSlugs.length > 0 ? serviceSlugs : orphanPolicyRetarget; | ||
| 854 | |||
| 855 | return { | ||
| 856 | policy, | ||
| 857 | action: "rewrite", | ||
| 858 | afterPatterns: matchedServices.map((slug) => rewritePatternIntegration(policy.pattern, slug)), | ||
| 859 | matchedServices, | ||
| 860 | }; | ||
| 861 | }; | ||
| 862 | |||
| 863 | export interface NeverWidenResult { | ||
| 864 | readonly ok: boolean; | ||
| 865 | readonly checkedPolicies: number; | ||
| 866 | readonly widened: readonly { | ||
| 867 | readonly policyId: string; | ||
| 868 | readonly beforePattern: string; | ||
| 869 | readonly afterPatterns: readonly string[]; | ||
| 870 | readonly extraAddresses: readonly string[]; | ||
| 871 | }[]; | ||
| 872 | readonly narrowed: readonly { | ||
| 873 | readonly policyId: string; | ||
| 874 | readonly beforePattern: string; | ||
| 875 | readonly missingServices: readonly string[]; | ||
| 876 | }[]; | ||
| 877 | } | ||
| 878 | |||
| 879 | export const verifyPolicyRewriteNeverWidens = ( | ||
| 880 | plan: MigrationPlan, | ||
| 881 | input: Pick<MigrationInput, "tools">, | ||
| 882 | ): NeverWidenResult => { | ||
| 883 | const widened: { | ||
| 884 | readonly policyId: string; | ||
| 885 | readonly beforePattern: string; | ||
| 886 | readonly afterPatterns: readonly string[]; | ||
| 887 | readonly extraAddresses: readonly string[]; | ||
| 888 | }[] = []; | ||
| 889 | const narrowed: { | ||
| 890 | readonly policyId: string; | ||
| 891 | readonly beforePattern: string; | ||
| 892 | readonly missingServices: readonly string[]; | ||
| 893 | }[] = []; | ||
| 894 | let checkedPolicies = 0; | ||
| 895 | |||
| 896 | for (const org of plan.orgs) { | ||
| 897 | const orgTools = input.tools.filter((tool) => tool.tenant === org.tenant); | ||
| 898 | for (const policy of org.policies) { | ||
| 899 | if (policy.action !== "rewrite") continue; | ||
| 900 | checkedPolicies += 1; | ||
| 901 | if (policy.policy.action === "block" || policy.policy.action === "require_approval") { | ||
| 902 | const afterServices = new Set( | ||
| 903 | policy.afterPatterns.map( | ||
| 904 | (afterPattern) => integrationPatternSegment(afterPattern).integration, | ||
| 905 | ), | ||
| 906 | ); | ||
| 907 | const missingServices = policy.matchedServices.filter((service) => { | ||
| 908 | if (!afterServices.has(service)) return true; | ||
| 909 | const expectedPattern = rewritePatternIntegration(policy.policy.pattern, service); | ||
| 910 | return !policy.afterPatterns.includes(expectedPattern); | ||
| 911 | }); | ||
| 912 | if (missingServices.length > 0) { | ||
| 913 | narrowed.push({ | ||
| 914 | policyId: policy.policy.id, | ||
| 915 | beforePattern: policy.policy.pattern, | ||
| 916 | missingServices, | ||
| 917 | }); | ||
| 918 | } | ||
| 919 | } | ||
| 920 | const before = new Set( | ||
| 921 | orgTools | ||
| 922 | .filter((tool) => policyMatches(policy.policy.pattern, tool)) | ||
| 923 | .flatMap((tool) => { | ||
| 924 | const monolith = org.deleteMonoliths.find((row) => row.slug === tool.integration); | ||
| 925 | if (!monolith) return []; | ||
| 926 | const services = policy.matchedServices.filter((slug) => | ||
| 927 | serviceForMatchedTool( | ||
| 928 | monolith.plugin_id as MonolithPluginId, | ||
| 929 | tool.name, | ||
| 930 | org.integrations.map((i) => i.target), | ||
| 931 | ).includes(slug), | ||
| 932 | ); | ||
| 933 | return services.map((slug) => `${slug}.${tool.owner}.${tool.connection}.${tool.name}`); | ||
| 934 | }), | ||
| 935 | ); | ||
| 936 | const after = new Set( | ||
| 937 | orgTools.flatMap((tool) => { | ||
| 938 | const monolith = org.deleteMonoliths.find((row) => row.slug === tool.integration); | ||
| 939 | if (!monolith) return []; | ||
| 940 | const toolServices = serviceForMatchedTool( | ||
| 941 | monolith.plugin_id as MonolithPluginId, | ||
| 942 | tool.name, | ||
| 943 | org.integrations.map((integration) => integration.target), | ||
| 944 | ); | ||
| 945 | return policy.afterPatterns | ||
| 946 | .filter((afterPattern) => { | ||
| 947 | const targetSlug = integrationPatternSegment(afterPattern).integration; | ||
| 948 | if (!toolServices.includes(targetSlug)) return false; | ||
| 949 | const targetTool = { ...tool, integration: targetSlug }; | ||
| 950 | return policyMatches(afterPattern, targetTool); | ||
| 951 | }) | ||
| 952 | .map((afterPattern) => { | ||
| 953 | const targetSlug = integrationPatternSegment(afterPattern).integration; | ||
| 954 | return `${targetSlug}.${tool.owner}.${tool.connection}.${tool.name}`; | ||
| 955 | }); | ||
| 956 | }), | ||
| 957 | ); | ||
| 958 | const extra = [...after].filter((address) => !before.has(address)); | ||
| 959 | if (extra.length > 0) { | ||
| 960 | widened.push({ | ||
| 961 | policyId: policy.policy.id, | ||
| 962 | beforePattern: policy.policy.pattern, | ||
| 963 | afterPatterns: policy.afterPatterns, | ||
| 964 | extraAddresses: extra.slice(0, 20), | ||
| 965 | }); | ||
| 966 | } | ||
| 967 | } | ||
| 968 | } | ||
| 969 | |||
| 970 | return { | ||
| 971 | ok: widened.length === 0 && narrowed.length === 0, | ||
| 972 | checkedPolicies, | ||
| 973 | widened, | ||
| 974 | narrowed, | ||
| 975 | }; | ||
| 976 | }; | ||
| 977 | |||
| 978 | export const planMigration = (input: MigrationInput): MigrationPlan => { | ||
| 979 | const completed = new Set(input.completedTenants ?? []); | ||
| 980 | const monoliths = input.integrations.filter( | ||
| 981 | (row) => row.plugin_id === "google" || row.plugin_id === "microsoft", | ||
| 982 | ); | ||
| 983 | const tenants = [...unique(monoliths.map((row) => row.tenant))].sort(); | ||
| 984 | const trafficLastTenant = input.trafficLastTenant; | ||
| 985 | const orderedTenants = trafficLastTenant | ||
| 986 | ? [ | ||
| 987 | ...tenants.filter((tenant) => tenant !== trafficLastTenant), | ||
| 988 | ...tenants.filter((tenant) => tenant === trafficLastTenant), | ||
| 989 | ] | ||
| 990 | : tenants; | ||
| 991 | |||
| 992 | const integrationExists = new Set(input.integrations.map((row) => rowKey(row.tenant, row.slug))); | ||
| 993 | const blobBackend = input.blobBackend ?? "database"; | ||
| 994 | const connectionExists = new Set( | ||
| 995 | input.connections.map((row) => | ||
| 996 | rowKey(row.tenant, row.owner, row.subject, row.integration, row.name), | ||
| 997 | ), | ||
| 998 | ); | ||
| 999 | |||
| 1000 | const orgs = orderedTenants.map((tenant): OrgPlan => { | ||
| 1001 | const orgMonoliths = monoliths.filter((row) => row.tenant === tenant); | ||
| 1002 | const orgTools = input.tools.filter((row) => row.tenant === tenant); | ||
| 1003 | const orgStorage = input.pluginStorage?.filter((row) => row.tenant === tenant) ?? []; | ||
| 1004 | const orgBlobs = input.blobs?.filter((row) => row.namespace.startsWith(`o:${tenant}/`)) ?? []; | ||
| 1005 | const orgConnections: PlannedConnection[] = []; | ||
| 1006 | const orgPolicies: PlannedPolicyRewrite[] = []; | ||
| 1007 | const orgBlobCopies: PlannedBlobCopy[] = []; | ||
| 1008 | const plannedBlobCopyKeys = new Set<string>(); | ||
| 1009 | const hardErrors: string[] = []; | ||
| 1010 | const plannedIntegrationsBySlug = new Map<string, PlannedIntegration>(); | ||
| 1011 | let clonedToolRows = 0; | ||
| 1012 | let operationsToBuild = 0; | ||
| 1013 | |||
| 1014 | for (const monolith of orgMonoliths) { | ||
| 1015 | const monolithTools = orgTools.filter((tool) => tool.integration === monolith.slug); | ||
| 1016 | const monolithConnections = input.connections.filter( | ||
| 1017 | (connection) => connection.tenant === tenant && connection.integration === monolith.slug, | ||
| 1018 | ); | ||
| 1019 | const monolithOperations = operationRowsForMonolith(monolith, orgStorage); | ||
| 1020 | let services: readonly ServiceTarget[]; | ||
| 1021 | try { | ||
| 1022 | services = deriveServices( | ||
| 1023 | monolith, | ||
| 1024 | monolithTools, | ||
| 1025 | input.bootRailCreateToolImpliedServices ?? false, | ||
| 1026 | ); | ||
| 1027 | } catch (error) { | ||
| 1028 | if (!input.collectPolicyErrors) throw error; | ||
| 1029 | hardErrors.push(error instanceof Error ? error.message : String(error)); | ||
| 1030 | continue; | ||
| 1031 | } | ||
| 1032 | if ( | ||
| 1033 | isConfigOnlyMonolith({ | ||
| 1034 | tools: monolithTools, | ||
| 1035 | connections: monolithConnections, | ||
| 1036 | operations: monolithOperations, | ||
| 1037 | }) | ||
| 1038 | ) { | ||
| 1039 | continue; | ||
| 1040 | } | ||
| 1041 | try { | ||
| 1042 | assertNoUnassignedRows(monolith, monolithTools, monolithOperations, services); | ||
| 1043 | } catch (error) { | ||
| 1044 | if (!input.collectPolicyErrors) throw error; | ||
| 1045 | hardErrors.push(error instanceof Error ? error.message : String(error)); | ||
| 1046 | continue; | ||
| 1047 | } | ||
| 1048 | const specHash = specHashFor(monolith); | ||
| 1049 | const namespace = pluginBlobNamespace(tenant, monolith.plugin_id); | ||
| 1050 | const targetNamespace = pluginBlobNamespace(tenant, "openapi"); | ||
| 1051 | const specBlobPresent = | ||
| 1052 | (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) || | ||
| 1053 | orgBlobs.some((blob) => blob.namespace === namespace && blob.key === `spec/${specHash}`); | ||
| 1054 | const defsBlobPresent = | ||
| 1055 | (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) || | ||
| 1056 | orgBlobs.some((blob) => blob.namespace === namespace && blob.key === `defs/${specHash}`); | ||
| 1057 | for (const key of [`spec/${specHash}`, `defs/${specHash}`]) { | ||
| 1058 | const copyKey = rowKey(namespace, targetNamespace, key); | ||
| 1059 | if (plannedBlobCopyKeys.has(copyKey)) continue; | ||
| 1060 | plannedBlobCopyKeys.add(copyKey); | ||
| 1061 | const sourcePresent = | ||
| 1062 | (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) || | ||
| 1063 | orgBlobs.some((blob) => blob.namespace === namespace && blob.key === key); | ||
| 1064 | const targetPresent = | ||
| 1065 | blobBackend === "external" | ||
| 1066 | ? false | ||
| 1067 | : orgBlobs.some((blob) => blob.namespace === targetNamespace && blob.key === key); | ||
| 1068 | if (!sourcePresent) { | ||
| 1069 | hardErrors.push( | ||
| 1070 | `Missing source blob ${namespace}/${key} for migrated OpenAPI service namespace ${targetNamespace}`, | ||
| 1071 | ); | ||
| 1072 | } | ||
| 1073 | orgBlobCopies.push({ | ||
| 1074 | source: monolith, | ||
| 1075 | specHash, | ||
| 1076 | key, | ||
| 1077 | sourceNamespace: namespace, | ||
| 1078 | targetNamespace, | ||
| 1079 | backend: blobBackend, | ||
| 1080 | sourcePresent, | ||
| 1081 | targetPresent, | ||
| 1082 | sourceObjectName: blobObjectName(namespace, key), | ||
| 1083 | targetObjectName: blobObjectName(targetNamespace, key), | ||
| 1084 | }); | ||
| 1085 | } | ||
| 1086 | for (const target of services) { | ||
| 1087 | const serviceOperations = operationRowsForService(monolith, target, orgStorage); | ||
| 1088 | const operationToolNames = unique( | ||
| 1089 | serviceOperations.flatMap((row) => operationToolName(row) ?? []), | ||
| 1090 | ); | ||
| 1091 | operationsToBuild += operationToolNames.length; | ||
| 1092 | const contribution = { | ||
| 1093 | source: monolith, | ||
| 1094 | specHash, | ||
| 1095 | operationsToBuild: operationToolNames.length, | ||
| 1096 | operationToolNames, | ||
| 1097 | specBlobPresent, | ||
| 1098 | defsBlobPresent, | ||
| 1099 | }; | ||
| 1100 | const existing = plannedIntegrationsBySlug.get(target.slug); | ||
| 1101 | if (existing) { | ||
| 1102 | const sourceContributions = [...existing.sourceContributions, contribution]; | ||
| 1103 | const winner = sourceContributions.reduce((best, candidate) => | ||
| 1104 | candidate.operationsToBuild > best.operationsToBuild ? candidate : best, | ||
| 1105 | ); | ||
| 1106 | const mergedOperationToolNames = unique( | ||
| 1107 | sourceContributions.flatMap((item) => item.operationToolNames), | ||
| 1108 | ); | ||
| 1109 | const next: PlannedIntegration = { | ||
| 1110 | ...existing, | ||
| 1111 | sourceContributions, | ||
| 1112 | source: winner.source, | ||
| 1113 | config: configForService(winner.source, target), | ||
| 1114 | servingState: { | ||
| 1115 | specHash: winner.specHash, | ||
| 1116 | specSource: `${winner.source.plugin_id}/${winner.source.slug}`, | ||
| 1117 | blobBackend, | ||
| 1118 | specBlobPresent: winner.specBlobPresent, | ||
| 1119 | defsBlobPresent: winner.defsBlobPresent, | ||
| 1120 | operationsToBuild: mergedOperationToolNames.length, | ||
| 1121 | operationToolNames: mergedOperationToolNames, | ||
| 1122 | expectedZeroOperations: false, | ||
| 1123 | }, | ||
| 1124 | }; | ||
| 1125 | plannedIntegrationsBySlug.set(target.slug, next); | ||
| 1126 | } else { | ||
| 1127 | const exists = integrationExists.has(rowKey(tenant, target.slug)); | ||
| 1128 | const planned: PlannedIntegration = { | ||
| 1129 | source: monolith, | ||
| 1130 | sourceContributions: [contribution], | ||
| 1131 | target, | ||
| 1132 | action: exists ? "skip_existing" : "create", | ||
| 1133 | config: configForService(monolith, target), | ||
| 1134 | ...(target.healthCheck ? { healthCheck: target.healthCheck } : {}), | ||
| 1135 | servingState: { | ||
| 1136 | specHash, | ||
| 1137 | specSource: `${monolith.plugin_id}/${monolith.slug}`, | ||
| 1138 | blobBackend, | ||
| 1139 | specBlobPresent, | ||
| 1140 | defsBlobPresent, | ||
| 1141 | operationsToBuild: operationToolNames.length, | ||
| 1142 | operationToolNames, | ||
| 1143 | expectedZeroOperations: false, | ||
| 1144 | }, | ||
| 1145 | }; | ||
| 1146 | plannedIntegrationsBySlug.set(target.slug, planned); | ||
| 1147 | } | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | for (const connection of monolithConnections) { | ||
| 1151 | for (const target of services) { | ||
| 1152 | const key = rowKey( | ||
| 1153 | tenant, | ||
| 1154 | connection.owner, | ||
| 1155 | connection.subject, | ||
| 1156 | target.slug, | ||
| 1157 | connection.name, | ||
| 1158 | ); | ||
| 1159 | const exists = connectionExists.has(key); | ||
| 1160 | orgConnections.push({ | ||
| 1161 | source: connection, | ||
| 1162 | targetIntegration: target.slug, | ||
| 1163 | action: exists ? "skip_existing" : "clone", | ||
| 1164 | tokenReuse: "copy_item_ids_and_oauth_columns", | ||
| 1165 | }); | ||
| 1166 | if (!exists) { | ||
| 1167 | connectionExists.add(key); | ||
| 1168 | } | ||
| 1169 | clonedToolRows += monolithTools.filter((tool) => | ||
| 1170 | serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, tool.name, [ | ||
| 1171 | target, | ||
| 1172 | ]).includes(target.slug), | ||
| 1173 | ).length; | ||
| 1174 | } | ||
| 1175 | } | ||
| 1176 | |||
| 1177 | const candidatePolicies = input.policies.filter((policy) => policy.tenant === tenant); | ||
| 1178 | for (const policy of candidatePolicies) { | ||
| 1179 | const patternIntegration = integrationPatternSegment(policy.pattern).integration; | ||
| 1180 | if (patternIntegration !== monolith.slug) continue; | ||
| 1181 | try { | ||
| 1182 | orgPolicies.push(rewritePolicy(policy, monolith, services, input.orphanPolicyMode)); | ||
| 1183 | } catch (error) { | ||
| 1184 | if (!input.collectPolicyErrors) throw error; | ||
| 1185 | hardErrors.push(error instanceof Error ? error.message : String(error)); | ||
| 1186 | } | ||
| 1187 | } | ||
| 1188 | } | ||
| 1189 | |||
| 1190 | return { | ||
| 1191 | tenant, | ||
| 1192 | tenantHash: tenantHash(tenant), | ||
| 1193 | completed: completed.has(tenant), | ||
| 1194 | integrations: [...plannedIntegrationsBySlug.values()], | ||
| 1195 | connections: orgConnections, | ||
| 1196 | policies: orgPolicies, | ||
| 1197 | blobCopies: orgBlobCopies, | ||
| 1198 | deleteMonoliths: orgMonoliths, | ||
| 1199 | clonedToolRows, | ||
| 1200 | operationsToBuild, | ||
| 1201 | hardErrors, | ||
| 1202 | }; | ||
| 1203 | }); | ||
| 1204 | |||
| 1205 | const activeOrgs = orgs.filter((org) => !org.completed && org.hardErrors.length === 0); | ||
| 1206 | const summary = { | ||
| 1207 | orgs: orgs.length, | ||
| 1208 | completedOrgs: orgs.filter((org) => org.completed).length, | ||
| 1209 | integrationsCreate: activeOrgs | ||
| 1210 | .flatMap((org) => org.integrations) | ||
| 1211 | .filter((row) => row.action === "create").length, | ||
| 1212 | integrationsSkipExisting: activeOrgs | ||
| 1213 | .flatMap((org) => org.integrations) | ||
| 1214 | .filter((row) => row.action === "skip_existing").length, | ||
| 1215 | connectionsClone: activeOrgs | ||
| 1216 | .flatMap((org) => org.connections) | ||
| 1217 | .filter((row) => row.action === "clone").length, | ||
| 1218 | connectionsSkipExisting: activeOrgs | ||
| 1219 | .flatMap((org) => org.connections) | ||
| 1220 | .filter((row) => row.action === "skip_existing").length, | ||
| 1221 | policiesRewrite: activeOrgs | ||
| 1222 | .flatMap((org) => org.policies) | ||
| 1223 | .filter((row) => row.action === "rewrite").length, | ||
| 1224 | policiesSkip: 0, | ||
| 1225 | policyRowsAfter: activeOrgs | ||
| 1226 | .flatMap((org) => org.policies) | ||
| 1227 | .reduce((sum, row) => sum + row.afterPatterns.length, 0), | ||
| 1228 | monolithDeletes: activeOrgs.flatMap((org) => org.deleteMonoliths).length, | ||
| 1229 | clonedToolRows: activeOrgs.reduce((sum, org) => sum + org.clonedToolRows, 0), | ||
| 1230 | operationsToBuild: activeOrgs.reduce((sum, org) => sum + org.operationsToBuild, 0), | ||
| 1231 | integrationsMissingSpecBlob: activeOrgs | ||
| 1232 | .flatMap((org) => org.integrations) | ||
| 1233 | .filter((row) => !row.servingState.specBlobPresent).length, | ||
| 1234 | integrationsMissingDefsBlob: activeOrgs | ||
| 1235 | .flatMap((org) => org.integrations) | ||
| 1236 | .filter((row) => !row.servingState.defsBlobPresent).length, | ||
| 1237 | hardErrorOrgs: orgs.filter((org) => !org.completed && org.hardErrors.length > 0).length, | ||
| 1238 | policyHardErrors: orgs.reduce((sum, org) => sum + org.hardErrors.length, 0), | ||
| 1239 | }; | ||
| 1240 | return { orgs, summary }; | ||
| 1241 | }; | ||
| 1242 | |||
| 1243 | const printableJson = (value: unknown): string => | ||
| 1244 | JSON.stringify(value, (_key, inner) => (inner === undefined ? null : inner), 2); | ||
| 1245 | |||
| 1246 | export const renderOrgDiff = (org: OrgPlan): string => { | ||
| 1247 | const lines: string[] = []; | ||
| 1248 | lines.push(`# Org ${org.tenantHash}`); | ||
| 1249 | lines.push(""); | ||
| 1250 | if (org.completed) { | ||
| 1251 | lines.push("Already completed, no changes planned."); | ||
| 1252 | lines.push(""); | ||
| 1253 | return lines.join("\n"); | ||
| 1254 | } | ||
| 1255 | if (org.hardErrors.length > 0) { | ||
| 1256 | lines.push("## Hard Errors"); | ||
| 1257 | lines.push("Apply is blocked for this org until these errors are handled."); | ||
| 1258 | for (const error of org.hardErrors) { | ||
| 1259 | lines.push(`- ${error}`); | ||
| 1260 | } | ||
| 1261 | lines.push(""); | ||
| 1262 | } | ||
| 1263 | lines.push("## Integrations"); | ||
| 1264 | for (const row of org.integrations) { | ||
| 1265 | lines.push( | ||
| 1266 | `- ${row.action}: ${row.source.plugin_id}/${row.source.slug} (${row.source.name ?? "unnamed"}) -> ${row.target.slug} (${row.target.name})`, | ||
| 1267 | ); | ||
| 1268 | lines.push( | ||
| 1269 | ` serving: operations to build: ${row.servingState.operationsToBuild} / spec source: ${row.servingState.specSource} / blob backend: ${row.servingState.blobBackend} / specHash: ${row.servingState.specHash} / spec blob: ${row.servingState.specBlobPresent ? "present" : "missing"} / defs blob: ${row.servingState.defsBlobPresent ? "present" : "missing"}`, | ||
| 1270 | ); | ||
| 1271 | if (row.sourceContributions.length > 1) { | ||
| 1272 | for (const contribution of row.sourceContributions) { | ||
| 1273 | lines.push( | ||
| 1274 | ` contribution: ${contribution.source.plugin_id}/${contribution.source.slug} / operations: ${contribution.operationsToBuild} / specHash: ${contribution.specHash ?? "none"}`, | ||
| 1275 | ); | ||
| 1276 | } | ||
| 1277 | } | ||
| 1278 | lines.push(` config: ${printableJson(row.config).replaceAll("\n", "\n ")}`); | ||
| 1279 | } | ||
| 1280 | for (const row of org.deleteMonoliths) { | ||
| 1281 | lines.push( | ||
| 1282 | `- delete monolith in apply mode: ${row.plugin_id}/${row.slug} (${row.name ?? "unnamed"})`, | ||
| 1283 | ); | ||
| 1284 | } | ||
| 1285 | lines.push(""); | ||
| 1286 | lines.push("## Blob Copies"); | ||
| 1287 | for (const row of org.blobCopies) { | ||
| 1288 | lines.push( | ||
| 1289 | `- ${row.backend}: ${row.sourceObjectName} -> ${row.targetObjectName} / source: ${row.sourcePresent ? "present" : "missing"} / target: ${row.targetPresent ? "present" : "missing"}`, | ||
| 1290 | ); | ||
| 1291 | } | ||
| 1292 | lines.push(""); | ||
| 1293 | lines.push("## Connections"); | ||
| 1294 | for (const row of org.connections) { | ||
| 1295 | lines.push( | ||
| 1296 | `- ${row.action}: ${row.source.integration}.${row.source.owner}.${row.source.name} -> ${row.targetIntegration}.${row.source.owner}.${row.source.name} (${row.tokenReuse})`, | ||
| 1297 | ); | ||
| 1298 | } | ||
| 1299 | lines.push(""); | ||
| 1300 | lines.push("## Policies"); | ||
| 1301 | for (const row of org.policies) { | ||
| 1302 | lines.push(`- rewrite ${row.policy.id}: ${row.policy.pattern}`); | ||
| 1303 | for (const after of row.afterPatterns) { | ||
| 1304 | lines.push(` -> ${after}`); | ||
| 1305 | } | ||
| 1306 | } | ||
| 1307 | lines.push(""); | ||
| 1308 | lines.push("## Internal Rows"); | ||
| 1309 | lines.push(`- tool rows cloned in apply mode: ${org.clonedToolRows}`); | ||
| 1310 | lines.push(`- operation rows copied in apply mode: ${org.operationsToBuild}`); | ||
| 1311 | lines.push(""); | ||
| 1312 | return lines.join("\n"); | ||
| 1313 | }; | ||
| 1314 | |||
| 1315 | export const renderSummary = (plan: MigrationPlan): string => { | ||
| 1316 | const s = plan.summary; | ||
| 1317 | return [ | ||
| 1318 | `orgs=${s.orgs}`, | ||
| 1319 | `completed_orgs=${s.completedOrgs}`, | ||
| 1320 | `integrations_create=${s.integrationsCreate}`, | ||
| 1321 | `integrations_skip_existing=${s.integrationsSkipExisting}`, | ||
| 1322 | `connections_clone=${s.connectionsClone}`, | ||
| 1323 | `connections_skip_existing=${s.connectionsSkipExisting}`, | ||
| 1324 | `policies_rewrite=${s.policiesRewrite}`, | ||
| 1325 | `policy_rows_after=${s.policyRowsAfter}`, | ||
| 1326 | `policies_skip=${s.policiesSkip}`, | ||
| 1327 | `monolith_deletes=${s.monolithDeletes}`, | ||
| 1328 | `tool_rows_clone=${s.clonedToolRows}`, | ||
| 1329 | `operation_rows_build=${s.operationsToBuild}`, | ||
| 1330 | `integrations_missing_spec_blob=${s.integrationsMissingSpecBlob}`, | ||
| 1331 | `integrations_missing_defs_blob=${s.integrationsMissingDefsBlob}`, | ||
| 1332 | `hard_error_orgs=${s.hardErrorOrgs}`, | ||
| 1333 | `policy_hard_errors=${s.policyHardErrors}`, | ||
| 1334 | ].join("\n"); | ||
| 1335 | }; | ||
| @@ -0,0 +1,337 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Schema } from "effect"; | ||
| 3 | import { | ||
| 4 | collectTables, | ||
| 5 | DataMigrationError, | ||
| 6 | runSqliteDataMigrations, | ||
| 7 | type SqliteDataMigrationClient, | ||
| 8 | } from "@executor-js/sdk"; | ||
| 9 | import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; | ||
| 10 | |||
| 11 | import { operationStorageKey } from "./planner"; | ||
| 12 | import { | ||
| 13 | providerServiceSplitDataMigration, | ||
| 14 | runSqliteProviderServiceSplitMigration, | ||
| 15 | } from "./sqlite"; | ||
| 16 | |||
| 17 | const now = 1_780_000_000_000; | ||
| 18 | const parseJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 19 | |||
| 20 | const insertIntegration = ( | ||
| 21 | client: SqliteDataMigrationClient, | ||
| 22 | row: { | ||
| 23 | readonly rowId: string; | ||
| 24 | readonly tenant: string; | ||
| 25 | readonly slug: string; | ||
| 26 | readonly pluginId: string; | ||
| 27 | readonly config: unknown; | ||
| 28 | readonly healthCheck?: unknown; | ||
| 29 | }, | ||
| 30 | ) => | ||
| 31 | client.execute({ | ||
| 32 | sql: `INSERT INTO integration | ||
| 33 | (row_id, tenant, slug, plugin_id, name, description, config, health_check, | ||
| 34 | can_remove, can_refresh, created_at, updated_at) | ||
| 35 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, | ||
| 36 | args: [ | ||
| 37 | row.rowId, | ||
| 38 | row.tenant, | ||
| 39 | row.slug, | ||
| 40 | row.pluginId, | ||
| 41 | row.slug, | ||
| 42 | row.slug, | ||
| 43 | JSON.stringify(row.config), | ||
| 44 | row.healthCheck ? JSON.stringify(row.healthCheck) : null, | ||
| 45 | now, | ||
| 46 | now, | ||
| 47 | ], | ||
| 48 | }); | ||
| 49 | |||
| 50 | const insertConnection = (client: SqliteDataMigrationClient, tenant = "org_1") => | ||
| 51 | client.execute({ | ||
| 52 | sql: `INSERT INTO connection | ||
| 53 | (integration, name, template, provider, item_ids, identity_label, description, | ||
| 54 | last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | ||
| 55 | expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | ||
| 56 | row_id, tenant, owner, subject) | ||
| 57 | VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 58 | args: [ | ||
| 59 | "google", | ||
| 60 | "main", | ||
| 61 | "googleOAuth", | ||
| 62 | "vault", | ||
| 63 | JSON.stringify({ token: "access_item", refresh: "refresh_item" }), | ||
| 64 | "person@example.test", | ||
| 65 | now, | ||
| 66 | "google", | ||
| 67 | "org", | ||
| 68 | "refresh_item", | ||
| 69 | now + 60_000, | ||
| 70 | "calendar gmail", | ||
| 71 | "https://oauth2.googleapis.com/token", | ||
| 72 | JSON.stringify({ token: "metadata" }), | ||
| 73 | now, | ||
| 74 | now, | ||
| 75 | `conn_google_${tenant}`, | ||
| 76 | tenant, | ||
| 77 | "org", | ||
| 78 | "", | ||
| 79 | ], | ||
| 80 | }); | ||
| 81 | |||
| 82 | const insertTool = (client: SqliteDataMigrationClient, name: string, tenant = "org_1") => | ||
| 83 | client.execute({ | ||
| 84 | sql: `INSERT INTO tool | ||
| 85 | (tenant, owner, subject, integration, connection, plugin_id, name, description, | ||
| 86 | input_schema, output_schema, annotations, created_at, updated_at, row_id) | ||
| 87 | VALUES (?, 'org', '', 'google', 'main', 'google', ?, 'tool', '{}', '{}', '{}', ?, ?, ?)`, | ||
| 88 | args: [tenant, name, now, now, `tool_${tenant}_${name}`], | ||
| 89 | }); | ||
| 90 | |||
| 91 | const insertOperation = (client: SqliteDataMigrationClient, name: string, tenant = "org_1") => | ||
| 92 | client.execute({ | ||
| 93 | sql: `INSERT INTO plugin_storage | ||
| 94 | (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id) | ||
| 95 | VALUES (?, 'org', '', 'google', 'operation', ?, ?, ?, ?, ?)`, | ||
| 96 | args: [ | ||
| 97 | tenant, | ||
| 98 | operationStorageKey("google", name), | ||
| 99 | JSON.stringify({ | ||
| 100 | integration: "google", | ||
| 101 | toolName: name, | ||
| 102 | binding: { method: "get", pathTemplate: `/${name}`, parameters: [] }, | ||
| 103 | description: name, | ||
| 104 | }), | ||
| 105 | now, | ||
| 106 | now, | ||
| 107 | `op_${tenant}_${name}`, | ||
| 108 | ], | ||
| 109 | }); | ||
| 110 | |||
| 111 | const insertBlob = (client: SqliteDataMigrationClient, key: string, tenant = "org_1") => | ||
| 112 | client.execute({ | ||
| 113 | sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)", | ||
| 114 | args: [ | ||
| 115 | `o:${tenant}/google`, | ||
| 116 | key, | ||
| 117 | "blob", | ||
| 118 | `blob_${tenant}_${key}`, | ||
| 119 | JSON.stringify([`o:${tenant}/google`, key]), | ||
| 120 | ], | ||
| 121 | }); | ||
| 122 | |||
| 123 | const insertPolicy = (client: SqliteDataMigrationClient) => | ||
| 124 | client.execute({ | ||
| 125 | sql: `INSERT INTO tool_policy | ||
| 126 | (id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 127 | VALUES (?, ?, 'block', 'a0', ?, ?, ?, 'org_1', 'org', '')`, | ||
| 128 | args: ["policy_orphan", "google.*.*.gmail.users.messages.send", now, now, "policy_orphan_row"], | ||
| 129 | }); | ||
| 130 | |||
| 131 | const seedCalendarOrg = ( | ||
| 132 | client: SqliteDataMigrationClient, | ||
| 133 | tenant = "org_1", | ||
| 134 | options: { readonly includeBlobs?: boolean } = { includeBlobs: true }, | ||
| 135 | ) => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | yield* Effect.promise(() => | ||
| 138 | insertIntegration(client, { | ||
| 139 | rowId: `google_row_${tenant}`, | ||
| 140 | tenant, | ||
| 141 | slug: "google", | ||
| 142 | pluginId: "google", | ||
| 143 | config: { | ||
| 144 | googleDiscoveryUrls: [ | ||
| 145 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 146 | "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest", | ||
| 147 | ], | ||
| 148 | specHash: "mono-hash", | ||
| 149 | }, | ||
| 150 | healthCheck: { operation: "oauth2.userinfo.get" }, | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | yield* Effect.promise(() => insertConnection(client, tenant)); | ||
| 154 | yield* Effect.promise(() => insertTool(client, "calendar.events.list", tenant)); | ||
| 155 | yield* Effect.promise(() => insertOperation(client, "calendar.events.list", tenant)); | ||
| 156 | yield* Effect.promise(() => insertOperation(client, "oauth2.userinfo.get", tenant)); | ||
| 157 | if (options.includeBlobs !== false) { | ||
| 158 | yield* Effect.promise(() => insertBlob(client, "spec/mono-hash", tenant)); | ||
| 159 | yield* Effect.promise(() => insertBlob(client, "defs/mono-hash", tenant)); | ||
| 160 | } | ||
| 161 | }); | ||
| 162 | |||
| 163 | describe("providerServiceSplitDataMigration", () => { | ||
| 164 | it.effect("splits libSQL monolith rows into openapi service rows and stamps once", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 167 | const client = db.client; | ||
| 168 | |||
| 169 | yield* Effect.promise(() => | ||
| 170 | insertIntegration(client, { | ||
| 171 | rowId: "google_row", | ||
| 172 | tenant: "org_1", | ||
| 173 | slug: "google", | ||
| 174 | pluginId: "google", | ||
| 175 | config: { | ||
| 176 | googleDiscoveryUrls: [ | ||
| 177 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 178 | "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest", | ||
| 179 | ], | ||
| 180 | specHash: "mono-hash", | ||
| 181 | }, | ||
| 182 | healthCheck: { operation: "oauth2.userinfo.get" }, | ||
| 183 | }), | ||
| 184 | ); | ||
| 185 | yield* Effect.promise(() => insertConnection(client)); | ||
| 186 | yield* Effect.promise(() => insertTool(client, "calendar.events.list")); | ||
| 187 | yield* Effect.promise(() => insertOperation(client, "calendar.events.list")); | ||
| 188 | yield* Effect.promise(() => insertOperation(client, "oauth2.userinfo.get")); | ||
| 189 | yield* Effect.promise(() => insertBlob(client, "spec/mono-hash")); | ||
| 190 | yield* Effect.promise(() => insertBlob(client, "defs/mono-hash")); | ||
| 191 | yield* Effect.promise(() => insertPolicy(client)); | ||
| 192 | |||
| 193 | expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual([ | ||
| 194 | "2026-07-08-provider-service-split", | ||
| 195 | ]); | ||
| 196 | expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual( | ||
| 197 | [], | ||
| 198 | ); | ||
| 199 | |||
| 200 | const integrations = yield* Effect.promise(() => | ||
| 201 | client.execute( | ||
| 202 | "SELECT slug, plugin_id, config, health_check FROM integration ORDER BY slug", | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | expect(integrations.rows).toHaveLength(1); | ||
| 206 | expect(integrations.rows[0]?.slug).toBe("google_calendar"); | ||
| 207 | expect(integrations.rows[0]?.plugin_id).toBe("openapi"); | ||
| 208 | expect(parseJson(String(integrations.rows[0]?.config))).toMatchObject({ | ||
| 209 | specHash: "mono-hash", | ||
| 210 | sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 211 | specFormat: "google-discovery", | ||
| 212 | family: "google", | ||
| 213 | }); | ||
| 214 | expect(parseJson(String(integrations.rows[0]?.health_check))).toEqual({ | ||
| 215 | operation: "calendar.calendarList.list", | ||
| 216 | }); | ||
| 217 | |||
| 218 | const connections = yield* Effect.promise(() => | ||
| 219 | client.execute("SELECT integration, item_ids, oauth_client FROM connection"), | ||
| 220 | ); | ||
| 221 | expect(connections.rows).toEqual([ | ||
| 222 | { | ||
| 223 | integration: "google_calendar", | ||
| 224 | item_ids: JSON.stringify({ | ||
| 225 | token: "access_item", | ||
| 226 | refresh: "refresh_item", | ||
| 227 | }), | ||
| 228 | oauth_client: "google", | ||
| 229 | }, | ||
| 230 | ]); | ||
| 231 | |||
| 232 | const storage = yield* Effect.promise(() => | ||
| 233 | client.execute( | ||
| 234 | "SELECT plugin_id, key, data FROM plugin_storage WHERE collection = 'operation'", | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | expect(storage.rows).toHaveLength(1); | ||
| 238 | expect(storage.rows[0]?.plugin_id).toBe("openapi"); | ||
| 239 | expect(storage.rows[0]?.key).toBe( | ||
| 240 | operationStorageKey("google_calendar", "calendar.events.list"), | ||
| 241 | ); | ||
| 242 | expect(parseJson(String(storage.rows[0]?.data))).toMatchObject({ | ||
| 243 | integration: "google_calendar", | ||
| 244 | toolName: "calendar.events.list", | ||
| 245 | }); | ||
| 246 | |||
| 247 | const tools = yield* Effect.promise(() => | ||
| 248 | client.execute("SELECT integration, plugin_id, name FROM tool"), | ||
| 249 | ); | ||
| 250 | expect(tools.rows).toEqual([ | ||
| 251 | { | ||
| 252 | integration: "google_calendar", | ||
| 253 | plugin_id: "openapi", | ||
| 254 | name: "calendar.events.list", | ||
| 255 | }, | ||
| 256 | ]); | ||
| 257 | |||
| 258 | const policies = yield* Effect.promise(() => | ||
| 259 | client.execute("SELECT id, pattern, action FROM tool_policy"), | ||
| 260 | ); | ||
| 261 | expect(policies.rows).toEqual([ | ||
| 262 | { | ||
| 263 | id: "policy_orphan", | ||
| 264 | pattern: "google_calendar.*.*.gmail.users.messages.send", | ||
| 265 | action: "block", | ||
| 266 | }, | ||
| 267 | ]); | ||
| 268 | |||
| 269 | yield* Effect.promise(() => db.close()); | ||
| 270 | }), | ||
| 271 | ); | ||
| 272 | |||
| 273 | it.effect("skips an org intact when a source blob is missing", () => | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 276 | const client = db.client; | ||
| 277 | |||
| 278 | yield* seedCalendarOrg(client, "org_1", { includeBlobs: false }); | ||
| 279 | |||
| 280 | expect(yield* runSqliteProviderServiceSplitMigration(client)).toBe(0); | ||
| 281 | |||
| 282 | const integrations = yield* Effect.promise(() => | ||
| 283 | client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"), | ||
| 284 | ); | ||
| 285 | expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]); | ||
| 286 | |||
| 287 | yield* Effect.promise(() => db.close()); | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | it.effect("uses the per-org ledger to recover after a mid-run crash", () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 294 | const client = db.client; | ||
| 295 | |||
| 296 | yield* seedCalendarOrg(client, "org_1"); | ||
| 297 | yield* seedCalendarOrg(client, "org_2"); | ||
| 298 | |||
| 299 | const failed = yield* Effect.flip( | ||
| 300 | runSqliteProviderServiceSplitMigration(client, { | ||
| 301 | beforeStampOrg: (org) => | ||
| 302 | org.tenant === "org_2" | ||
| 303 | ? Effect.fail( | ||
| 304 | new DataMigrationError({ | ||
| 305 | migration: "2026-07-08-provider-service-split", | ||
| 306 | cause: "crash before org_2 stamp", | ||
| 307 | }), | ||
| 308 | ) | ||
| 309 | : Effect.void, | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | expect(failed).toBeInstanceOf(DataMigrationError); | ||
| 313 | |||
| 314 | const ledgerAfterCrash = yield* Effect.promise(() => | ||
| 315 | client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"), | ||
| 316 | ); | ||
| 317 | expect(ledgerAfterCrash.rows).toEqual([{ tenant: "org_1" }]); | ||
| 318 | |||
| 319 | expect(yield* runSqliteProviderServiceSplitMigration(client)).toBe(1); | ||
| 320 | |||
| 321 | const ledgerAfterRerun = yield* Effect.promise(() => | ||
| 322 | client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"), | ||
| 323 | ); | ||
| 324 | expect(ledgerAfterRerun.rows).toEqual([{ tenant: "org_1" }, { tenant: "org_2" }]); | ||
| 325 | |||
| 326 | const integrations = yield* Effect.promise(() => | ||
| 327 | client.execute("SELECT tenant, slug, plugin_id FROM integration ORDER BY tenant, slug"), | ||
| 328 | ); | ||
| 329 | expect(integrations.rows).toEqual([ | ||
| 330 | { tenant: "org_1", slug: "google_calendar", plugin_id: "openapi" }, | ||
| 331 | { tenant: "org_2", slug: "google_calendar", plugin_id: "openapi" }, | ||
| 332 | ]); | ||
| 333 | |||
| 334 | yield* Effect.promise(() => db.close()); | ||
| 335 | }), | ||
| 336 | ); | ||
| 337 | }); | ||
| @@ -0,0 +1,549 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-json-parse -- boundary: one-shot provider service split data migration preserves legacy SQLite row coercion and typed DataMigrationError wrapping */ | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | operationStorageKey, | ||
| 7 | planMigration, | ||
| 8 | storageDataRecord, | ||
| 9 | tenantHash, | ||
| 10 | type BlobRow, | ||
| 11 | type ConnectionRow, | ||
| 12 | type IntegrationRow, | ||
| 13 | type MigrationInput, | ||
| 14 | type OrgPlan, | ||
| 15 | type PluginStorageRow, | ||
| 16 | type ToolPolicyRow, | ||
| 17 | type ToolRow, | ||
| 18 | } from "./planner"; | ||
| 19 | |||
| 20 | const MIGRATION_NAME = "2026-07-08-provider-service-split"; | ||
| 21 | const LEDGER_TABLE = "provider_service_split_org_migration"; | ||
| 22 | |||
| 23 | const operationKeyPrefix = (integration: string): string => | ||
| 24 | `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`; | ||
| 25 | |||
| 26 | const execute = ( | ||
| 27 | client: SqliteDataMigrationClient, | ||
| 28 | stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, | ||
| 29 | ) => | ||
| 30 | Effect.tryPromise({ | ||
| 31 | try: () => client.execute(stmt), | ||
| 32 | catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | const parseJsonLike = (value: unknown): unknown => { | ||
| 36 | if (typeof value !== "string") return value; | ||
| 37 | try { | ||
| 38 | return JSON.parse(value) as unknown; | ||
| 39 | } catch { | ||
| 40 | return value; | ||
| 41 | } | ||
| 42 | }; | ||
| 43 | |||
| 44 | const scrubJson = (value: unknown): unknown => { | ||
| 45 | if (Array.isArray(value)) return value.map(scrubJson); | ||
| 46 | if (value && typeof value === "object") { | ||
| 47 | return Object.fromEntries( | ||
| 48 | Object.entries(value) | ||
| 49 | .filter(([, inner]) => inner !== undefined) | ||
| 50 | .map(([key, inner]) => [key, scrubJson(inner)]), | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | return value; | ||
| 54 | }; | ||
| 55 | |||
| 56 | const stableId = (...parts: readonly string[]): string => { | ||
| 57 | const text = parts.join("\u0000"); | ||
| 58 | let hash = 0; | ||
| 59 | for (let index = 0; index < text.length; index += 1) { | ||
| 60 | hash = (hash * 31 + text.charCodeAt(index)) >>> 0; | ||
| 61 | } | ||
| 62 | return `service_split_${hash.toString(36)}`; | ||
| 63 | }; | ||
| 64 | |||
| 65 | const tableExists = (client: SqliteDataMigrationClient, table: string) => | ||
| 66 | execute(client, { | ||
| 67 | sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", | ||
| 68 | args: [table], | ||
| 69 | }).pipe(Effect.map((result) => result.rows.length > 0)); | ||
| 70 | |||
| 71 | const ensureLedgerTable = (client: SqliteDataMigrationClient) => | ||
| 72 | execute( | ||
| 73 | client, | ||
| 74 | `CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} ( | ||
| 75 | tenant text PRIMARY KEY NOT NULL, | ||
| 76 | time_completed integer NOT NULL | ||
| 77 | )`, | ||
| 78 | ); | ||
| 79 | |||
| 80 | const readCompletedTenants = (client: SqliteDataMigrationClient) => | ||
| 81 | execute(client, `SELECT tenant FROM ${LEDGER_TABLE} ORDER BY tenant`).pipe( | ||
| 82 | Effect.map((result) => result.rows.map((row) => String(row.tenant))), | ||
| 83 | ); | ||
| 84 | |||
| 85 | const stampCompletedTenant = (client: SqliteDataMigrationClient, tenant: string) => | ||
| 86 | execute(client, { | ||
| 87 | sql: `INSERT INTO ${LEDGER_TABLE} (tenant, time_completed) | ||
| 88 | VALUES (?, ?) | ||
| 89 | ON CONFLICT(tenant) DO UPDATE SET time_completed = excluded.time_completed`, | ||
| 90 | args: [tenant, Date.now()], | ||
| 91 | }); | ||
| 92 | |||
| 93 | const readDatabaseInput = ( | ||
| 94 | client: SqliteDataMigrationClient, | ||
| 95 | options: { | ||
| 96 | readonly blobBackend?: MigrationInput["blobBackend"]; | ||
| 97 | readonly blobs?: readonly BlobRow[]; | ||
| 98 | } = {}, | ||
| 99 | ): Effect.Effect<MigrationInput, DataMigrationError> => | ||
| 100 | Effect.gen(function* () { | ||
| 101 | yield* ensureLedgerTable(client); | ||
| 102 | const completedTenants = yield* readCompletedTenants(client); | ||
| 103 | const integrations = yield* execute( | ||
| 104 | client, | ||
| 105 | `SELECT tenant, slug, plugin_id, name, description, config, health_check, | ||
| 106 | config_revised_at, can_remove, can_refresh, CAST(created_at AS TEXT) AS created_at, | ||
| 107 | CAST(updated_at AS TEXT) AS updated_at, row_id | ||
| 108 | FROM integration | ||
| 109 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 110 | OR slug IN ('google', 'microsoft') | ||
| 111 | OR tenant IN ( | ||
| 112 | SELECT tenant FROM integration | ||
| 113 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 114 | ) | ||
| 115 | ORDER BY tenant, slug`, | ||
| 116 | ); | ||
| 117 | const monolithTenants = new Set( | ||
| 118 | integrations.rows | ||
| 119 | .filter((row) => row.plugin_id === "google" || row.plugin_id === "microsoft") | ||
| 120 | .map((row) => String(row.tenant)), | ||
| 121 | ); | ||
| 122 | if (monolithTenants.size === 0) { | ||
| 123 | return { | ||
| 124 | integrations: [], | ||
| 125 | connections: [], | ||
| 126 | tools: [], | ||
| 127 | pluginStorage: [], | ||
| 128 | blobs: options.blobs ?? [], | ||
| 129 | policies: [], | ||
| 130 | completedTenants, | ||
| 131 | }; | ||
| 132 | } | ||
| 133 | |||
| 134 | const connections = yield* execute( | ||
| 135 | client, | ||
| 136 | `SELECT tenant, owner, subject, integration, name, template, provider, item_ids, | ||
| 137 | identity_label, description, last_health, tools_synced_at, oauth_client, | ||
| 138 | oauth_client_owner, refresh_item_id, expires_at, oauth_scope, oauth_token_url, | ||
| 139 | provider_state, CAST(created_at AS TEXT) AS created_at, | ||
| 140 | CAST(updated_at AS TEXT) AS updated_at, row_id | ||
| 141 | FROM connection | ||
| 142 | ORDER BY tenant, integration, owner, subject, name`, | ||
| 143 | ); | ||
| 144 | const tools = yield* execute( | ||
| 145 | client, | ||
| 146 | `SELECT tenant, owner, subject, integration, connection, plugin_id, name, description, | ||
| 147 | input_schema, output_schema, annotations, CAST(created_at AS TEXT) AS created_at, | ||
| 148 | CAST(updated_at AS TEXT) AS updated_at, row_id | ||
| 149 | FROM tool | ||
| 150 | ORDER BY tenant, integration, connection, name`, | ||
| 151 | ); | ||
| 152 | const pluginStorage = yield* execute( | ||
| 153 | client, | ||
| 154 | `SELECT tenant, owner, subject, plugin_id, collection, key, data, | ||
| 155 | CAST(created_at AS TEXT) AS created_at, CAST(updated_at AS TEXT) AS updated_at, row_id | ||
| 156 | FROM plugin_storage | ||
| 157 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 158 | AND collection = 'operation' | ||
| 159 | ORDER BY tenant, plugin_id, collection, key`, | ||
| 160 | ); | ||
| 161 | const blobs = yield* execute( | ||
| 162 | client, | ||
| 163 | `SELECT id, namespace, key | ||
| 164 | FROM blob | ||
| 165 | WHERE key LIKE 'spec/%' OR key LIKE 'defs/%' | ||
| 166 | ORDER BY namespace, key`, | ||
| 167 | ); | ||
| 168 | const policies = yield* execute( | ||
| 169 | client, | ||
| 170 | `SELECT tenant, owner, subject, id, pattern, action, position, | ||
| 171 | CAST(created_at AS TEXT) AS created_at, CAST(updated_at AS TEXT) AS updated_at, row_id | ||
| 172 | FROM tool_policy | ||
| 173 | ORDER BY tenant, owner, subject, position, id`, | ||
| 174 | ); | ||
| 175 | |||
| 176 | const tenantFilter = (row: Record<string, unknown>) => monolithTenants.has(String(row.tenant)); | ||
| 177 | return { | ||
| 178 | integrations: integrations.rows.filter(tenantFilter).map( | ||
| 179 | (row): IntegrationRow => ({ | ||
| 180 | tenant: String(row.tenant), | ||
| 181 | slug: String(row.slug), | ||
| 182 | plugin_id: String(row.plugin_id), | ||
| 183 | name: typeof row.name === "string" ? row.name : null, | ||
| 184 | description: typeof row.description === "string" ? row.description : null, | ||
| 185 | config: parseJsonLike(row.config), | ||
| 186 | health_check: parseJsonLike(row.health_check), | ||
| 187 | config_revised_at: row.config_revised_at as string | number | bigint | null, | ||
| 188 | can_remove: Boolean(row.can_remove), | ||
| 189 | can_refresh: Boolean(row.can_refresh), | ||
| 190 | created_at: String(row.created_at), | ||
| 191 | updated_at: String(row.updated_at), | ||
| 192 | row_id: String(row.row_id), | ||
| 193 | }), | ||
| 194 | ), | ||
| 195 | connections: connections.rows.filter(tenantFilter).map( | ||
| 196 | (row): ConnectionRow => ({ | ||
| 197 | tenant: String(row.tenant), | ||
| 198 | owner: String(row.owner), | ||
| 199 | subject: String(row.subject), | ||
| 200 | integration: String(row.integration), | ||
| 201 | name: String(row.name), | ||
| 202 | template: String(row.template), | ||
| 203 | provider: String(row.provider), | ||
| 204 | item_ids: parseJsonLike(row.item_ids), | ||
| 205 | identity_label: typeof row.identity_label === "string" ? row.identity_label : null, | ||
| 206 | description: typeof row.description === "string" ? row.description : null, | ||
| 207 | last_health: parseJsonLike(row.last_health), | ||
| 208 | tools_synced_at: row.tools_synced_at as string | number | bigint | null, | ||
| 209 | oauth_client: typeof row.oauth_client === "string" ? row.oauth_client : null, | ||
| 210 | oauth_client_owner: | ||
| 211 | typeof row.oauth_client_owner === "string" ? row.oauth_client_owner : null, | ||
| 212 | refresh_item_id: typeof row.refresh_item_id === "string" ? row.refresh_item_id : null, | ||
| 213 | expires_at: row.expires_at as string | number | bigint | null, | ||
| 214 | oauth_scope: typeof row.oauth_scope === "string" ? row.oauth_scope : null, | ||
| 215 | oauth_token_url: typeof row.oauth_token_url === "string" ? row.oauth_token_url : null, | ||
| 216 | provider_state: parseJsonLike(row.provider_state), | ||
| 217 | created_at: String(row.created_at), | ||
| 218 | updated_at: String(row.updated_at), | ||
| 219 | row_id: String(row.row_id), | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | tools: tools.rows.filter(tenantFilter).map( | ||
| 223 | (row): ToolRow => ({ | ||
| 224 | tenant: String(row.tenant), | ||
| 225 | owner: String(row.owner), | ||
| 226 | subject: String(row.subject), | ||
| 227 | integration: String(row.integration), | ||
| 228 | connection: String(row.connection), | ||
| 229 | plugin_id: String(row.plugin_id), | ||
| 230 | name: String(row.name), | ||
| 231 | description: typeof row.description === "string" ? row.description : undefined, | ||
| 232 | input_schema: parseJsonLike(row.input_schema), | ||
| 233 | output_schema: parseJsonLike(row.output_schema), | ||
| 234 | annotations: parseJsonLike(row.annotations), | ||
| 235 | created_at: String(row.created_at), | ||
| 236 | updated_at: String(row.updated_at), | ||
| 237 | row_id: String(row.row_id), | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | pluginStorage: pluginStorage.rows.filter(tenantFilter).map( | ||
| 241 | (row): PluginStorageRow => ({ | ||
| 242 | tenant: String(row.tenant), | ||
| 243 | owner: String(row.owner), | ||
| 244 | subject: String(row.subject), | ||
| 245 | plugin_id: String(row.plugin_id), | ||
| 246 | collection: String(row.collection), | ||
| 247 | key: String(row.key), | ||
| 248 | data: parseJsonLike(row.data), | ||
| 249 | created_at: String(row.created_at), | ||
| 250 | updated_at: String(row.updated_at), | ||
| 251 | row_id: String(row.row_id), | ||
| 252 | }), | ||
| 253 | ), | ||
| 254 | blobs: [ | ||
| 255 | ...blobs.rows.map( | ||
| 256 | (row): BlobRow => ({ | ||
| 257 | id: String(row.id), | ||
| 258 | namespace: String(row.namespace), | ||
| 259 | key: String(row.key), | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ...(options.blobs ?? []), | ||
| 263 | ], | ||
| 264 | policies: policies.rows.filter(tenantFilter).map( | ||
| 265 | (row): ToolPolicyRow => ({ | ||
| 266 | tenant: String(row.tenant), | ||
| 267 | owner: String(row.owner), | ||
| 268 | subject: String(row.subject), | ||
| 269 | id: String(row.id), | ||
| 270 | pattern: String(row.pattern), | ||
| 271 | action: String(row.action), | ||
| 272 | position: String(row.position), | ||
| 273 | created_at: String(row.created_at), | ||
| 274 | updated_at: String(row.updated_at), | ||
| 275 | row_id: String(row.row_id), | ||
| 276 | }), | ||
| 277 | ), | ||
| 278 | blobBackend: options.blobBackend ?? "database", | ||
| 279 | orphanPolicyMode: "retarget_all", | ||
| 280 | collectPolicyErrors: true, | ||
| 281 | bootRailCreateToolImpliedServices: true, | ||
| 282 | completedTenants, | ||
| 283 | }; | ||
| 284 | }); | ||
| 285 | |||
| 286 | const applyOrg = ( | ||
| 287 | client: SqliteDataMigrationClient, | ||
| 288 | org: OrgPlan, | ||
| 289 | ): Effect.Effect<void, DataMigrationError> => | ||
| 290 | Effect.gen(function* () { | ||
| 291 | if (org.completed) return; | ||
| 292 | if (org.hardErrors.length > 0) return; | ||
| 293 | const now = Date.now(); | ||
| 294 | |||
| 295 | for (const copy of org.blobCopies.filter((item) => item.backend === "database")) { | ||
| 296 | const source = yield* execute(client, { | ||
| 297 | sql: "SELECT value FROM blob WHERE namespace = ? AND key = ? LIMIT 1", | ||
| 298 | args: [copy.sourceNamespace, copy.key], | ||
| 299 | }); | ||
| 300 | const sourceRow = source.rows[0]; | ||
| 301 | if (!sourceRow || typeof sourceRow.value !== "string") { | ||
| 302 | return yield* new DataMigrationError({ | ||
| 303 | migration: MIGRATION_NAME, | ||
| 304 | cause: `Missing source blob ${copy.sourceNamespace}/${copy.key}`, | ||
| 305 | }); | ||
| 306 | } | ||
| 307 | yield* execute(client, { | ||
| 308 | sql: `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id) | ||
| 309 | VALUES (?, ?, ?, ?, ?)`, | ||
| 310 | args: [ | ||
| 311 | copy.targetNamespace, | ||
| 312 | copy.key, | ||
| 313 | sourceRow.value, | ||
| 314 | stableId("blob", org.tenant, copy.targetNamespace, copy.key), | ||
| 315 | JSON.stringify([copy.targetNamespace, copy.key]), | ||
| 316 | ], | ||
| 317 | }); | ||
| 318 | } | ||
| 319 | |||
| 320 | for (const row of org.integrations.filter((item) => item.action === "create")) { | ||
| 321 | yield* execute(client, { | ||
| 322 | sql: `INSERT OR IGNORE INTO integration | ||
| 323 | (slug, plugin_id, name, description, config, health_check, config_revised_at, | ||
| 324 | can_remove, can_refresh, created_at, updated_at, row_id, tenant) | ||
| 325 | VALUES (?, ?, ?, ?, ?, ?, NULL, 1, 1, ?, ?, ?, ?)`, | ||
| 326 | args: [ | ||
| 327 | row.target.slug, | ||
| 328 | row.target.pluginId, | ||
| 329 | row.target.name, | ||
| 330 | row.target.description, | ||
| 331 | JSON.stringify(scrubJson(row.config)), | ||
| 332 | row.healthCheck ? JSON.stringify(scrubJson(row.healthCheck)) : null, | ||
| 333 | now, | ||
| 334 | now, | ||
| 335 | stableId("integration", org.tenant, row.target.slug), | ||
| 336 | org.tenant, | ||
| 337 | ], | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | |||
| 341 | for (const row of org.connections.filter((item) => item.action === "clone")) { | ||
| 342 | yield* execute(client, { | ||
| 343 | sql: `INSERT OR IGNORE INTO connection | ||
| 344 | (integration, name, template, provider, item_ids, identity_label, description, | ||
| 345 | last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | ||
| 346 | expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | ||
| 347 | row_id, tenant, owner, subject) | ||
| 348 | SELECT ?, name, template, provider, item_ids, identity_label, description, | ||
| 349 | NULL, NULL, oauth_client, oauth_client_owner, refresh_item_id, expires_at, | ||
| 350 | oauth_scope, oauth_token_url, provider_state, created_at, ?, ?, tenant, owner, subject | ||
| 351 | FROM connection | ||
| 352 | WHERE tenant = ? AND owner = ? AND subject = ? AND integration = ? AND name = ?`, | ||
| 353 | args: [ | ||
| 354 | row.targetIntegration, | ||
| 355 | now, | ||
| 356 | stableId( | ||
| 357 | "connection", | ||
| 358 | org.tenant, | ||
| 359 | row.source.owner, | ||
| 360 | row.source.subject, | ||
| 361 | row.targetIntegration, | ||
| 362 | row.source.name, | ||
| 363 | ), | ||
| 364 | org.tenant, | ||
| 365 | row.source.owner, | ||
| 366 | row.source.subject, | ||
| 367 | row.source.integration, | ||
| 368 | row.source.name, | ||
| 369 | ], | ||
| 370 | }); | ||
| 371 | } | ||
| 372 | |||
| 373 | for (const integration of org.integrations) { | ||
| 374 | for (const contribution of integration.sourceContributions) { | ||
| 375 | for (const toolName of contribution.operationToolNames) { | ||
| 376 | const operation = yield* execute(client, { | ||
| 377 | sql: `SELECT data, created_at, updated_at | ||
| 378 | FROM plugin_storage | ||
| 379 | WHERE tenant = ? | ||
| 380 | AND owner = 'org' | ||
| 381 | AND subject = '' | ||
| 382 | AND plugin_id = ? | ||
| 383 | AND collection = 'operation' | ||
| 384 | AND ( | ||
| 385 | key = ? | ||
| 386 | OR ( | ||
| 387 | json_extract(data, '$.integration') = ? | ||
| 388 | AND json_extract(data, '$.toolName') = ? | ||
| 389 | ) | ||
| 390 | ) | ||
| 391 | LIMIT 1`, | ||
| 392 | args: [ | ||
| 393 | org.tenant, | ||
| 394 | contribution.source.plugin_id, | ||
| 395 | operationStorageKey(contribution.source.slug, toolName), | ||
| 396 | contribution.source.slug, | ||
| 397 | toolName, | ||
| 398 | ], | ||
| 399 | }); | ||
| 400 | const source = operation.rows[0]; | ||
| 401 | if (!source) { | ||
| 402 | return yield* new DataMigrationError({ | ||
| 403 | migration: MIGRATION_NAME, | ||
| 404 | cause: new Error( | ||
| 405 | `Missing operation row for ${tenantHash(org.tenant)}/${contribution.source.slug}/${toolName}`, | ||
| 406 | ), | ||
| 407 | }); | ||
| 408 | } | ||
| 409 | yield* execute(client, { | ||
| 410 | sql: `INSERT INTO plugin_storage | ||
| 411 | (plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 412 | VALUES (?, 'operation', ?, ?, ?, ?, ?, ?, 'org', '') | ||
| 413 | ON CONFLICT(tenant, owner, subject, plugin_id, collection, key) | ||
| 414 | DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, | ||
| 415 | args: [ | ||
| 416 | integration.target.pluginId, | ||
| 417 | operationStorageKey(integration.target.slug, toolName), | ||
| 418 | JSON.stringify( | ||
| 419 | scrubJson({ | ||
| 420 | ...storageDataRecord({ data: parseJsonLike(source.data) }), | ||
| 421 | integration: integration.target.slug, | ||
| 422 | toolName, | ||
| 423 | }), | ||
| 424 | ), | ||
| 425 | source.created_at, | ||
| 426 | now, | ||
| 427 | stableId("operation", org.tenant, integration.target.slug, toolName), | ||
| 428 | org.tenant, | ||
| 429 | ], | ||
| 430 | }); | ||
| 431 | yield* execute(client, { | ||
| 432 | sql: `INSERT OR IGNORE INTO tool | ||
| 433 | (integration, connection, plugin_id, name, description, input_schema, output_schema, | ||
| 434 | annotations, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 435 | SELECT ?, connection, ?, name, description, input_schema, output_schema, | ||
| 436 | annotations, created_at, ?, ? || '_' || row_id, tenant, owner, subject | ||
| 437 | FROM tool | ||
| 438 | WHERE tenant = ? AND integration = ? AND name = ?`, | ||
| 439 | args: [ | ||
| 440 | integration.target.slug, | ||
| 441 | integration.target.pluginId, | ||
| 442 | now, | ||
| 443 | stableId("tool", org.tenant, integration.target.slug), | ||
| 444 | org.tenant, | ||
| 445 | contribution.source.slug, | ||
| 446 | toolName, | ||
| 447 | ], | ||
| 448 | }); | ||
| 449 | } | ||
| 450 | } | ||
| 451 | } | ||
| 452 | |||
| 453 | for (const policy of org.policies) { | ||
| 454 | yield* execute(client, { | ||
| 455 | sql: "DELETE FROM tool_policy WHERE tenant = ? AND owner = ? AND subject = ? AND id = ?", | ||
| 456 | args: [org.tenant, policy.policy.owner, policy.policy.subject, policy.policy.id], | ||
| 457 | }); | ||
| 458 | for (const [index, pattern] of policy.afterPatterns.entries()) { | ||
| 459 | yield* execute(client, { | ||
| 460 | sql: `INSERT OR IGNORE INTO tool_policy | ||
| 461 | (id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject) | ||
| 462 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 463 | args: [ | ||
| 464 | policy.afterPatterns.length === 1 | ||
| 465 | ? policy.policy.id | ||
| 466 | : `${policy.policy.id}_${index + 1}`, | ||
| 467 | pattern, | ||
| 468 | policy.policy.action, | ||
| 469 | policy.policy.position, | ||
| 470 | now, | ||
| 471 | now, | ||
| 472 | stableId("policy", org.tenant, policy.policy.id, pattern), | ||
| 473 | org.tenant, | ||
| 474 | policy.policy.owner, | ||
| 475 | policy.policy.subject, | ||
| 476 | ], | ||
| 477 | }); | ||
| 478 | } | ||
| 479 | } | ||
| 480 | |||
| 481 | for (const monolith of org.deleteMonoliths) { | ||
| 482 | yield* execute(client, { | ||
| 483 | sql: "DELETE FROM tool WHERE tenant = ? AND integration = ?", | ||
| 484 | args: [org.tenant, monolith.slug], | ||
| 485 | }); | ||
| 486 | yield* execute(client, { | ||
| 487 | sql: "DELETE FROM connection WHERE tenant = ? AND integration = ?", | ||
| 488 | args: [org.tenant, monolith.slug], | ||
| 489 | }); | ||
| 490 | yield* execute(client, { | ||
| 491 | sql: `DELETE FROM plugin_storage | ||
| 492 | WHERE tenant = ? | ||
| 493 | AND owner = 'org' | ||
| 494 | AND subject = '' | ||
| 495 | AND plugin_id = ? | ||
| 496 | AND collection = 'operation' | ||
| 497 | AND ( | ||
| 498 | json_extract(data, '$.integration') = ? | ||
| 499 | OR key LIKE ? | ||
| 500 | )`, | ||
| 501 | args: [org.tenant, monolith.plugin_id, monolith.slug, operationKeyPrefix(monolith.slug)], | ||
| 502 | }); | ||
| 503 | yield* execute(client, { | ||
| 504 | sql: "DELETE FROM integration WHERE tenant = ? AND slug = ? AND plugin_id = ?", | ||
| 505 | args: [org.tenant, monolith.slug, monolith.plugin_id], | ||
| 506 | }); | ||
| 507 | } | ||
| 508 | }); | ||
| 509 | |||
| 510 | export const runSqliteProviderServiceSplitMigration = ( | ||
| 511 | client: SqliteDataMigrationClient, | ||
| 512 | options: { | ||
| 513 | readonly beforeStampOrg?: (org: OrgPlan) => Effect.Effect<void, DataMigrationError>; | ||
| 514 | readonly blobBackend?: MigrationInput["blobBackend"]; | ||
| 515 | readonly blobs?: readonly BlobRow[]; | ||
| 516 | } = {}, | ||
| 517 | ): Effect.Effect<number, DataMigrationError> => | ||
| 518 | Effect.gen(function* () { | ||
| 519 | if (!(yield* tableExists(client, "integration"))) return 0; | ||
| 520 | const input = yield* readDatabaseInput(client, options); | ||
| 521 | const plan = planMigration(input); | ||
| 522 | const moved = plan.orgs.filter((org) => !org.completed && org.hardErrors.length === 0).length; | ||
| 523 | if (moved === 0) return 0; | ||
| 524 | |||
| 525 | for (const org of plan.orgs) { | ||
| 526 | if (org.completed) continue; | ||
| 527 | if (org.hardErrors.length > 0) { | ||
| 528 | console.warn( | ||
| 529 | `provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`, | ||
| 530 | ); | ||
| 531 | continue; | ||
| 532 | } | ||
| 533 | yield* execute(client, "BEGIN"); | ||
| 534 | const applyOne = Effect.gen(function* () { | ||
| 535 | yield* applyOrg(client, org); | ||
| 536 | if (options.beforeStampOrg) yield* options.beforeStampOrg(org); | ||
| 537 | yield* stampCompletedTenant(client, org.tenant); | ||
| 538 | yield* execute(client, "COMMIT"); | ||
| 539 | }); | ||
| 540 | yield* applyOne.pipe(Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore))); | ||
| 541 | } | ||
| 542 | return moved; | ||
| 543 | }); | ||
| 544 | |||
| 545 | export const providerServiceSplitDataMigration = { | ||
| 546 | name: MIGRATION_NAME, | ||
| 547 | run: (client: SqliteDataMigrationClient) => | ||
| 548 | runSqliteProviderServiceSplitMigration(client).pipe(Effect.asVoid), | ||
| 549 | }; | ||
| @@ -19,6 +19,5 @@ · 18 unmodified lines | |||
| 19 | } | 19 | } |
| 20 | ] | 20 | ] |
| 21 | }, | 21 | }, |
| 22 | "include": ["src/**/*.ts", "src/**/*.tsx"], | 22 | "include": ["src/**/*.ts"] |
| 23 | "exclude": ["src/promise.ts"] | ||
| 24 | } | 23 | } |
| @@ -2,9 +2,7 @@ import { defineConfig } from "tsup"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | export default defineConfig({ | 3 | export default defineConfig({ |
| 4 | entry: { | 4 | entry: { |
| 5 | index: "src/promise.ts", | 5 | index: "src/index.ts", |
| 6 | core: "src/sdk/index.ts", | ||
| 7 | client: "src/react/plugin-client.tsx", | ||
| 8 | }, | 6 | }, |
| 9 | format: ["esm"], | 7 | format: ["esm"], |
| 10 | dts: false, | 8 | dts: false, |
| @@ -0,0 +1,8 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts"], | ||
| 6 | testTimeout: 15_000, | ||
| 7 | }, | ||
| 8 | }); | ||
| @@ -366,6 +366,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 365 unmodified lines | |||
| 366 | oauthClient: null, | 366 | oauthClient: null, |
| 367 | oauthClientOwner: null, | 367 | oauthClientOwner: null, |
| 368 | oauthScope: null, | 368 | oauthScope: null, |
| 369 | missingOAuthScopes: [], | ||
| 369 | lastHealth: null, | 370 | lastHealth: null, |
| 370 | }; | 371 | }; |
| 371 | return [optimistic, ...rows]; | 372 | return [optimistic, ...rows]; |
| @@ -85,16 +85,17 @@ function AccountRow(props: { · 84 unmodified lines | |||
| 85 | const { probe, status, runCheck } = useConnectionHealth(connection); | 85 | const { probe, status, runCheck } = useConnectionHealth(connection); |
| 86 | const indicator = HEALTH_INDICATOR_COLOR[status]; | 86 | const indicator = HEALTH_INDICATOR_COLOR[status]; |
| 87 | 87 | ||
| 88 | // Prefer a probed identity (the live account), then the stored label, then the | 88 | // Prefer the stored label from the connection row, then a probed identity, |
| 89 | // connection name. The probe is the whole point: it shows WHICH account this is. | 89 | // then the connection name. OAuth labels come from the grant's OIDC claims, |
| 90 | // while health identities remain useful for non-OAuth probes. | ||
| 90 | const identity = | 91 | const identity = |
| 91 | (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ?? | ||
| 92 | (connection.identityLabel && connection.identityLabel.length > 0 | 92 | (connection.identityLabel && connection.identityLabel.length > 0 |
| 93 | ? connection.identityLabel | 93 | ? connection.identityLabel |
| 94 | : null); | 94 | : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); |
| 95 | const displayLabel = identity ?? String(connection.name); | 95 | const displayLabel = identity ?? String(connection.name); |
| 96 | 96 | ||
| 97 | const expired = status === "expired"; | 97 | const expired = status === "expired"; |
| 98 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; | ||
| 98 | 99 | ||
| 99 | const handleCheck = async () => { | 100 | const handleCheck = async () => { |
| 100 | if (checking) return; | 101 | if (checking) return; |
| @@ -150,6 +151,11 @@ function AccountRow(props: { · 49 unmodified lines | |||
| 150 | This connection wasn't granted all the access this integration now needs. | 151 | This connection wasn't granted all the access this integration now needs. |
| 151 | </CardStackEntryDescription> | 152 | </CardStackEntryDescription> |
| 152 | ) : null} | 153 | ) : null} |
| 154 | {missingOAuthScopes.length > 0 ? ( | ||
| 155 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> | ||
| 156 | Missing scopes: {missingOAuthScopes.join(", ")} | ||
| 157 | </CardStackEntryDescription> | ||
| 158 | ) : null} | ||
| 153 | </CardStackEntryContent> | 159 | </CardStackEntryContent> |
| 154 | <CardStackEntryActions className="self-start pt-0.5"> | 160 | <CardStackEntryActions className="self-start pt-0.5"> |
| 155 | {props.showOwnerLabel ? ( | 161 | {props.showOwnerLabel ? ( |
| @@ -477,8 +477,8 @@ function HealthCheckEditorSheet(props: { · 476 unmodified lines | |||
| 477 | <SheetHeader> | 477 | <SheetHeader> |
| 478 | <SheetTitle>Health check</SheetTitle> | 478 | <SheetTitle>Health check</SheetTitle> |
| 479 | <SheetDescription> | 479 | <SheetDescription> |
| 480 | One read-only call Executor runs to tell whether a connection's credential is still | 480 | Optional read-only depth for live upstream checks. OAuth validity and identity come from |
| 481 | alive and, optionally, whose account it is. A 401 or 403 marks the connection expired. | 481 | the grant itself. For non-OAuth methods, a 401 or 403 marks the connection expired. |
| 482 | </SheetDescription> | 482 | </SheetDescription> |
| 483 | </SheetHeader> | 483 | </SheetHeader> |
| 484 | 484 | ||
| @@ -553,8 +553,8 @@ export function HealthCheckEditor(props: { · 68 unmodified lines | |||
| 553 | <div className="space-y-1"> | 553 | <div className="space-y-1"> |
| 554 | <h3 className="text-sm font-medium">Health check</h3> | 554 | <h3 className="text-sm font-medium">Health check</h3> |
| 555 | <p className="text-xs text-muted-foreground"> | 555 | <p className="text-xs text-muted-foreground"> |
| 556 | A read-only call Executor runs to tell whether a connection's credential is still alive | 556 | Optional read-only depth for live upstream checks. OAuth identity comes from the account |
| 557 | and, optionally, whose account it is. | 557 | grant, while probe identity fields are useful for non-OAuth methods. |
| 558 | </p> | 558 | </p> |
| 559 | </div> | 559 | </div> |
| 560 | <div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5"> | 560 | <div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5"> |
| @@ -224,6 +224,28 @@ describe("IntegrationFavicon", () => { · 223 unmodified lines | |||
| 224 | ).toBe("https://example.com/stripe.png"); | 224 | ).toBe("https://example.com/stripe.png"); |
| 225 | }); | 225 | }); |
| 226 | 226 | ||
| 227 | it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => { | ||
| 228 | expect( | ||
| 229 | integrationPresetIconUrl({ id: "google_gmail", kind: "openapi", name: "Gmail" }, [ | ||
| 230 | { | ||
| 231 | key: "openapi", | ||
| 232 | label: "OpenAPI", | ||
| 233 | add: () => null, | ||
| 234 | edit: () => null, | ||
| 235 | presets: [ | ||
| 236 | { | ||
| 237 | id: "google-gmail", | ||
| 238 | name: "Gmail", | ||
| 239 | summary: "Messages.", | ||
| 240 | defaultSlug: "google_gmail", | ||
| 241 | icon: "https://example.com/gmail.png", | ||
| 242 | }, | ||
| 243 | ], | ||
| 244 | }, | ||
| 245 | ]), | ||
| 246 | ).toBe("https://example.com/gmail.png"); | ||
| 247 | }); | ||
| 248 | |||
| 227 | it("does not split generic words into brand matches", () => { | 249 | it("does not split generic words into brand matches", () => { |
| 228 | expect( | 250 | expect( |
| 229 | integrationPresetIconUrl( | 251 | integrationPresetIconUrl( |
| @@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines | |||
| 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; | 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; |
| 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); | 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 159 | const presets = plugin?.presets ?? []; | 159 | const presets = plugin?.presets ?? []; |
| 160 | const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon; | ||
| 161 | if (exactSlugIcon) return exactSlugIcon; | ||
| 162 | |||
| 160 | const sourceUrl = normalizeUrl(source.url); | 163 | const sourceUrl = normalizeUrl(source.url); |
| 161 | const sourceGoogleService = googleApiServiceFromUrl(source.url); | 164 | const sourceGoogleService = googleApiServiceFromUrl(source.url); |
| 162 | const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)]; | 165 | const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)]; |
| @@ -0,0 +1,80 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | groupIntegrations, | ||
| 6 | integrationFamily, | ||
| 7 | type IntegrationFamilyGroup, | ||
| 8 | } from "./integration-grouping"; | ||
| 9 | |||
| 10 | const integration = (slug: string, kind: string, name = slug, family?: string): Integration => ({ | ||
| 11 | slug: IntegrationSlug.make(slug), | ||
| 12 | name, | ||
| 13 | description: name, | ||
| 14 | kind, | ||
| 15 | canRemove: true, | ||
| 16 | canRefresh: true, | ||
| 17 | authMethods: [], | ||
| 18 | ...(family ? { family } : {}), | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("groupIntegrations", () => { | ||
| 22 | it("collapses multi-member config families under one umbrella", () => { | ||
| 23 | const items = groupIntegrations([ | ||
| 24 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 25 | integration("google_gmail", "openapi", "Gmail", "google"), | ||
| 26 | integration("google_drive", "openapi", "Google Drive", "google"), | ||
| 27 | ]); | ||
| 28 | |||
| 29 | expect(items).toHaveLength(1); | ||
| 30 | const group = items[0] as IntegrationFamilyGroup; | ||
| 31 | expect(group.type).toBe("group"); | ||
| 32 | expect(group.family).toBe("google"); | ||
| 33 | expect(group.label).toBe("Google"); | ||
| 34 | expect(group.members.map((m) => String(m.slug))).toEqual([ | ||
| 35 | "google_calendar", | ||
| 36 | "google_gmail", | ||
| 37 | "google_drive", | ||
| 38 | ]); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("leaves integrations without config family ungrouped", () => { | ||
| 42 | const items = groupIntegrations([ | ||
| 43 | integration("stripe", "openapi", "Stripe"), | ||
| 44 | integration("sentry", "mcp", "Sentry"), | ||
| 45 | ]); | ||
| 46 | |||
| 47 | expect(items).toHaveLength(2); | ||
| 48 | expect(items.map((item) => item.type)).toEqual(["single", "single"]); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("renders a lone family member flat", () => { | ||
| 52 | const items = groupIntegrations([ | ||
| 53 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 54 | ]); | ||
| 55 | |||
| 56 | expect(items).toHaveLength(1); | ||
| 57 | expect(items[0]?.type).toBe("single"); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("places each group where its first member appeared", () => { | ||
| 61 | const items = groupIntegrations([ | ||
| 62 | integration("stripe", "openapi", "Stripe"), | ||
| 63 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 64 | integration("microsoft_mail", "openapi", "Outlook Mail", "microsoft"), | ||
| 65 | integration("google_gmail", "openapi", "Gmail", "google"), | ||
| 66 | integration("microsoft_calendar", "openapi", "Outlook Calendar", "microsoft"), | ||
| 67 | integration("sentry", "mcp", "Sentry"), | ||
| 68 | ]); | ||
| 69 | |||
| 70 | expect( | ||
| 71 | items.map((item) => (item.type === "group" ? `group:${item.family}` : "single")), | ||
| 72 | ).toEqual(["single", "group:google", "group:microsoft", "single"]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("does not group from integration kind alone", () => { | ||
| 76 | expect( | ||
| 77 | integrationFamily(integration("google_calendar", "google", "Google Calendar")), | ||
| 78 | ).toBeNull(); | ||
| 79 | }); | ||
| 80 | }); | ||
| @@ -0,0 +1,69 @@ | |||
| 1 | import type { Integration } from "@executor-js/sdk/shared"; | ||
| 2 | |||
| 3 | export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]); | ||
| 4 | |||
| 5 | const FAMILY_LABELS: Record<string, string> = { | ||
| 6 | google: "Google", | ||
| 7 | microsoft: "Microsoft", | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const familyLabel = (family: string): string => | ||
| 11 | FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1); | ||
| 12 | |||
| 13 | export const integrationFamily = (integration: Integration): string | null => { | ||
| 14 | const family = integration.family?.trim(); | ||
| 15 | return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export interface IntegrationFamilyGroup { | ||
| 19 | readonly type: "group"; | ||
| 20 | readonly family: string; | ||
| 21 | readonly label: string; | ||
| 22 | readonly members: readonly Integration[]; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface IntegrationSingle { | ||
| 26 | readonly type: "single"; | ||
| 27 | readonly integration: Integration; | ||
| 28 | } | ||
| 29 | |||
| 30 | export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle; | ||
| 31 | |||
| 32 | export function groupIntegrations( | ||
| 33 | integrations: readonly Integration[], | ||
| 34 | ): readonly IntegrationGridItem[] { | ||
| 35 | const familyCounts = new Map<string, number>(); | ||
| 36 | for (const integration of integrations) { | ||
| 37 | const family = integrationFamily(integration); | ||
| 38 | if (family) familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1); | ||
| 39 | } | ||
| 40 | |||
| 41 | const items: IntegrationGridItem[] = []; | ||
| 42 | const groupIndexByFamily = new Map<string, number>(); | ||
| 43 | |||
| 44 | for (const integration of integrations) { | ||
| 45 | const family = integrationFamily(integration); | ||
| 46 | const grouped = family !== null && (familyCounts.get(family) ?? 0) > 1; | ||
| 47 | |||
| 48 | if (!grouped || family === null) { | ||
| 49 | items.push({ type: "single", integration }); | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | |||
| 53 | const existing = groupIndexByFamily.get(family); | ||
| 54 | if (existing === undefined) { | ||
| 55 | groupIndexByFamily.set(family, items.length); | ||
| 56 | items.push({ | ||
| 57 | type: "group", | ||
| 58 | family, | ||
| 59 | label: familyLabel(family), | ||
| 60 | members: [integration], | ||
| 61 | }); | ||
| 62 | } else { | ||
| 63 | const group = items[existing] as IntegrationFamilyGroup; | ||
| 64 | items[existing] = { ...group, members: [...group.members, integration] }; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | return items; | ||
| 69 | } | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Suspense, useCallback, useMemo, useState } from "react"; | 1 | import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -33,11 +33,14 @@ import { · 28 unmodified lines | |||
| 33 | CardStackEntryDescription, | 33 | CardStackEntryDescription, |
| 34 | CardStackEntryMedia, | 34 | CardStackEntryMedia, |
| 35 | CardStackEntryTitle, | 35 | CardStackEntryTitle, |
| 36 | CardStackHeader, | ||
| 36 | } from "../components/card-stack"; | 37 | } from "../components/card-stack"; |
| 37 | import { | 38 | import { |
| 39 | IntegrationFavicon, | ||
| 38 | integrationInferredUrl, | 40 | integrationInferredUrl, |
| 39 | integrationPresetIconUrl, | 41 | integrationPresetIconUrl, |
| 40 | } from "../components/integration-favicon"; | 42 | } from "../components/integration-favicon"; |
| 43 | import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; | ||
| 41 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; | 44 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 42 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; | 45 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 43 | import { Skeleton } from "../components/skeleton"; | 46 | import { Skeleton } from "../components/skeleton"; |
| @@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines | |||
| 349 | const q = (props.searchQuery ?? "").trim().toLowerCase(); | 352 | const q = (props.searchQuery ?? "").trim().toLowerCase(); |
| 350 | if (q.length === 0) return allPresets; | 353 | if (q.length === 0) return allPresets; |
| 351 | return allPresets.filter(({ preset, pluginLabel }) => { | 354 | return allPresets.filter(({ preset, pluginLabel }) => { |
| 352 | const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); | 355 | const corpus = |
| 356 | `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); | ||
| 353 | return corpus.includes(q); | 357 | return corpus.includes(q); |
| 354 | }); | 358 | }); |
| 355 | }, [allPresets, props.searchQuery]); | 359 | }, [allPresets, props.searchQuery]); |
| @@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines | |||
| 434 | return out; | 438 | return out; |
| 435 | }, [integrationPlugins]); | 439 | }, [integrationPlugins]); |
| 436 | 440 | ||
| 441 | const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]); | ||
| 442 | |||
| 443 | const renderEntry = (integration: Integration) => { | ||
| 444 | const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; | ||
| 445 | const plugin = pluginByKind.get(pluginKey); | ||
| 446 | const SummaryComponent = plugin?.summary; | ||
| 447 | const slug = String(integration.slug); | ||
| 448 | const name = integration.name || slug; | ||
| 449 | return ( | ||
| 450 | <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> | ||
| 451 | <Link | ||
| 452 | to="/{-$orgSlug}/integrations/$namespace" | ||
| 453 | params={{ namespace: slug }} | ||
| 454 | data-testid={`integration-entry-${slug}`} | ||
| 455 | > | ||
| 456 | <IntegrationIconWithAccount | ||
| 457 | icon={integrationPresetIconUrl( | ||
| 458 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, | ||
| 459 | integrationPlugins, | ||
| 460 | )} | ||
| 461 | sourceId={slug} | ||
| 462 | url={integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined} | ||
| 463 | /> | ||
| 464 | <CardStackEntryContent> | ||
| 465 | <CardStackEntryTitle>{name}</CardStackEntryTitle> | ||
| 466 | <CardStackEntryDescription>{slug}</CardStackEntryDescription> | ||
| 467 | </CardStackEntryContent> | ||
| 468 | <CardStackEntryActions> | ||
| 469 | {SummaryComponent && ( | ||
| 470 | <Suspense fallback={null}> | ||
| 471 | <SummaryComponent sourceId={slug} /> | ||
| 472 | </Suspense> | ||
| 473 | )} | ||
| 474 | <IntegrationHealthSummary integration={integration.slug} /> | ||
| 475 | </CardStackEntryActions> | ||
| 476 | </Link> | ||
| 477 | </CardStackEntry> | ||
| 478 | ); | ||
| 479 | }; | ||
| 480 | |||
| 481 | const rendered: ReactNode[] = []; | ||
| 482 | let flatRun: Integration[] = []; | ||
| 483 | const flushFlat = () => { | ||
| 484 | if (flatRun.length === 0) return; | ||
| 485 | const run = flatRun; | ||
| 486 | flatRun = []; | ||
| 487 | rendered.push( | ||
| 488 | <CardStack key={`flat-${String(run[0]!.slug)}`} searchable> | ||
| 489 | <CardStackContent>{run.map(renderEntry)}</CardStackContent> | ||
| 490 | </CardStack>, | ||
| 491 | ); | ||
| 492 | }; | ||
| 493 | |||
| 494 | for (const item of items) { | ||
| 495 | if (item.type === "single") { | ||
| 496 | flatRun.push(item.integration); | ||
| 497 | continue; | ||
| 498 | } | ||
| 499 | flushFlat(); | ||
| 500 | rendered.push( | ||
| 501 | <IntegrationFamilyGroupCard | ||
| 502 | key={`group-${item.family}`} | ||
| 503 | group={item} | ||
| 504 | plugin={pluginByKind.get("openapi")} | ||
| 505 | renderEntry={renderEntry} | ||
| 506 | />, | ||
| 507 | ); | ||
| 508 | } | ||
| 509 | flushFlat(); | ||
| 510 | |||
| 511 | return <div className="space-y-3">{rendered}</div>; | ||
| 512 | } | ||
| 513 | |||
| 514 | function IntegrationFamilyGroupCard(props: { | ||
| 515 | group: IntegrationFamilyGroup; | ||
| 516 | plugin: IntegrationPlugin | undefined; | ||
| 517 | renderEntry: (integration: Integration) => ReactNode; | ||
| 518 | }) { | ||
| 519 | const { group, plugin, renderEntry } = props; | ||
| 520 | const headerIcon = | ||
| 521 | plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null; | ||
| 437 | return ( | 522 | return ( |
| 438 | <CardStack searchable> | 523 | <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}> |
| 439 | <CardStackContent> | 524 | <CardStackHeader> |
| 440 | {props.integrations.map((integration) => { | 525 | <span className="flex min-w-0 items-center gap-2"> |
| 441 | const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; | 526 | <span className="flex size-5 shrink-0 items-center justify-center"> |
| 442 | const plugin = pluginByKind.get(pluginKey); | 527 | <IntegrationFavicon icon={headerIcon} size={16} /> |
| 443 | const SummaryComponent = plugin?.summary; | 528 | </span> |
| 444 | const slug = String(integration.slug); | 529 | <span className="truncate">{group.label}</span> |
| 445 | const name = integration.name || slug; | 530 | <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground"> |
| 446 | return ( | 531 | {group.members.length} |
| 447 | <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> | 532 | </span> |
| 448 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> | 533 | </span> |
| 449 | <IntegrationIconWithAccount | 534 | </CardStackHeader> |
| 450 | icon={integrationPresetIconUrl( | 535 | <CardStackContent>{group.members.map(renderEntry)}</CardStackContent> |
| 451 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, | ||
| 452 | integrationPlugins, | ||
| 453 | )} | ||
| 454 | sourceId={slug} | ||
| 455 | url={ | ||
| 456 | integration.displayUrl ?? | ||
| 457 | integrationInferredUrl({ id: slug, name }) ?? | ||
| 458 | undefined | ||
| 459 | } | ||
| 460 | /> | ||
| 461 | <CardStackEntryContent> | ||
| 462 | <CardStackEntryTitle>{name}</CardStackEntryTitle> | ||
| 463 | <CardStackEntryDescription>{slug}</CardStackEntryDescription> | ||
| 464 | </CardStackEntryContent> | ||
| 465 | <CardStackEntryActions> | ||
| 466 | {SummaryComponent && ( | ||
| 467 | <Suspense fallback={null}> | ||
| 468 | <SummaryComponent sourceId={slug} /> | ||
| 469 | </Suspense> | ||
| 470 | )} | ||
| 471 | <IntegrationHealthSummary integration={integration.slug} /> | ||
| 472 | </CardStackEntryActions> | ||
| 473 | </Link> | ||
| 474 | </CardStackEntry> | ||
| 475 | ); | ||
| 476 | })} | ||
| 477 | </CardStackContent> | ||
| 478 | </CardStack> | 536 | </CardStack> |
| 479 | ); | 537 | ); |
| 480 | } | 538 | } |
| @@ -0,0 +1,139 @@ | |||
| 1 | # Providers as OpenAPI catalog entries | ||
| 2 | |||
| 3 | Decision (Rhys, 2026-07-07): dissolve `plugin-google` / `plugin-microsoft` into | ||
| 4 | the openapi plugin. No bulk-select picker — per-service OAuth killed the shared | ||
| 5 | consent that justified it; every service is added and authed individually, like | ||
| 6 | any other openapi integration. The dedicated Google/Microsoft add pages go away. | ||
| 7 | |||
| 8 | ## Why this is small | ||
| 9 | |||
| 10 | Serving is already 100% openapi: both provider plugins' `resolveTools` / | ||
| 11 | `invokeTool` / `storage` are pass-throughs to `@executor-js/plugin-openapi`. | ||
| 12 | What the provider plugins actually own is add-time (spec acquisition/conversion, | ||
| 13 | scope selection UI) and identity (presets, icons, OAuth templates). The add-time | ||
| 14 | UI is being deleted, so what remains is data plus two spec converters. | ||
| 15 | |||
| 16 | ## End-state model | ||
| 17 | |||
| 18 | ### 1. Catalog entries (presets grow a little) | ||
| 19 | |||
| 20 | Each service/workload is one `IntegrationPreset` on the openapi plugin: | ||
| 21 | |||
| 22 | ```ts | ||
| 23 | // packages/core/sdk — IntegrationPreset gains: | ||
| 24 | readonly family?: string; // "google" | "microsoft" — grid grouping + umbrella label | ||
| 25 | readonly specFormat?: string; // "google-discovery" | "microsoft-graph" (default: openapi) | ||
| 26 | readonly defaultSlug?: string; // "google_gmail" — stable slug for policy/migration identity | ||
| 27 | readonly authTemplate?: readonly Authentication[]; // per-service OAuth: ONLY this service's scopes | ||
| 28 | readonly healthCheck?: HealthCheckSpec; | ||
| 29 | ``` | ||
| 30 | |||
| 31 | Google Calendar, Gmail, Drive, Docs, Sheets, YouTube, Photos (picker scope is | ||
| 32 | just this entry's scopes), Search Console, … and Microsoft Mail, Calendar, | ||
| 33 | Teams, Files, … are rows in this catalog. `featured` controls grid presence; | ||
| 34 | search finds the rest. The "single Google page" is gone — Google appears in the | ||
| 35 | picker as N entries (grid groups them under one umbrella, see §4). | ||
| 36 | |||
| 37 | ### 2. Spec-format adapters (the only real new interface) | ||
| 38 | |||
| 39 | The openapi plugin accepts registered converters: | ||
| 40 | |||
| 41 | ```ts | ||
| 42 | export interface SpecFormatAdapter { | ||
| 43 | readonly id: string; // matches preset.specFormat | ||
| 44 | /** Fetch + convert to the OpenAPI document shape the plugin already parses. | ||
| 45 | * Receives the preset URL(s) and auth credentials for authed spec hosts. */ | ||
| 46 | readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>; | ||
| 47 | /** Optional: derive identity (slug/name/description) from the fetched doc — | ||
| 48 | * powers custom Discovery URLs (google_tasks from the tasks discovery doc). */ | ||
| 49 | readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; | ||
| 50 | } | ||
| 51 | ``` | ||
| 52 | |||
| 53 | Two adapters exist, both already written: | ||
| 54 | |||
| 55 | - `google-discovery`: `convertGoogleDiscoveryBundleToOpenApi` (discovery.ts) + | ||
| 56 | the derived-identity rules from the parked stack (b14985d7) | ||
| 57 | - `microsoft-graph`: the 37MB Graph structural split (graph.ts). Fetch stays | ||
| 58 | serial per process — the measured contention finding carries over as a note | ||
| 59 | on the adapter, though without bulk add it rarely triggers. | ||
| 60 | |||
| 61 | Custom Discovery URL = the normal "add openapi spec" flow with format | ||
| 62 | `google-discovery`; identity derived from the doc; `google_custom` remains only | ||
| 63 | as the fetch-failure fallback. `microsoft_graph_custom` keeps explicit identity | ||
| 64 | (scopes carry no service identity). | ||
| 65 | |||
| 66 | ### 3. Where the data lives | ||
| 67 | |||
| 68 | `@executor-js/plugin-google` / `-microsoft` shrink to data-only packages (or | ||
| 69 | fold into plugin-openapi outright — decide by size after deletion; bias: fold | ||
| 70 | in, packages are cheap to resurrect). They export `googleCatalog: | ||
| 71 | IntegrationPreset[]`, `googleDiscoveryAdapter`, `microsoftCatalog`, | ||
| 72 | `microsoftGraphAdapter`. Apps compose: | ||
| 73 | |||
| 74 | ```ts | ||
| 75 | openapiPlugin({ | ||
| 76 | presets: [...googleCatalog, ...microsoftCatalog], | ||
| 77 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | ||
| 78 | }); | ||
| 79 | ``` | ||
| 80 | |||
| 81 | Deleted entirely: AddGoogleSource.tsx, AddMicrosoftSource.tsx, | ||
| 82 | GoogleProductPicker, MicrosoftScopePicker, oauth-batches (scope batching served | ||
| 83 | the union-consent flow), the fan-out add APIs (addServices/addWorkloads, | ||
| 84 | added/skipped/failed result model, per-slug dedup locks, concurrency knobs), | ||
| 85 | both plugins' api/ groups and handlers, source-plugin.ts registrations. | ||
| 86 | |||
| 87 | ### 4. Grid | ||
| 88 | |||
| 89 | Grouping (#1336) is harvested nearly as-is, with one change: key off | ||
| 90 | `preset.family` / integration config instead of `integration.kind` (everything | ||
| 91 | is kind "openapi" now). The family tag is stamped into the integration config | ||
| 92 | at add time so grouping works without a preset lookup. Umbrella card, entry | ||
| 93 | testids, slug-keyed icons all carry over. | ||
| 94 | |||
| 95 | ### 5. OAuth / accounts | ||
| 96 | |||
| 97 | Each catalog entry's `authTemplate` carries exactly its service's scopes — | ||
| 98 | the consent screen asks for one service's access, which is the behavior that | ||
| 99 | motivated killing shared auth. The account-entity/login_hint work (#1311, | ||
| 100 | merged) is unaffected: connections still group by provider account, so adding | ||
| 101 | Gmail then Calendar with the same account is two consents but one visible | ||
| 102 | account. Connection cloning in the migration hands every derived service a | ||
| 103 | copy of the monolith token (union scopes) — grandfathered; converges as users | ||
| 104 | reconnect. | ||
| 105 | |||
| 106 | ## Migration retarget | ||
| 107 | |||
| 108 | The planner from #1337 (policy fan-out with never-widen, connection cloning, | ||
| 109 | serving-state carry, ledger/resume, hard-error gating) survives with a new | ||
| 110 | target shape: | ||
| 111 | |||
| 112 | - created rows: `plugin_id = 'openapi'`, slug = catalog `defaultSlug` | ||
| 113 | (unchanged: google_gmail, …), config = openapi config with | ||
| 114 | `specFormat: "google-discovery"`, discovery URLs, family, specHash carried | ||
| 115 | - plugin_storage operation rows re-keyed under the openapi plugin's collection | ||
| 116 | (same content-addressing; R2 blobs untouched) | ||
| 117 | - policies: same fan-out, same slugs — policy semantics identical to v2 dry-run | ||
| 118 | - connections: cloned (approved), provider/account fields preserved | ||
| 119 | - refactor into pure planner + two runners (cloud Postgres CLI; libSQL boot | ||
| 120 | migration for selfhost + local registries, name | ||
| 121 | `2026-07-XX-provider-service-split`). Boot rail can't ask a human: orphan | ||
| 122 | block/require_approval policies are retargeted to the derived service as | ||
| 123 | dormant rows, never dropped, never boot-blocking. The cloud CLI keeps | ||
| 124 | fail-fast + the org 8fcaa0e54239 decision. | ||
| 125 | - prod dry-run rerun against the new shape before any apply; same review gate. | ||
| 126 | |||
| 127 | ## Sequencing | ||
| 128 | |||
| 129 | 1. Core: preset fields + openapi `specFormats` option + adapter interface | ||
| 130 | 2. Move discovery.ts / graph.ts behind adapters; catalog data; delete provider | ||
| 131 | add UI/APIs/plugins; grid family-tag grouping | ||
| 132 | 3. e2e: adapt parked-stack specs (add google_calendar via catalog; custom | ||
| 133 | discovery URL derives google_tasks; grid umbrella) | ||
| 134 | 4. Migration planner refactor + both runners + dry-run v3 | ||
| 135 | 5. Park→close stack PRs #1308/1309/1315/1334/1336/1337 once the branch is up | ||
| 136 | |||
| 137 | Open items to settle during build, not before: exact `ConvertedSpec` shape | ||
| 138 | (reuse existing `OpenApiIntegrationConfig` plumbing), whether photos picker | ||
| 139 | needs any UI affordance beyond its scope set, fold-in vs data-only packages. | ||
| @@ -0,0 +1,639 @@ | |||
| 1 | /* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: one-shot migration CLI */ | ||
| 2 | |||
| 3 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| 4 | import { resolve } from "node:path"; | ||
| 5 | import { pathToFileURL } from "node:url"; | ||
| 6 | |||
| 7 | import postgres from "../../apps/cloud/node_modules/postgres/src/index.js"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | operationStorageKey, | ||
| 11 | planMigration, | ||
| 12 | renderOrgDiff, | ||
| 13 | renderSummary, | ||
| 14 | storageDataRecord, | ||
| 15 | tenantHash, | ||
| 16 | verifyPolicyRewriteNeverWidens, | ||
| 17 | type BlobRow, | ||
| 18 | type ConnectionRow, | ||
| 19 | type IntegrationRow, | ||
| 20 | type MigrationInput, | ||
| 21 | type OrgPlan, | ||
| 22 | type PluginStorageRow, | ||
| 23 | type ToolPolicyRow, | ||
| 24 | type ToolRow, | ||
| 25 | } from "@executor-js/plugin-provider-service-split/planner"; | ||
| 26 | |||
| 27 | export interface SqlClient { | ||
| 28 | readonly unsafe: <T extends readonly object[] = readonly Record<string, unknown>[]>( | ||
| 29 | query: string, | ||
| 30 | params?: readonly unknown[], | ||
| 31 | ) => Promise<T>; | ||
| 32 | readonly begin: <T>(fn: (sql: SqlClient) => Promise<T>) => Promise<T>; | ||
| 33 | readonly end: (options?: { readonly timeout?: number }) => Promise<void>; | ||
| 34 | } | ||
| 35 | |||
| 36 | const args = process.argv.slice(2); | ||
| 37 | const hasArg = (name: string): boolean => args.includes(name); | ||
| 38 | const argValue = (name: string): string | undefined => { | ||
| 39 | const index = args.indexOf(name); | ||
| 40 | return index >= 0 ? args[index + 1] : undefined; | ||
| 41 | }; | ||
| 42 | |||
| 43 | const apply = hasArg("--apply"); | ||
| 44 | const inputJson = argValue("--input-json"); | ||
| 45 | const outputDir = resolve( | ||
| 46 | argValue("--output-dir") ?? "~/agent-workspace/scratch/service-split-migration-dryrun", | ||
| 47 | ).replace(/^~(?=\/)/, process.env.HOME ?? "~"); | ||
| 48 | const orgFilter = argValue("--org"); | ||
| 49 | const trafficLastTenant = argValue("--traffic-last-tenant"); | ||
| 50 | const databaseUrl = argValue("--database-url") ?? process.env.DATABASE_URL; | ||
| 51 | const r2CopiesDone = hasArg("--r2-copies-done"); | ||
| 52 | export const LEDGER_TABLE = "provider_service_split_org_migration"; | ||
| 53 | |||
| 54 | const parseInputJson = (path: string): MigrationInput => | ||
| 55 | JSON.parse(readFileSync(path, "utf8")) as MigrationInput; | ||
| 56 | |||
| 57 | const readRows = async <T extends readonly object[]>(sql: SqlClient, query: string): Promise<T> => | ||
| 58 | sql.unsafe<T>(query); | ||
| 59 | |||
| 60 | const readCompletedTenants = async ( | ||
| 61 | sql: SqlClient, | ||
| 62 | dryRun: boolean, | ||
| 63 | ): Promise<readonly string[]> => { | ||
| 64 | const [table] = await sql.unsafe<{ ledger_table: string | null }[]>( | ||
| 65 | `SELECT to_regclass('public.${LEDGER_TABLE}')::text AS ledger_table`, | ||
| 66 | ); | ||
| 67 | if (!table?.ledger_table) { | ||
| 68 | if (dryRun) return []; | ||
| 69 | await sql.unsafe(` | ||
| 70 | CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} ( | ||
| 71 | tenant text PRIMARY KEY, | ||
| 72 | time_completed bigint NOT NULL | ||
| 73 | ) | ||
| 74 | `); | ||
| 75 | return []; | ||
| 76 | } | ||
| 77 | const rows = await sql.unsafe<{ tenant: string }[]>( | ||
| 78 | `SELECT tenant FROM ${LEDGER_TABLE} ORDER BY tenant`, | ||
| 79 | ); | ||
| 80 | return rows.map((row) => row.tenant); | ||
| 81 | }; | ||
| 82 | |||
| 83 | const filterInputByOrg = (input: MigrationInput, filter: string | undefined): MigrationInput => { | ||
| 84 | if (!filter) return input; | ||
| 85 | const tenantMatches = new Set( | ||
| 86 | input.integrations | ||
| 87 | .map((row) => row.tenant) | ||
| 88 | .filter((tenant) => tenant === filter || tenantHash(tenant) === filter), | ||
| 89 | ); | ||
| 90 | return { | ||
| 91 | ...input, | ||
| 92 | integrations: input.integrations.filter((row) => tenantMatches.has(row.tenant)), | ||
| 93 | connections: input.connections.filter((row) => tenantMatches.has(row.tenant)), | ||
| 94 | tools: input.tools.filter((row) => tenantMatches.has(row.tenant)), | ||
| 95 | pluginStorage: input.pluginStorage?.filter((row) => tenantMatches.has(row.tenant)), | ||
| 96 | blobs: input.blobs?.filter((row) => { | ||
| 97 | for (const tenant of tenantMatches) { | ||
| 98 | if (row.namespace.startsWith(`o:${tenant}/`)) return true; | ||
| 99 | } | ||
| 100 | return false; | ||
| 101 | }), | ||
| 102 | policies: input.policies.filter((row) => tenantMatches.has(row.tenant)), | ||
| 103 | completedTenants: input.completedTenants?.filter((tenant) => tenantMatches.has(tenant)), | ||
| 104 | }; | ||
| 105 | }; | ||
| 106 | |||
| 107 | export const readDatabaseInput = async ( | ||
| 108 | sql: SqlClient, | ||
| 109 | dryRun: boolean, | ||
| 110 | blobBackend: MigrationInput["blobBackend"], | ||
| 111 | ): Promise<MigrationInput> => { | ||
| 112 | const completedTenants = await readCompletedTenants(sql, dryRun); | ||
| 113 | const integrations = await readRows<IntegrationRow[]>( | ||
| 114 | sql, | ||
| 115 | ` | ||
| 116 | SELECT tenant, slug, plugin_id, name, description, config, health_check, config_revised_at, | ||
| 117 | can_remove, can_refresh, created_at::text, updated_at::text, row_id | ||
| 118 | FROM integration | ||
| 119 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 120 | OR slug IN ('google', 'microsoft') | ||
| 121 | OR tenant IN ( | ||
| 122 | SELECT tenant FROM integration | ||
| 123 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 124 | ) | ||
| 125 | ORDER BY tenant, slug | ||
| 126 | `, | ||
| 127 | ); | ||
| 128 | const connections = await readRows<ConnectionRow[]>( | ||
| 129 | sql, | ||
| 130 | ` | ||
| 131 | SELECT tenant, owner, subject, integration, name, template, provider, item_ids, | ||
| 132 | identity_label, description, last_health, tools_synced_at, oauth_client, | ||
| 133 | oauth_client_owner, refresh_item_id, expires_at, oauth_scope, oauth_token_url, | ||
| 134 | provider_state, created_at::text, updated_at::text, row_id | ||
| 135 | FROM connection | ||
| 136 | WHERE tenant IN ( | ||
| 137 | SELECT tenant FROM integration | ||
| 138 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 139 | ) | ||
| 140 | ORDER BY tenant, integration, owner, subject, name | ||
| 141 | `, | ||
| 142 | ); | ||
| 143 | const tools = await readRows<ToolRow[]>( | ||
| 144 | sql, | ||
| 145 | ` | ||
| 146 | SELECT tenant, owner, subject, integration, connection, plugin_id, name, description, | ||
| 147 | input_schema, output_schema, annotations, created_at::text, updated_at::text, row_id | ||
| 148 | FROM tool | ||
| 149 | WHERE tenant IN ( | ||
| 150 | SELECT tenant FROM integration | ||
| 151 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 152 | ) | ||
| 153 | ORDER BY tenant, integration, connection, name | ||
| 154 | `, | ||
| 155 | ); | ||
| 156 | const pluginStorage = await sql.unsafe<PluginStorageRow[]>( | ||
| 157 | ` | ||
| 158 | SELECT tenant, owner, subject, plugin_id, collection, key, data, | ||
| 159 | created_at::text, updated_at::text, row_id | ||
| 160 | FROM plugin_storage | ||
| 161 | WHERE tenant IN ( | ||
| 162 | SELECT tenant FROM integration | ||
| 163 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 164 | ) | ||
| 165 | AND plugin_id IN ('google', 'microsoft') | ||
| 166 | AND collection = 'operation' | ||
| 167 | ORDER BY tenant, plugin_id, collection, key | ||
| 168 | `, | ||
| 169 | ); | ||
| 170 | const blobs = await readRows<BlobRow[]>( | ||
| 171 | sql, | ||
| 172 | ` | ||
| 173 | SELECT id, namespace, key | ||
| 174 | FROM blob | ||
| 175 | WHERE namespace IN ( | ||
| 176 | SELECT 'o:' || tenant || '/' || plugin_id | ||
| 177 | FROM integration | ||
| 178 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 179 | ) | ||
| 180 | AND (key LIKE 'spec/%' OR key LIKE 'defs/%') | ||
| 181 | ORDER BY namespace, key | ||
| 182 | `, | ||
| 183 | ); | ||
| 184 | const policies = await readRows<ToolPolicyRow[]>( | ||
| 185 | sql, | ||
| 186 | ` | ||
| 187 | SELECT tenant, owner, subject, id, pattern, action, position, created_at::text, | ||
| 188 | updated_at::text, row_id | ||
| 189 | FROM tool_policy | ||
| 190 | WHERE tenant IN ( | ||
| 191 | SELECT tenant FROM integration | ||
| 192 | WHERE plugin_id IN ('google', 'microsoft') | ||
| 193 | ) | ||
| 194 | ORDER BY tenant, owner, subject, position, id | ||
| 195 | `, | ||
| 196 | ); | ||
| 197 | return { | ||
| 198 | integrations, | ||
| 199 | connections, | ||
| 200 | tools, | ||
| 201 | pluginStorage, | ||
| 202 | blobs, | ||
| 203 | policies, | ||
| 204 | completedTenants, | ||
| 205 | trafficLastTenant, | ||
| 206 | blobBackend, | ||
| 207 | assumeExternalBlobSourcePresent: blobBackend === "external", | ||
| 208 | }; | ||
| 209 | }; | ||
| 210 | |||
| 211 | const scrubJson = (value: unknown): unknown => { | ||
| 212 | if (Array.isArray(value)) return value.map(scrubJson); | ||
| 213 | if (value && typeof value === "object") { | ||
| 214 | return Object.fromEntries( | ||
| 215 | Object.entries(value) | ||
| 216 | .filter(([, inner]) => inner !== undefined) | ||
| 217 | .map(([key, inner]) => [key, scrubJson(inner)]), | ||
| 218 | ); | ||
| 219 | } | ||
| 220 | return value; | ||
| 221 | }; | ||
| 222 | |||
| 223 | const stableId = (...parts: readonly string[]): string => { | ||
| 224 | const text = parts.join("\u0000"); | ||
| 225 | let hash = 0; | ||
| 226 | for (let index = 0; index < text.length; index += 1) { | ||
| 227 | hash = (hash * 31 + text.charCodeAt(index)) >>> 0; | ||
| 228 | } | ||
| 229 | return `service_split_${hash.toString(36)}`; | ||
| 230 | }; | ||
| 231 | |||
| 232 | const operationKeyPrefix = (integration: string): string => | ||
| 233 | `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`; | ||
| 234 | |||
| 235 | export const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => { | ||
| 236 | if (org.completed) return; | ||
| 237 | if (org.hardErrors.length > 0) { | ||
| 238 | throw new Error(`org ${org.tenantHash} has unresolved hard errors; apply is blocked`); | ||
| 239 | } | ||
| 240 | await sql.begin(async (tx) => { | ||
| 241 | const now = new Date(); | ||
| 242 | for (const copy of org.blobCopies.filter((item) => item.backend === "database")) { | ||
| 243 | const [source] = await tx.unsafe<{ readonly value: string }[]>( | ||
| 244 | "SELECT value FROM blob WHERE namespace = $1 AND key = $2 LIMIT 1", | ||
| 245 | [copy.sourceNamespace, copy.key], | ||
| 246 | ); | ||
| 247 | if (!source) { | ||
| 248 | throw new Error(`Missing source blob ${copy.sourceNamespace}/${copy.key}`); | ||
| 249 | } | ||
| 250 | await tx.unsafe( | ||
| 251 | ` | ||
| 252 | INSERT INTO blob (namespace, key, value, row_id, id) | ||
| 253 | VALUES ($1, $2, $3, $4, $5) | ||
| 254 | ON CONFLICT (id) DO NOTHING | ||
| 255 | `, | ||
| 256 | [ | ||
| 257 | copy.targetNamespace, | ||
| 258 | copy.key, | ||
| 259 | source.value, | ||
| 260 | stableId("blob", org.tenant, copy.targetNamespace, copy.key), | ||
| 261 | JSON.stringify([copy.targetNamespace, copy.key]), | ||
| 262 | ], | ||
| 263 | ); | ||
| 264 | } | ||
| 265 | |||
| 266 | for (const row of org.integrations.filter((item) => item.action === "create")) { | ||
| 267 | await tx.unsafe( | ||
| 268 | ` | ||
| 269 | INSERT INTO integration ( | ||
| 270 | slug, plugin_id, name, description, config, health_check, config_revised_at, | ||
| 271 | can_remove, can_refresh, created_at, updated_at, row_id, tenant | ||
| 272 | ) | ||
| 273 | VALUES ($1, $2, $3, $4, $5::json, $6::json, NULL, true, true, $7, $7, $8, $9) | ||
| 274 | ON CONFLICT (tenant, slug) DO NOTHING | ||
| 275 | `, | ||
| 276 | [ | ||
| 277 | row.target.slug, | ||
| 278 | row.target.pluginId, | ||
| 279 | row.target.name, | ||
| 280 | row.target.description, | ||
| 281 | JSON.stringify(scrubJson(row.config)), | ||
| 282 | row.healthCheck ? JSON.stringify(scrubJson(row.healthCheck)) : null, | ||
| 283 | now, | ||
| 284 | stableId("integration", org.tenant, row.target.slug), | ||
| 285 | org.tenant, | ||
| 286 | ], | ||
| 287 | ); | ||
| 288 | } | ||
| 289 | |||
| 290 | for (const row of org.connections.filter((item) => item.action === "clone")) { | ||
| 291 | await tx.unsafe( | ||
| 292 | ` | ||
| 293 | INSERT INTO connection ( | ||
| 294 | integration, name, template, provider, item_ids, identity_label, description, | ||
| 295 | last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | ||
| 296 | expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | ||
| 297 | row_id, tenant, owner, subject | ||
| 298 | ) | ||
| 299 | SELECT $1, name, template, provider, item_ids, identity_label, description, | ||
| 300 | NULL, NULL, oauth_client, oauth_client_owner, refresh_item_id, expires_at, | ||
| 301 | oauth_scope, oauth_token_url, provider_state, created_at, $2, $3, tenant, owner, subject | ||
| 302 | FROM connection | ||
| 303 | WHERE tenant = $4 AND owner = $5 AND subject = $6 AND integration = $7 AND name = $8 | ||
| 304 | ON CONFLICT (tenant, owner, subject, integration, name) DO NOTHING | ||
| 305 | `, | ||
| 306 | [ | ||
| 307 | row.targetIntegration, | ||
| 308 | now, | ||
| 309 | stableId( | ||
| 310 | "connection", | ||
| 311 | org.tenant, | ||
| 312 | row.source.owner, | ||
| 313 | row.source.subject, | ||
| 314 | row.targetIntegration, | ||
| 315 | row.source.name, | ||
| 316 | ), | ||
| 317 | org.tenant, | ||
| 318 | row.source.owner, | ||
| 319 | row.source.subject, | ||
| 320 | row.source.integration, | ||
| 321 | row.source.name, | ||
| 322 | ], | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | |||
| 326 | for (const integration of org.integrations) { | ||
| 327 | for (const contribution of integration.sourceContributions) { | ||
| 328 | for (const toolName of contribution.operationToolNames) { | ||
| 329 | const [operation] = await tx.unsafe< | ||
| 330 | { | ||
| 331 | readonly data: unknown; | ||
| 332 | readonly created_at: string; | ||
| 333 | readonly updated_at: string; | ||
| 334 | }[] | ||
| 335 | >( | ||
| 336 | ` | ||
| 337 | SELECT data, created_at::text, updated_at::text | ||
| 338 | FROM plugin_storage | ||
| 339 | WHERE tenant = $1 | ||
| 340 | AND owner = 'org' | ||
| 341 | AND subject = '' | ||
| 342 | AND plugin_id = $2 | ||
| 343 | AND collection = 'operation' | ||
| 344 | AND ( | ||
| 345 | key = $3 | ||
| 346 | OR ( | ||
| 347 | data::jsonb ->> 'integration' = $4 | ||
| 348 | AND data::jsonb ->> 'toolName' = $5 | ||
| 349 | ) | ||
| 350 | ) | ||
| 351 | LIMIT 1 | ||
| 352 | `, | ||
| 353 | [ | ||
| 354 | org.tenant, | ||
| 355 | contribution.source.plugin_id, | ||
| 356 | operationStorageKey(contribution.source.slug, toolName), | ||
| 357 | contribution.source.slug, | ||
| 358 | toolName, | ||
| 359 | ], | ||
| 360 | ); | ||
| 361 | if (!operation) { | ||
| 362 | throw new Error( | ||
| 363 | `Missing operation row for ${tenantHash(org.tenant)}/${contribution.source.slug}/${toolName}`, | ||
| 364 | ); | ||
| 365 | } | ||
| 366 | await tx.unsafe( | ||
| 367 | ` | ||
| 368 | INSERT INTO plugin_storage ( | ||
| 369 | plugin_id, collection, key, data, created_at, updated_at, row_id, | ||
| 370 | tenant, owner, subject | ||
| 371 | ) | ||
| 372 | VALUES ($1, 'operation', $2, $3::json, $4, $5, $6, $7, 'org', '') | ||
| 373 | ON CONFLICT (tenant, owner, subject, plugin_id, collection, key) | ||
| 374 | DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at | ||
| 375 | `, | ||
| 376 | [ | ||
| 377 | integration.target.pluginId, | ||
| 378 | operationStorageKey(integration.target.slug, toolName), | ||
| 379 | JSON.stringify( | ||
| 380 | scrubJson({ | ||
| 381 | ...storageDataRecord(operation), | ||
| 382 | integration: integration.target.slug, | ||
| 383 | toolName, | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | operation.created_at, | ||
| 387 | now, | ||
| 388 | stableId("operation", org.tenant, integration.target.slug, toolName), | ||
| 389 | org.tenant, | ||
| 390 | ], | ||
| 391 | ); | ||
| 392 | await tx.unsafe( | ||
| 393 | ` | ||
| 394 | INSERT INTO tool ( | ||
| 395 | integration, connection, plugin_id, name, description, input_schema, output_schema, | ||
| 396 | annotations, created_at, updated_at, row_id, tenant, owner, subject | ||
| 397 | ) | ||
| 398 | SELECT $1, connection, $2, name, description, input_schema, output_schema, | ||
| 399 | annotations, created_at, $3, concat($4::text, '_', row_id), tenant, owner, subject | ||
| 400 | FROM tool | ||
| 401 | WHERE tenant = $5 AND integration = $6 AND name = $7 | ||
| 402 | ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING | ||
| 403 | `, | ||
| 404 | [ | ||
| 405 | integration.target.slug, | ||
| 406 | integration.target.pluginId, | ||
| 407 | now, | ||
| 408 | stableId("tool", org.tenant, integration.target.slug), | ||
| 409 | org.tenant, | ||
| 410 | contribution.source.slug, | ||
| 411 | toolName, | ||
| 412 | ], | ||
| 413 | ); | ||
| 414 | } | ||
| 415 | } | ||
| 416 | } | ||
| 417 | |||
| 418 | for (const policy of org.policies.filter((item) => item.action === "rewrite")) { | ||
| 419 | await tx.unsafe( | ||
| 420 | "DELETE FROM tool_policy WHERE tenant = $1 AND owner = $2 AND subject = $3 AND id = $4", | ||
| 421 | [org.tenant, policy.policy.owner, policy.policy.subject, policy.policy.id], | ||
| 422 | ); | ||
| 423 | for (const [index, pattern] of policy.afterPatterns.entries()) { | ||
| 424 | await tx.unsafe( | ||
| 425 | ` | ||
| 426 | INSERT INTO tool_policy ( | ||
| 427 | id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject | ||
| 428 | ) | ||
| 429 | VALUES ($1, $2, $3, $4, $5, $5, $6, $7, $8, $9) | ||
| 430 | ON CONFLICT (tenant, owner, subject, id) DO NOTHING | ||
| 431 | `, | ||
| 432 | [ | ||
| 433 | policy.afterPatterns.length === 1 | ||
| 434 | ? policy.policy.id | ||
| 435 | : `${policy.policy.id}_${index + 1}`, | ||
| 436 | pattern, | ||
| 437 | policy.policy.action, | ||
| 438 | policy.policy.position, | ||
| 439 | now, | ||
| 440 | stableId("policy", org.tenant, policy.policy.id, pattern), | ||
| 441 | org.tenant, | ||
| 442 | policy.policy.owner, | ||
| 443 | policy.policy.subject, | ||
| 444 | ], | ||
| 445 | ); | ||
| 446 | } | ||
| 447 | } | ||
| 448 | |||
| 449 | for (const monolith of org.deleteMonoliths) { | ||
| 450 | await tx.unsafe("DELETE FROM tool WHERE tenant = $1 AND integration = $2", [ | ||
| 451 | org.tenant, | ||
| 452 | monolith.slug, | ||
| 453 | ]); | ||
| 454 | await tx.unsafe("DELETE FROM connection WHERE tenant = $1 AND integration = $2", [ | ||
| 455 | org.tenant, | ||
| 456 | monolith.slug, | ||
| 457 | ]); | ||
| 458 | await tx.unsafe( | ||
| 459 | "DELETE FROM integration WHERE tenant = $1 AND slug = $2 AND plugin_id = $3", | ||
| 460 | [org.tenant, monolith.slug, monolith.plugin_id], | ||
| 461 | ); | ||
| 462 | await tx.unsafe( | ||
| 463 | ` | ||
| 464 | DELETE FROM plugin_storage | ||
| 465 | WHERE tenant = $1 | ||
| 466 | AND owner = 'org' | ||
| 467 | AND subject = '' | ||
| 468 | AND plugin_id = $2 | ||
| 469 | AND collection = 'operation' | ||
| 470 | AND ( | ||
| 471 | data::jsonb ->> 'integration' = $3 | ||
| 472 | OR key LIKE $4 | ||
| 473 | ) | ||
| 474 | `, | ||
| 475 | [org.tenant, monolith.plugin_id, monolith.slug, operationKeyPrefix(monolith.slug)], | ||
| 476 | ); | ||
| 477 | } | ||
| 478 | |||
| 479 | await tx.unsafe( | ||
| 480 | ` | ||
| 481 | INSERT INTO ${LEDGER_TABLE} (tenant, time_completed) | ||
| 482 | VALUES ($1, $2) | ||
| 483 | ON CONFLICT (tenant) DO UPDATE SET time_completed = EXCLUDED.time_completed | ||
| 484 | `, | ||
| 485 | [org.tenant, Date.now()], | ||
| 486 | ); | ||
| 487 | }); | ||
| 488 | }; | ||
| 489 | |||
| 490 | const writeDryRun = (planInput: MigrationInput): void => { | ||
| 491 | const plan = planMigration({ | ||
| 492 | ...planInput, | ||
| 493 | collectPolicyErrors: true, | ||
| 494 | orphanPolicyMode: "retarget_all", | ||
| 495 | }); | ||
| 496 | const neverWiden = verifyPolicyRewriteNeverWidens(plan, planInput); | ||
| 497 | const r2Copies = plan.orgs | ||
| 498 | .filter((org) => !org.completed && org.hardErrors.length === 0) | ||
| 499 | .flatMap((org) => | ||
| 500 | org.blobCopies | ||
| 501 | .filter((copy) => copy.backend === "external") | ||
| 502 | .map((copy) => ({ | ||
| 503 | tenantHash: org.tenantHash, | ||
| 504 | tenant: org.tenant, | ||
| 505 | sourceKey: copy.sourceObjectName, | ||
| 506 | destKey: copy.targetObjectName, | ||
| 507 | })), | ||
| 508 | ); | ||
| 509 | mkdirSync(outputDir, { recursive: true }); | ||
| 510 | for (const org of plan.orgs) { | ||
| 511 | writeFileSync(`${outputDir}/org-${org.tenantHash}.md`, renderOrgDiff(org)); | ||
| 512 | } | ||
| 513 | writeFileSync(`${outputDir}/r2-copy-manifest.json`, `${JSON.stringify(r2Copies, null, 2)}\n`); | ||
| 514 | writeFileSync( | ||
| 515 | `${outputDir}/summary.md`, | ||
| 516 | `${renderSummary(plan)}\npolicy_never_widen=${neverWiden.ok}\npolicy_never_widen_checked=${neverWiden.checkedPolicies}\nr2_copy_manifest=${outputDir}/r2-copy-manifest.json\nr2_copy_count=${r2Copies.length}\nr2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe\n`, | ||
| 517 | ); | ||
| 518 | console.log(renderSummary(plan)); | ||
| 519 | console.log(`policy_never_widen=${neverWiden.ok}`); | ||
| 520 | console.log(`policy_never_widen_checked=${neverWiden.checkedPolicies}`); | ||
| 521 | console.log(`r2_copy_manifest=${outputDir}/r2-copy-manifest.json`); | ||
| 522 | console.log(`r2_copy_count=${r2Copies.length}`); | ||
| 523 | console.log( | ||
| 524 | "r2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe", | ||
| 525 | ); | ||
| 526 | console.log(`diff_dir=${outputDir}`); | ||
| 527 | if (!neverWiden.ok) { | ||
| 528 | throw new Error( | ||
| 529 | `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`, | ||
| 530 | ); | ||
| 531 | } | ||
| 532 | if ( | ||
| 533 | plan.summary.integrationsMissingSpecBlob > 0 || | ||
| 534 | plan.summary.integrationsMissingDefsBlob > 0 | ||
| 535 | ) { | ||
| 536 | throw new Error( | ||
| 537 | `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`, | ||
| 538 | ); | ||
| 539 | } | ||
| 540 | const zeroOperationIntegrations = plan.orgs | ||
| 541 | .filter((org) => !org.completed && org.hardErrors.length === 0) | ||
| 542 | .flatMap((org) => org.integrations) | ||
| 543 | .filter( | ||
| 544 | (integration) => | ||
| 545 | integration.servingState.operationsToBuild === 0 && | ||
| 546 | !integration.servingState.expectedZeroOperations, | ||
| 547 | ); | ||
| 548 | if (zeroOperationIntegrations.length > 0) { | ||
| 549 | throw new Error( | ||
| 550 | `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`, | ||
| 551 | ); | ||
| 552 | } | ||
| 553 | }; | ||
| 554 | |||
| 555 | const main = async (): Promise<void> => { | ||
| 556 | if (inputJson) { | ||
| 557 | const input = parseInputJson(inputJson); | ||
| 558 | writeDryRun( | ||
| 559 | filterInputByOrg( | ||
| 560 | { | ||
| 561 | ...input, | ||
| 562 | trafficLastTenant: trafficLastTenant ?? input.trafficLastTenant, | ||
| 563 | }, | ||
| 564 | orgFilter, | ||
| 565 | ), | ||
| 566 | ); | ||
| 567 | return; | ||
| 568 | } | ||
| 569 | if (!databaseUrl) { | ||
| 570 | throw new Error("Set DATABASE_URL or pass --input-json"); | ||
| 571 | } | ||
| 572 | const usesLocalDatabase = databaseUrl.includes("127.0.0.1") || databaseUrl.includes("localhost"); | ||
| 573 | const sql = postgres(databaseUrl, { | ||
| 574 | max: 1, | ||
| 575 | prepare: false, | ||
| 576 | ...(usesLocalDatabase ? {} : { ssl: "require" as const }), | ||
| 577 | }) as unknown as SqlClient; | ||
| 578 | try { | ||
| 579 | const input = filterInputByOrg( | ||
| 580 | await readDatabaseInput(sql, !apply, usesLocalDatabase ? "database" : "external"), | ||
| 581 | orgFilter, | ||
| 582 | ); | ||
| 583 | if (!apply) { | ||
| 584 | writeDryRun(input); | ||
| 585 | return; | ||
| 586 | } | ||
| 587 | // Orphan block/require_approval policies (a guardrail targeting a service | ||
| 588 | // the org never derived, e.g. a gmail send-block on a Search-Console-only | ||
| 589 | // org) retarget to every derived service as dormant rows rather than | ||
| 590 | // blocking apply. Approved by Rhys 2026-07-08; matches the boot rail. | ||
| 591 | const plan = planMigration({ ...input, orphanPolicyMode: "retarget_all" }); | ||
| 592 | const neverWiden = verifyPolicyRewriteNeverWidens(plan, input); | ||
| 593 | if (!neverWiden.ok) { | ||
| 594 | throw new Error( | ||
| 595 | `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`, | ||
| 596 | ); | ||
| 597 | } | ||
| 598 | if ( | ||
| 599 | plan.summary.integrationsMissingSpecBlob > 0 || | ||
| 600 | plan.summary.integrationsMissingDefsBlob > 0 | ||
| 601 | ) { | ||
| 602 | throw new Error( | ||
| 603 | `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`, | ||
| 604 | ); | ||
| 605 | } | ||
| 606 | const zeroOperationIntegrations = plan.orgs | ||
| 607 | .filter((org) => !org.completed && org.hardErrors.length === 0) | ||
| 608 | .flatMap((org) => org.integrations) | ||
| 609 | .filter( | ||
| 610 | (integration) => | ||
| 611 | integration.servingState.operationsToBuild === 0 && | ||
| 612 | !integration.servingState.expectedZeroOperations, | ||
| 613 | ); | ||
| 614 | if (zeroOperationIntegrations.length > 0) { | ||
| 615 | throw new Error( | ||
| 616 | `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`, | ||
| 617 | ); | ||
| 618 | } | ||
| 619 | const requiredR2Copies = plan.orgs | ||
| 620 | .filter((org) => !org.completed && org.hardErrors.length === 0) | ||
| 621 | .flatMap((org) => org.blobCopies.filter((copy) => copy.backend === "external")); | ||
| 622 | if (requiredR2Copies.length > 0 && !r2CopiesDone) { | ||
| 623 | throw new Error( | ||
| 624 | `External R2 blob copies are required before apply (${requiredR2Copies.length}); run dry-run, execute r2-copy-manifest.json, then pass --r2-copies-done`, | ||
| 625 | ); | ||
| 626 | } | ||
| 627 | for (const org of plan.orgs) { | ||
| 628 | await applyOrg(sql, org); | ||
| 629 | console.log(`applied_org=${tenantHash(org.tenant)}`); | ||
| 630 | } | ||
| 631 | console.log(renderSummary(plan)); | ||
| 632 | } finally { | ||
| 633 | await sql.end({ timeout: 0 }); | ||
| 634 | } | ||
| 635 | }; | ||
| 636 | |||
| 637 | if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { | ||
| 638 | await main(); | ||
| 639 | } | ||
| @@ -0,0 +1,468 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { PGlite } from "../../apps/cloud/node_modules/@electric-sql/pglite"; | ||
| 5 | import { PGLiteSocketServer } from "../../apps/cloud/node_modules/@electric-sql/pglite-socket"; | ||
| 6 | import postgres from "../../apps/cloud/node_modules/postgres/src/index.js"; | ||
| 7 | import { | ||
| 8 | applyOrg, | ||
| 9 | LEDGER_TABLE, | ||
| 10 | readDatabaseInput, | ||
| 11 | type SqlClient, | ||
| 12 | } from "../../scripts/migration/service-split-postgres-cli"; | ||
| 13 | import { | ||
| 14 | operationStorageKey, | ||
| 15 | planMigration, | ||
| 16 | } from "@executor-js/plugin-provider-service-split/planner"; | ||
| 17 | |||
| 18 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 19 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 20 | |||
| 21 | const parseJson = (value: unknown): unknown => | ||
| 22 | typeof value === "string" ? decodeJson(value) : value; | ||
| 23 | |||
| 24 | const createSchema = async (sql: SqlClient): Promise<void> => { | ||
| 25 | await sql.unsafe(` | ||
| 26 | CREATE TABLE integration ( | ||
| 27 | slug varchar(255) NOT NULL, | ||
| 28 | plugin_id varchar(255) NOT NULL, | ||
| 29 | name text, | ||
| 30 | description text, | ||
| 31 | config json, | ||
| 32 | health_check json, | ||
| 33 | config_revised_at bigint, | ||
| 34 | can_remove boolean NOT NULL, | ||
| 35 | can_refresh boolean NOT NULL, | ||
| 36 | created_at timestamp NOT NULL, | ||
| 37 | updated_at timestamp NOT NULL, | ||
| 38 | row_id text NOT NULL, | ||
| 39 | tenant text NOT NULL | ||
| 40 | ); | ||
| 41 | CREATE UNIQUE INDEX integration_uidx ON integration (tenant, slug); | ||
| 42 | |||
| 43 | CREATE TABLE connection ( | ||
| 44 | integration varchar(255) NOT NULL, | ||
| 45 | name varchar(255) NOT NULL, | ||
| 46 | template varchar(255) NOT NULL, | ||
| 47 | provider text NOT NULL, | ||
| 48 | item_ids json, | ||
| 49 | identity_label text, | ||
| 50 | description text, | ||
| 51 | last_health json, | ||
| 52 | tools_synced_at bigint, | ||
| 53 | oauth_client text, | ||
| 54 | oauth_client_owner text, | ||
| 55 | refresh_item_id text, | ||
| 56 | expires_at bigint, | ||
| 57 | oauth_scope text, | ||
| 58 | oauth_token_url text, | ||
| 59 | provider_state json, | ||
| 60 | created_at timestamp NOT NULL, | ||
| 61 | updated_at timestamp NOT NULL, | ||
| 62 | row_id text NOT NULL, | ||
| 63 | tenant text NOT NULL, | ||
| 64 | owner text NOT NULL, | ||
| 65 | subject text NOT NULL | ||
| 66 | ); | ||
| 67 | CREATE UNIQUE INDEX connection_uidx ON connection | ||
| 68 | (tenant, owner, subject, integration, name); | ||
| 69 | |||
| 70 | CREATE TABLE tool ( | ||
| 71 | integration varchar(255) NOT NULL, | ||
| 72 | connection varchar(255) NOT NULL, | ||
| 73 | plugin_id varchar(255) NOT NULL, | ||
| 74 | name text NOT NULL, | ||
| 75 | description text, | ||
| 76 | input_schema json, | ||
| 77 | output_schema json, | ||
| 78 | annotations json, | ||
| 79 | created_at timestamp NOT NULL, | ||
| 80 | updated_at timestamp NOT NULL, | ||
| 81 | row_id text NOT NULL, | ||
| 82 | tenant text NOT NULL, | ||
| 83 | owner text NOT NULL, | ||
| 84 | subject text NOT NULL | ||
| 85 | ); | ||
| 86 | CREATE UNIQUE INDEX tool_uidx ON tool | ||
| 87 | (tenant, owner, subject, integration, connection, name); | ||
| 88 | |||
| 89 | CREATE TABLE plugin_storage ( | ||
| 90 | plugin_id varchar(255) NOT NULL, | ||
| 91 | collection varchar(255) NOT NULL, | ||
| 92 | key text NOT NULL, | ||
| 93 | data jsonb, | ||
| 94 | created_at timestamp NOT NULL, | ||
| 95 | updated_at timestamp NOT NULL, | ||
| 96 | row_id text NOT NULL, | ||
| 97 | tenant text NOT NULL, | ||
| 98 | owner text NOT NULL, | ||
| 99 | subject text NOT NULL | ||
| 100 | ); | ||
| 101 | CREATE UNIQUE INDEX plugin_storage_uidx ON plugin_storage | ||
| 102 | (tenant, owner, subject, plugin_id, collection, key); | ||
| 103 | |||
| 104 | CREATE TABLE blob ( | ||
| 105 | id text NOT NULL, | ||
| 106 | namespace text NOT NULL, | ||
| 107 | key text NOT NULL, | ||
| 108 | value text NOT NULL, | ||
| 109 | row_id text NOT NULL | ||
| 110 | ); | ||
| 111 | CREATE UNIQUE INDEX blob_id_uidx ON blob (id); | ||
| 112 | |||
| 113 | CREATE TABLE tool_policy ( | ||
| 114 | id text NOT NULL, | ||
| 115 | pattern text NOT NULL, | ||
| 116 | action text NOT NULL, | ||
| 117 | position text NOT NULL, | ||
| 118 | created_at timestamp NOT NULL, | ||
| 119 | updated_at timestamp NOT NULL, | ||
| 120 | row_id text NOT NULL, | ||
| 121 | tenant text NOT NULL, | ||
| 122 | owner text NOT NULL, | ||
| 123 | subject text NOT NULL | ||
| 124 | ); | ||
| 125 | CREATE UNIQUE INDEX tool_policy_uidx ON tool_policy (tenant, owner, subject, id); | ||
| 126 | `); | ||
| 127 | }; | ||
| 128 | |||
| 129 | const seedMonolith = async (sql: SqlClient): Promise<void> => { | ||
| 130 | await sql.unsafe( | ||
| 131 | ` | ||
| 132 | INSERT INTO integration ( | ||
| 133 | slug, plugin_id, name, description, config, health_check, config_revised_at, | ||
| 134 | can_remove, can_refresh, created_at, updated_at, row_id, tenant | ||
| 135 | ) | ||
| 136 | VALUES ('google', 'google', 'Google', 'Google APIs', $1::json, $2::json, NULL, | ||
| 137 | true, true, $3, $3, 'google_row', 'org_1') | ||
| 138 | `, | ||
| 139 | [ | ||
| 140 | JSON.stringify({ | ||
| 141 | googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 142 | specHash: "mono-hash", | ||
| 143 | }), | ||
| 144 | JSON.stringify({ operation: "oauth2.userinfo.get" }), | ||
| 145 | now, | ||
| 146 | ], | ||
| 147 | ); | ||
| 148 | await sql.unsafe( | ||
| 149 | ` | ||
| 150 | INSERT INTO connection ( | ||
| 151 | integration, name, template, provider, item_ids, identity_label, description, | ||
| 152 | last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | ||
| 153 | expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | ||
| 154 | row_id, tenant, owner, subject | ||
| 155 | ) | ||
| 156 | VALUES ('google', 'main', 'googleOAuth', 'vault', $1::json, 'person@example.test', | ||
| 157 | NULL, NULL, 1, 'google', 'org', 'refresh_item', 2, 'calendar', | ||
| 158 | 'https://oauth2.googleapis.com/token', $2::json, $3, $3, 'conn_google', | ||
| 159 | 'org_1', 'org', '') | ||
| 160 | `, | ||
| 161 | [JSON.stringify({ access: "access_item", refresh: "refresh_item" }), "{}", now], | ||
| 162 | ); | ||
| 163 | await sql.unsafe( | ||
| 164 | ` | ||
| 165 | INSERT INTO tool ( | ||
| 166 | integration, connection, plugin_id, name, description, input_schema, output_schema, | ||
| 167 | annotations, created_at, updated_at, row_id, tenant, owner, subject | ||
| 168 | ) | ||
| 169 | VALUES ('google', 'main', 'google', 'calendar.events.list', 'tool', '{}'::json, | ||
| 170 | '{}'::json, '{}'::json, $1, $1, 'tool_calendar_events_list', 'org_1', 'org', '') | ||
| 171 | `, | ||
| 172 | [now], | ||
| 173 | ); | ||
| 174 | await sql.unsafe( | ||
| 175 | ` | ||
| 176 | INSERT INTO plugin_storage ( | ||
| 177 | plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject | ||
| 178 | ) | ||
| 179 | VALUES ('google', 'operation', $1, $2::jsonb, $3, $3, 'op_calendar_events_list', | ||
| 180 | 'org_1', 'org', '') | ||
| 181 | `, | ||
| 182 | [ | ||
| 183 | operationStorageKey("google", "calendar.events.list"), | ||
| 184 | JSON.stringify({ | ||
| 185 | integration: "google", | ||
| 186 | toolName: "calendar.events.list", | ||
| 187 | binding: { | ||
| 188 | method: "get", | ||
| 189 | pathTemplate: "/calendar.events.list", | ||
| 190 | parameters: [], | ||
| 191 | }, | ||
| 192 | description: "calendar.events.list", | ||
| 193 | }), | ||
| 194 | now, | ||
| 195 | ], | ||
| 196 | ); | ||
| 197 | await sql.unsafe( | ||
| 198 | `INSERT INTO blob (id, namespace, key, value, row_id) | ||
| 199 | VALUES | ||
| 200 | ($1, 'o:org_1/google', 'spec/mono-hash', 'spec text', 'spec_row'), | ||
| 201 | ($2, 'o:org_1/google', 'defs/mono-hash', 'defs text', 'defs_row')`, | ||
| 202 | [ | ||
| 203 | JSON.stringify(["o:org_1/google", "spec/mono-hash"]), | ||
| 204 | JSON.stringify(["o:org_1/google", "defs/mono-hash"]), | ||
| 205 | ], | ||
| 206 | ); | ||
| 207 | await sql.unsafe( | ||
| 208 | ` | ||
| 209 | INSERT INTO tool_policy ( | ||
| 210 | id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject | ||
| 211 | ) | ||
| 212 | VALUES ('policy_google_all', 'google.*', 'block', 'a0', $1, $1, 'policy_row', | ||
| 213 | 'org_1', 'org', '') | ||
| 214 | `, | ||
| 215 | [now], | ||
| 216 | ); | ||
| 217 | }; | ||
| 218 | |||
| 219 | const seedNonCanonicalGoogleMonolith = async (sql: SqlClient): Promise<void> => { | ||
| 220 | await sql.unsafe( | ||
| 221 | ` | ||
| 222 | INSERT INTO integration ( | ||
| 223 | slug, plugin_id, name, description, config, health_check, config_revised_at, | ||
| 224 | can_remove, can_refresh, created_at, updated_at, row_id, tenant | ||
| 225 | ) | ||
| 226 | VALUES ('google_photos_youtube', 'google', 'Google Photos YouTube', 'Google APIs', | ||
| 227 | $1::json, NULL, NULL, true, true, $2, $2, 'google_photos_youtube_row', 'org_1') | ||
| 228 | `, | ||
| 229 | [ | ||
| 230 | JSON.stringify({ | ||
| 231 | googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest"], | ||
| 232 | specHash: "youtube-hash", | ||
| 233 | }), | ||
| 234 | now, | ||
| 235 | ], | ||
| 236 | ); | ||
| 237 | await sql.unsafe( | ||
| 238 | ` | ||
| 239 | INSERT INTO connection ( | ||
| 240 | integration, name, template, provider, item_ids, identity_label, description, | ||
| 241 | last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, | ||
| 242 | expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at, | ||
| 243 | row_id, tenant, owner, subject | ||
| 244 | ) | ||
| 245 | VALUES ('google_photos_youtube', 'main', 'googleOAuth', 'vault', '{}'::json, | ||
| 246 | 'person@example.test', NULL, NULL, 1, 'google', 'org', 'refresh_item', 2, | ||
| 247 | 'youtube', 'https://oauth2.googleapis.com/token', '{}'::json, $1, $1, | ||
| 248 | 'conn_google_photos_youtube', 'org_1', 'org', '') | ||
| 249 | `, | ||
| 250 | [now], | ||
| 251 | ); | ||
| 252 | await sql.unsafe( | ||
| 253 | ` | ||
| 254 | INSERT INTO tool ( | ||
| 255 | integration, connection, plugin_id, name, description, input_schema, output_schema, | ||
| 256 | annotations, created_at, updated_at, row_id, tenant, owner, subject | ||
| 257 | ) | ||
| 258 | VALUES ('google_photos_youtube', 'main', 'google', 'youtube.channels.list', 'tool', | ||
| 259 | '{}'::json, '{}'::json, '{}'::json, $1, $1, 'tool_youtube_channels_list', | ||
| 260 | 'org_1', 'org', '') | ||
| 261 | `, | ||
| 262 | [now], | ||
| 263 | ); | ||
| 264 | await sql.unsafe( | ||
| 265 | ` | ||
| 266 | INSERT INTO plugin_storage ( | ||
| 267 | plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject | ||
| 268 | ) | ||
| 269 | VALUES ('google', 'operation', $1, $2::jsonb, $3, $3, 'op_youtube_channels_list', | ||
| 270 | 'org_1', 'org', '') | ||
| 271 | `, | ||
| 272 | [ | ||
| 273 | operationStorageKey("google_photos_youtube", "youtube.channels.list"), | ||
| 274 | JSON.stringify({ | ||
| 275 | integration: "google_photos_youtube", | ||
| 276 | toolName: "youtube.channels.list", | ||
| 277 | }), | ||
| 278 | now, | ||
| 279 | ], | ||
| 280 | ); | ||
| 281 | await sql.unsafe( | ||
| 282 | `INSERT INTO blob (id, namespace, key, value, row_id) | ||
| 283 | VALUES | ||
| 284 | ($1, 'o:org_1/google', 'spec/youtube-hash', 'spec text', 'spec_youtube_row'), | ||
| 285 | ($2, 'o:org_1/google', 'defs/youtube-hash', 'defs text', 'defs_youtube_row')`, | ||
| 286 | [ | ||
| 287 | JSON.stringify(["o:org_1/google", "spec/youtube-hash"]), | ||
| 288 | JSON.stringify(["o:org_1/google", "defs/youtube-hash"]), | ||
| 289 | ], | ||
| 290 | ); | ||
| 291 | }; | ||
| 292 | |||
| 293 | describe("service-split-postgres-cli", () => { | ||
| 294 | it("reads non-canonical provider monolith slugs from Postgres input", async () => { | ||
| 295 | const pglite = await PGlite.create("memory://"); | ||
| 296 | const server = new PGLiteSocketServer({ | ||
| 297 | db: pglite, | ||
| 298 | port: 0, | ||
| 299 | host: "127.0.0.1", | ||
| 300 | }); | ||
| 301 | await server.start(); | ||
| 302 | const port = Number(server.getServerConn().split(":").at(1)); | ||
| 303 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { | ||
| 304 | max: 1, | ||
| 305 | prepare: false, | ||
| 306 | }) as SqlClient; | ||
| 307 | |||
| 308 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test cleans up pglite/postgres resources after assertions | ||
| 309 | try { | ||
| 310 | await createSchema(sql); | ||
| 311 | await seedNonCanonicalGoogleMonolith(sql); | ||
| 312 | |||
| 313 | const input = await readDatabaseInput(sql, false, "database"); | ||
| 314 | const plan = planMigration(input); | ||
| 315 | |||
| 316 | expect(input.integrations.map((row) => row.slug)).toContain("google_photos_youtube"); | ||
| 317 | expect(input.tools.map((row) => row.integration)).toEqual(["google_photos_youtube"]); | ||
| 318 | expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([ | ||
| 319 | "google_youtube_data", | ||
| 320 | ]); | ||
| 321 | } finally { | ||
| 322 | await sql.end({ timeout: 0 }); | ||
| 323 | await server.stop(); | ||
| 324 | await pglite.close(); | ||
| 325 | } | ||
| 326 | }); | ||
| 327 | |||
| 328 | it("applies a synthetic monolith split through the Postgres runner and stamps the ledger", async () => { | ||
| 329 | const pglite = await PGlite.create("memory://"); | ||
| 330 | const server = new PGLiteSocketServer({ | ||
| 331 | db: pglite, | ||
| 332 | port: 0, | ||
| 333 | host: "127.0.0.1", | ||
| 334 | }); | ||
| 335 | await server.start(); | ||
| 336 | const port = Number(server.getServerConn().split(":").at(1)); | ||
| 337 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { | ||
| 338 | max: 1, | ||
| 339 | prepare: false, | ||
| 340 | }) as SqlClient; | ||
| 341 | |||
| 342 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test cleans up pglite/postgres resources after assertions | ||
| 343 | try { | ||
| 344 | await createSchema(sql); | ||
| 345 | await seedMonolith(sql); | ||
| 346 | |||
| 347 | const input = await readDatabaseInput(sql, false, "database"); | ||
| 348 | const plan = planMigration(input); | ||
| 349 | expect(plan.summary).toMatchObject({ | ||
| 350 | integrationsCreate: 1, | ||
| 351 | connectionsClone: 1, | ||
| 352 | policiesRewrite: 1, | ||
| 353 | monolithDeletes: 1, | ||
| 354 | }); | ||
| 355 | |||
| 356 | const org = plan.orgs[0]; | ||
| 357 | expect(org).toBeDefined(); | ||
| 358 | await applyOrg(sql, org!); | ||
| 359 | |||
| 360 | const integrations = await sql.unsafe< | ||
| 361 | { | ||
| 362 | readonly slug: string; | ||
| 363 | readonly plugin_id: string; | ||
| 364 | readonly config: unknown; | ||
| 365 | }[] | ||
| 366 | >("SELECT slug, plugin_id, config::jsonb AS config FROM integration ORDER BY slug"); | ||
| 367 | expect(integrations).toHaveLength(1); | ||
| 368 | expect(integrations[0]).toMatchObject({ | ||
| 369 | slug: "google_calendar", | ||
| 370 | plugin_id: "openapi", | ||
| 371 | }); | ||
| 372 | expect(parseJson(integrations[0]?.config)).toMatchObject({ | ||
| 373 | specHash: "mono-hash", | ||
| 374 | sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 375 | specFormat: "google-discovery", | ||
| 376 | family: "google", | ||
| 377 | }); | ||
| 378 | |||
| 379 | const connections = await sql.unsafe< | ||
| 380 | { | ||
| 381 | readonly integration: string; | ||
| 382 | readonly provider: string; | ||
| 383 | readonly item_ids: unknown; | ||
| 384 | readonly oauth_client: string; | ||
| 385 | }[] | ||
| 386 | >("SELECT integration, provider, item_ids, oauth_client FROM connection"); | ||
| 387 | expect(connections).toHaveLength(1); | ||
| 388 | expect(connections[0]).toMatchObject({ | ||
| 389 | integration: "google_calendar", | ||
| 390 | provider: "vault", | ||
| 391 | oauth_client: "google", | ||
| 392 | }); | ||
| 393 | expect(parseJson(connections[0]?.item_ids)).toEqual({ | ||
| 394 | access: "access_item", | ||
| 395 | refresh: "refresh_item", | ||
| 396 | }); | ||
| 397 | |||
| 398 | const operations = await sql.unsafe< | ||
| 399 | { | ||
| 400 | readonly plugin_id: string; | ||
| 401 | readonly key: string; | ||
| 402 | readonly data: unknown; | ||
| 403 | }[] | ||
| 404 | >("SELECT plugin_id, key, data FROM plugin_storage WHERE collection = 'operation'"); | ||
| 405 | expect(operations).toHaveLength(1); | ||
| 406 | expect(operations[0]).toMatchObject({ | ||
| 407 | plugin_id: "openapi", | ||
| 408 | key: operationStorageKey("google_calendar", "calendar.events.list"), | ||
| 409 | }); | ||
| 410 | expect(parseJson(operations[0]?.data)).toMatchObject({ | ||
| 411 | integration: "google_calendar", | ||
| 412 | toolName: "calendar.events.list", | ||
| 413 | }); | ||
| 414 | |||
| 415 | const tools = await sql.unsafe< | ||
| 416 | { | ||
| 417 | readonly integration: string; | ||
| 418 | readonly plugin_id: string; | ||
| 419 | readonly name: string; | ||
| 420 | }[] | ||
| 421 | >("SELECT integration, plugin_id, name FROM tool"); | ||
| 422 | expect(tools).toEqual([ | ||
| 423 | { | ||
| 424 | integration: "google_calendar", | ||
| 425 | plugin_id: "openapi", | ||
| 426 | name: "calendar.events.list", | ||
| 427 | }, | ||
| 428 | ]); | ||
| 429 | |||
| 430 | const policies = await sql.unsafe<{ readonly pattern: string; readonly action: string }[]>( | ||
| 431 | "SELECT pattern, action FROM tool_policy", | ||
| 432 | ); | ||
| 433 | expect(policies).toEqual([{ pattern: "google_calendar.*", action: "block" }]); | ||
| 434 | |||
| 435 | const blobs = await sql.unsafe< | ||
| 436 | { | ||
| 437 | readonly namespace: string; | ||
| 438 | readonly key: string; | ||
| 439 | readonly value: string; | ||
| 440 | }[] | ||
| 441 | >("SELECT namespace, key, value FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"); | ||
| 442 | expect(blobs).toEqual([ | ||
| 443 | { | ||
| 444 | namespace: "o:org_1/openapi", | ||
| 445 | key: "defs/mono-hash", | ||
| 446 | value: "defs text", | ||
| 447 | }, | ||
| 448 | { | ||
| 449 | namespace: "o:org_1/openapi", | ||
| 450 | key: "spec/mono-hash", | ||
| 451 | value: "spec text", | ||
| 452 | }, | ||
| 453 | ]); | ||
| 454 | |||
| 455 | const ledger = await sql.unsafe<{ readonly tenant: string }[]>( | ||
| 456 | `SELECT tenant FROM ${LEDGER_TABLE}`, | ||
| 457 | ); | ||
| 458 | expect(ledger).toEqual([{ tenant: "org_1" }]); | ||
| 459 | |||
| 460 | const rerunInput = await readDatabaseInput(sql, false, "database"); | ||
| 461 | expect(planMigration(rerunInput).summary.orgs).toBe(0); | ||
| 462 | } finally { | ||
| 463 | await sql.end({ timeout: 0 }); | ||
| 464 | await server.stop(); | ||
| 465 | await pglite.close(); | ||
| 466 | } | ||
| 467 | }); | ||
| 468 | }); | ||
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | convertGoogleDiscoveryToOpenApi, | 10 | convertGoogleDiscoveryToOpenApi, |
| 11 | fetchGoogleDiscoveryDocument, | 11 | fetchGoogleDiscoveryDocument, |
| 12 | isGoogleDiscoveryUrl, | 12 | isGoogleDiscoveryUrl, |
| 13 | } from "../packages/plugins/google/src/sdk/discovery"; | 13 | } from "../packages/plugins/openapi/src/providers/google/discovery"; |
| 14 | import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; | 14 | import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; |
| 15 | import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; | 15 | import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; |
| 16 | import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; | 16 | import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; |