* 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 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { | |
| 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 | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 13 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines | ||
| 42 | 48 | * falls back to the credential-driven default. */ |
| 43 | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | 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 | 53 | export default defineExecutorConfig({ |
| 52 | − plugins: ({ | |
| 53 | − workosCredentials, | |
| 54 | − workosVaultClient, | |
| 55 | − activeToolkitSlug, | |
| 56 | − allowLocalNetwork, | |
| 57 | − }: CloudPluginDeps = {}) => | |
| 54 | + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => | |
| 58 | 55 | [ |
| 59 | − openApiHttpPlugin(), | |
| 60 | − googleHttpPlugin(), | |
| 61 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | |
| 56 | + openApiHttpPlugin({ | |
| 57 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 58 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 59 | + }), | |
| 62 | 60 | mcpHttpPlugin({ |
| 63 | 61 | dangerouslyAllowStdioMCP: false, |
| 64 | 62 | }), |
| @@ -41,10 +41,8 @@ · 40 unmodified lines | ||
| 41 | 41 | "@executor-js/execution": "workspace:*", |
| 42 | 42 | "@executor-js/fumadb": "workspace:*", |
| 43 | 43 | "@executor-js/host-mcp": "workspace:*", |
| 44 | − "@executor-js/plugin-google": "workspace:*", | |
| 45 | 44 | "@executor-js/plugin-graphql": "workspace:*", |
| 46 | 45 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 48 | 46 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | 47 | "@executor-js/plugin-toolkits": "workspace:*", |
| 50 | 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 | 67 | activeToolkitSlug: |
| 68 | 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 | 35 | "@executor-js/local": "workspace:*", |
| 36 | 36 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 37 | 37 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 38 | − "@executor-js/plugin-google": "workspace:*", | |
| 39 | 38 | "@executor-js/plugin-graphql": "workspace:*", |
| 40 | 39 | "@executor-js/plugin-keychain": "workspace:*", |
| 41 | 40 | "@executor-js/plugin-mcp": "workspace:*", |
| 42 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 43 | 41 | "@executor-js/plugin-onepassword": "workspace:*", |
| 44 | 42 | "@executor-js/plugin-openapi": "workspace:*", |
| 45 | 43 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { | |
| 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 | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 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 | 25 | export default defineExecutorConfig({ |
| 20 | 26 | plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => |
| 21 | 27 | [ |
| 22 | − openApiHttpPlugin(), | |
| 23 | − googleHttpPlugin(), | |
| 24 | − microsoftHttpPlugin(), | |
| 28 | + openApiHttpPlugin({ | |
| 29 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 30 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 31 | + }), | |
| 25 | 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 26 | 33 | graphqlHttpPlugin(), |
| 27 | 34 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -24,11 +24,10 @@ · 23 unmodified lines | ||
| 24 | 24 | "@executor-js/fumadb": "workspace:*", |
| 25 | 25 | "@executor-js/host-mcp": "workspace:*", |
| 26 | 26 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 27 | − "@executor-js/plugin-google": "workspace:*", | |
| 28 | 27 | "@executor-js/plugin-graphql": "workspace:*", |
| 29 | 28 | "@executor-js/plugin-mcp": "workspace:*", |
| 30 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 31 | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 30 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 32 | 31 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | 32 | "@executor-js/react": "workspace:*", |
| 34 | 33 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -110,11 +110,11 @@ const insertOperationStorage = ( · 109 unmodified lines | ||
| 110 | 110 | args: [ |
| 111 | 111 | row.tenant, |
| 112 | 112 | row.pluginId, |
| 113 | − `${row.integration}.items.list`, | |
| 113 | + `${row.integration}.calendar.events.list`, | |
| 114 | 114 | JSON.stringify({ |
| 115 | 115 | integration: row.integration, |
| 116 | − toolName: "items.list", | |
| 117 | − binding: { method: "get", pathTemplate: "/items" }, | |
| 116 | + toolName: "calendar.events.list", | |
| 117 | + binding: { method: "get", pathTemplate: "/calendar/events" }, | |
| 118 | 118 | }), |
| 119 | 119 | now, |
| 120 | 120 | now, |
| @@ -239,6 +239,7 @@ describe("runCloudflareDataMigrations", () => { · 118 unmodified lines | ||
| 239 | 239 | const d1 = makeFakeD1(db.client); |
| 240 | 240 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ |
| 241 | 241 | "2026-06-20-google-openapi-ownership", |
| 242 | + "2026-07-08-provider-service-split", | |
| 242 | 243 | ]); |
| 243 | 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 | 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 | 282 | const d1 = makeFakeD1(db.client); |
| 281 | 283 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([ |
| 282 | 284 | "2026-06-20-google-openapi-ownership", |
| 285 | + "2026-07-08-provider-service-split", | |
| 283 | 286 | ]); |
| 284 | 287 | expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); |
| 285 | 288 | |
| @@ -288,12 +291,58 @@ describe("runCloudflareDataMigrations", () => { · 2 unmodified lines | ||
| 288 | 291 | const integrations = yield* Effect.promise(() => |
| 289 | 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 | 296 | const storage = yield* Effect.promise(() => |
| 294 | 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 | 347 | yield* Effect.promise(() => db.close()); |
| 299 | 348 | }), |
| @@ -7,7 +7,13 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type SqliteDataMigration, |
| 8 | 8 | type SqliteDataMigrationClient, |
| 9 | 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 | 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 | 225 | const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [ |
| 165 | 226 | { |
| 166 | 227 | name: googleOpenApiOwnershipDataMigration.name, |
| @@ -170,6 +231,17 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite · 3 unmodified lines | ||
| 170 | 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 | 247 | export const runCloudflareDataMigrations = ( |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 2 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 3 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 2 | +import { | |
| 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 | 10 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | 11 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | 12 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -22,9 +28,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines | ||
| 22 | 28 | options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, |
| 23 | 29 | ) => |
| 24 | 30 | [ |
| 25 | − openApiHttpPlugin(), | |
| 26 | − googleHttpPlugin(), | |
| 27 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }), | |
| 31 | + openApiHttpPlugin({ | |
| 32 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 33 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 34 | + }), | |
| 28 | 35 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 29 | 36 | graphqlHttpPlugin(), |
| 30 | 37 | toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), |
| @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; · 5 unmodified lines | ||
| 6 | 6 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | import { Schema } from "effect"; |
| 8 | 8 | import { unstable_dev, type Unstable_DevWorker } from "wrangler"; |
| 9 | +import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft"; | |
| 9 | 10 | |
| 10 | 11 | // --------------------------------------------------------------------------- |
| 11 | 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 | 188 | expect(integration?.slug).toBe(slug); |
| 188 | 189 | }, 60_000); |
| 189 | 190 | |
| 190 | − it("registers the Microsoft Graph API route in the runtime plugin set", async () => { | |
| 191 | − const res = await worker.fetch("/api/microsoft/graph", { | |
| 191 | + it("exposes Microsoft catalog presets through the OpenAPI integration catalog", async () => { | |
| 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 | 198 | method: "POST", |
| 193 | 199 | headers: { "content-type": "application/json" }, |
| 194 | 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 | 235 | it("gates the API when dev-auth is on but treats the request as the dev admin", async () => { |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { | |
| 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 | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 13 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -21,18 +27,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines | ||
| 21 | 27 | |
| 22 | 28 | interface SelfHostPluginDeps { |
| 23 | 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 | 32 | export default defineExecutorConfig({ |
| 31 | − plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) => | |
| 33 | + plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => | |
| 32 | 34 | [ |
| 33 | − openApiHttpPlugin(), | |
| 34 | − googleHttpPlugin(), | |
| 35 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | |
| 35 | + openApiHttpPlugin({ | |
| 36 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 37 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 38 | + }), | |
| 36 | 39 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 37 | 40 | graphqlHttpPlugin(), |
| 38 | 41 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -27,11 +27,10 @@ · 26 unmodified lines | ||
| 27 | 27 | "@executor-js/fumadb": "workspace:*", |
| 28 | 28 | "@executor-js/host-mcp": "workspace:*", |
| 29 | 29 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 30 | − "@executor-js/plugin-google": "workspace:*", | |
| 31 | 30 | "@executor-js/plugin-graphql": "workspace:*", |
| 32 | 31 | "@executor-js/plugin-mcp": "workspace:*", |
| 33 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 34 | 32 | "@executor-js/plugin-openapi": "workspace:*", |
| 33 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 35 | 34 | "@executor-js/plugin-toolkits": "workspace:*", |
| 36 | 35 | "@executor-js/react": "workspace:*", |
| 37 | 36 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -12,8 +12,9 @@ import { · 11 unmodified lines | ||
| 12 | 12 | openApiSpecBlobDataMigration, |
| 13 | 13 | } from "@executor-js/plugin-openapi"; |
| 14 | 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 | 18 | import { authConfigTransforms } from "./auth-config-migration"; |
| 18 | 19 | |
| 19 | 20 | export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ |
| @@ -31,4 +32,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines | ||
| 31 | 32 | openApiSpecBlobDataMigration, |
| 32 | 33 | graphqlIntrospectionBlobDataMigration, |
| 33 | 34 | googleOpenApiOwnershipDataMigration, |
| 35 | + providerServiceSplitDataMigration, | |
| 34 | 36 | ]; |
| @@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines | ||
| 44 | 44 | executorConfig.plugins({ |
| 45 | 45 | activeToolkitSlug: |
| 46 | 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 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { | |
| 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 | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 13 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| @@ -26,9 +32,10 @@ interface LocalPluginDeps { · 18 unmodified lines | ||
| 26 | 32 | export default defineExecutorConfig({ |
| 27 | 33 | plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => |
| 28 | 34 | [ |
| 29 | − openApiHttpPlugin(), | |
| 30 | − googleHttpPlugin(), | |
| 31 | − microsoftHttpPlugin(), | |
| 35 | + openApiHttpPlugin({ | |
| 36 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 37 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 38 | + }), | |
| 32 | 39 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 33 | 40 | graphqlHttpPlugin(), |
| 34 | 41 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -32,13 +32,12 @@ · 31 unmodified lines | ||
| 32 | 32 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 33 | 33 | "@executor-js/plugin-example": "workspace:*", |
| 34 | 34 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 35 | − "@executor-js/plugin-google": "workspace:*", | |
| 36 | 35 | "@executor-js/plugin-graphql": "workspace:*", |
| 37 | 36 | "@executor-js/plugin-keychain": "workspace:*", |
| 38 | 37 | "@executor-js/plugin-mcp": "workspace:*", |
| 39 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 40 | 38 | "@executor-js/plugin-onepassword": "workspace:*", |
| 41 | 39 | "@executor-js/plugin-openapi": "workspace:*", |
| 40 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 42 | 41 | "@executor-js/plugin-toolkits": "workspace:*", |
| 43 | 42 | "@executor-js/react": "workspace:*", |
| 44 | 43 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -17,8 +17,9 @@ import { · 16 unmodified lines | ||
| 17 | 17 | openApiSpecBlobDataMigration, |
| 18 | 18 | } from "@executor-js/plugin-openapi"; |
| 19 | 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 | 23 | import { authConfigTransforms } from "./auth-config-migration"; |
| 23 | 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 | 43 | openApiSpecBlobDataMigration, |
| 43 | 44 | graphqlIntrospectionBlobDataMigration, |
| 44 | 45 | googleOpenApiOwnershipDataMigration, |
| 46 | + providerServiceSplitDataMigration, | |
| 45 | 47 | // GC dead DCR oauth_client rows (old always-register duplicates) and backfill |
| 46 | 48 | // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C). |
| 47 | 49 | oauthClientGcSqliteMigration, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | "@effect/language-service": "^0.85.1", |
| 11 | 11 | "@effect/tsgo": "^0.5.2", |
| 12 | 12 | "@effect/vitest": "catalog:", |
| 13 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 13 | 14 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 14 | 15 | "@vitest/expect": "catalog:", |
| 15 | 16 | "@vitest/mocker": "catalog:", |
| @@ -66,10 +67,8 @@ · 50 unmodified lines | ||
| 66 | 67 | "@executor-js/execution": "workspace:*", |
| 67 | 68 | "@executor-js/fumadb": "workspace:*", |
| 68 | 69 | "@executor-js/host-mcp": "workspace:*", |
| 69 | − "@executor-js/plugin-google": "workspace:*", | |
| 70 | 70 | "@executor-js/plugin-graphql": "workspace:*", |
| 71 | 71 | "@executor-js/plugin-mcp": "workspace:*", |
| 72 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 73 | 72 | "@executor-js/plugin-openapi": "workspace:*", |
| 74 | 73 | "@executor-js/plugin-toolkits": "workspace:*", |
| 75 | 74 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -142,11 +141,9 @@ · 66 unmodified lines | ||
| 142 | 141 | "@executor-js/local": "workspace:*", |
| 143 | 142 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 144 | 143 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 145 | − "@executor-js/plugin-google": "workspace:*", | |
| 146 | 144 | "@executor-js/plugin-graphql": "workspace:*", |
| 147 | 145 | "@executor-js/plugin-keychain": "workspace:*", |
| 148 | 146 | "@executor-js/plugin-mcp": "workspace:*", |
| 149 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 150 | 147 | "@executor-js/plugin-onepassword": "workspace:*", |
| 151 | 148 | "@executor-js/plugin-openapi": "workspace:*", |
| 152 | 149 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -176,11 +173,10 @@ · 23 unmodified lines | ||
| 176 | 173 | "@executor-js/fumadb": "workspace:*", |
| 177 | 174 | "@executor-js/host-mcp": "workspace:*", |
| 178 | 175 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 179 | − "@executor-js/plugin-google": "workspace:*", | |
| 180 | 176 | "@executor-js/plugin-graphql": "workspace:*", |
| 181 | 177 | "@executor-js/plugin-mcp": "workspace:*", |
| 182 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 183 | 178 | "@executor-js/plugin-openapi": "workspace:*", |
| 179 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 184 | 180 | "@executor-js/plugin-toolkits": "workspace:*", |
| 185 | 181 | "@executor-js/react": "workspace:*", |
| 186 | 182 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -225,11 +221,10 @@ · 38 unmodified lines | ||
| 225 | 221 | "@executor-js/fumadb": "workspace:*", |
| 226 | 222 | "@executor-js/host-mcp": "workspace:*", |
| 227 | 223 | "@executor-js/plugin-encrypted-secrets": "workspace:*", |
| 228 | − "@executor-js/plugin-google": "workspace:*", | |
| 229 | 224 | "@executor-js/plugin-graphql": "workspace:*", |
| 230 | 225 | "@executor-js/plugin-mcp": "workspace:*", |
| 231 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 232 | 226 | "@executor-js/plugin-openapi": "workspace:*", |
| 227 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 233 | 228 | "@executor-js/plugin-toolkits": "workspace:*", |
| 234 | 229 | "@executor-js/react": "workspace:*", |
| 235 | 230 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -276,13 +271,12 @@ · 40 unmodified lines | ||
| 276 | 271 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 277 | 272 | "@executor-js/plugin-example": "workspace:*", |
| 278 | 273 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 279 | − "@executor-js/plugin-google": "workspace:*", | |
| 280 | 274 | "@executor-js/plugin-graphql": "workspace:*", |
| 281 | 275 | "@executor-js/plugin-keychain": "workspace:*", |
| 282 | 276 | "@executor-js/plugin-mcp": "workspace:*", |
| 283 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 284 | 277 | "@executor-js/plugin-onepassword": "workspace:*", |
| 285 | 278 | "@executor-js/plugin-openapi": "workspace:*", |
| 279 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 286 | 280 | "@executor-js/plugin-toolkits": "workspace:*", |
| 287 | 281 | "@executor-js/react": "workspace:*", |
| 288 | 282 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -346,10 +340,8 @@ · 57 unmodified lines | ||
| 346 | 340 | "@executor-js/api": "workspace:*", |
| 347 | 341 | "@executor-js/emulate": "^0.13.3", |
| 348 | 342 | "@executor-js/mcporter": "^0.11.4", |
| 349 | − "@executor-js/plugin-google": "workspace:*", | |
| 350 | 343 | "@executor-js/plugin-graphql": "workspace:*", |
| 351 | 344 | "@executor-js/plugin-mcp": "workspace:*", |
| 352 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 353 | 345 | "@executor-js/plugin-openapi": "workspace:*", |
| 354 | 346 | "@executor-js/plugin-toolkits": "workspace:*", |
| 355 | 347 | "@executor-js/sdk": "workspace:*", |
| @@ -381,7 +373,6 @@ · 25 unmodified lines | ||
| 381 | 373 | "version": "0.0.47", |
| 382 | 374 | "dependencies": { |
| 383 | 375 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 384 | − "@executor-js/plugin-google": "workspace:*", | |
| 385 | 376 | "@executor-js/plugin-graphql": "workspace:*", |
| 386 | 377 | "@executor-js/plugin-keychain": "workspace:*", |
| 387 | 378 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -849,43 +840,6 @@ · 461 unmodified lines | ||
| 849 | 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 | 843 | "packages/plugins/graphql": { |
| 890 | 844 | "name": "@executor-js/plugin-graphql", |
| 891 | 845 | "version": "1.5.29", |
| @@ -984,43 +938,6 @@ · 92 unmodified lines | ||
| 984 | 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 | 941 | "packages/plugins/onepassword": { |
| 1025 | 942 | "name": "@executor-js/plugin-onepassword", |
| 1026 | 943 | "version": "1.5.29", |
| @@ -1096,6 +1013,23 @@ · 69 unmodified lines | ||
| 1096 | 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 | 1033 | "packages/plugins/toolkits": { |
| 1100 | 1034 | "name": "@executor-js/plugin-toolkits", |
| 1101 | 1035 | "version": "1.5.21", |
| @@ -1784,20 +1718,18 @@ · 682 unmodified lines | ||
| 1784 | 1718 | |
| 1785 | 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 | 1721 | "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], |
| 1790 | 1722 | |
| 1791 | 1723 | "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], |
| 1792 | 1724 | |
| 1793 | 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 | 1727 | "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"], |
| 1798 | 1728 | |
| 1799 | 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 | 1733 | "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], |
| 1802 | 1734 | |
| 1803 | 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 | 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 | 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 | 3149 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], |
| @@ -5920,9 +5892,9 @@ · 2742 unmodified lines | ||
| 5920 | 5892 | |
| 5921 | 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 | 5899 | "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], |
| 5928 | 5900 | |
| @@ -24,10 +24,8 @@ · 23 unmodified lines | ||
| 24 | 24 | "@executor-js/api": "workspace:*", |
| 25 | 25 | "@executor-js/emulate": "^0.13.3", |
| 26 | 26 | "@executor-js/mcporter": "^0.11.4", |
| 27 | − "@executor-js/plugin-google": "workspace:*", | |
| 28 | 27 | "@executor-js/plugin-graphql": "workspace:*", |
| 29 | 28 | "@executor-js/plugin-mcp": "workspace:*", |
| 30 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 31 | 29 | "@executor-js/plugin-openapi": "workspace:*", |
| 32 | 30 | "@executor-js/plugin-toolkits": "workspace:*", |
| 33 | 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 | 5 | import { Browser, Target } from "../src/services"; |
| 6 | 6 | |
| 7 | 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 | 10 | Effect.gen(function* () { |
| 11 | 11 | const target = yield* Target; |
| @@ -13,30 +13,31 @@ scenario( · 1 unmodified line | ||
| 13 | 13 | const identity = yield* target.newIdentity(); |
| 14 | 14 | |
| 15 | 15 | yield* browser.session(identity, async ({ page, step }) => { |
| 16 | − await step("Find the Google Photos preset from the integrations picker", async () => { | |
| 17 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 18 | − await page.getByRole("button", { name: "Connect" }).click(); | |
| 19 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 20 | − await dialog.waitFor(); | |
| 21 | − await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); | |
| 22 | − await dialog.getByRole("link", { name: /^Google Photos\b/ }).waitFor(); | |
| 23 | − }); | |
| 16 | + await step( | |
| 17 | + "Find the separated Google Photos presets from the integrations picker", | |
| 18 | + async () => { | |
| 19 | + await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 20 | + await page.getByRole("button", { name: "Connect" }).click(); | |
| 21 | + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 22 | + await dialog.waitFor(); | |
| 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 | 30 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 27 | − await dialog.getByRole("link", { name: /^Google Photos\b/ }).click(); | |
| 28 | − await page.waitForURL(/\/integrations\/add\/google/); | |
| 29 | − await page.getByRole("heading", { name: "Add Google integration" }).waitFor(); | |
| 31 | + await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click(); | |
| 32 | + await page.waitForURL(/\/integrations\/add\/openapi/); | |
| 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 () => { | |
| 33 | − await page.locator('input[value="Google Photos"]').waitFor(); | |
| 34 | − await page.locator('input[value="google_photos"]').waitFor(); | |
| 35 | − await page.getByText("Google Photos Library").first().waitFor(); | |
| 36 | − await page.getByText("Google Photos Picker").first().waitFor(); | |
| 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); | |
| 36 | + await step("The Library link carries the focused OpenAPI preset", async () => { | |
| 37 | + const url = new URL(page.url()); | |
| 38 | + expect(url.searchParams.get("preset")).toBe("google-photos-library"); | |
| 39 | + expect(url.searchParams.get("url")).toContain("photoslibrary"); | |
| 40 | + await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary"); | |
| 40 | 41 | }); |
| 41 | 42 | }); |
| 42 | 43 | }), |
| @@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines | ||
| 5 | 5 | import { Effect } from "effect"; |
| 6 | 6 | import { composePluginApi } from "@executor-js/api/server"; |
| 7 | 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; |
| 8 | −import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | |
| 9 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 8 | +import { | |
| 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 | 14 | import { |
| 11 | 15 | AuthTemplateSlug, |
| 12 | 16 | ConnectionName, |
| @@ -17,7 +21,9 @@ import { · 4 unmodified lines | ||
| 17 | 21 | import { scenario } from "../src/scenario"; |
| 18 | 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 | 28 | type OAuthTemplateView = { |
| 23 | 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 | 87 | scenario( |
| 82 | 88 | "Microsoft · client credentials against the emulator mint a Graph connection and call /users", |
| 83 | − { | |
| 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 | − }, | |
| 89 | + { timeout: 180_000 }, | |
| 95 | 90 | Effect.scoped( |
| 96 | 91 | Effect.gen(function* () { |
| 97 | 92 | const target = yield* Target; |
| @@ -110,18 +105,26 @@ scenario( · 12 unmodified lines | ||
| 110 | 105 | |
| 111 | 106 | yield* Effect.ensuring( |
| 112 | 107 | Effect.gen(function* () { |
| 113 | − yield* client.microsoft.addGraph({ | |
| 108 | + yield* client.openapi.addSpec({ | |
| 114 | 109 | payload: { |
| 115 | − presetIds: ["users"], | |
| 116 | − customScopes: [], | |
| 110 | + spec: { kind: "url", url: emulator.openapiUrl }, | |
| 117 | 111 | slug: integration, |
| 118 | 112 | name: "Microsoft Graph Emulator", |
| 119 | 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 | 128 | params: { slug: integration }, |
| 126 | 129 | }); |
| 127 | 130 | const appOnlyTemplate = config?.authenticationTemplate?.find( |
| @@ -213,7 +216,7 @@ scenario( · 85 unmodified lines | ||
| 213 | 216 | params: { slug: oauthClient }, |
| 214 | 217 | payload: { owner: "org" }, |
| 215 | 218 | }); |
| 216 | − yield* client.microsoft.removeGraph({ params: { slug: integration } }); | |
| 219 | + yield* client.openapi.removeSpec({ params: { slug: integration } }); | |
| 217 | 220 | }).pipe(Effect.ignore), |
| 218 | 221 | ); |
| 219 | 222 | }), |
| @@ -6,10 +6,12 @@ import { composePluginApi } from "@executor-js/api/server"; · 5 unmodified lines | ||
| 6 | 6 | import { |
| 7 | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | 8 | MICROSOFT_AUTHORIZATION_URL, |
| 9 | − MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | |
| 9 | + MICROSOFT_GRAPH_OPENAPI_URL, | |
| 10 | 10 | MICROSOFT_TOKEN_URL, |
| 11 | −} from "@executor-js/plugin-microsoft"; | |
| 12 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 11 | + microsoftCatalog, | |
| 12 | + microsoftGraphAdapter, | |
| 13 | +} from "@executor-js/plugin-openapi/providers/microsoft"; | |
| 14 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 13 | 15 | import { |
| 14 | 16 | AuthTemplateSlug, |
| 15 | 17 | ConnectionName, |
| @@ -20,24 +22,26 @@ import { · 4 unmodified lines | ||
| 20 | 22 | import { scenario } from "../src/scenario"; |
| 21 | 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 | 29 | type ToolView = { |
| 26 | 30 | readonly name: string; |
| 27 | 31 | }; |
| 28 | 32 | |
| 29 | 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 | |
| 32 | −// GitHub's 2-core runners have: /api/microsoft/graph 500s and the dev stack is | |
| 33 | −// dead for every scenario after it in the shard. Local runs (and the | |
| 34 | −// production Workers streaming path) are unaffected — CI-only quarantine. | |
| 36 | +// Compiling the Graph spec inside dev workerd needs more headroom than GitHub's | |
| 37 | +// 2-core runners have. Local runs and the production Workers streaming path are | |
| 38 | +// unaffected, so CI keeps this narrow catalog scenario quarantined. | |
| 35 | 39 | const CI_GRAPH_SPEC_SKIP = process.env.CI |
| 36 | 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 | 41 | : undefined; |
| 38 | 42 | |
| 39 | 43 | scenario( |
| 40 | − "Microsoft Graph: default add stores common Microsoft 365 workloads", | |
| 44 | + "Microsoft Graph: catalog add stores the OneDrive files service", | |
| 41 | 45 | { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP }, |
| 42 | 46 | Effect.gen(function* () { |
| 43 | 47 | const target = yield* Target; |
| @@ -45,59 +49,41 @@ scenario( · 1 unmodified line | ||
| 45 | 49 | const identity = yield* target.newIdentity(); |
| 46 | 50 | const client = yield* makeApiClient(api, identity); |
| 47 | 51 | |
| 48 | − const integration = unique("msgraph_default"); | |
| 52 | + const integration = unique("msgraph_files"); | |
| 49 | 53 | const connection = ConnectionName.make("main"); |
| 50 | 54 | const oauthClient = OAuthClientSlug.make(unique("msgraph_app")); |
| 51 | 55 | |
| 52 | 56 | yield* Effect.ensuring( |
| 53 | 57 | Effect.gen(function* () { |
| 54 | − const added = yield* client.microsoft.addGraph({ | |
| 58 | + const added = yield* client.openapi.addSpec({ | |
| 55 | 59 | payload: { |
| 56 | − presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS], | |
| 57 | − customScopes: [], | |
| 60 | + spec: { | |
| 61 | + kind: "url", | |
| 62 | + url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, | |
| 63 | + }, | |
| 58 | 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 | 70 | expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration); |
| 63 | 71 | expect( |
| 64 | 72 | added.toolCount, |
| 65 | − "the default Microsoft Graph add extracts common user-facing operations", | |
| 66 | − ).toBeGreaterThan(100); | |
| 73 | + "the Microsoft files catalog preset extracts a focused operation subtree", | |
| 74 | + ).toBeGreaterThan(10); | |
| 67 | 75 | |
| 68 | − const config = yield* client.microsoft.getConfig({ | |
| 76 | + const config = yield* client.openapi.getConfig({ | |
| 69 | 77 | params: { slug: integration }, |
| 70 | 78 | }); |
| 71 | − expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([ | |
| 72 | − ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | |
| 73 | − ]); | |
| 74 | − expect( | |
| 75 | − config?.microsoftGraphCoversFullGraph, | |
| 76 | − "the default selection is not full Graph", | |
| 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", | |
| 79 | + const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => | |
| 80 | + template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" | |
| 81 | + ? [...template.scopes] | |
| 82 | + : [], | |
| 83 | + ); | |
| 84 | + expect(delegatedScopes, "the files delegated OAuth asks for file scopes").toContain( | |
| 91 | 85 | "Files.ReadWrite.All", |
| 92 | − "Sites.ReadWrite.All", | |
| 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 | − ]); | |
| 86 | + ); | |
| 101 | 87 | |
| 102 | 88 | yield* client.oauth.createClient({ |
| 103 | 89 | payload: { |
| @@ -128,12 +114,12 @@ scenario( · 24 unmodified lines | ||
| 128 | 114 | const authorizeUrl = new URL(authorizationUrl || "https://invalid.example"); |
| 129 | 115 | expect( |
| 130 | 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 | 118 | ).toBeLessThan(2_000); |
| 133 | 119 | expect( |
| 134 | 120 | authorizeUrl.searchParams.get("scope"), |
| 135 | − "default Microsoft Graph OAuth asks for common workload scopes", | |
| 136 | − ).toBe(config?.microsoftGraphScopes?.join(" ")); | |
| 121 | + "Microsoft files OAuth asks for the focused scope set", | |
| 122 | + ).toBe(delegatedScopes?.join(" ")); | |
| 137 | 123 | |
| 138 | 124 | yield* client.connections.create({ |
| 139 | 125 | payload: { |
| @@ -149,15 +135,15 @@ scenario( · 9 unmodified lines | ||
| 149 | 135 | query: { integration: IntegrationSlug.make(integration), connection }, |
| 150 | 136 | }); |
| 151 | 137 | const names = tools.map((tool: ToolView) => tool.name); |
| 152 | − const messageTools = names.filter((name) => name.toLowerCase().includes("message")); | |
| 153 | − const siteTools = names.filter((name) => name.toLowerCase().includes("site")); | |
| 154 | − expect( | |
| 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( | |
| 138 | + const driveTools = names.filter((name) => name.toLowerCase().includes("drive")); | |
| 139 | + const shareTools = names.filter((name) => name.toLowerCase().includes("share")); | |
| 140 | + expect(driveTools, "the retrieved catalog includes Microsoft drive 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 | 148 | Effect.gen(function* () { |
| 163 | 149 | yield* client.connections |
| @@ -169,8 +155,8 @@ scenario( · 5 unmodified lines | ||
| 169 | 155 | }, |
| 170 | 156 | }) |
| 171 | 157 | .pipe(Effect.ignore); |
| 172 | − yield* client.microsoft | |
| 173 | − .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 158 | + yield* client.openapi | |
| 159 | + .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 174 | 160 | .pipe(Effect.ignore); |
| 175 | 161 | yield* client.oauth |
| 176 | 162 | .removeClient({ |
| @@ -5,34 +5,40 @@ import { Effect } from "effect"; · 4 unmodified lines | ||
| 5 | 5 | import { composePluginApi } from "@executor-js/api/server"; |
| 6 | 6 | import { |
| 7 | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | − MICROSOFT_GRAPH_ALL_PRESET_IDS, | |
| 9 | − MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | |
| 10 | −} from "@executor-js/plugin-microsoft"; | |
| 11 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 8 | + MICROSOFT_GRAPH_OPENAPI_URL, | |
| 9 | + microsoftCatalog, | |
| 10 | + microsoftGraphAdapter, | |
| 11 | +} from "@executor-js/plugin-openapi/providers/microsoft"; | |
| 12 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 12 | 13 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 13 | 14 | |
| 14 | 15 | import { scenario } from "../src/scenario"; |
| 15 | 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 | 22 | type ToolView = { |
| 20 | 23 | readonly name: string; |
| 21 | 24 | }; |
| 22 | 25 | |
| 23 | 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 | |
| 26 | −// (~37MB, ~16.5k operations) and persists a binding per operation. That whole- | |
| 27 | −// document path used to 503 on the Cloudflare worker: parsing the spec, and | |
| 28 | −// then re-parsing it on every tools/list, each rebuilt a ~300MB JS tree that | |
| 29 | −// blew the 128MB isolate. This scenario is the regression guard for both sites | |
| 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. | |
| 35 | +// Adding a catalog service extracts only that service's Microsoft Graph subtree | |
| 36 | +// and persists a binding per operation. This is the regression guard for both | |
| 37 | +// former worker pressure sites: the add streams compile and persist, and | |
| 38 | +// tools/list serves from persisted bindings plus the content-addressed defs blob | |
| 39 | +// without re-parsing the Graph spec. | |
| 34 | 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 | 42 | { timeout: 300_000 }, |
| 37 | 43 | Effect.gen(function* () { |
| 38 | 44 | const target = yield* Target; |
| @@ -40,39 +46,41 @@ scenario( · 1 unmodified line | ||
| 40 | 46 | const identity = yield* target.newIdentity(); |
| 41 | 47 | const client = yield* makeApiClient(api, identity); |
| 42 | 48 | |
| 43 | − const integration = unique("msgraph_full"); | |
| 49 | + const integration = unique("msgraph_files"); | |
| 44 | 50 | const connection = ConnectionName.make("main"); |
| 45 | 51 | |
| 46 | 52 | yield* Effect.ensuring( |
| 47 | 53 | Effect.gen(function* () { |
| 48 | − // Add path (1st former OOM site): the full spec is fetched and | |
| 49 | − // stream-compiled into one persisted binding per operation. | |
| 50 | − const added = yield* client.microsoft.addGraph({ | |
| 54 | + // Add path, first former OOM site: the Graph spec is fetched and | |
| 55 | + // stream-compiled into one persisted binding per selected operation. | |
| 56 | + const added = yield* client.openapi.addSpec({ | |
| 51 | 57 | payload: { |
| 52 | − presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | |
| 53 | − customScopes: [], | |
| 58 | + spec: { | |
| 59 | + kind: "url", | |
| 60 | + url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, | |
| 61 | + }, | |
| 54 | 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 | 69 | expect( |
| 60 | 70 | added.toolCount, |
| 61 | − "adding every Graph workload extracts the whole catalog (thousands of operations)", | |
| 62 | − ).toBeGreaterThan(5_000); | |
| 71 | + "adding the files catalog service extracts a focused Graph operation subtree", | |
| 72 | + ).toBeGreaterThan(10); | |
| 63 | 73 | |
| 64 | − const config = yield* client.microsoft.getConfig({ params: { slug: integration } }); | |
| 65 | − expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual( | |
| 66 | − [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | |
| 74 | + const config = yield* client.openapi.getConfig({ params: { slug: integration } }); | |
| 75 | + const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => | |
| 76 | + template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" | |
| 77 | + ? [...template.scopes] | |
| 78 | + : [], | |
| 67 | 79 | ); |
| 68 | 80 | expect( |
| 69 | − config?.microsoftGraphCoversFullGraph, | |
| 70 | − "selecting every workload is recognized as full Graph", | |
| 71 | − ).toBe(true); | |
| 72 | − expect( | |
| 73 | − config?.microsoftGraphScopes, | |
| 74 | − "full Graph delegates the app-registration default scope set", | |
| 75 | − ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]); | |
| 81 | + delegatedScopes, | |
| 82 | + "the files service delegates only the file-service scope set", | |
| 83 | + ).toEqual([...MICROSOFT_FILES_DELEGATED_SCOPES]); | |
| 76 | 84 | |
| 77 | 85 | yield* client.connections.create({ |
| 78 | 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 | |
| 88 | − // the persisted bindings. The whole catalog must come back, with real | |
| 89 | − // descriptions, and without re-parsing the 37MB spec. | |
| 95 | + // Serve path, second former OOM site: tools/list rebuilds the catalog | |
| 96 | + // from persisted bindings, with real descriptions, and without | |
| 97 | + // re-parsing the Graph spec. | |
| 90 | 98 | const tools = yield* client.tools.list({ |
| 91 | 99 | query: { integration: IntegrationSlug.make(integration), connection }, |
| 92 | 100 | }); |
| 93 | 101 | expect( |
| 94 | 102 | tools.length, |
| 95 | − "the served catalog returns the whole set of operations, not a re-parse failure", | |
| 96 | − ).toBeGreaterThan(5_000); | |
| 103 | + "the served catalog returns the files operation subtree, not a re-parse failure", | |
| 104 | + ).toBeGreaterThan(10); | |
| 97 | 105 | |
| 98 | 106 | const names = tools.map((tool: ToolView) => tool.name); |
| 99 | − const messageTools = names.filter((name) => name.toLowerCase().includes("message")); | |
| 100 | − const siteTools = names.filter((name) => name.toLowerCase().includes("site")); | |
| 101 | − const userTools = names.filter((name) => name.toLowerCase().includes("user")); | |
| 102 | − expect(messageTools, "the served catalog spans mail 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([]); | |
| 107 | + const driveTools = names.filter((name) => name.toLowerCase().includes("drive")); | |
| 108 | + const shareTools = names.filter((name) => name.toLowerCase().includes("share")); | |
| 109 | + expect(driveTools, "the served catalog spans drive operations").not.toEqual([]); | |
| 110 | + expect(shareTools, "the served catalog spans sharing operations").not.toEqual([]); | |
| 105 | 111 | }), |
| 106 | 112 | Effect.gen(function* () { |
| 107 | 113 | yield* client.connections |
| @@ -113,8 +119,8 @@ scenario( · 5 unmodified lines | ||
| 113 | 119 | }, |
| 114 | 120 | }) |
| 115 | 121 | .pipe(Effect.ignore); |
| 116 | − yield* client.microsoft | |
| 117 | − .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 122 | + yield* client.openapi | |
| 123 | + .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 118 | 124 | .pipe(Effect.ignore); |
| 119 | 125 | }), |
| 120 | 126 | ); |
| @@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines | ||
| 25 | 25 | import { AccountHttpApi } from "@executor-js/api"; |
| 26 | 26 | import { composePluginApi } from "@executor-js/api/server"; |
| 27 | 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; |
| 28 | −import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | |
| 29 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 28 | +import { | |
| 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 | 34 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; |
| 31 | 35 | |
| 32 | 36 | import { scenario } from "../src/scenario"; |
| 33 | 37 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 34 | 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 | 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 | 317 | scenario( |
| 312 | 318 | "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects", |
| 313 | − { | |
| 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 | − }, | |
| 319 | + { timeout: 240_000 }, | |
| 325 | 320 | Effect.gen(function* () { |
| 326 | 321 | const target = yield* Target; |
| 327 | 322 | const { client: makeApiClient } = yield* Api; |
| @@ -356,14 +351,22 @@ scenario( · 28 unmodified lines | ||
| 356 | 351 | Effect.gen(function* () { |
| 357 | 352 | // Register the Microsoft Graph integration so the console has an OAuth |
| 358 | 353 | // method to register a client against. |
| 359 | − yield* client.microsoft.addGraph({ | |
| 354 | + yield* client.openapi.addSpec({ | |
| 360 | 355 | payload: { |
| 361 | − presetIds: ["users"], | |
| 362 | − customScopes: [], | |
| 356 | + spec: { kind: "url", url: emulator.openapiUrl }, | |
| 363 | 357 | slug: integration, |
| 364 | 358 | name: "Microsoft Graph Emulator", |
| 365 | 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 | 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 | 429 | // onCreated returns to the Add-connection view — the register form closes. |
| 427 | 430 | await page |
| 428 | 431 | .getByRole("heading", { name: "Register OAuth app" }) |
| @@ -482,7 +485,7 @@ scenario( · 53 unmodified lines | ||
| 482 | 485 | payload: { owner: "org" }, |
| 483 | 486 | }) |
| 484 | 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 | 489 | }).pipe(Effect.ignore), |
| 487 | 490 | ); |
| 488 | 491 | }), |
| @@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines | ||
| 5 | 5 | import { Browser, Target } from "../src/services"; |
| 6 | 6 | |
| 7 | 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 | 10 | Effect.gen(function* () { |
| 11 | 11 | const target = yield* Target; |
| @@ -19,56 +19,46 @@ scenario( · 7 unmodified lines | ||
| 19 | 19 | await page.getByRole("dialog", { name: "Connect an integration" }).waitFor(); |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | − await step( | |
| 23 | − "The provider list exposes OpenAPI, Google, and Microsoft separately", | |
| 24 | − async () => { | |
| 25 | − const dialog = page.getByRole("dialog", { | |
| 26 | − name: "Connect an integration", | |
| 27 | − }); | |
| 28 | − await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); | |
| 29 | − await dialog.getByRole("link", { name: "Google", exact: true }).waitFor(); | |
| 30 | − await dialog.getByRole("link", { name: "Microsoft", exact: true }).waitFor(); | |
| 31 | − }, | |
| 32 | − ); | |
| 22 | + await step("The picker exposes OpenAPI plus provider service presets", async () => { | |
| 23 | + const dialog = page.getByRole("dialog", { | |
| 24 | + name: "Connect an integration", | |
| 25 | + }); | |
| 26 | + const search = dialog.getByPlaceholder(/Search or paste a URL/); | |
| 27 | + await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); | |
| 28 | + | |
| 29 | + await search.fill("gmail"); | |
| 30 | + await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); | |
| 31 | + | |
| 32 | + await search.fill("onedrive"); | |
| 33 | + await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); | |
| 34 | + }); | |
| 33 | 35 | |
| 34 | 36 | await step("OpenAPI add remains generic", async () => { |
| 35 | 37 | await page.goto("/integrations/add/openapi", { |
| 36 | 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 | 41 | await page.getByText("OpenAPI Spec").waitFor(); |
| 40 | 42 | expect(await page.getByText("Customize your Google connection").count()).toBe(0); |
| 41 | 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 () => { | |
| 45 | − await page.goto("/integrations/add/google", { | |
| 46 | − waitUntil: "domcontentloaded", | |
| 47 | − }); | |
| 48 | − await page.getByRole("heading", { name: "Add Google integration" }).waitFor(); | |
| 49 | − await page.getByText("Customize your Google connection").waitFor(); | |
| 50 | − await page.getByText("Gmail").first().waitFor(); | |
| 51 | − await page.getByText("Google Calendar").first().waitFor(); | |
| 46 | + await step("A Google service preset opens the OpenAPI add flow", async () => { | |
| 47 | + await page.goto( | |
| 48 | + "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest", | |
| 49 | + { waitUntil: "domcontentloaded" }, | |
| 50 | + ); | |
| 51 | + await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 52 | + await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); | |
| 52 | 53 | }); |
| 53 | 54 | |
| 54 | − await step("Microsoft has its own Graph scope picker", async () => { | |
| 55 | − await page.goto("/integrations/add/microsoft", { | |
| 56 | − waitUntil: "domcontentloaded", | |
| 57 | − }); | |
| 58 | − await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor(); | |
| 59 | − await page.getByText("Customize Microsoft Graph").waitFor(); | |
| 60 | − expect(await page.getByText("All Microsoft Graph", { exact: true }).count()).toBe(0); | |
| 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(); | |
| 55 | + await step("A Microsoft service preset opens the OpenAPI add flow", async () => { | |
| 56 | + await page.goto( | |
| 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", | |
| 58 | + { waitUntil: "domcontentloaded" }, | |
| 59 | + ); | |
| 60 | + await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 61 | + await expect.poll(() => page.locator("textarea").inputValue()).toContain("preset=files"); | |
| 72 | 62 | }); |
| 73 | 63 | }); |
| 74 | 64 | }), |
| @@ -10,7 +10,6 @@ · 9 unmodified lines | ||
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 13 | − "@executor-js/plugin-google": "workspace:*", | |
| 14 | 13 | "@executor-js/plugin-graphql": "workspace:*", |
| 15 | 14 | "@executor-js/plugin-keychain": "workspace:*", |
| 16 | 15 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -33,12 +33,19 @@ import { · 32 unmodified lines | ||
| 33 | 33 | } from "@executor-js/sdk"; |
| 34 | 34 | |
| 35 | 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 | 40 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; |
| 38 | 41 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| 39 | 42 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 40 | 43 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; |
| 41 | 44 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; |
| 45 | +import { | |
| 46 | + microsoftCatalog, | |
| 47 | + microsoftGraphAdapter, | |
| 48 | +} from "@executor-js/plugin-openapi/providers/microsoft"; | |
| 42 | 49 | import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; |
| 43 | 50 | |
| 44 | 51 | // --------------------------------------------------------------------------- |
| @@ -78,10 +85,12 @@ const plugins = [ · 33 unmodified lines | ||
| 78 | 85 | // Integration plugins — these declare their own schemas (tables), register |
| 79 | 86 | // integrations via their extension methods (`addSpec` / `addServer` / |
| 80 | 87 | // `addIntegration`), and produce tools per connection. |
| 81 | − googlePlugin(), | |
| 82 | 88 | graphqlPlugin(), |
| 83 | 89 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 84 | − openApiPlugin(), | |
| 90 | + openApiPlugin({ | |
| 91 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 92 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 93 | + }), | |
| 85 | 94 | |
| 86 | 95 | // workos-vault is a cloud-hosted credential provider. It would contribute a |
| 87 | 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 | 427 | console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); |
| 419 | 428 | |
| 420 | 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 | 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 | 41 | "packages/plugins/file-secrets": { |
| 42 | 42 | includeEntryExports: true, |
| 43 | 43 | }, |
| 44 | − "packages/plugins/google": { | |
| 45 | − includeEntryExports: true, | |
| 46 | − }, | |
| 47 | 44 | "packages/plugins/onepassword": { |
| 48 | 45 | includeEntryExports: true, |
| 49 | 46 | ignoreDependencies: ["@executor-js/react"], |
| @@ -75,6 +75,7 @@ · 74 unmodified lines | ||
| 75 | 75 | "@effect/language-service": "^0.85.1", |
| 76 | 76 | "@effect/tsgo": "^0.5.2", |
| 77 | 77 | "@effect/vitest": "catalog:", |
| 78 | + "@executor-js/plugin-provider-service-split": "workspace:*", | |
| 78 | 79 | "@typescript/native-preview": "^7.0.0-dev.20260410.1", |
| 79 | 80 | "@vitest/expect": "catalog:", |
| 80 | 81 | "@vitest/mocker": "catalog:", |
| @@ -58,6 +58,7 @@ const ConnectionResponse = Schema.Struct({ · 57 unmodified lines | ||
| 58 | 58 | oauthClient: Schema.NullOr(OAuthClientSlug), |
| 59 | 59 | oauthClientOwner: Schema.NullOr(Owner), |
| 60 | 60 | oauthScope: Schema.NullOr(Schema.String), |
| 61 | + missingOAuthScopes: Schema.Array(Schema.String), | |
| 61 | 62 | // Last persisted health-check verdict (written by every checkHealth run), |
| 62 | 63 | // so the list can show alive/expired at a glance without probing. |
| 63 | 64 | lastHealth: Schema.NullOr(HealthCheckResult), |
| @@ -28,6 +28,7 @@ const toResponse = (c: Connection) => ({ · 27 unmodified lines | ||
| 28 | 28 | oauthClient: c.oauthClient ?? null, |
| 29 | 29 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 30 | 30 | oauthScope: c.oauthScope ?? null, |
| 31 | + missingOAuthScopes: c.missingOAuthScopes ?? [], | |
| 31 | 32 | lastHealth: c.lastHealth ?? null, |
| 32 | 33 | }); |
| 33 | 34 | |
| @@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines | ||
| 15 | 15 | canRefresh: i.canRefresh, |
| 16 | 16 | authMethods: i.authMethods, |
| 17 | 17 | ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}), |
| 18 | + ...(i.family ? { family: i.family } : {}), | |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => |
| @@ -45,6 +45,7 @@ const connectionToResponse = (c: Connection) => ({ · 44 unmodified lines | ||
| 45 | 45 | oauthClient: c.oauthClient ?? null, |
| 46 | 46 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 47 | 47 | oauthScope: c.oauthScope ?? null, |
| 48 | + missingOAuthScopes: c.missingOAuthScopes ?? [], | |
| 48 | 49 | }); |
| 49 | 50 | |
| 50 | 51 | const startResultToResponse = (result: ConnectResult) => |
| @@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines | ||
| 81 | 81 | authMethods: Schema.Array(AuthMethodDescriptorSchema), |
| 82 | 82 | /** Non-secret URL derived from opaque integration config for favicons. */ |
| 83 | 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 | 88 | const UpdateIntegrationPayload = Schema.Struct({ |
| @@ -50,6 +50,7 @@ const ConnectionResponse = Schema.Struct({ · 49 unmodified lines | ||
| 50 | 50 | oauthClient: Schema.NullOr(OAuthClientSlug), |
| 51 | 51 | oauthClientOwner: Schema.NullOr(Owner), |
| 52 | 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 | 22 | import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 23 | 23 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 24 | 24 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 25 | +import type { HealthCheckSpec } from "./health-check"; | |
| 25 | 26 | |
| 26 | 27 | // --------------------------------------------------------------------------- |
| 27 | 28 | // Re-exports — the curated set of primitives a plugin author needs to |
| @@ -112,8 +113,33 @@ export interface IntegrationPreset { · 84 unmodified lines | ||
| 112 | 113 | readonly icon?: string; |
| 113 | 114 | /** Shown in the top-level grid on the integrations page when true. */ |
| 114 | 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 | 143 | export interface IntegrationAccountHandoff { |
| 118 | 144 | /** Changes on each handoff URL, so the accounts UI can open once per link. */ |
| 119 | 145 | readonly key: string; |
| @@ -54,6 +54,9 @@ export interface Connection { · 53 unmodified lines | ||
| 54 | 54 | * declared scopes to decide whether this connection must reconnect to grant |
| 55 | 55 | * newly-needed access. Null for static creds / when the AS omitted `scope`. */ |
| 56 | 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 | 60 | /** Last health-check verdict, persisted by every `checkHealth` run. Answers |
| 58 | 61 | * "has this expired?" at a glance in the connections list without probing. |
| 59 | 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 | 644 | describe("execute over a connection", () => { |
| 622 | 645 | it.effect("resolves the credential value and hands it to invokeTool", () => |
| 623 | 646 | Effect.gen(function* () { |
| @@ -86,6 +86,7 @@ import type { · 85 unmodified lines | ||
| 86 | 86 | AuthMethodDescriptor, |
| 87 | 87 | Integration, |
| 88 | 88 | IntegrationConfig, |
| 89 | + IntegrationDisplayDescriptor, | |
| 89 | 90 | RegisterIntegrationInput, |
| 90 | 91 | } from "./integration"; |
| 91 | 92 | import { |
| @@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines | ||
| 522 | 523 | const rowToIntegration = ( |
| 523 | 524 | row: IntegrationRow, |
| 524 | 525 | authMethods: readonly AuthMethodDescriptor[] = [], |
| 525 | − displayUrl?: string, | |
| 526 | + display?: IntegrationDisplayDescriptor, | |
| 526 | 527 | ): Integration => ({ |
| 527 | 528 | slug: IntegrationSlug.make(row.slug), |
| 528 | 529 | // Pre-split rows have no `name`; their description WAS the display name. |
| @@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines | ||
| 534 | 535 | canRemove: Boolean(row.can_remove), |
| 535 | 536 | canRefresh: Boolean(row.can_refresh), |
| 536 | 537 | authMethods, |
| 537 | − ...(displayUrl ? { displayUrl } : {}), | |
| 538 | + ...(display?.url ? { displayUrl: display.url } : {}), | |
| 539 | + ...(display?.family ? { family: display.family } : {}), | |
| 538 | 540 | }); |
| 539 | 541 | |
| 540 | 542 | const rowToIntegrationRecord = ( |
| @@ -548,6 +550,15 @@ const rowToIntegrationRecord = ( · 7 unmodified lines | ||
| 548 | 550 | const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult); |
| 549 | 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 | 562 | const rowToConnection = (row: ConnectionRow): Connection => { |
| 552 | 563 | const owner = row.owner as Owner; |
| 553 | 564 | const integration = IntegrationSlug.make(row.integration); |
| @@ -566,6 +577,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 12 unmodified lines | ||
| 566 | 577 | oauthClientOwner: |
| 567 | 578 | row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner), |
| 568 | 579 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), |
| 580 | + missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), | |
| 569 | 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 | 1775 | const runtime = runtimes.get(row.plugin_id); |
| 1764 | 1776 | const describe = runtime?.plugin.describeIntegrationDisplay; |
| 1765 | − if (!describe) return undefined; | |
| 1777 | + if (!describe) return {}; | |
| 1766 | 1778 | const record = rowToIntegrationRecord(row); |
| 1767 | 1779 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read |
| 1768 | 1780 | try { |
| 1769 | 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 | 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 | 1816 | const rows = yield* core.findMany("integration", {}); |
| 1802 | 1817 | const staticIntegrations = staticSources().map(staticSourceToIntegration); |
| 1803 | 1818 | const dbIntegrations = rows.map((row) => |
| 1804 | − rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), | |
| 1819 | + rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)), | |
| 1805 | 1820 | ); |
| 1806 | 1821 | // A scoped toolkit must not advertise providers it grants no tools from |
| 1807 | 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 | 1843 | if (staticSource) return staticSourceToIntegration(staticSource); |
| 1829 | 1844 | const row = yield* findIntegrationRow(slug); |
| 1830 | 1845 | return row |
| 1831 | − ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)) | |
| 1846 | + ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)) | |
| 1832 | 1847 | : null; |
| 1833 | 1848 | }); |
| 1834 | 1849 | |
| @@ -2442,6 +2457,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 607 unmodified lines | ||
| 2442 | 2457 | expires_at: input.expiresAt, |
| 2443 | 2458 | oauth_scope: input.oauthScope, |
| 2444 | 2459 | oauth_token_url: input.oauthTokenUrl ?? null, |
| 2460 | + provider_state: | |
| 2461 | + input.missingOAuthScopes && input.missingOAuthScopes.length > 0 | |
| 2462 | + ? { missingOAuthScopes: input.missingOAuthScopes } | |
| 2463 | + : null, | |
| 2445 | 2464 | updated_at: now, |
| 2446 | 2465 | }; |
| 2447 | 2466 | if (existing) { |
| @@ -2474,7 +2493,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines | ||
| 2474 | 2493 | expires_at: input.expiresAt, |
| 2475 | 2494 | oauth_scope: input.oauthScope, |
| 2476 | 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 | 2500 | created_at: now, |
| 2479 | 2501 | updated_at: now, |
| 2480 | 2502 | }); |
| @@ -2508,7 +2530,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 27 unmodified lines | ||
| 2508 | 2530 | expires_at: input.expiresAt, |
| 2509 | 2531 | oauth_scope: input.oauthScope, |
| 2510 | 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 | 2537 | created_at: now, |
| 2513 | 2538 | updated_at: now, |
| 2514 | 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 | 2744 | // Resolve an in-flight credential's value map (key-first validation) without |
| 2687 | 2745 | // saving anything. Mirrors `resolveConnectionValues` for the saved-row path: |
| 2688 | 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 | 2800 | const runtime = runtimes.get(integrationRow.plugin_id); |
| 2743 | 2801 | const check = runtime?.plugin.checkHealth; |
| 2744 | 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 | 2810 | const result = yield* Effect.gen(function* () { |
| 2747 | 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 | 2825 | // Core resolves the declared spec (its own column) and hands it to the |
| 2762 | 2826 | // plugin; plugins no longer read it out of their config. |
| 2763 | − const spec = describeHealthCheckForRow(integrationRow) ?? undefined; | |
| 2764 | 2827 | return yield* foldPluginFailure( |
| 2765 | 2828 | check({ ctx: runtime.ctx, integration: record, credential, spec }), |
| 2766 | 2829 | `Health check for connection "${ref.name}" failed.`, |
| @@ -316,6 +316,7 @@ export { · 315 unmodified lines | ||
| 316 | 316 | type IntegrationConfigureDecl, |
| 317 | 317 | type IntegrationConfigureSchema, |
| 318 | 318 | type IntegrationPreset, |
| 319 | + type IntegrationPresetAuthentication, | |
| 319 | 320 | type IntegrationPresetCatalogEntry, |
| 320 | 321 | type ResolveToolsInput, |
| 321 | 322 | type ResolveToolsResult, |
| @@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines | ||
| 22 | 22 | export interface IntegrationDisplayDescriptor { |
| 23 | 23 | /** Non-secret URL suitable for display metadata such as favicons. */ |
| 24 | 24 | readonly url?: string; |
| 25 | + /** Catalog family derived from plugin config for grouped display. */ | |
| 26 | + readonly family?: string; | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | /** Where a credential value is carried. `header`/`query` place it on an |
| @@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines | ||
| 100 | 102 | /** Non-secret display URL derived by the owning plugin from opaque config. |
| 101 | 103 | * Used for catalog favicons; never includes credentials or plugin config. */ |
| 102 | 104 | readonly displayUrl?: string; |
| 105 | + /** Catalog family derived by the owning plugin from opaque config. */ | |
| 106 | + readonly family?: string; | |
| 103 | 107 | } |
| 104 | 108 | |
| 105 | 109 | /** Plugin-owned, opaque-to-core configuration stored on the integration row. The |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "./ids"; |
| 13 | 13 | import { decodeOAuthCallbackState } from "./oauth"; |
| 14 | 14 | import { OAuthStartError } from "./oauth-client"; |
| 15 | +import { missingGrantedOAuthScopes } from "./oauth-service"; | |
| 15 | 16 | import { definePlugin } from "./plugin"; |
| 16 | 17 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | 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 | 445 | it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () => |
| 298 | 446 | Effect.scoped( |
| 299 | 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 | 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 | 19 | createPkceCodeVerifier, |
| 20 | 20 | exchangeAuthorizationCode, |
| 21 | 21 | exchangeClientCredentials, |
| 22 | + idTokenIdentityLabel, | |
| 22 | 23 | refreshAccessToken, |
| 23 | 24 | shouldRefreshToken, |
| 24 | 25 | } from "./oauth-helpers"; |
| @@ -362,6 +363,81 @@ describe("exchangeAuthorizationCode", () => { · 337 unmodified lines | ||
| 362 | 363 | }); |
| 363 | 364 | expect(result.access_token).toBe("tok"); |
| 364 | 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 | 482 | expect(result.access_token).toBe("tok"); |
| 407 | 483 | expect(result.refresh_token).toBe("rtok"); |
| 408 | 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 | 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 | 20 | import * as oauth from "oauth4webapi"; |
| 21 | 21 | |
| 22 | 22 | // --------------------------------------------------------------------------- |
| @@ -45,6 +45,7 @@ export type OAuth2TokenResponse = { · 22 unmodified lines | ||
| 45 | 45 | readonly refresh_token?: string; |
| 46 | 46 | readonly expires_in?: number; |
| 47 | 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 | 462 | scope: r.scope, |
| 462 | 463 | }); |
| 463 | 464 | |
| 464 | −// MCP source connections are pure OAuth 2.0 — we never request `openid` and | |
| 465 | −// never consume `id_token`. Some providers (PostHog, etc.) front an OIDC | |
| 466 | −// backend and emit an `id_token` anyway; oauth4webapi then strict-validates | |
| 467 | −// its claims against the AS metadata and rejects mismatches we don't care | |
| 468 | −// about. Strip the field before delegation. | |
| 469 | −const stripIdToken = async (response: Response): Promise<Response> => { | |
| 465 | +const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); | |
| 466 | +const decodeJwtClaims = Schema.decodeUnknownOption(Schema.fromJsonString(JwtClaims)); | |
| 467 | + | |
| 468 | +const stringClaim = ( | |
| 469 | + claims: Readonly<Record<string, unknown>>, | |
| 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 | 515 | const body = await response |
| 471 | 516 | .clone() |
| 472 | 517 | .json() |
| @@ -475,24 +520,33 @@ const stripIdToken = async (response: Response): Promise<Response> => { · 2 unmodified lines | ||
| 475 | 520 | () => null, |
| 476 | 521 | ); |
| 477 | 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>; | |
| 481 | − return new Response(JSON.stringify(rest), { | |
| 482 | − status: response.status, | |
| 483 | − statusText: response.statusText, | |
| 484 | − headers: response.headers, | |
| 485 | − }); | |
| 525 | + const { id_token: idToken, ...rest } = body as Record<string, unknown>; | |
| 526 | + const label = typeof idToken === "string" ? idTokenIdentityLabel(idToken) : undefined; | |
| 527 | + return { | |
| 528 | + response: new Response(JSON.stringify(rest), { | |
| 529 | + status: response.status, | |
| 530 | + statusText: response.statusText, | |
| 531 | + headers: response.headers, | |
| 532 | + }), | |
| 533 | + ...(label ? { idTokenIdentityLabel: label } : {}), | |
| 534 | + }; | |
| 486 | 535 | }; |
| 487 | 536 | |
| 488 | 537 | const processTokenEndpointResponse = async ( |
| 489 | 538 | as: oauth.AuthorizationServer, |
| 490 | 539 | client: oauth.Client, |
| 491 | 540 | response: Response, |
| 492 | −): Promise<OAuth2TokenResponse> => | |
| 493 | − tokenResponseFrom( | |
| 494 | − await oauth.processGenericTokenEndpointResponse(as, client, await stripIdToken(response)), | |
| 541 | +): Promise<OAuth2TokenResponse> => { | |
| 542 | + const stripped = 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 | 552 | // Exchange authorization code → tokens |
| @@ -681,7 +735,7 @@ export const refreshAccessToken = ( · 182 unmodified lines | ||
| 681 | 735 | const result = await oauth.processRefreshTokenResponse( |
| 682 | 736 | as, |
| 683 | 737 | client, |
| 684 | − await stripIdToken(response), | |
| 738 | + (await stripIdToken(response)).response, | |
| 685 | 739 | ); |
| 686 | 740 | return tokenResponseFrom(result); |
| 687 | 741 | }, |
| @@ -91,6 +91,7 @@ export interface MintOAuthConnectionInput { · 90 unmodified lines | ||
| 91 | 91 | readonly refreshItemId: string | null; |
| 92 | 92 | readonly expiresAt: number | null; |
| 93 | 93 | readonly oauthScope: string | null; |
| 94 | + readonly missingOAuthScopes?: readonly string[]; | |
| 94 | 95 | /** Per-connection override for the token endpoint, persisted only when the |
| 95 | 96 | * code was redeemed at a region other than the client's configured token |
| 96 | 97 | * host (Datadog multi-site). Null means refresh uses the client's token URL. */ |
| @@ -203,6 +204,52 @@ const recordedOAuthScope = ( · 106 unmodified lines | ||
| 203 | 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 | 253 | const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); |
| 207 | 254 | |
| 208 | 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 | 1256 | name: session.name, |
| 1210 | 1257 | integration: session.integration, |
| 1211 | 1258 | template: session.template, |
| 1212 | − identityLabel: session.identityLabel, | |
| 1259 | + identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null, | |
| 1213 | 1260 | }, |
| 1214 | 1261 | client, |
| 1215 | 1262 | token, |
| @@ -1278,6 +1325,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 62 unmodified lines | ||
| 1278 | 1325 | yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); |
| 1279 | 1326 | } |
| 1280 | 1327 | |
| 1328 | + const oauthScope = recordedOAuthScope(token, requestedScopes); | |
| 1281 | 1329 | return yield* deps.mintOAuthConnection({ |
| 1282 | 1330 | owner: target.owner, |
| 1283 | 1331 | name: target.name, |
| @@ -1294,7 +1342,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines | ||
| 1294 | 1342 | // Microsoft, issue a refresh token for `offline_access` but omit that |
| 1295 | 1343 | // non-resource scope from the token `scope` string, so preserve it when |
| 1296 | 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 | 1350 | oauthTokenUrl, |
| 1299 | 1351 | }); |
| 1300 | 1352 | }); |
| @@ -505,12 +505,37 @@ export interface IntegrationPreset { · 504 unmodified lines | ||
| 505 | 505 | readonly endpoint?: string; |
| 506 | 506 | readonly icon?: string; |
| 507 | 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 | 513 | readonly transport?: "remote" | "stdio"; |
| 509 | 514 | readonly command?: string; |
| 510 | 515 | readonly args?: readonly string[]; |
| 511 | 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 | 539 | export interface IntegrationPresetCatalogEntry extends IntegrationPreset { |
| 515 | 540 | readonly pluginId: string; |
| 516 | 541 | } |
| @@ -55,6 +55,8 @@ export interface OAuthTestServerOptions { · 54 unmodified lines | ||
| 55 | 55 | readonly supportRefresh?: boolean; |
| 56 | 56 | readonly tokenExpiresInSeconds?: number; |
| 57 | 57 | readonly invalidRefreshTokenDescription?: string; |
| 58 | + readonly idTokenClaims?: Readonly<Record<string, unknown>>; | |
| 59 | + readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>; | |
| 58 | 60 | /** Gate Dynamic Client Registration on the requested redirect URIs. When set, |
| 59 | 61 | * `/register` returns `400 invalid_redirect_uri` unless every requested |
| 60 | 62 | * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. |
| @@ -127,6 +129,7 @@ const TokenResponse = Schema.Struct({ · 66 unmodified lines | ||
| 127 | 129 | token_type: Schema.String, |
| 128 | 130 | expires_in: Schema.optional(Schema.Number), |
| 129 | 131 | scope: Schema.optional(Schema.String), |
| 132 | + id_token: Schema.optional(Schema.String), | |
| 130 | 133 | }); |
| 131 | 134 | const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); |
| 132 | 135 | |
| @@ -179,6 +182,12 @@ const decodeBasicAuthorization = ( · 46 unmodified lines | ||
| 179 | 182 | const codeChallengeForVerifier = (verifier: string): string => |
| 180 | 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 | 191 | const oauthError = (status: number, error: string, errorDescription: string) => |
| 183 | 192 | jsonResponse( |
| 184 | 193 | status, |
| @@ -652,6 +661,7 @@ export const serveOAuthTestServer = ( · 467 unmodified lines | ||
| 652 | 661 | token_type: "Bearer", |
| 653 | 662 | expires_in: tokenExpiresInSeconds, |
| 654 | 663 | ...(scope ? { scope } : {}), |
| 664 | + ...(options.idTokenClaims ? { id_token: unsignedJwt(options.idTokenClaims) } : {}), | |
| 655 | 665 | }, |
| 656 | 666 | { "cache-control": "no-store" }, |
| 657 | 667 | ); |
| @@ -680,6 +690,9 @@ export const serveOAuthTestServer = ( · 22 unmodified lines | ||
| 680 | 690 | token_type: "Bearer", |
| 681 | 691 | expires_in: tokenExpiresInSeconds, |
| 682 | 692 | ...(scope ? { scope } : {}), |
| 693 | + ...(options.refreshIdTokenClaims | |
| 694 | + ? { id_token: unsignedJwt(options.refreshIdTokenClaims) } | |
| 695 | + : {}), | |
| 683 | 696 | }, |
| 684 | 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 | 23 | useSlugAlreadyExists, |
| 24 | 24 | } from "@executor-js/react/lib/integration-add"; |
| 25 | 25 | |
| 26 | −import { addGraphqlIntegrationOptimistic } from "./atoms"; | |
| 26 | +import { createGraphqlIntegrationOptimistic } from "./atoms"; | |
| 27 | 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 29 | 29 | import type { GraphqlAuthMethodInput } from "../sdk/types"; |
| @@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines | ||
| 52 | 52 | const [adding, setAdding] = useState(false); |
| 53 | 53 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 54 | |
| 55 | − const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, { | |
| 55 | + const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, { | |
| 56 | 56 | mode: "promiseExit", |
| 57 | 57 | }); |
| 58 | 58 | |
| @@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines | ||
| 33 | 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 | 38 | // Merge-append custom auth methods onto an integration's `authenticationTemplate`. |
| 39 | 39 | export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); |
| @@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line | ||
| 41 | 41 | // Optimistically slot a pending row into the shared integration catalog so the |
| 42 | 42 | // integrations list reflects the new GraphQL integration before the server |
| 43 | 43 | // round-trips. The reducer mirrors the catalog `Integration` shape. |
| 44 | −export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | |
| 44 | +export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | |
| 45 | 45 | Atom.optimisticFn({ |
| 46 | 46 | reducer: ( |
| 47 | 47 | current, |
| @@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines | ||
| 69 | 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 | 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; |
| 2 | 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 | 22 | "./react": "./src/react/index.ts", |
| 23 | 23 | "./client": "./src/react/plugin-client.tsx", |
| 24 | 24 | "./presets": "./src/sdk/presets.ts", |
| 25 | + "./providers/google": "./src/providers/google/index.ts", | |
| 26 | + "./providers/microsoft": "./src/providers/microsoft/index.ts", | |
| 25 | 27 | "./testing": "./src/testing/index.ts" |
| 26 | 28 | }, |
| 27 | 29 | "publishConfig": { |
| @@ -45,6 +47,18 @@ · 17 unmodified lines | ||
| 45 | 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 | 62 | "./testing": { |
| 49 | 63 | "import": { |
| 50 | 64 | "types": "./dist/testing/index.d.ts", |
| @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; |
| 4 | 4 | import { |
| 5 | + HealthCheckSpec, | |
| 5 | 6 | InternalError, |
| 6 | 7 | IntegrationAlreadyExistsError, |
| 7 | 8 | IntegrationNotFoundError, |
| @@ -68,25 +69,31 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 60 unmodified lines | ||
| 68 | 69 | |
| 69 | 70 | const AddSpecPayload = Schema.Struct({ |
| 70 | 71 | spec: OpenApiSpecInputPayload, |
| 71 | − slug: Schema.String, | |
| 72 | + slug: Schema.optional(Schema.String), | |
| 72 | 73 | name: Schema.optional(Schema.String), |
| 73 | 74 | description: Schema.optional(Schema.String), |
| 74 | 75 | baseUrl: Schema.optional(Schema.String), |
| 75 | 76 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 76 | 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 | 81 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), |
| 78 | 82 | }); |
| 79 | 83 | |
| 80 | 84 | const PreviewSpecPayload = Schema.Struct({ |
| 81 | 85 | spec: Schema.String, |
| 86 | + specFormat: Schema.optional(Schema.String), | |
| 82 | 87 | }); |
| 83 | 88 | |
| 84 | 89 | // The `configure` payload — the new/updated auth methods to merge onto the |
| 85 | −// integration's `authenticationTemplate`. Reuses the same `AuthenticationPayload` | |
| 86 | −// schema as `addSpec` so a custom apiKey method round-trips identically. | |
| 90 | +// integration's `authenticationTemplate`, plus request routing metadata. | |
| 91 | +// Reuses the same `AuthenticationPayload` schema as `addSpec` so a custom apiKey | |
| 92 | +// method round-trips identically. | |
| 87 | 93 | const ConfigurePayload = Schema.Struct({ |
| 88 | − authenticationTemplate: Schema.Array(AuthenticationPayload), | |
| 94 | + authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), | |
| 89 | 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 | 44 | capture( |
| 45 | 45 | Effect.gen(function* () { |
| 46 | 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 | 51 | return specPreviewSummary(preview); |
| 49 | 52 | }), |
| 50 | 53 | ), |
| @@ -61,6 +64,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines | ||
| 61 | 64 | baseUrl: payload.baseUrl, |
| 62 | 65 | headers: payload.headers ? { ...payload.headers } : undefined, |
| 63 | 66 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 67 | + specFormat: payload.specFormat, | |
| 68 | + family: payload.family, | |
| 69 | + healthCheck: payload.healthCheck, | |
| 64 | 70 | authenticationTemplate: payload.authenticationTemplate, |
| 65 | 71 | }); |
| 66 | 72 | }), |
| @@ -109,6 +115,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines | ||
| 109 | 115 | const authenticationTemplate = yield* ext.configure(params.slug, { |
| 110 | 116 | authenticationTemplate: payload.authenticationTemplate, |
| 111 | 117 | mode: payload.mode ?? "merge", |
| 118 | + baseUrl: payload.baseUrl, | |
| 112 | 119 | }); |
| 113 | 120 | return { authenticationTemplate: [...authenticationTemplate] }; |
| 114 | 121 | }), |
File renamed without changes.
| @@ -1,5 +1,7 @@ | ||
| 1 | 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 | 6 | export const GoogleIntegrationConfigSchema = Schema.Struct({ |
| 5 | 7 | specHash: Schema.optional(Schema.String), |
File renamed without changes.
| @@ -5,7 +5,8 @@ · 4 unmodified lines | ||
| 5 | 5 | import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; |
| 6 | 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 | 10 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; |
| 10 | 11 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | 12 | |
| @@ -7,6 +7,7 @@ export { · 6 unmodified lines | ||
| 7 | 7 | } from "./discovery"; |
| 8 | 8 | export { |
| 9 | 9 | googleOpenApiBundlePreset, |
| 10 | + googleCatalog, | |
| 10 | 11 | googleOpenApiPresets, |
| 11 | 12 | googlePhotosOpenApiBundlePreset, |
| 12 | 13 | googlePhotosOpenApiPresets, |
| @@ -16,6 +17,7 @@ export { · 3 unmodified lines | ||
| 16 | 17 | googleStandardUserOAuthPresets, |
| 17 | 18 | googleOAuthConsentScopes, |
| 18 | 19 | googleOAuthConsentScopesForPreset, |
| 20 | + googleServiceSlug, | |
| 19 | 21 | googleAudienceWarningsForUrls, |
| 20 | 22 | googlePresetForDiscoveryUrl, |
| 21 | 23 | type GoogleOpenApiOAuthAudience, |
| @@ -27,20 +29,7 @@ export { · 5 unmodified lines | ||
| 27 | 29 | filterGoogleUserConsentOAuthScopes, |
| 28 | 30 | isGoogleUserConsentOAuthScope, |
| 29 | 31 | } from "./oauth-scopes"; |
| 30 | −export { | |
| 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"; | |
| 32 | +export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | |
| 44 | 33 | export { |
| 45 | 34 | googleOpenApiOwnershipDataMigration, |
| 46 | 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 | 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; |
| 2 | +import { compactGoogleOAuthScopes } from "./oauth-scopes"; | |
| 3 | +import type { HealthCheckSpec, IntegrationPreset } from "@executor-js/sdk/core"; | |
| 2 | 4 | |
| 3 | 5 | export interface GooglePreset { |
| 4 | 6 | readonly id: string; |
| @@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines | ||
| 238 | 240 | // The picker shows the OAuth consent a user is about to grant BEFORE connecting |
| 239 | 241 | // (the "View scopes" panel), but the authoritative scope list only exists in |
| 240 | 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 | 244 | // declares the broad top-level scope(s) a full integration grants. These flow |
| 243 | 245 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their |
| 244 | 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 | 278 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => |
| 277 | 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 | 348 | // Resolve a stored/normalized Discovery URL back to its preset, so a bundled |
| 281 | 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 | 8 | } from "@executor-js/plugin-openapi"; |
| 9 | 9 | |
| 10 | 10 | import { microsoftGraphKeepPathItem } from "./graph"; |
| 11 | +import { | |
| 12 | + microsoftGraphExactPathsForPresetIds, | |
| 13 | + microsoftGraphPathPrefixesForPresetIds, | |
| 14 | + microsoftGraphTagPrefixesForPresetIds, | |
| 15 | +} from "./presets"; | |
| 11 | 16 | |
| 12 | 17 | // Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every |
| 13 | 18 | // success response uses the OpenAPI wildcard status key `2XX` (the real spec |
| @@ -220,6 +225,29 @@ paths: · 206 unmodified lines | ||
| 220 | 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 | 251 | const fullGraphSelection = { |
| 224 | 252 | coversFullGraph: true, |
| 225 | 253 | presetIds: [], |
| @@ -268,6 +296,36 @@ const keptIdentityFixturePaths = ( · 42 unmodified lines | ||
| 268 | 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 | 329 | const keptPathItem = (fixture: string): Record<string, unknown> => { |
| 272 | 330 | const structure = structuralSplit(fixture); |
| 273 | 331 | expect(structure).not.toBeNull(); |
| @@ -338,6 +396,16 @@ it("keeps all /me operations when profile selects the path", () => { · 64 unmodified lines | ||
| 338 | 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 | 409 | it("keeps already-binary drive content responses untouched", () => { |
| 342 | 410 | const pathItem = keptPathItem(driveContentFixture); |
| 343 | 411 | |
| @@ -3,18 +3,17 @@ import type { Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | 4 | |
| 5 | 5 | import { AuthTemplateSlug } from "@executor-js/sdk/core"; |
| 6 | +import { AuthenticationSchema, type OpenApiIntegrationConfig } from "../../sdk/config"; | |
| 7 | +import { OpenApiParseError } from "../../sdk/errors"; | |
| 6 | 8 | import { |
| 7 | − AuthenticationSchema, | |
| 8 | − OpenApiParseError, | |
| 9 | 9 | parseEntry, |
| 10 | 10 | parseHead, |
| 11 | 11 | parseSmallComponents, |
| 12 | 12 | structuralSplit, |
| 13 | − type Authentication, | |
| 14 | 13 | type KeepPathItem, |
| 15 | − type OpenApiIntegrationConfig, | |
| 16 | 14 | type SpecStructure, |
| 17 | −} from "@executor-js/plugin-openapi"; | |
| 15 | +} from "../../sdk/split"; | |
| 16 | +import type { Authentication } from "../../sdk/types"; | |
| 18 | 17 | |
| 19 | 18 | import { |
| 20 | 19 | MICROSOFT_AUTHORIZATION_URL, |
| @@ -13,6 +13,7 @@ export { · 12 unmodified lines | ||
| 13 | 13 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, |
| 14 | 14 | MICROSOFT_GRAPH_PRESET_ID, |
| 15 | 15 | MICROSOFT_TOKEN_URL, |
| 16 | + microsoftCatalog, | |
| 16 | 17 | microsoftGraphExactPathsForPresetIds, |
| 17 | 18 | microsoftGraphPathPrefixesForPresetIds, |
| 18 | 19 | microsoftGraphPreset, |
| @@ -21,6 +22,7 @@ export { · 2 unmodified lines | ||
| 21 | 22 | microsoftGraphScopePresets, |
| 22 | 23 | microsoftGraphScopesForPresetIds, |
| 23 | 24 | microsoftGraphTagPrefixesForPresetIds, |
| 25 | + microsoftServiceSlug, | |
| 24 | 26 | type MicrosoftGraphPreset, |
| 25 | 27 | type MicrosoftGraphScopeAudience, |
| 26 | 28 | type MicrosoftGraphScopePreset, |
| @@ -36,12 +38,4 @@ export { · 9 unmodified lines | ||
| 36 | 38 | type MicrosoftGraphSelectionInput, |
| 37 | 39 | type MicrosoftGraphSpecBuild, |
| 38 | 40 | } from "./graph"; |
| 39 | −export { | |
| 40 | − microsoftPlugin, | |
| 41 | − type MicrosoftConfigureInput, | |
| 42 | − type MicrosoftGraphConfig, | |
| 43 | − type MicrosoftPluginExtension, | |
| 44 | − type MicrosoftPluginOptions, | |
| 45 | − type MicrosoftUpdateInput, | |
| 46 | − type MicrosoftUpdateResult, | |
| 47 | −} from "./plugin"; | |
| 41 | +export { microsoftGraphAdapter } from "./spec-format-adapter"; | |
| @@ -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 | 3 | export interface MicrosoftGraphPreset { |
| 2 | 4 | readonly id: string; |
| 3 | 5 | readonly name: string; |
| @@ -522,3 +524,42 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines | ||
| 522 | 524 | orderedUnique( |
| 523 | 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 | 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 | 19 | describe("baseUrlFromSpecInput", () => { |
| 6 | 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 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | + AuthTemplateSlug, | |
| 8 | 9 | IntegrationSlug, |
| 9 | 10 | type HealthCheckCandidate, |
| 10 | 11 | type HealthCheckSpec, |
| 11 | 12 | } from "@executor-js/sdk/shared"; |
| 13 | +import { useIntegrationPlugins } from "@executor-js/sdk/client"; | |
| 12 | 14 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | 15 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; |
| 14 | 16 | import { |
| @@ -43,7 +45,6 @@ import { · 28 unmodified lines | ||
| 43 | 45 | } from "./auth-method-config"; |
| 44 | 46 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 45 | 47 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 46 | −import { openApiPresets } from "../sdk/presets"; | |
| 47 | 48 | import type { SpecPreviewSummary } from "../sdk/preview"; |
| 48 | 49 | import { type Authentication } from "../sdk/types"; |
| 49 | 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| @@ -82,6 +83,58 @@ export const baseUrlFromSpecInput = (input: string): string => { · 32 unmodified lines | ||
| 82 | 83 | export const openApiPreviewFailureMessage = (message: string | null | undefined): string => |
| 83 | 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 | 139 | // Component: single progressive form. Post-redesign: preview -> addSpec |
| 87 | 140 | // (register the integration catalog entry with ALL detected auth methods) → |
| @@ -96,6 +149,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 96 | 149 | initialPreset?: string; |
| 97 | 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 | 156 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); |
| 100 | 157 | const [analyzing, setAnalyzing] = useState(false); |
| 101 | 158 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| @@ -106,7 +163,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines | ||
| 106 | 163 | // Agent-visible description: prefilled from the spec's `info.description` |
| 107 | 164 | // until the user types (null = untouched, keep deriving from the preview). |
| 108 | 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 | 168 | const identity = useIntegrationIdentity({ |
| 111 | 169 | fallbackName: identityFallbackName, |
| 112 | 170 | fallbackNamespace: props.initialNamespace, |
| @@ -159,12 +217,13 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines | ||
| 159 | 217 | ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) |
| 160 | 218 | : ""; |
| 161 | 219 | const previewPresetIcon = |
| 162 | − openApiPresets.find( | |
| 220 | + openApiPresets?.find( | |
| 163 | 221 | (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), |
| 164 | 222 | )?.icon ?? null; |
| 165 | 223 | |
| 166 | 224 | const resolvedBaseUrl = baseUrl.trim(); |
| 167 | 225 | const resolvedSourceId = |
| 226 | + initialPreset?.defaultSlug || | |
| 168 | 227 | slugifyNamespace(identity.namespace) || |
| 169 | 228 | (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); |
| 170 | 229 | const resolvedDisplayName = |
| @@ -176,15 +235,26 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | ||
| 176 | 235 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 177 | 236 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the |
| 178 | 237 | // freshly-allocated `?? []` fallback arrays. |
| 179 | − const authenticationTemplate: readonly Authentication[] = useMemo( | |
| 180 | − () => | |
| 181 | − detectedAuthenticationTemplates( | |
| 182 | − preview?.headerPresets ?? [], | |
| 183 | − preview?.oauth2Presets ?? [], | |
| 184 | − resolvedBaseUrl, | |
| 185 | − ), | |
| 186 | − [preview, resolvedBaseUrl], | |
| 187 | − ); | |
| 238 | + const authenticationTemplate: readonly Authentication[] = useMemo(() => { | |
| 239 | + if (initialPreset?.authTemplate) { | |
| 240 | + return initialPreset.authTemplate.flatMap((template): readonly Authentication[] => | |
| 241 | + template.kind === "oauth2" | |
| 242 | + ? [ | |
| 243 | + { | |
| 244 | + ...template, | |
| 245 | + slug: AuthTemplateSlug.make(template.slug), | |
| 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 | 259 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 190 | 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 | 341 | setAnalyzing(true); |
| 272 | 342 | setAnalyzeError(null); |
| 273 | 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 | 347 | if (Exit.isFailure(exit)) { |
| 276 | 348 | setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); |
| 277 | 349 | setAnalyzing(false); |
| @@ -298,6 +370,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines | ||
| 298 | 370 | ? { description: resolvedDescription.trim() } |
| 299 | 371 | : {}), |
| 300 | 372 | baseUrl: resolvedBaseUrl, |
| 373 | + specFormat: initialPreset?.specFormat, | |
| 374 | + family: initialPreset?.family, | |
| 375 | + healthCheck: initialPreset?.healthCheck, | |
| 301 | 376 | // Always send the edited method list (even empty) when the user has |
| 302 | 377 | // inspected a preview: an explicit [] means "no auth methods", while |
| 303 | 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 | 395 | resolvedDescription, |
| 321 | 396 | resolvedBaseUrl, |
| 322 | 397 | editedAuthenticationTemplate, |
| 398 | + initialPreset?.family, | |
| 399 | + initialPreset?.healthCheck, | |
| 400 | + initialPreset?.specFormat, | |
| 323 | 401 | ]); |
| 324 | 402 | |
| 325 | 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 && ( | |
| 469 | − <section className="space-y-4"> | |
| 470 | − <div className="space-y-1"> | |
| 471 | − <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3> | |
| 472 | − <p className="text-[11px] text-muted-foreground"> | |
| 473 | − One read-only call Executor runs to tell whether a connection's credential is still | |
| 474 | − alive and, optionally, whose account it is. You can change this later, and run a live | |
| 475 | − preview against a key, from the integration page. | |
| 476 | − </p> | |
| 477 | − </div> | |
| 478 | − <HealthCheckConfigFields | |
| 479 | − candidates={healthCheckCandidates} | |
| 480 | − selected={hcSelected} | |
| 481 | − operation={hcOperation} | |
| 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 | − )} | |
| 546 | + {preview ? ( | |
| 547 | + <AddOpenApiHealthCheckSection | |
| 548 | + presetHealthCheck={initialPreset?.healthCheck} | |
| 549 | + candidates={healthCheckCandidates} | |
| 550 | + selected={hcSelected} | |
| 551 | + operation={hcOperation} | |
| 552 | + onOperationChange={onHcOperationChange} | |
| 553 | + identityField={hcIdentityField} | |
| 554 | + onIdentityFieldChange={setHcIdentityField} | |
| 555 | + args={hcArgs} | |
| 556 | + onArgChange={onHcArgChange} | |
| 557 | + disabled={adding} | |
| 558 | + /> | |
| 559 | + ) : null} | |
| 492 | 560 | |
| 493 | 561 | {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} |
| 494 | 562 | |
| @@ -12,9 +12,11 @@ · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 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({ | |
| 18 | − id: "openapi" as const, | |
| 19 | − integrationPlugin: openApiIntegrationPlugin, | |
| 20 | −}); | |
| 17 | +export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) { | |
| 18 | + return defineClientPlugin({ | |
| 19 | + id: "openapi" as const, | |
| 20 | + integrationPlugin: createOpenApiIntegrationPlugin(config), | |
| 21 | + }); | |
| 22 | +} | |
| @@ -1,21 +1,29 @@ | ||
| 1 | 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 | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | 6 | const importEditSheet = () => import("./UpdateSpecSection"); |
| 7 | 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 | 16 | key: "openapi", |
| 11 | 17 | label: "OpenAPI", |
| 12 | 18 | add: lazy(importAdd), |
| 13 | 19 | editSheet: lazy(importEditSheet), |
| 14 | 20 | accounts: lazy(importAccounts), |
| 15 | − presets: openApiPresets, | |
| 21 | + presets: [...openApiPresets, ...(config?.presets ?? [])], | |
| 16 | 22 | preload: () => { |
| 17 | 23 | void importAdd(); |
| 18 | 24 | void importEditSheet(); |
| 19 | 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 | 51 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 52 | 52 | /** Static query params applied to every request (no secret material). */ |
| 53 | 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 | 58 | /** The auth methods a connection's value can be applied through. */ |
| 55 | 59 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 56 | 60 | }); |
| @@ -51,6 +51,14 @@ export { · 50 unmodified lines | ||
| 51 | 51 | type OpenApiPluginExtension, |
| 52 | 52 | type OpenApiPluginOptions, |
| 53 | 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 | 62 | export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store"; |
| 55 | 63 | export { |
| 56 | 64 | decodeOpenApiIntegrationConfig, |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | IntegrationSlug, |
| 10 | 10 | ToolResult, |
| 11 | 11 | definePlugin, |
| 12 | + HealthCheckSpec, | |
| 12 | 13 | mergeAuthTemplates, |
| 13 | 14 | sha256Hex, |
| 14 | 15 | tool, |
| @@ -33,13 +34,19 @@ import { · 18 unmodified lines | ||
| 33 | 34 | type SpecPreview, |
| 34 | 35 | } from "./preview"; |
| 35 | 36 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 36 | −import { openApiPresets } from "./presets"; | |
| 37 | +import { openApiPresets, type OpenApiPreset } from "./presets"; | |
| 37 | 38 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| 39 | +import { | |
| 40 | + resolveSpecFormatAdapter, | |
| 41 | + type ConvertedSpec, | |
| 42 | + type SpecFormatAdapter, | |
| 43 | +} from "./spec-format"; | |
| 38 | 44 | import type { Authentication } from "./types"; |
| 39 | 45 | import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; |
| 40 | 46 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; |
| 41 | 47 | import { |
| 42 | 48 | checkHealthOpenApi, |
| 49 | + compileAndPersistOpenApiSpecStreaming, | |
| 43 | 50 | compileOpenApiSpec, |
| 44 | 51 | invokeOpenApiBackedTool, |
| 45 | 52 | listHealthCheckCandidatesOpenApi, |
| @@ -59,6 +66,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 13 unmodified lines | ||
| 59 | 66 | |
| 60 | 67 | export interface OpenApiPreviewInput { |
| 61 | 68 | readonly spec: string; |
| 69 | + readonly specFormat?: string; | |
| 62 | 70 | } |
| 63 | 71 | |
| 64 | 72 | /** Add an OpenAPI integration to the catalog. The integration is the API |
| @@ -67,7 +75,7 @@ export interface OpenApiPreviewInput { · 2 unmodified lines | ||
| 67 | 75 | export interface OpenApiSpecConfig { |
| 68 | 76 | readonly spec: OpenApiSpecInput; |
| 69 | 77 | /** The catalog slug for the new integration (the `<integration>` segment). */ |
| 70 | − readonly slug: string; | |
| 78 | + readonly slug?: string; | |
| 71 | 79 | /** Display name (defaults to the spec title). */ |
| 72 | 80 | readonly name?: string; |
| 73 | 81 | /** Agent-visible description (defaults to the spec's `info.description`, |
| @@ -78,6 +86,9 @@ export interface OpenApiSpecConfig { · 4 unmodified lines | ||
| 78 | 86 | readonly headers?: Record<string, string>; |
| 79 | 87 | /** Static query params applied to every request. */ |
| 80 | 88 | readonly queryParams?: Record<string, string>; |
| 89 | + readonly specFormat?: string; | |
| 90 | + readonly family?: string; | |
| 91 | + readonly healthCheck?: HealthCheckSpec; | |
| 81 | 92 | /** Auth methods a connection's value renders through - canonical |
| 82 | 93 | * placements or the request-shaped authoring dialect. */ |
| 83 | 94 | readonly authenticationTemplate?: readonly AuthenticationInput[]; |
| @@ -88,14 +99,16 @@ export interface OpenApiExtensionFailure { · 4 unmodified lines | ||
| 88 | 99 | } |
| 89 | 100 | |
| 90 | 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 | 104 | export interface OpenApiConfigureInput { |
| 93 | 105 | /** The auth methods to add. Each entry is appended to (or, when its `slug` |
| 94 | 106 | * already exists, replaces) the integration's existing template array. A |
| 95 | 107 | * custom apiKey method with no `slug` is assigned a generated `custom_<id>` |
| 96 | 108 | * slug that is collision-checked against the existing template. */ |
| 97 | − readonly authenticationTemplate: readonly AuthenticationInput[]; | |
| 109 | + readonly authenticationTemplate?: readonly AuthenticationInput[]; | |
| 98 | 110 | readonly mode?: "merge" | "replace"; |
| 111 | + readonly baseUrl?: string; | |
| 99 | 112 | } |
| 100 | 113 | |
| 101 | 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 | 179 | const PreviewSpecInputSchema = Schema.Struct({ |
| 167 | 180 | spec: Schema.String, |
| 181 | + specFormat: Schema.optional(Schema.String), | |
| 168 | 182 | }); |
| 169 | 183 | |
| 170 | 184 | const StaticPreviewServerVariableSchema = Schema.Struct({ |
| @@ -261,11 +275,15 @@ const AuthenticationSchema = Schema.Union([ · 90 unmodified lines | ||
| 261 | 275 | |
| 262 | 276 | const AddSourceInputSchema = Schema.Struct({ |
| 263 | 277 | spec: OpenApiSpecInputSchema, |
| 264 | − slug: Schema.String, | |
| 278 | + slug: Schema.optional(Schema.String), | |
| 279 | + name: Schema.optional(Schema.String), | |
| 265 | 280 | description: Schema.optional(Schema.String), |
| 266 | 281 | baseUrl: Schema.optional(Schema.String), |
| 267 | 282 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 268 | 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 | 287 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 270 | 288 | }); |
| 271 | 289 | |
| @@ -553,9 +571,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines | ||
| 553 | 571 | |
| 554 | 572 | export const describeOpenApiIntegrationDisplay = ( |
| 555 | 573 | record: IntegrationRecord, |
| 556 | −): { readonly url?: string } => { | |
| 574 | +): { readonly url?: string; readonly family?: string } => { | |
| 557 | 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 | 586 | export interface OpenApiPluginOptions { |
| 566 | 587 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 567 | 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 | 599 | const resolveSpecForInput = ( |
| 572 | − spec: OpenApiSpecInput, | |
| 600 | + config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">, | |
| 573 | 601 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 574 | − ): Effect.Effect< | |
| 575 | − { | |
| 576 | − readonly specText: string; | |
| 577 | − }, | |
| 578 | − OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | |
| 579 | − > => | |
| 602 | + ): Effect.Effect<ConvertedSpec, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError> => | |
| 580 | 603 | Effect.gen(function* () { |
| 581 | − if (spec.kind === "url") { | |
| 582 | − const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer)); | |
| 583 | − return { specText }; | |
| 604 | + const adapter = yield* resolveSpecFormatAdapter( | |
| 605 | + options?.specFormats ?? [], | |
| 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 | 632 | return { |
| 589 | 633 | id: "openapi" as const, |
| 590 | 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 | 637 | id: preset.id, |
| 593 | 638 | name: preset.name, |
| 594 | 639 | summary: preset.summary, |
| 595 | 640 | ...(preset.url ? { url: preset.url } : {}), |
| 596 | 641 | ...(preset.icon ? { icon: preset.icon } : {}), |
| 597 | 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 | 649 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 600 | 650 | |
| @@ -650,8 +700,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 49 unmodified lines | ||
| 650 | 700 | Effect.gen(function* () { |
| 651 | 701 | // Resolve URL → text and parse BEFORE opening a transaction. Holding |
| 652 | 702 | // `BEGIN` across a network fetch is the Hyperdrive deadlock path. |
| 653 | − const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); | |
| 654 | − const compiled = yield* compileOpenApiSpec(resolved.specText); | |
| 703 | + const resolved = yield* resolveSpecForInput(config, httpClientLayer); | |
| 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 | 722 | // Defaults the add page derives from its preview, applied here so |
| 657 | 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 | 731 | // end with nowhere to paste a credential) |
| 666 | 732 | // An explicit input always wins; for auth, an explicit EMPTY array |
| 667 | 733 | // means "no auth methods" and suppresses the derivation. |
| 668 | − const explicitBaseUrl = config.baseUrl; | |
| 734 | + const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl; | |
| 669 | 735 | const needsDerivedBaseUrl = explicitBaseUrl == null; |
| 670 | 736 | const needsDerivedAuth = config.authenticationTemplate == null; |
| 671 | 737 | const preview = |
| @@ -675,8 +741,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines | ||
| 675 | 741 | enrichPreviewWithDiscoveredOAuth({ |
| 676 | 742 | specText: resolved.specText, |
| 677 | 743 | preview: rawPreview, |
| 678 | − sourceUrl: specInputToSourceUrl(config.spec), | |
| 679 | − baseUrl: config.baseUrl, | |
| 744 | + sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec), | |
| 745 | + baseUrl: explicitBaseUrl, | |
| 680 | 746 | }), |
| 681 | 747 | ), |
| 682 | 748 | ) |
| @@ -689,7 +755,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 6 unmodified lines | ||
| 689 | 755 | ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl) |
| 690 | 756 | : undefined; |
| 691 | 757 | |
| 692 | − const slug = IntegrationSlug.make(config.slug); | |
| 758 | + const slug = IntegrationSlug.make(resolvedSlug); | |
| 693 | 759 | |
| 694 | 760 | // Block re-adding an existing slug. The core `integrations.register` |
| 695 | 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 | 770 | const specHash = yield* sha256Hex(resolved.specText); |
| 705 | 771 | |
| 706 | 772 | const integrationConfig: OpenApiIntegrationConfig = { |
| 773 | + ...(resolved.config ?? {}), | |
| 707 | 774 | specHash, |
| 708 | − ...(specInputToSourceUrl(config.spec) !== undefined | |
| 709 | − ? { sourceUrl: specInputToSourceUrl(config.spec) } | |
| 775 | + ...((resolved.sourceUrl ?? specInputToSourceUrl(config.spec)) !== undefined | |
| 776 | + ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec) } | |
| 710 | 777 | : {}), |
| 711 | 778 | // baseUrl is an optional override only. The host is otherwise |
| 712 | 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 | 781 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), |
| 715 | 782 | ...(config.headers ? { headers: config.headers } : {}), |
| 716 | 783 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 784 | + ...(config.specFormat ? { specFormat: config.specFormat } : {}), | |
| 785 | + ...(config.family ? { family: config.family } : {}), | |
| 717 | 786 | // Prefer the caller's explicit template; otherwise derive from the |
| 718 | 787 | // spec's declared security schemes. |
| 719 | 788 | ...(config.authenticationTemplate |
| 720 | 789 | ? { |
| 721 | 790 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), |
| 722 | 791 | } |
| 723 | − : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 | |
| 724 | − ? { authenticationTemplate: derivedAuthenticationTemplate } | |
| 725 | − : {}), | |
| 792 | + : resolved.authenticationTemplate && resolved.authenticationTemplate.length > 0 | |
| 793 | + ? { authenticationTemplate: resolved.authenticationTemplate } | |
| 794 | + : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 | |
| 795 | + ? { authenticationTemplate: derivedAuthenticationTemplate } | |
| 796 | + : {}), | |
| 726 | 797 | }; |
| 727 | 798 | |
| 728 | 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 | 804 | // The content-addressed defs blob lets the serve path resolve the |
| 734 | 805 | // shared `definitions` without re-parsing the spec. Same idempotent, |
| 735 | 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 | 811 | yield* ctx.transaction( |
| 739 | 812 | Effect.gen(function* () { |
| 740 | 813 | yield* ctx.core.integrations.register({ |
| 741 | 814 | slug, |
| 742 | − name: config.name?.trim() || compiled.title || config.slug, | |
| 815 | + name: | |
| 816 | + config.name?.trim() || derivedIdentity?.name || compiled?.title || resolvedSlug, | |
| 743 | 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 | 823 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 746 | 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( | |
| 750 | − config.slug, | |
| 751 | − openApiStoredOperationsFromCompiled(config.slug, compiled), | |
| 752 | − ); | |
| 844 | + return persisted.toolCount; | |
| 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 | 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 | 882 | // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock |
| 786 | 883 | // rule as addSpec: never hold BEGIN across a network fetch). |
| 787 | − const resolved = yield* resolveSpecForInput(specInput, httpClientLayer); | |
| 788 | − const compiled = yield* compileOpenApiSpec(resolved.specText); | |
| 884 | + const resolved = yield* resolveSpecForInput( | |
| 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 | 898 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); |
| 791 | 899 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); |
| 792 | − const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); | |
| 793 | 900 | |
| 794 | 901 | // The resolved spec text lives in the plugin blob store keyed by its |
| 795 | 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 | 904 | // aborted config update just leaves an unreferenced blob. |
| 798 | 905 | const specHash = yield* sha256Hex(resolved.specText); |
| 799 | 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 | 911 | const nextConfig: OpenApiIntegrationConfig = { |
| 803 | 912 | ...current, |
| 804 | 913 | specHash, |
| 805 | − ...(specInputToSourceUrl(specInput) !== undefined | |
| 806 | − ? { sourceUrl: specInputToSourceUrl(specInput) } | |
| 914 | + ...((resolved.sourceUrl ?? specInputToSourceUrl(specInput)) !== undefined | |
| 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 | 921 | yield* ctx.core.integrations.update(slug, { |
| 813 | 922 | config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 814 | 923 | }); |
| 815 | − yield* ctx.storage.putOperations( | |
| 816 | − rawSlug, | |
| 817 | − openApiStoredOperationsFromCompiled(rawSlug, compiled), | |
| 818 | − ); | |
| 924 | + if (compiled) { | |
| 925 | + yield* ctx.storage.putOperations( | |
| 926 | + rawSlug, | |
| 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 | 946 | // Rebuild each connection's tool rows from the new spec. Outside the |
| 823 | 947 | // transaction: refresh opens its own, and a half-refreshed catalog |
| 824 | 948 | // self-heals on the next refresh anyway. |
| @@ -843,7 +967,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 18 unmodified lines | ||
| 843 | 967 | |
| 844 | 968 | return { |
| 845 | 969 | slug, |
| 846 | − toolCount: compiled.definitions.length, | |
| 970 | + toolCount: nextNames.size, | |
| 847 | 971 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), |
| 848 | 972 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), |
| 849 | 973 | } satisfies UpdateSpecResult; |
| @@ -857,14 +981,18 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines | ||
| 857 | 981 | previewSpec: (input: string | OpenApiPreviewInput) => |
| 858 | 982 | Effect.gen(function* () { |
| 859 | 983 | const previewInput = typeof input === "string" ? { spec: input } : input; |
| 860 | − const specText = yield* resolveSpecText(previewInput.spec).pipe( | |
| 861 | − Effect.provide(httpClientLayer), | |
| 984 | + const spec = maybeUrl(previewInput.spec.trim()) | |
| 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 | 992 | return yield* enrichPreviewWithDiscoveredOAuth({ |
| 865 | − specText, | |
| 993 | + specText: resolved.specText, | |
| 866 | 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 | 1045 | const current = decodeOpenApiIntegrationConfig(record.config); |
| 918 | 1046 | if (!current) return [] as readonly Authentication[]; |
| 919 | 1047 | |
| 920 | − const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | |
| 921 | 1048 | const merged = |
| 922 | − input.mode === "replace" | |
| 923 | − ? incoming | |
| 924 | − : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | |
| 1049 | + input.authenticationTemplate === undefined | |
| 1050 | + ? (current.authenticationTemplate ?? []) | |
| 1051 | + : input.mode === "replace" | |
| 1052 | + ? normalizeOpenApiAuthInputs(input.authenticationTemplate) | |
| 1053 | + : mergeAuthTemplates( | |
| 1054 | + current.authenticationTemplate ?? [], | |
| 1055 | + normalizeOpenApiAuthInputs(input.authenticationTemplate), | |
| 1056 | + ); | |
| 925 | 1057 | |
| 926 | 1058 | const next: OpenApiIntegrationConfig = { |
| 927 | 1059 | ...current, |
| 1060 | + ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), | |
| 928 | 1061 | authenticationTemplate: merged, |
| 929 | 1062 | }; |
| 930 | 1063 | |
| @@ -978,10 +1111,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 47 unmodified lines | ||
| 978 | 1111 | .addSpec({ |
| 979 | 1112 | spec: input.spec, |
| 980 | 1113 | slug: input.slug, |
| 1114 | + name: input.name, | |
| 981 | 1115 | description: input.description, |
| 982 | 1116 | baseUrl: input.baseUrl, |
| 983 | 1117 | headers: input.headers, |
| 984 | 1118 | queryParams: input.queryParams, |
| 1119 | + specFormat: input.specFormat, | |
| 1120 | + family: input.family, | |
| 1121 | + healthCheck: input.healthCheck, | |
| 985 | 1122 | authenticationTemplate: input.authenticationTemplate as |
| 986 | 1123 | | readonly AuthenticationInput[] |
| 987 | 1124 | | undefined, |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import type { HealthCheckSpec, IntegrationPresetAuthentication } from "@executor-js/sdk/core"; | |
| 2 | + | |
| 1 | 3 | export interface OpenApiPreset { |
| 2 | 4 | readonly id: string; |
| 3 | 5 | readonly name: string; |
| @@ -5,6 +7,11 @@ export interface OpenApiPreset { · 1 unmodified line | ||
| 5 | 7 | readonly url?: string; |
| 6 | 8 | readonly icon?: string; |
| 7 | 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 | 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 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | 7 | client: "src/react/plugin-client.tsx", |
| 8 | + "providers/google": "src/providers/google/index.ts", | |
| 9 | + "providers/microsoft": "src/providers/microsoft/index.ts", | |
| 8 | 10 | testing: "src/testing/index.ts", |
| 9 | 11 | }, |
| 10 | 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"], | |
| 23 | − "exclude": ["src/promise.ts"] | |
| 22 | + "include": ["src/**/*.ts"] | |
| 24 | 23 | } |
| @@ -2,9 +2,7 @@ import { defineConfig } from "tsup"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | export default defineConfig({ |
| 4 | 4 | entry: { |
| 5 | − index: "src/promise.ts", | |
| 6 | − core: "src/sdk/index.ts", | |
| 7 | − client: "src/react/plugin-client.tsx", | |
| 5 | + index: "src/index.ts", | |
| 8 | 6 | }, |
| 9 | 7 | format: ["esm"], |
| 10 | 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 | 366 | oauthClient: null, |
| 367 | 367 | oauthClientOwner: null, |
| 368 | 368 | oauthScope: null, |
| 369 | + missingOAuthScopes: [], | |
| 369 | 370 | lastHealth: null, |
| 370 | 371 | }; |
| 371 | 372 | return [optimistic, ...rows]; |
| @@ -85,16 +85,17 @@ function AccountRow(props: { · 84 unmodified lines | ||
| 85 | 85 | const { probe, status, runCheck } = useConnectionHealth(connection); |
| 86 | 86 | const indicator = HEALTH_INDICATOR_COLOR[status]; |
| 87 | 87 | |
| 88 | − // Prefer a probed identity (the live account), then the stored label, then the | |
| 89 | − // connection name. The probe is the whole point: it shows WHICH account this is. | |
| 88 | + // Prefer the stored label from the connection row, then a probed identity, | |
| 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 | 91 | const identity = |
| 91 | − (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ?? | |
| 92 | 92 | (connection.identityLabel && connection.identityLabel.length > 0 |
| 93 | 93 | ? connection.identityLabel |
| 94 | − : null); | |
| 94 | + : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); | |
| 95 | 95 | const displayLabel = identity ?? String(connection.name); |
| 96 | 96 | |
| 97 | 97 | const expired = status === "expired"; |
| 98 | + const missingOAuthScopes = connection.missingOAuthScopes ?? []; | |
| 98 | 99 | |
| 99 | 100 | const handleCheck = async () => { |
| 100 | 101 | if (checking) return; |
| @@ -150,6 +151,11 @@ function AccountRow(props: { · 49 unmodified lines | ||
| 150 | 151 | This connection wasn't granted all the access this integration now needs. |
| 151 | 152 | </CardStackEntryDescription> |
| 152 | 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 | 159 | </CardStackEntryContent> |
| 154 | 160 | <CardStackEntryActions className="self-start pt-0.5"> |
| 155 | 161 | {props.showOwnerLabel ? ( |
| @@ -477,8 +477,8 @@ function HealthCheckEditorSheet(props: { · 476 unmodified lines | ||
| 477 | 477 | <SheetHeader> |
| 478 | 478 | <SheetTitle>Health check</SheetTitle> |
| 479 | 479 | <SheetDescription> |
| 480 | − One read-only call Executor runs to tell whether a connection's credential is still | |
| 481 | − alive and, optionally, whose account it is. A 401 or 403 marks the connection expired. | |
| 480 | + Optional read-only depth for live upstream checks. OAuth validity and identity come from | |
| 481 | + the grant itself. For non-OAuth methods, a 401 or 403 marks the connection expired. | |
| 482 | 482 | </SheetDescription> |
| 483 | 483 | </SheetHeader> |
| 484 | 484 | |
| @@ -553,8 +553,8 @@ export function HealthCheckEditor(props: { · 68 unmodified lines | ||
| 553 | 553 | <div className="space-y-1"> |
| 554 | 554 | <h3 className="text-sm font-medium">Health check</h3> |
| 555 | 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 | |
| 557 | − and, optionally, whose account it is. | |
| 556 | + Optional read-only depth for live upstream checks. OAuth identity comes from the account | |
| 557 | + grant, while probe identity fields are useful for non-OAuth methods. | |
| 558 | 558 | </p> |
| 559 | 559 | </div> |
| 560 | 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 | 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 | 249 | it("does not split generic words into brand matches", () => { |
| 228 | 250 | expect( |
| 229 | 251 | integrationPresetIconUrl( |
| @@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines | ||
| 157 | 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; |
| 158 | 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 159 | 159 | const presets = plugin?.presets ?? []; |
| 160 | + const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon; | |
| 161 | + if (exactSlugIcon) return exactSlugIcon; | |
| 162 | + | |
| 160 | 163 | const sourceUrl = normalizeUrl(source.url); |
| 161 | 164 | const sourceGoogleService = googleApiServiceFromUrl(source.url); |
| 162 | 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 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -33,11 +33,14 @@ import { · 28 unmodified lines | ||
| 33 | 33 | CardStackEntryDescription, |
| 34 | 34 | CardStackEntryMedia, |
| 35 | 35 | CardStackEntryTitle, |
| 36 | + CardStackHeader, | |
| 36 | 37 | } from "../components/card-stack"; |
| 37 | 38 | import { |
| 39 | + IntegrationFavicon, | |
| 38 | 40 | integrationInferredUrl, |
| 39 | 41 | integrationPresetIconUrl, |
| 40 | 42 | } from "../components/integration-favicon"; |
| 43 | +import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; | |
| 41 | 44 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 42 | 45 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 43 | 46 | import { Skeleton } from "../components/skeleton"; |
| @@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines | ||
| 349 | 352 | const q = (props.searchQuery ?? "").trim().toLowerCase(); |
| 350 | 353 | if (q.length === 0) return allPresets; |
| 351 | 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 | 357 | return corpus.includes(q); |
| 354 | 358 | }); |
| 355 | 359 | }, [allPresets, props.searchQuery]); |
| @@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines | ||
| 434 | 438 | return out; |
| 435 | 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 | 522 | return ( |
| 438 | − <CardStack searchable> | |
| 439 | − <CardStackContent> | |
| 440 | − {props.integrations.map((integration) => { | |
| 441 | − const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; | |
| 442 | − const plugin = pluginByKind.get(pluginKey); | |
| 443 | − const SummaryComponent = plugin?.summary; | |
| 444 | − const slug = String(integration.slug); | |
| 445 | − const name = integration.name || slug; | |
| 446 | − return ( | |
| 447 | − <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> | |
| 448 | − <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> | |
| 449 | − <IntegrationIconWithAccount | |
| 450 | − icon={integrationPresetIconUrl( | |
| 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> | |
| 523 | + <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}> | |
| 524 | + <CardStackHeader> | |
| 525 | + <span className="flex min-w-0 items-center gap-2"> | |
| 526 | + <span className="flex size-5 shrink-0 items-center justify-center"> | |
| 527 | + <IntegrationFavicon icon={headerIcon} size={16} /> | |
| 528 | + </span> | |
| 529 | + <span className="truncate">{group.label}</span> | |
| 530 | + <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground"> | |
| 531 | + {group.members.length} | |
| 532 | + </span> | |
| 533 | + </span> | |
| 534 | + </CardStackHeader> | |
| 535 | + <CardStackContent>{group.members.map(renderEntry)}</CardStackContent> | |
| 478 | 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 | 10 | convertGoogleDiscoveryToOpenApi, |
| 11 | 11 | fetchGoogleDiscoveryDocument, |
| 12 | 12 | isGoogleDiscoveryUrl, |
| 13 | −} from "../packages/plugins/google/src/sdk/discovery"; | |
| 13 | +} from "../packages/plugins/openapi/src/providers/google/discovery"; | |
| 14 | 14 | import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; |
| 15 | 15 | import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; |
| 16 | 16 | import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; |