History

Move Google and Microsoft onto the OpenAPI plugin (#1366)

* 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

Rhys Sullivan committed 4 months ago

Commit 55a24631 parent 93b000a148 files · +8259 −6034Browse files

Unified · Split

148 files
  1. apps/cloud/executor.config.ts +13 −15
  2. apps/cloud/package.json +0 −2
  3. apps/cloud/src/engine/execution-stack.ts +0 −1
  4. apps/desktop/package.json +0 −2
  5. apps/host-cloudflare/executor.config.ts +12 −5
  6. apps/host-cloudflare/package.json +1 −2
  7. apps/host-cloudflare/src/db/data-migrations.test.ts +54 −5
  8. apps/host-cloudflare/src/db/data-migrations.ts +73 −1
  9. apps/host-cloudflare/src/plugins.ts +12 −5
  10. apps/host-cloudflare/src/worker.e2e.node.test.ts +39 −3
  11. apps/host-selfhost/executor.config.ts +13 −10
  12. apps/host-selfhost/package.json +1 −2
  13. apps/host-selfhost/src/db/data-migrations.ts +3 −1
  14. apps/host-selfhost/src/execution.ts +0 −4
  15. apps/local/executor.config.ts +12 −5
  16. apps/local/package.json +1 −2
  17. apps/local/src/db/data-migrations.ts +3 −1
  18. bun.lock +65 −93
  19. e2e/package.json +0 −2
  20. e2e/scenarios/google-health-checks.test.ts +351 −0
  21. e2e/scenarios/google-photos-preset-ui.test.ts +22 −21
  22. e2e/scenarios/microsoft-emulator.test.ts +24 −21
  23. e2e/scenarios/microsoft-graph-default.test.ts +44 −58
  24. e2e/scenarios/microsoft-graph-full.test.ts +54 −48
  25. e2e/scenarios/oauth-client-handoff.test.ts +24 −21
  26. e2e/scenarios/provider-plugins-ui.test.ts +29 −39
  27. examples/all-plugins/package.json +0 −1
  28. examples/all-plugins/src/main.ts +13 −4
  29. knip.config.ts +0 −3
  30. package.json +1 −0
  31. packages/core/api/src/connections/api.ts +1 −0
  32. packages/core/api/src/handlers/connections.ts +1 −0
  33. packages/core/api/src/handlers/integrations.ts +1 −0
  34. packages/core/api/src/handlers/oauth.ts +1 −0
  35. packages/core/api/src/integrations/api.ts +2 −0
  36. packages/core/api/src/oauth/api.ts +1 −0
  37. packages/core/sdk/src/client.ts +26 −0
  38. packages/core/sdk/src/connection.ts +3 −0
  39. packages/core/sdk/src/connections.test.ts +23 −0
  40. packages/core/sdk/src/executor.ts +74 −11
  41. packages/core/sdk/src/index.ts +1 −0
  42. packages/core/sdk/src/integration.ts +4 −0
  43. packages/core/sdk/src/oauth-flow.test.ts +354 −0
  44. packages/core/sdk/src/oauth-helpers.test.ts +77 −0
  45. packages/core/sdk/src/oauth-helpers.ts +72 −18
  46. packages/core/sdk/src/oauth-service.ts +54 −2
  47. packages/core/sdk/src/plugin.ts +25 −0
  48. packages/core/sdk/src/testing/oauth-test-server.ts +13 −0
  49. packages/plugins/google/CHANGELOG.md +0 −143
  50. packages/plugins/google/package.json +0 −98
  51. packages/plugins/google/src/api/group.ts +0 −142
  52. packages/plugins/google/src/api/handlers.ts +0 −105
  53. packages/plugins/google/src/api/index.ts +0 −15
  54. packages/plugins/google/src/promise.ts +0 −1
  55. packages/plugins/google/src/react/AddGoogleSource.tsx +0 −178
  56. packages/plugins/google/src/react/GoogleAccountsPanel.tsx +0 −148
  57. packages/plugins/google/src/react/GoogleProductPicker.tsx +0 −363
  58. packages/plugins/google/src/react/atoms.ts +0 −30
  59. packages/plugins/google/src/react/client.ts +0 −13
  60. packages/plugins/google/src/react/index.ts +0 −12
  61. packages/plugins/google/src/react/plugin-client.tsx +0 −8
  62. packages/plugins/google/src/react/source-plugin.ts +0 −18
  63. packages/plugins/google/src/sdk/oauth-batches.test.ts +0 −98
  64. packages/plugins/google/src/sdk/oauth-batches.ts +0 −69
  65. packages/plugins/google/src/sdk/plugin.test.ts +0 −799
  66. packages/plugins/google/src/sdk/plugin.ts +0 −469
  67. packages/plugins/google/src/sdk/presets.test.ts +0 −29
  68. packages/plugins/google/src/sdk/product-picker-scopes.test.ts +0 −122
  69. packages/plugins/google/tsconfig.json +0 −24
  70. packages/plugins/google/tsup.config.ts +0 −14
  71. packages/plugins/google/vitest.config.ts +0 −12
  72. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +2 −2
  73. packages/plugins/graphql/src/react/atoms.ts +3 −3
  74. packages/plugins/graphql/src/react/index.ts +1 −1
  75. packages/plugins/microsoft/CHANGELOG.md +0 −141
  76. packages/plugins/microsoft/package.json +0 −98
  77. packages/plugins/microsoft/src/api/group.ts +0 −163
  78. packages/plugins/microsoft/src/api/handlers.ts +0 −144
  79. packages/plugins/microsoft/src/api/index.ts +0 −15
  80. packages/plugins/microsoft/src/promise.ts +0 −1
  81. packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx +0 −155
  82. packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx +0 −118
  83. packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx +0 −286
  84. packages/plugins/microsoft/src/react/atoms.ts +0 −30
  85. packages/plugins/microsoft/src/react/client.ts +0 −13
  86. packages/plugins/microsoft/src/react/index.ts +0 −12
  87. packages/plugins/microsoft/src/react/plugin-client.tsx +0 −8
  88. packages/plugins/microsoft/src/react/source-plugin.ts +0 −18
  89. packages/plugins/microsoft/src/sdk/plugin.test.ts +0 −692
  90. packages/plugins/microsoft/src/sdk/plugin.ts +0 −436
  91. packages/plugins/microsoft/src/sdk/presets.test.ts +0 −169
  92. packages/plugins/microsoft/vitest.config.ts +0 −12
  93. packages/plugins/openapi/package.json +14 −0
  94. packages/plugins/openapi/src/api/group.ts +11 −4
  95. packages/plugins/openapi/src/api/handlers.ts +8 −1
  96. packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap +0 −0
  97. packages/plugins/openapi/src/providers/google/config.ts +3 −1
  98. packages/plugins/openapi/src/providers/google/discovery.test.ts +0 −0
  99. packages/plugins/openapi/src/providers/google/discovery.ts +2 −1
  100. packages/plugins/openapi/src/providers/google/index.ts +3 −14
  101. packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts +0 −0
  102. packages/plugins/openapi/src/providers/google/oauth-scopes.ts +0 −0
  103. packages/plugins/openapi/src/providers/google/openapi-ownership-migration.test.ts +0 −0
  104. packages/plugins/openapi/src/providers/google/openapi-ownership-migration.ts +0 −0
  105. packages/plugins/openapi/src/providers/google/presets.test.ts +283 −0
  106. packages/plugins/openapi/src/providers/google/presets.ts +69 −1
  107. packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +167 −0
  108. packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +85 −0
  109. packages/plugins/openapi/src/providers/microsoft/graph.test.ts +68 −0
  110. packages/plugins/openapi/src/providers/microsoft/graph.ts +4 −5
  111. packages/plugins/openapi/src/providers/microsoft/index.ts +3 −9
  112. packages/plugins/openapi/src/providers/microsoft/presets.test.ts +332 −0
  113. packages/plugins/openapi/src/providers/microsoft/presets.ts +41 −0
  114. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +92 −0
  115. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +52 −0
  116. packages/plugins/openapi/src/react/AddOpenApiSource.test.ts +68 −1
  117. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +105 −37
  118. packages/plugins/openapi/src/react/plugin-client.tsx +7 −5
  119. packages/plugins/openapi/src/react/source-plugin.ts +12 −4
  120. packages/plugins/openapi/src/sdk/config.ts +4 −0
  121. packages/plugins/openapi/src/sdk/index.ts +8 −0
  122. packages/plugins/openapi/src/sdk/plugin.ts +197 −60
  123. packages/plugins/openapi/src/sdk/presets.ts +7 −0
  124. packages/plugins/openapi/src/sdk/spec-format.test.ts +32 −0
  125. packages/plugins/openapi/src/sdk/spec-format.ts +51 −0
  126. packages/plugins/openapi/tsup.config.ts +2 −0
  127. packages/plugins/provider-service-split/CHANGELOG.md +7 −0
  128. packages/plugins/provider-service-split/package.json +31 −0
  129. packages/plugins/provider-service-split/src/index.ts +2 −0
  130. packages/plugins/provider-service-split/src/planner.test.ts +1119 −0
  131. packages/plugins/provider-service-split/src/planner.ts +1335 −0
  132. packages/plugins/provider-service-split/src/sqlite.test.ts +337 −0
  133. packages/plugins/provider-service-split/src/sqlite.ts +549 −0
  134. packages/plugins/provider-service-split/tsconfig.json +1 −2
  135. packages/plugins/provider-service-split/tsup.config.ts +1 −3
  136. packages/plugins/provider-service-split/vitest.config.ts +8 −0
  137. packages/react/src/api/atoms.tsx +1 −0
  138. packages/react/src/components/accounts-section.tsx +10 −4
  139. packages/react/src/components/health-check-editor.tsx +4 −4
  140. packages/react/src/components/integration-favicon.test.tsx +22 −0
  141. packages/react/src/components/integration-favicon.tsx +3 −0
  142. packages/react/src/lib/integration-grouping.test.ts +80 −0
  143. packages/react/src/lib/integration-grouping.ts +69 −0
  144. packages/react/src/pages/integrations.tsx +100 −42
  145. plans/openapi-provider-catalog-design.md +139 −0
  146. scripts/migration/service-split-postgres-cli.ts +639 −0
  147. tests/migration/service-split-postgres-cli.test.ts +468 −0
  148. tests/presets-reachable.test.ts +1 −1
apps/cloud/executor.config.ts+13−15
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";13import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines
42 * falls back to the credential-driven default. */48 * falls back to the credential-driven default. */
43 readonly workosVaultClient?: WorkOSVaultClient;49 readonly workosVaultClient?: WorkOSVaultClient;
44 readonly activeToolkitSlug?: string;50 readonly activeToolkitSlug?: string;
45 /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets
46 * `microsoft.addGraph` point at a loopback emulator instead of the pinned
47 * Microsoft Graph URLs. Off by default; production leaves it unset. */
48 readonly allowLocalNetwork?: boolean;
49}51}
50 52
51export default defineExecutorConfig({53export default defineExecutorConfig({
52 plugins: ({54 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53 workosCredentials,
54 workosVaultClient,
55 activeToolkitSlug,
56 allowLocalNetwork,
57 }: CloudPluginDeps = {}) =>
58 [55 [
59 openApiHttpPlugin(),56 openApiHttpPlugin({
60 googleHttpPlugin(),57 presets: [...googleCatalog, ...microsoftCatalog],
61 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),58 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
59 }),
62 mcpHttpPlugin({60 mcpHttpPlugin({
63 dangerouslyAllowStdioMCP: false,61 dangerouslyAllowStdioMCP: false,
64 }),62 }),
apps/cloud/package.json+0−2
@@ -41,10 +41,8 @@ · 40 unmodified lines
41 "@executor-js/execution": "workspace:*",41 "@executor-js/execution": "workspace:*",
42 "@executor-js/fumadb": "workspace:*",42 "@executor-js/fumadb": "workspace:*",
43 "@executor-js/host-mcp": "workspace:*",43 "@executor-js/host-mcp": "workspace:*",
44 "@executor-js/plugin-google": "workspace:*",
45 "@executor-js/plugin-graphql": "workspace:*",44 "@executor-js/plugin-graphql": "workspace:*",
46 "@executor-js/plugin-mcp": "workspace:*",45 "@executor-js/plugin-mcp": "workspace:*",
47 "@executor-js/plugin-microsoft": "workspace:*",
48 "@executor-js/plugin-openapi": "workspace:*",46 "@executor-js/plugin-openapi": "workspace:*",
49 "@executor-js/plugin-toolkits": "workspace:*",47 "@executor-js/plugin-toolkits": "workspace:*",
50 "@executor-js/plugin-workos-vault": "workspace:*",48 "@executor-js/plugin-workos-vault": "workspace:*",
apps/cloud/src/engine/execution-stack.ts+0−1
@@ -66,7 +66,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 65 unmodified lines
66 },66 },
67 activeToolkitSlug:67 activeToolkitSlug:
68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
69 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
70 }),69 }),
71});70});
72 71
apps/desktop/package.json+0−2
@@ -35,11 +35,9 @@ · 34 unmodified lines
35 "@executor-js/local": "workspace:*",35 "@executor-js/local": "workspace:*",
36 "@executor-js/plugin-desktop-settings": "workspace:*",36 "@executor-js/plugin-desktop-settings": "workspace:*",
37 "@executor-js/plugin-file-secrets": "workspace:*",37 "@executor-js/plugin-file-secrets": "workspace:*",
38 "@executor-js/plugin-google": "workspace:*",
39 "@executor-js/plugin-graphql": "workspace:*",38 "@executor-js/plugin-graphql": "workspace:*",
40 "@executor-js/plugin-keychain": "workspace:*",39 "@executor-js/plugin-keychain": "workspace:*",
41 "@executor-js/plugin-mcp": "workspace:*",40 "@executor-js/plugin-mcp": "workspace:*",
42 "@executor-js/plugin-microsoft": "workspace:*",
43 "@executor-js/plugin-onepassword": "workspace:*",41 "@executor-js/plugin-onepassword": "workspace:*",
44 "@executor-js/plugin-openapi": "workspace:*",42 "@executor-js/plugin-openapi": "workspace:*",
45 "@executor-js/runtime-quickjs": "workspace:*",43 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-cloudflare/executor.config.ts+12−5
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";13import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -19,9 +25,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines
19export default defineExecutorConfig({25export default defineExecutorConfig({
20 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>26 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
21 [27 [
22 openApiHttpPlugin(),28 openApiHttpPlugin({
23 googleHttpPlugin(),29 presets: [...googleCatalog, ...microsoftCatalog],
24 microsoftHttpPlugin(),30 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
31 }),
25 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
26 graphqlHttpPlugin(),33 graphqlHttpPlugin(),
27 toolkitsPlugin({ activeToolkitSlug }),34 toolkitsPlugin({ activeToolkitSlug }),
apps/host-cloudflare/package.json+1−2
@@ -24,11 +24,10 @@ · 23 unmodified lines
24 "@executor-js/fumadb": "workspace:*",24 "@executor-js/fumadb": "workspace:*",
25 "@executor-js/host-mcp": "workspace:*",25 "@executor-js/host-mcp": "workspace:*",
26 "@executor-js/plugin-encrypted-secrets": "workspace:*",26 "@executor-js/plugin-encrypted-secrets": "workspace:*",
27 "@executor-js/plugin-google": "workspace:*",
28 "@executor-js/plugin-graphql": "workspace:*",27 "@executor-js/plugin-graphql": "workspace:*",
29 "@executor-js/plugin-mcp": "workspace:*",28 "@executor-js/plugin-mcp": "workspace:*",
30 "@executor-js/plugin-microsoft": "workspace:*",
31 "@executor-js/plugin-openapi": "workspace:*",29 "@executor-js/plugin-openapi": "workspace:*",
30 "@executor-js/plugin-provider-service-split": "workspace:*",
32 "@executor-js/plugin-toolkits": "workspace:*",31 "@executor-js/plugin-toolkits": "workspace:*",
33 "@executor-js/react": "workspace:*",32 "@executor-js/react": "workspace:*",
34 "@executor-js/runtime-quickjs": "workspace:*",33 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-cloudflare/src/db/data-migrations.test.ts+54−5
@@ -110,11 +110,11 @@ const insertOperationStorage = ( · 109 unmodified lines
110 args: [110 args: [
111 row.tenant,111 row.tenant,
112 row.pluginId,112 row.pluginId,
113 `${row.integration}.items.list`,113 `${row.integration}.calendar.events.list`,
114 JSON.stringify({114 JSON.stringify({
115 integration: row.integration,115 integration: row.integration,
116 toolName: "items.list",116 toolName: "calendar.events.list",
117 binding: { method: "get", pathTemplate: "/items" },117 binding: { method: "get", pathTemplate: "/calendar/events" },
118 }),118 }),
119 now,119 now,
120 now,120 now,
@@ -239,6 +239,7 @@ describe("runCloudflareDataMigrations", () => { · 118 unmodified lines
239 const d1 = makeFakeD1(db.client);239 const d1 = makeFakeD1(db.client);
240 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([240 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
241 "2026-06-20-google-openapi-ownership",241 "2026-06-20-google-openapi-ownership",
242 "2026-07-08-provider-service-split",
242 ]);243 ]);
243 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);244 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
244 245
@@ -276,10 +277,12 @@ describe("runCloudflareDataMigrations", () => { · 31 unmodified lines
276 }),277 }),
277 );278 );
278 objects.set("o:org_1/openapi/spec/googlehash", "google spec");279 objects.set("o:org_1/openapi/spec/googlehash", "google spec");
280 objects.set("o:org_1/google/defs/googlehash", "google defs");
279 281
280 const d1 = makeFakeD1(db.client);282 const d1 = makeFakeD1(db.client);
281 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([283 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
282 "2026-06-20-google-openapi-ownership",284 "2026-06-20-google-openapi-ownership",
285 "2026-07-08-provider-service-split",
283 ]);286 ]);
284 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);287 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
285 288
@@ -288,12 +291,58 @@ describe("runCloudflareDataMigrations", () => { · 2 unmodified lines
288 const integrations = yield* Effect.promise(() =>291 const integrations = yield* Effect.promise(() =>
289 db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),292 db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
290 );293 );
291 expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]);294 expect(integrations.rows).toEqual([{ slug: "google_calendar", plugin_id: "openapi" }]);
292 295
293 const storage = yield* Effect.promise(() =>296 const storage = yield* Effect.promise(() =>
294 db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"),297 db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"),
295 );298 );
296 expect(storage.rows).toEqual([{ plugin_id: "google", key: "google.items.list" }]);299 expect(storage.rows).toEqual([{ plugin_id: "openapi", key: expect.stringMatching(/^op\./) }]);
300
301 yield* Effect.promise(() => db.close());
302 }),
303 );
304
305 it.effect("splits Google monolith ownership and copies R2 spec and defs objects", () =>
306 Effect.gen(function* () {
307 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
308 const { bucket, objects } = makeFakeR2();
309
310 yield* Effect.promise(() =>
311 insertIntegration(db.client, {
312 rowId: "google-monolith-row",
313 tenant: "org_1",
314 slug: "google",
315 pluginId: "google",
316 config: {
317 specHash: "mono-hash",
318 googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
319 },
320 }),
321 );
322 yield* Effect.promise(() =>
323 insertOperationStorage(db.client, {
324 tenant: "org_1",
325 pluginId: "google",
326 integration: "google",
327 }),
328 );
329 objects.set("o:org_1/google/spec/mono-hash", "google spec");
330 objects.set("o:org_1/google/defs/mono-hash", "google defs");
331
332 const d1 = makeFakeD1(db.client);
333 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
334 "2026-06-20-google-openapi-ownership",
335 "2026-07-08-provider-service-split",
336 ]);
337 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
338
339 expect(objects.get("o:org_1/openapi/spec/mono-hash")).toBe("google spec");
340 expect(objects.get("o:org_1/openapi/defs/mono-hash")).toBe("google defs");
341
342 const integrations = yield* Effect.promise(() =>
343 db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
344 );
345 expect(integrations.rows).toEqual([{ slug: "google_calendar", plugin_id: "openapi" }]);
297 346
298 yield* Effect.promise(() => db.close());347 yield* Effect.promise(() => db.close());
299 }),348 }),
apps/host-cloudflare/src/db/data-migrations.ts+73−1
@@ -7,7 +7,13 @@ import { · 6 unmodified lines
7 type SqliteDataMigration,7 type SqliteDataMigration,
8 type SqliteDataMigrationClient,8 type SqliteDataMigrationClient,
9} from "@executor-js/sdk";9} from "@executor-js/sdk";
10import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";10import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
11
12import {
13 providerServiceSplitDataMigration,
14 runSqliteProviderServiceSplitMigration,
15 type BlobRow,
16} from "@executor-js/plugin-provider-service-split";
11 17
12const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]);18const 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
170const 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
164const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [225const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
165 {226 {
166 name: googleOpenApiOwnershipDataMigration.name,227 name: googleOpenApiOwnershipDataMigration.name,
@@ -170,6 +231,17 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite · 3 unmodified lines
170 yield* googleOpenApiOwnershipDataMigration.run(client);231 yield* googleOpenApiOwnershipDataMigration.run(client);
171 }),232 }),
172 },233 },
234 {
235 name: providerServiceSplitDataMigration.name,
236 run: (client) =>
237 Effect.gen(function* () {
238 const blobs = bucket ? yield* copyProviderServiceSplitBlobsToR2(client, bucket) : [];
239 yield* runSqliteProviderServiceSplitMigration(client, {
240 blobBackend: bucket ? "external" : "database",
241 blobs,
242 }).pipe(Effect.asVoid);
243 }),
244 },
173];245];
174 246
175export const runCloudflareDataMigrations = (247export const runCloudflareDataMigrations = (
apps/host-cloudflare/src/plugins.ts+12−5
@@ -1,6 +1,12 @@
1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2import { googleHttpPlugin } from "@executor-js/plugin-google/api";2import {
3import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";3 googleCatalog,
4 googleDiscoveryAdapter,
5} from "@executor-js/plugin-openapi/providers/google";
6import {
7 microsoftCatalog,
8 microsoftGraphAdapter,
9} from "@executor-js/plugin-openapi/providers/microsoft";
4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";10import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
5import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";11import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
6import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";12import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -22,9 +28,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines
22 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},28 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},
23) =>29) =>
24 [30 [
25 openApiHttpPlugin(),31 openApiHttpPlugin({
26 googleHttpPlugin(),32 presets: [...googleCatalog, ...microsoftCatalog],
27 microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }),33 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
34 }),
28 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),35 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
29 graphqlHttpPlugin(),36 graphqlHttpPlugin(),
30 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),37 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
apps/host-cloudflare/src/worker.e2e.node.test.ts+39−3
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; · 5 unmodified lines
6import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";6import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
7import { Schema } from "effect";7import { Schema } from "effect";
8import { unstable_dev, type Unstable_DevWorker } from "wrangler";8import { unstable_dev, type Unstable_DevWorker } from "wrangler";
9import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft";
9 10
10// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
11// End-to-end test for the Cloudflare host: boots the REAL worker on workerd via12// End-to-end test for the Cloudflare host: boots the REAL worker on workerd via
@@ -187,13 +188,48 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 175 unmodified lines
187 expect(integration?.slug).toBe(slug);188 expect(integration?.slug).toBe(slug);
188 }, 60_000);189 }, 60_000);
189 190
190 it("registers the Microsoft Graph API route in the runtime plugin set", async () => {191 it("exposes Microsoft catalog presets through the OpenAPI integration catalog", async () => {
191 const res = await worker.fetch("/api/microsoft/graph", {192 const microsoftFilesPreset = microsoftCatalog.find(
193 (preset) => preset.defaultSlug === "microsoft_files",
194 );
195 expect(microsoftFilesPreset).toBeTruthy();
196
197 const removedProviderRoute = await worker.fetch("/api/microsoft/graph", {
192 method: "POST",198 method: "POST",
193 headers: { "content-type": "application/json" },199 headers: { "content-type": "application/json" },
194 body: JSON.stringify({}),200 body: JSON.stringify({}),
195 });201 });
196 expect(res.status).toBe(400);202 expect(removedProviderRoute.status).toBe(404);
203
204 const slug = `microsoft-files-${runId}`;
205 const add = await worker.fetch("/api/openapi/specs", {
206 method: "POST",
207 headers: { "content-type": "application/json" },
208 body: JSON.stringify({
209 spec: { kind: "blob", value: SPEC },
210 slug,
211 name: microsoftFilesPreset?.name,
212 description: microsoftFilesPreset?.summary,
213 family: microsoftFilesPreset?.family,
214 authenticationTemplate: microsoftFilesPreset?.authTemplate,
215 }),
216 });
217 expect(add.status).toBe(200);
218
219 const res = await worker.fetch(`/api/integrations/${slug}`);
220 expect(res.status).toBe(200);
221 const integration = (await res.json()) as {
222 readonly slug: string;
223 readonly kind: string;
224 readonly family?: string;
225 readonly authMethods: readonly { readonly template: string; readonly kind: string }[];
226 };
227 expect(integration.slug).toBe(slug);
228 expect(integration.kind).toBe("openapi");
229 expect(integration.family).toBe("microsoft");
230 expect(integration.authMethods.some((method) => method.template === "azureAdDelegated")).toBe(
231 true,
232 );
197 });233 });
198 234
199 it("gates the API when dev-auth is on but treats the request as the dev admin", async () => {235 it("gates the API when dev-auth is on but treats the request as the dev admin", async () => {
apps/host-selfhost/executor.config.ts+13−10
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";13import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -21,18 +27,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines
21 27
22interface SelfHostPluginDeps {28interface SelfHostPluginDeps {
23 readonly activeToolkitSlug?: string;29 readonly activeToolkitSlug?: string;
24 /** Mirrors `HostConfig.allowLocalNetwork` (EXECUTOR_ALLOW_LOCAL_NETWORK):
25 * lets `microsoft.addGraph` point at a loopback emulator instead of the
26 * pinned Microsoft Graph URLs. Off by default. */
27 readonly allowLocalNetwork?: boolean;
28}30}
29 31
30export default defineExecutorConfig({32export default defineExecutorConfig({
31 plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) =>33 plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
32 [34 [
33 openApiHttpPlugin(),35 openApiHttpPlugin({
34 googleHttpPlugin(),36 presets: [...googleCatalog, ...microsoftCatalog],
35 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),37 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
38 }),
36 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),39 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
37 graphqlHttpPlugin(),40 graphqlHttpPlugin(),
38 toolkitsPlugin({ activeToolkitSlug }),41 toolkitsPlugin({ activeToolkitSlug }),
apps/host-selfhost/package.json+1−2
@@ -27,11 +27,10 @@ · 26 unmodified lines
27 "@executor-js/fumadb": "workspace:*",27 "@executor-js/fumadb": "workspace:*",
28 "@executor-js/host-mcp": "workspace:*",28 "@executor-js/host-mcp": "workspace:*",
29 "@executor-js/plugin-encrypted-secrets": "workspace:*",29 "@executor-js/plugin-encrypted-secrets": "workspace:*",
30 "@executor-js/plugin-google": "workspace:*",
31 "@executor-js/plugin-graphql": "workspace:*",30 "@executor-js/plugin-graphql": "workspace:*",
32 "@executor-js/plugin-mcp": "workspace:*",31 "@executor-js/plugin-mcp": "workspace:*",
33 "@executor-js/plugin-microsoft": "workspace:*",
34 "@executor-js/plugin-openapi": "workspace:*",32 "@executor-js/plugin-openapi": "workspace:*",
33 "@executor-js/plugin-provider-service-split": "workspace:*",
35 "@executor-js/plugin-toolkits": "workspace:*",34 "@executor-js/plugin-toolkits": "workspace:*",
36 "@executor-js/react": "workspace:*",35 "@executor-js/react": "workspace:*",
37 "@executor-js/runtime-quickjs": "workspace:*",36 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-selfhost/src/db/data-migrations.ts+3−1
@@ -12,8 +12,9 @@ import { · 11 unmodified lines
12 openApiSpecBlobDataMigration,12 openApiSpecBlobDataMigration,
13} from "@executor-js/plugin-openapi";13} from "@executor-js/plugin-openapi";
14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
15import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";15import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
16 16
17import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
17import { authConfigTransforms } from "./auth-config-migration";18import { authConfigTransforms } from "./auth-config-migration";
18 19
19export const selfHostDataMigrations: readonly SqliteDataMigration[] = [20export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
@@ -31,4 +32,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines
31 openApiSpecBlobDataMigration,32 openApiSpecBlobDataMigration,
32 graphqlIntrospectionBlobDataMigration,33 graphqlIntrospectionBlobDataMigration,
33 googleOpenApiOwnershipDataMigration,34 googleOpenApiOwnershipDataMigration,
35 providerServiceSplitDataMigration,
34];36];
apps/host-selfhost/src/execution.ts+0−4
@@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines
44 executorConfig.plugins({44 executorConfig.plugins({
45 activeToolkitSlug:45 activeToolkitSlug:
46 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,46 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47 // Read the env directly (same computation as loadConfig().allowLocalNetwork):
48 // plugins() runs per request, and loadConfig does filesystem work
49 // (data dir, secret key) that must not ride the request path.
50 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
51 }),47 }),
52 },48 },
53);49);
apps/local/executor.config.ts+12−5
@@ -1,7 +1,13 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import {
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4 googleCatalog,
5 googleDiscoveryAdapter,
6} from "@executor-js/plugin-openapi/providers/google";
7import {
8 microsoftCatalog,
9 microsoftGraphAdapter,
10} from "@executor-js/plugin-openapi/providers/microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";11import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";12import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { keychainPlugin } from "@executor-js/plugin-keychain";13import { keychainPlugin } from "@executor-js/plugin-keychain";
@@ -26,9 +32,10 @@ interface LocalPluginDeps { · 18 unmodified lines
26export default defineExecutorConfig({32export default defineExecutorConfig({
27 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>33 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
28 [34 [
29 openApiHttpPlugin(),35 openApiHttpPlugin({
30 googleHttpPlugin(),36 presets: [...googleCatalog, ...microsoftCatalog],
31 microsoftHttpPlugin(),37 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
38 }),
32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),39 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
33 graphqlHttpPlugin(),40 graphqlHttpPlugin(),
34 toolkitsPlugin({ activeToolkitSlug }),41 toolkitsPlugin({ activeToolkitSlug }),
apps/local/package.json+1−2
@@ -32,13 +32,12 @@ · 31 unmodified lines
32 "@executor-js/plugin-desktop-settings": "workspace:*",32 "@executor-js/plugin-desktop-settings": "workspace:*",
33 "@executor-js/plugin-example": "workspace:*",33 "@executor-js/plugin-example": "workspace:*",
34 "@executor-js/plugin-file-secrets": "workspace:*",34 "@executor-js/plugin-file-secrets": "workspace:*",
35 "@executor-js/plugin-google": "workspace:*",
36 "@executor-js/plugin-graphql": "workspace:*",35 "@executor-js/plugin-graphql": "workspace:*",
37 "@executor-js/plugin-keychain": "workspace:*",36 "@executor-js/plugin-keychain": "workspace:*",
38 "@executor-js/plugin-mcp": "workspace:*",37 "@executor-js/plugin-mcp": "workspace:*",
39 "@executor-js/plugin-microsoft": "workspace:*",
40 "@executor-js/plugin-onepassword": "workspace:*",38 "@executor-js/plugin-onepassword": "workspace:*",
41 "@executor-js/plugin-openapi": "workspace:*",39 "@executor-js/plugin-openapi": "workspace:*",
40 "@executor-js/plugin-provider-service-split": "workspace:*",
42 "@executor-js/plugin-toolkits": "workspace:*",41 "@executor-js/plugin-toolkits": "workspace:*",
43 "@executor-js/react": "workspace:*",42 "@executor-js/react": "workspace:*",
44 "@executor-js/runtime-quickjs": "workspace:*",43 "@executor-js/runtime-quickjs": "workspace:*",
apps/local/src/db/data-migrations.ts+3−1
@@ -17,8 +17,9 @@ import { · 16 unmodified lines
17 openApiSpecBlobDataMigration,17 openApiSpecBlobDataMigration,
18} from "@executor-js/plugin-openapi";18} from "@executor-js/plugin-openapi";
19import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";19import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
20import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";20import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
21 21
22import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
22import { authConfigTransforms } from "./auth-config-migration";23import { authConfigTransforms } from "./auth-config-migration";
23import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";24import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";
24 25
@@ -42,6 +43,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines
42 openApiSpecBlobDataMigration,43 openApiSpecBlobDataMigration,
43 graphqlIntrospectionBlobDataMigration,44 graphqlIntrospectionBlobDataMigration,
44 googleOpenApiOwnershipDataMigration,45 googleOpenApiOwnershipDataMigration,
46 providerServiceSplitDataMigration,
45 // GC dead DCR oauth_client rows (old always-register duplicates) and backfill47 // GC dead DCR oauth_client rows (old always-register duplicates) and backfill
46 // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C).48 // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C).
47 oauthClientGcSqliteMigration,49 oauthClientGcSqliteMigration,
bun.lock+65−93
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 "@effect/language-service": "^0.85.1",10 "@effect/language-service": "^0.85.1",
11 "@effect/tsgo": "^0.5.2",11 "@effect/tsgo": "^0.5.2",
12 "@effect/vitest": "catalog:",12 "@effect/vitest": "catalog:",
13 "@executor-js/plugin-provider-service-split": "workspace:*",
13 "@typescript/native-preview": "^7.0.0-dev.20260410.1",14 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
14 "@vitest/expect": "catalog:",15 "@vitest/expect": "catalog:",
15 "@vitest/mocker": "catalog:",16 "@vitest/mocker": "catalog:",
@@ -66,10 +67,8 @@ · 50 unmodified lines
66 "@executor-js/execution": "workspace:*",67 "@executor-js/execution": "workspace:*",
67 "@executor-js/fumadb": "workspace:*",68 "@executor-js/fumadb": "workspace:*",
68 "@executor-js/host-mcp": "workspace:*",69 "@executor-js/host-mcp": "workspace:*",
69 "@executor-js/plugin-google": "workspace:*",
70 "@executor-js/plugin-graphql": "workspace:*",70 "@executor-js/plugin-graphql": "workspace:*",
71 "@executor-js/plugin-mcp": "workspace:*",71 "@executor-js/plugin-mcp": "workspace:*",
72 "@executor-js/plugin-microsoft": "workspace:*",
73 "@executor-js/plugin-openapi": "workspace:*",72 "@executor-js/plugin-openapi": "workspace:*",
74 "@executor-js/plugin-toolkits": "workspace:*",73 "@executor-js/plugin-toolkits": "workspace:*",
75 "@executor-js/plugin-workos-vault": "workspace:*",74 "@executor-js/plugin-workos-vault": "workspace:*",
@@ -142,11 +141,9 @@ · 66 unmodified lines
142 "@executor-js/local": "workspace:*",141 "@executor-js/local": "workspace:*",
143 "@executor-js/plugin-desktop-settings": "workspace:*",142 "@executor-js/plugin-desktop-settings": "workspace:*",
144 "@executor-js/plugin-file-secrets": "workspace:*",143 "@executor-js/plugin-file-secrets": "workspace:*",
145 "@executor-js/plugin-google": "workspace:*",
146 "@executor-js/plugin-graphql": "workspace:*",144 "@executor-js/plugin-graphql": "workspace:*",
147 "@executor-js/plugin-keychain": "workspace:*",145 "@executor-js/plugin-keychain": "workspace:*",
148 "@executor-js/plugin-mcp": "workspace:*",146 "@executor-js/plugin-mcp": "workspace:*",
149 "@executor-js/plugin-microsoft": "workspace:*",
150 "@executor-js/plugin-onepassword": "workspace:*",147 "@executor-js/plugin-onepassword": "workspace:*",
151 "@executor-js/plugin-openapi": "workspace:*",148 "@executor-js/plugin-openapi": "workspace:*",
152 "@executor-js/runtime-quickjs": "workspace:*",149 "@executor-js/runtime-quickjs": "workspace:*",
@@ -176,11 +173,10 @@ · 23 unmodified lines
176 "@executor-js/fumadb": "workspace:*",173 "@executor-js/fumadb": "workspace:*",
177 "@executor-js/host-mcp": "workspace:*",174 "@executor-js/host-mcp": "workspace:*",
178 "@executor-js/plugin-encrypted-secrets": "workspace:*",175 "@executor-js/plugin-encrypted-secrets": "workspace:*",
179 "@executor-js/plugin-google": "workspace:*",
180 "@executor-js/plugin-graphql": "workspace:*",176 "@executor-js/plugin-graphql": "workspace:*",
181 "@executor-js/plugin-mcp": "workspace:*",177 "@executor-js/plugin-mcp": "workspace:*",
182 "@executor-js/plugin-microsoft": "workspace:*",
183 "@executor-js/plugin-openapi": "workspace:*",178 "@executor-js/plugin-openapi": "workspace:*",
179 "@executor-js/plugin-provider-service-split": "workspace:*",
184 "@executor-js/plugin-toolkits": "workspace:*",180 "@executor-js/plugin-toolkits": "workspace:*",
185 "@executor-js/react": "workspace:*",181 "@executor-js/react": "workspace:*",
186 "@executor-js/runtime-quickjs": "workspace:*",182 "@executor-js/runtime-quickjs": "workspace:*",
@@ -225,11 +221,10 @@ · 38 unmodified lines
225 "@executor-js/fumadb": "workspace:*",221 "@executor-js/fumadb": "workspace:*",
226 "@executor-js/host-mcp": "workspace:*",222 "@executor-js/host-mcp": "workspace:*",
227 "@executor-js/plugin-encrypted-secrets": "workspace:*",223 "@executor-js/plugin-encrypted-secrets": "workspace:*",
228 "@executor-js/plugin-google": "workspace:*",
229 "@executor-js/plugin-graphql": "workspace:*",224 "@executor-js/plugin-graphql": "workspace:*",
230 "@executor-js/plugin-mcp": "workspace:*",225 "@executor-js/plugin-mcp": "workspace:*",
231 "@executor-js/plugin-microsoft": "workspace:*",
232 "@executor-js/plugin-openapi": "workspace:*",226 "@executor-js/plugin-openapi": "workspace:*",
227 "@executor-js/plugin-provider-service-split": "workspace:*",
233 "@executor-js/plugin-toolkits": "workspace:*",228 "@executor-js/plugin-toolkits": "workspace:*",
234 "@executor-js/react": "workspace:*",229 "@executor-js/react": "workspace:*",
235 "@executor-js/runtime-quickjs": "workspace:*",230 "@executor-js/runtime-quickjs": "workspace:*",
@@ -276,13 +271,12 @@ · 40 unmodified lines
276 "@executor-js/plugin-desktop-settings": "workspace:*",271 "@executor-js/plugin-desktop-settings": "workspace:*",
277 "@executor-js/plugin-example": "workspace:*",272 "@executor-js/plugin-example": "workspace:*",
278 "@executor-js/plugin-file-secrets": "workspace:*",273 "@executor-js/plugin-file-secrets": "workspace:*",
279 "@executor-js/plugin-google": "workspace:*",
280 "@executor-js/plugin-graphql": "workspace:*",274 "@executor-js/plugin-graphql": "workspace:*",
281 "@executor-js/plugin-keychain": "workspace:*",275 "@executor-js/plugin-keychain": "workspace:*",
282 "@executor-js/plugin-mcp": "workspace:*",276 "@executor-js/plugin-mcp": "workspace:*",
283 "@executor-js/plugin-microsoft": "workspace:*",
284 "@executor-js/plugin-onepassword": "workspace:*",277 "@executor-js/plugin-onepassword": "workspace:*",
285 "@executor-js/plugin-openapi": "workspace:*",278 "@executor-js/plugin-openapi": "workspace:*",
279 "@executor-js/plugin-provider-service-split": "workspace:*",
286 "@executor-js/plugin-toolkits": "workspace:*",280 "@executor-js/plugin-toolkits": "workspace:*",
287 "@executor-js/react": "workspace:*",281 "@executor-js/react": "workspace:*",
288 "@executor-js/runtime-quickjs": "workspace:*",282 "@executor-js/runtime-quickjs": "workspace:*",
@@ -346,10 +340,8 @@ · 57 unmodified lines
346 "@executor-js/api": "workspace:*",340 "@executor-js/api": "workspace:*",
347 "@executor-js/emulate": "^0.13.3",341 "@executor-js/emulate": "^0.13.3",
348 "@executor-js/mcporter": "^0.11.4",342 "@executor-js/mcporter": "^0.11.4",
349 "@executor-js/plugin-google": "workspace:*",
350 "@executor-js/plugin-graphql": "workspace:*",343 "@executor-js/plugin-graphql": "workspace:*",
351 "@executor-js/plugin-mcp": "workspace:*",344 "@executor-js/plugin-mcp": "workspace:*",
352 "@executor-js/plugin-microsoft": "workspace:*",
353 "@executor-js/plugin-openapi": "workspace:*",345 "@executor-js/plugin-openapi": "workspace:*",
354 "@executor-js/plugin-toolkits": "workspace:*",346 "@executor-js/plugin-toolkits": "workspace:*",
355 "@executor-js/sdk": "workspace:*",347 "@executor-js/sdk": "workspace:*",
@@ -381,7 +373,6 @@ · 25 unmodified lines
381 "version": "0.0.47",373 "version": "0.0.47",
382 "dependencies": {374 "dependencies": {
383 "@executor-js/plugin-file-secrets": "workspace:*",375 "@executor-js/plugin-file-secrets": "workspace:*",
384 "@executor-js/plugin-google": "workspace:*",
385 "@executor-js/plugin-graphql": "workspace:*",376 "@executor-js/plugin-graphql": "workspace:*",
386 "@executor-js/plugin-keychain": "workspace:*",377 "@executor-js/plugin-keychain": "workspace:*",
387 "@executor-js/plugin-mcp": "workspace:*",378 "@executor-js/plugin-mcp": "workspace:*",
@@ -849,43 +840,6 @@ · 461 unmodified lines
849 "effect": "catalog:",840 "effect": "catalog:",
850 },841 },
851 },842 },
852 "packages/plugins/google": {
853 "name": "@executor-js/plugin-google",
854 "version": "1.5.28",
855 "dependencies": {
856 "@executor-js/plugin-openapi": "workspace:*",
857 "@executor-js/sdk": "workspace:*",
858 "lucide-react": "^1.7.0",
859 },
860 "devDependencies": {
861 "@effect/atom-react": "catalog:",
862 "@effect/vitest": "catalog:",
863 "@executor-js/api": "workspace:*",
864 "@executor-js/react": "workspace:*",
865 "@types/node": "catalog:",
866 "@types/react": "catalog:",
867 "bun-types": "catalog:",
868 "effect": "catalog:",
869 "react": "catalog:",
870 "tsup": "catalog:",
871 "vitest": "catalog:",
872 },
873 "peerDependencies": {
874 "@effect/atom-react": "catalog:",
875 "@executor-js/api": "workspace:*",
876 "@executor-js/react": "workspace:*",
877 "@tanstack/react-router": "catalog:",
878 "effect": "catalog:",
879 "react": "catalog:",
880 },
881 "optionalPeers": [
882 "@effect/atom-react",
883 "@executor-js/api",
884 "@executor-js/react",
885 "@tanstack/react-router",
886 "react",
887 ],
888 },
889 "packages/plugins/graphql": {843 "packages/plugins/graphql": {
890 "name": "@executor-js/plugin-graphql",844 "name": "@executor-js/plugin-graphql",
891 "version": "1.5.29",845 "version": "1.5.29",
@@ -984,43 +938,6 @@ · 92 unmodified lines
984 "react",938 "react",
985 ],939 ],
986 },940 },
987 "packages/plugins/microsoft": {
988 "name": "@executor-js/plugin-microsoft",
989 "version": "1.5.28",
990 "dependencies": {
991 "@executor-js/plugin-openapi": "workspace:*",
992 "@executor-js/sdk": "workspace:*",
993 "lucide-react": "^1.7.0",
994 },
995 "devDependencies": {
996 "@effect/atom-react": "catalog:",
997 "@effect/vitest": "catalog:",
998 "@executor-js/api": "workspace:*",
999 "@executor-js/react": "workspace:*",
1000 "@types/node": "catalog:",
1001 "@types/react": "catalog:",
1002 "bun-types": "catalog:",
1003 "effect": "catalog:",
1004 "react": "catalog:",
1005 "tsup": "catalog:",
1006 "vitest": "catalog:",
1007 },
1008 "peerDependencies": {
1009 "@effect/atom-react": "catalog:",
1010 "@executor-js/api": "workspace:*",
1011 "@executor-js/react": "workspace:*",
1012 "@tanstack/react-router": "catalog:",
1013 "effect": "catalog:",
1014 "react": "catalog:",
1015 },
1016 "optionalPeers": [
1017 "@effect/atom-react",
1018 "@executor-js/api",
1019 "@executor-js/react",
1020 "@tanstack/react-router",
1021 "react",
1022 ],
1023 },
1024 "packages/plugins/onepassword": {941 "packages/plugins/onepassword": {
1025 "name": "@executor-js/plugin-onepassword",942 "name": "@executor-js/plugin-onepassword",
1026 "version": "1.5.29",943 "version": "1.5.29",
@@ -1096,6 +1013,23 @@ · 69 unmodified lines
1096 "react",1013 "react",
1097 ],1014 ],
1098 },1015 },
1016 "packages/plugins/provider-service-split": {
1017 "name": "@executor-js/plugin-provider-service-split",
1018 "version": "0.0.0",
1019 "dependencies": {
1020 "@executor-js/plugin-openapi": "workspace:*",
1021 "@executor-js/sdk": "workspace:*",
1022 "effect": "catalog:",
1023 },
1024 "devDependencies": {
1025 "@effect/vitest": "catalog:",
1026 "@types/node": "catalog:",
1027 "bun-types": "catalog:",
1028 "tsup": "catalog:",
1029 "typescript": "catalog:",
1030 "vitest": "catalog:",
1031 },
1032 },
1099 "packages/plugins/toolkits": {1033 "packages/plugins/toolkits": {
1100 "name": "@executor-js/plugin-toolkits",1034 "name": "@executor-js/plugin-toolkits",
1101 "version": "1.5.21",1035 "version": "1.5.21",
@@ -1784,20 +1718,18 @@ · 682 unmodified lines
1784 1718
1785 "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"],1719 "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"],
1786 1720
1787 "@executor-js/plugin-google": ["@executor-js/plugin-google@workspace:packages/plugins/google"],
1788
1789 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],1721 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],
1790 1722
1791 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],1723 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],
1792 1724
1793 "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"],1725 "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"],
1794 1726
1795 "@executor-js/plugin-microsoft": ["@executor-js/plugin-microsoft@workspace:packages/plugins/microsoft"],
1796
1797 "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"],1727 "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"],
1798 1728
1799 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],1729 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
1800 1730
1731 "@executor-js/plugin-provider-service-split": ["@executor-js/plugin-provider-service-split@workspace:packages/plugins/provider-service-split"],
1732
1801 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],1733 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
1802 1734
1803 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],1735 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
@@ -3172,6 +3104,46 @@ · 1368 unmodified lines
3172 3104
3173 "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z+rclKC7FqkUsqQ+ErgWJmf5J55LEl/rooFq71prC6V0vCBa5yLMmLBmFxZLLj2BsCBuwbN2O7aWUWrpCUEkmw=="],3105 "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z+rclKC7FqkUsqQ+ErgWJmf5J55LEl/rooFq71prC6V0vCBa5yLMmLBmFxZLLj2BsCBuwbN2O7aWUWrpCUEkmw=="],
3174 3106
3107 "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
3108
3109 "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
3110
3111 "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
3112
3113 "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
3114
3115 "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
3116
3117 "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
3118
3119 "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
3120
3121 "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
3122
3123 "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
3124
3125 "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
3126
3127 "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
3128
3129 "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
3130
3131 "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
3132
3133 "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
3134
3135 "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
3136
3137 "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
3138
3139 "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
3140
3141 "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
3142
3143 "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
3144
3145 "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
3146
3175 "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="],3147 "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="],
3176 3148
3177 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],3149 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -5920,9 +5892,9 @@ · 2742 unmodified lines
5920 5892
5921 "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],5893 "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
5922 5894
5923 "@executor-js/example-all-plugins/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],5895 "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
5924 5896
5925 "@executor-js/example-promise-sdk/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],5897 "@executor-js/example-promise-sdk/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
5926 5898
5927 "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],5899 "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
5928 5900
e2e/package.json+0−2
@@ -24,10 +24,8 @@ · 23 unmodified lines
24 "@executor-js/api": "workspace:*",24 "@executor-js/api": "workspace:*",
25 "@executor-js/emulate": "^0.13.3",25 "@executor-js/emulate": "^0.13.3",
26 "@executor-js/mcporter": "^0.11.4",26 "@executor-js/mcporter": "^0.11.4",
27 "@executor-js/plugin-google": "workspace:*",
28 "@executor-js/plugin-graphql": "workspace:*",27 "@executor-js/plugin-graphql": "workspace:*",
29 "@executor-js/plugin-mcp": "workspace:*",28 "@executor-js/plugin-mcp": "workspace:*",
30 "@executor-js/plugin-microsoft": "workspace:*",
31 "@executor-js/plugin-openapi": "workspace:*",29 "@executor-js/plugin-openapi": "workspace:*",
32 "@executor-js/plugin-toolkits": "workspace:*",30 "@executor-js/plugin-toolkits": "workspace:*",
33 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
e2e/scenarios/google-health-checks.test.ts+351−0
@@ -0,0 +1,351 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import type { HttpApiClient } from "effect/unstable/httpapi";
6import { composePluginApi } from "@executor-js/api/server";
7import { connectEmulator, type EmulatorClient } from "@executor-js/emulate";
8import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9import {
10 AuthTemplateSlug,
11 ConnectionName,
12 IntegrationSlug,
13 OAuthClientSlug,
14} from "@executor-js/sdk/shared";
15
16import { scenario } from "../src/scenario";
17import { Api, Browser, Target } from "../src/services";
18import type { Identity, Target as TargetShape } from "../src/target";
19import type { BrowserSurface } from "../src/surfaces/browser";
20
21const api = composePluginApi([openApiHttpPlugin()] as const);
22type Client = HttpApiClient.ForApi<typeof api>;
23const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
24const CONNECTION = ConnectionName.make("main");
25const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";
26
27const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
28
29const decodeHtml = (value: string): string =>
30 value
31 .replaceAll("&amp;", "&")
32 .replaceAll("&quot;", '"')
33 .replaceAll("&#x27;", "'")
34 .replaceAll("&lt;", "<")
35 .replaceAll("&gt;", ">");
36
37const 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
48const formAction = (html: string, fallback: string): string => {
49 const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1];
50 return action ? decodeHtml(action) : fallback;
51};
52
53const 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
72const 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
82const 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
111const 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
172scenario(
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
278scenario(
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);
e2e/scenarios/google-photos-preset-ui.test.ts+22−21
@@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6 6
7scenario(7scenario(
8 "Google Photos: the focused preset opens a Photos-scoped add flow",8 "Google Photos: separated catalog presets open a Photos service add flow",
9 {},9 {},
10 Effect.gen(function* () {10 Effect.gen(function* () {
11 const target = yield* Target;11 const target = yield* Target;
@@ -13,30 +13,31 @@ scenario( · 1 unmodified line
13 const identity = yield* target.newIdentity();13 const identity = yield* target.newIdentity();
14 14
15 yield* browser.session(identity, async ({ page, step }) => {15 yield* browser.session(identity, async ({ page, step }) => {
16 await step("Find the Google Photos preset from the integrations picker", async () => {16 await step(
17 await page.goto("/integrations", { waitUntil: "networkidle" });17 "Find the separated Google Photos presets from the integrations picker",
18 await page.getByRole("button", { name: "Connect" }).click();18 async () => {
19 const dialog = page.getByRole("dialog", { name: "Connect an integration" });19 await page.goto("/integrations", { waitUntil: "networkidle" });
20 await dialog.waitFor();20 await page.getByRole("button", { name: "Connect" }).click();
21 await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");21 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
22 await dialog.getByRole("link", { name: /^Google Photos\b/ }).waitFor();22 await dialog.waitFor();
23 });23 await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");
24 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor();
25 await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor();
26 },
27 );
24 28
25 await step("Open the Google Photos scoped add flow", async () => {29 await step("Open the Google Photos Library add flow", async () => {
26 const dialog = page.getByRole("dialog", { name: "Connect an integration" });30 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
27 await dialog.getByRole("link", { name: /^Google Photos\b/ }).click();31 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click();
28 await page.waitForURL(/\/integrations\/add\/google/);32 await page.waitForURL(/\/integrations\/add\/openapi/);
29 await page.getByRole("heading", { name: "Add Google integration" }).waitFor();33 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
30 });34 });
31 35
32 await step("The Photos preset defaults to the focused namespace and products", async () => {36 await step("The Library link carries the focused OpenAPI preset", async () => {
33 await page.locator('input[value="Google Photos"]').waitFor();37 const url = new URL(page.url());
34 await page.locator('input[value="google_photos"]').waitFor();38 expect(url.searchParams.get("preset")).toBe("google-photos-library");
35 await page.getByText("Google Photos Library").first().waitFor();39 expect(url.searchParams.get("url")).toContain("photoslibrary");
36 await page.getByText("Google Photos Picker").first().waitFor();40 await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary");
37 await page.getByText("2 Google APIs").waitFor();
38 expect(await page.locator('input[value="Google"]').count()).toBe(0);
39 expect(await page.locator('input[value="google"]').count()).toBe(0);
40 });41 });
41 });42 });
42 }),43 }),
e2e/scenarios/microsoft-emulator.test.ts+24−21
@@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines
5import { Effect } from "effect";5import { Effect } from "effect";
6import { composePluginApi } from "@executor-js/api/server";6import { composePluginApi } from "@executor-js/api/server";
7import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";7import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";
8import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";8import {
9import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";9 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
10 microsoftCatalog,
11 microsoftGraphAdapter,
12} from "@executor-js/plugin-openapi/providers/microsoft";
13import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
10import {14import {
11 AuthTemplateSlug,15 AuthTemplateSlug,
12 ConnectionName,16 ConnectionName,
@@ -17,7 +21,9 @@ import { · 4 unmodified lines
17import { scenario } from "../src/scenario";21import { scenario } from "../src/scenario";
18import { Api, Target } from "../src/services";22import { Api, Target } from "../src/services";
19 23
20const api = composePluginApi([microsoftHttpPlugin()] as const);24const api = composePluginApi([
25 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
26] as const);
21 27
22type OAuthTemplateView = {28type OAuthTemplateView = {
23 readonly slug: string;29 readonly slug: string;
@@ -80,18 +86,7 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul · 56 unmodified lines
80 86
81scenario(87scenario(
82 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",88 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",
83 {89 { timeout: 180_000 },
84 // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the
85 // canonical Graph spec in the streamable block-YAML profile — it structurally
86 // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB
87 // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on
88 // anything else. The @executor-js/emulate Microsoft emulator serves a small
89 // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix
90 // needs the emulator to serve a block-YAML-profile Graph spec (or a
91 // non-Workers compile path); tracked separately.
92 skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile",
93 timeout: 180_000,
94 },
95 Effect.scoped(90 Effect.scoped(
96 Effect.gen(function* () {91 Effect.gen(function* () {
97 const target = yield* Target;92 const target = yield* Target;
@@ -110,18 +105,26 @@ scenario( · 12 unmodified lines
110 105
111 yield* Effect.ensuring(106 yield* Effect.ensuring(
112 Effect.gen(function* () {107 Effect.gen(function* () {
113 yield* client.microsoft.addGraph({108 yield* client.openapi.addSpec({
114 payload: {109 payload: {
115 presetIds: ["users"],110 spec: { kind: "url", url: emulator.openapiUrl },
116 customScopes: [],
117 slug: integration,111 slug: integration,
118 name: "Microsoft Graph Emulator",112 name: "Microsoft Graph Emulator",
119 baseUrl: emulator.url,113 baseUrl: emulator.url,
120 specUrl: emulator.openapiUrl,114 family: "microsoft",
115 authenticationTemplate: [
116 {
117 slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
118 kind: "oauth2",
119 authorizationUrl: oauth.authorizationUrl,
120 tokenUrl: oauth.tokenUrl,
121 scopes: ["https://graph.microsoft.com/.default"],
122 },
123 ],
121 },124 },
122 });125 });
123 126
124 const config = yield* client.microsoft.getConfig({127 const config = yield* client.openapi.getConfig({
125 params: { slug: integration },128 params: { slug: integration },
126 });129 });
127 const appOnlyTemplate = config?.authenticationTemplate?.find(130 const appOnlyTemplate = config?.authenticationTemplate?.find(
@@ -213,7 +216,7 @@ scenario( · 85 unmodified lines
213 params: { slug: oauthClient },216 params: { slug: oauthClient },
214 payload: { owner: "org" },217 payload: { owner: "org" },
215 });218 });
216 yield* client.microsoft.removeGraph({ params: { slug: integration } });219 yield* client.openapi.removeSpec({ params: { slug: integration } });
217 }).pipe(Effect.ignore),220 }).pipe(Effect.ignore),
218 );221 );
219 }),222 }),
e2e/scenarios/microsoft-graph-default.test.ts+44−58
@@ -6,10 +6,12 @@ import { composePluginApi } from "@executor-js/api/server"; · 5 unmodified lines
6import {6import {
7 MICROSOFT_AUTH_TEMPLATE_SLUG,7 MICROSOFT_AUTH_TEMPLATE_SLUG,
8 MICROSOFT_AUTHORIZATION_URL,8 MICROSOFT_AUTHORIZATION_URL,
9 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,9 MICROSOFT_GRAPH_OPENAPI_URL,
10 MICROSOFT_TOKEN_URL,10 MICROSOFT_TOKEN_URL,
11} from "@executor-js/plugin-microsoft";11 microsoftCatalog,
12import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";12 microsoftGraphAdapter,
13} from "@executor-js/plugin-openapi/providers/microsoft";
14import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import {15import {
14 AuthTemplateSlug,16 AuthTemplateSlug,
15 ConnectionName,17 ConnectionName,
@@ -20,24 +22,26 @@ import { · 4 unmodified lines
20import { scenario } from "../src/scenario";22import { scenario } from "../src/scenario";
21import { Api, Target } from "../src/services";23import { Api, Target } from "../src/services";
22 24
23const api = composePluginApi([microsoftHttpPlugin()] as const);25const api = composePluginApi([
26 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
27] as const);
24 28
25type ToolView = {29type ToolView = {
26 readonly name: string;30 readonly name: string;
27};31};
28 32
29const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;33const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
34const MICROSOFT_FILES_PRESET_ID = "files";
30 35
31// Compiling the ~37MB Graph spec inside dev workerd needs more headroom than36// Compiling the Graph spec inside dev workerd needs more headroom than GitHub's
32// GitHub's 2-core runners have: /api/microsoft/graph 500s and the dev stack is37// 2-core runners have. Local runs and the production Workers streaming path are
33// dead for every scenario after it in the shard. Local runs (and the38// unaffected, so CI keeps this narrow catalog scenario quarantined.
34// production Workers streaming path) are unaffected — CI-only quarantine.
35const CI_GRAPH_SPEC_SKIP = process.env.CI39const CI_GRAPH_SPEC_SKIP = process.env.CI
36 ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard"40 ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard"
37 : undefined;41 : undefined;
38 42
39scenario(43scenario(
40 "Microsoft Graph: default add stores common Microsoft 365 workloads",44 "Microsoft Graph: catalog add stores the OneDrive files service",
41 { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP },45 { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP },
42 Effect.gen(function* () {46 Effect.gen(function* () {
43 const target = yield* Target;47 const target = yield* Target;
@@ -45,59 +49,41 @@ scenario( · 1 unmodified line
45 const identity = yield* target.newIdentity();49 const identity = yield* target.newIdentity();
46 const client = yield* makeApiClient(api, identity);50 const client = yield* makeApiClient(api, identity);
47 51
48 const integration = unique("msgraph_default");52 const integration = unique("msgraph_files");
49 const connection = ConnectionName.make("main");53 const connection = ConnectionName.make("main");
50 const oauthClient = OAuthClientSlug.make(unique("msgraph_app"));54 const oauthClient = OAuthClientSlug.make(unique("msgraph_app"));
51 55
52 yield* Effect.ensuring(56 yield* Effect.ensuring(
53 Effect.gen(function* () {57 Effect.gen(function* () {
54 const added = yield* client.microsoft.addGraph({58 const added = yield* client.openapi.addSpec({
55 payload: {59 payload: {
56 presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS],60 spec: {
57 customScopes: [],61 kind: "url",
62 url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`,
63 },
58 slug: integration,64 slug: integration,
59 name: "Microsoft Graph Defaults",65 name: "Microsoft Graph Files",
66 family: "microsoft",
67 specFormat: "microsoft-graph",
60 },68 },
61 });69 });
62 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);70 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);
63 expect(71 expect(
64 added.toolCount,72 added.toolCount,
65 "the default Microsoft Graph add extracts common user-facing operations",73 "the Microsoft files catalog preset extracts a focused operation subtree",
66 ).toBeGreaterThan(100);74 ).toBeGreaterThan(10);
67 75
68 const config = yield* client.microsoft.getConfig({76 const config = yield* client.openapi.getConfig({
69 params: { slug: integration },77 params: { slug: integration },
70 });78 });
71 expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([79 const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
72 ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,80 template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
73 ]);81 ? [...template.scopes]
74 expect(82 : [],
75 config?.microsoftGraphCoversFullGraph,83 );
76 "the default selection is not full Graph",84 expect(delegatedScopes, "the files delegated OAuth asks for file scopes").toContain(
77 ).toBe(false);
78 expect(
79 config?.microsoftGraphScopes,
80 "default delegated OAuth requests the selected common workload scopes",
81 ).toEqual([
82 "offline_access",
83 "User.Read",
84 "Mail.ReadWrite",
85 "Mail.Send",
86 "MailboxSettings.ReadWrite",
87 "Calendars.ReadWrite",
88 "Contacts.ReadWrite",
89 "People.Read.All",
90 "Tasks.ReadWrite",
91 "Files.ReadWrite.All",85 "Files.ReadWrite.All",
92 "Sites.ReadWrite.All",86 );
93 "Notes.ReadWrite",
94 "Chat.ReadWrite",
95 "Team.ReadBasic.All",
96 "Channel.ReadBasic.All",
97 "ChannelMessage.Read.All",
98 "ChannelMessage.Send",
99 "OnlineMeetings.ReadWrite",
100 ]);
101 87
102 yield* client.oauth.createClient({88 yield* client.oauth.createClient({
103 payload: {89 payload: {
@@ -128,12 +114,12 @@ scenario( · 24 unmodified lines
128 const authorizeUrl = new URL(authorizationUrl || "https://invalid.example");114 const authorizeUrl = new URL(authorizationUrl || "https://invalid.example");
129 expect(115 expect(
130 authorizeUrl.toString().length,116 authorizeUrl.toString().length,
131 "default Microsoft Graph OAuth authorize URLs stay under ordinary proxy limits",117 "Microsoft files OAuth authorize URLs stay under ordinary proxy limits",
132 ).toBeLessThan(2_000);118 ).toBeLessThan(2_000);
133 expect(119 expect(
134 authorizeUrl.searchParams.get("scope"),120 authorizeUrl.searchParams.get("scope"),
135 "default Microsoft Graph OAuth asks for common workload scopes",121 "Microsoft files OAuth asks for the focused scope set",
136 ).toBe(config?.microsoftGraphScopes?.join(" "));122 ).toBe(delegatedScopes?.join(" "));
137 123
138 yield* client.connections.create({124 yield* client.connections.create({
139 payload: {125 payload: {
@@ -149,15 +135,15 @@ scenario( · 9 unmodified lines
149 query: { integration: IntegrationSlug.make(integration), connection },135 query: { integration: IntegrationSlug.make(integration), connection },
150 });136 });
151 const names = tools.map((tool: ToolView) => tool.name);137 const names = tools.map((tool: ToolView) => tool.name);
152 const messageTools = names.filter((name) => name.toLowerCase().includes("message"));138 const driveTools = names.filter((name) => name.toLowerCase().includes("drive"));
153 const siteTools = names.filter((name) => name.toLowerCase().includes("site"));139 const shareTools = names.filter((name) => name.toLowerCase().includes("share"));
154 expect(140 expect(driveTools, "the retrieved catalog includes Microsoft drive operations").not.toEqual(
155 messageTools,
156 "the retrieved catalog includes Microsoft message operations",
157 ).not.toEqual([]);
158 expect(siteTools, "the retrieved catalog includes SharePoint site operations").not.toEqual(
159 [],141 [],
160 );142 );
143 expect(
144 shareTools,
145 "the retrieved catalog includes Microsoft sharing operations",
146 ).not.toEqual([]);
161 }),147 }),
162 Effect.gen(function* () {148 Effect.gen(function* () {
163 yield* client.connections149 yield* client.connections
@@ -169,8 +155,8 @@ scenario( · 5 unmodified lines
169 },155 },
170 })156 })
171 .pipe(Effect.ignore);157 .pipe(Effect.ignore);
172 yield* client.microsoft158 yield* client.openapi
173 .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })159 .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
174 .pipe(Effect.ignore);160 .pipe(Effect.ignore);
175 yield* client.oauth161 yield* client.oauth
176 .removeClient({162 .removeClient({
e2e/scenarios/microsoft-graph-full.test.ts+54−48
@@ -5,34 +5,40 @@ import { Effect } from "effect"; · 4 unmodified lines
5import { composePluginApi } from "@executor-js/api/server";5import { composePluginApi } from "@executor-js/api/server";
6import {6import {
7 MICROSOFT_AUTH_TEMPLATE_SLUG,7 MICROSOFT_AUTH_TEMPLATE_SLUG,
8 MICROSOFT_GRAPH_ALL_PRESET_IDS,8 MICROSOFT_GRAPH_OPENAPI_URL,
9 MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,9 microsoftCatalog,
10} from "@executor-js/plugin-microsoft";10 microsoftGraphAdapter,
11import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";11} from "@executor-js/plugin-openapi/providers/microsoft";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";13import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
13 14
14import { scenario } from "../src/scenario";15import { scenario } from "../src/scenario";
15import { Api, Target } from "../src/services";16import { Api, Target } from "../src/services";
16 17
17const api = composePluginApi([microsoftHttpPlugin()] as const);18const api = composePluginApi([
19 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
20] as const);
18 21
19type ToolView = {22type ToolView = {
20 readonly name: string;23 readonly name: string;
21};24};
22 25
23const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;26const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
27const MICROSOFT_FILES_PRESET_ID = "files";
28const 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 document35// Adding a catalog service extracts only that service's Microsoft Graph subtree
26// (~37MB, ~16.5k operations) and persists a binding per operation. That whole-36// and persists a binding per operation. This is the regression guard for both
27// document path used to 503 on the Cloudflare worker: parsing the spec, and37// former worker pressure sites: the add streams compile and persist, and
28// then re-parsing it on every tools/list, each rebuilt a ~300MB JS tree that38// tools/list serves from persisted bindings plus the content-addressed defs blob
29// blew the 128MB isolate. This scenario is the regression guard for both sites39// without re-parsing the Graph spec.
30// at real scale: the add streams the compile + persist, and tools/list serves
31// the catalog back from the persisted bindings (+ the content-addressed defs
32// blob) without ever re-parsing the spec. It drives only the public API, so a
33// green run is evidence the full catalog lands and serves end to end.
34scenario(40scenario(
35 "Microsoft Graph: the full catalog adds and serves without re-parsing the spec",41 "Microsoft Graph: the files catalog service adds and serves without re-parsing the spec",
36 { timeout: 300_000 },42 { timeout: 300_000 },
37 Effect.gen(function* () {43 Effect.gen(function* () {
38 const target = yield* Target;44 const target = yield* Target;
@@ -40,39 +46,41 @@ scenario( · 1 unmodified line
40 const identity = yield* target.newIdentity();46 const identity = yield* target.newIdentity();
41 const client = yield* makeApiClient(api, identity);47 const client = yield* makeApiClient(api, identity);
42 48
43 const integration = unique("msgraph_full");49 const integration = unique("msgraph_files");
44 const connection = ConnectionName.make("main");50 const connection = ConnectionName.make("main");
45 51
46 yield* Effect.ensuring(52 yield* Effect.ensuring(
47 Effect.gen(function* () {53 Effect.gen(function* () {
48 // Add path (1st former OOM site): the full spec is fetched and54 // Add path, first former OOM site: the Graph spec is fetched and
49 // stream-compiled into one persisted binding per operation.55 // stream-compiled into one persisted binding per selected operation.
50 const added = yield* client.microsoft.addGraph({56 const added = yield* client.openapi.addSpec({
51 payload: {57 payload: {
52 presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],58 spec: {
53 customScopes: [],59 kind: "url",
60 url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`,
61 },
54 slug: integration,62 slug: integration,
55 name: "Microsoft Graph (full)",63 name: "Microsoft Graph Files",
64 family: "microsoft",
65 specFormat: "microsoft-graph",
56 },66 },
57 });67 });
58 expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration);68 expect(added.slug, "the Microsoft files source keeps the requested slug").toBe(integration);
59 expect(69 expect(
60 added.toolCount,70 added.toolCount,
61 "adding every Graph workload extracts the whole catalog (thousands of operations)",71 "adding the files catalog service extracts a focused Graph operation subtree",
62 ).toBeGreaterThan(5_000);72 ).toBeGreaterThan(10);
63 73
64 const config = yield* client.microsoft.getConfig({ params: { slug: integration } });74 const config = yield* client.openapi.getConfig({ params: { slug: integration } });
65 expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual(75 const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
66 [...MICROSOFT_GRAPH_ALL_PRESET_IDS],76 template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
77 ? [...template.scopes]
78 : [],
67 );79 );
68 expect(80 expect(
69 config?.microsoftGraphCoversFullGraph,81 delegatedScopes,
70 "selecting every workload is recognized as full Graph",82 "the files service delegates only the file-service scope set",
71 ).toBe(true);83 ).toEqual([...MICROSOFT_FILES_DELEGATED_SCOPES]);
72 expect(
73 config?.microsoftGraphScopes,
74 "full Graph delegates the app-registration default scope set",
75 ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]);
76 84
77 yield* client.connections.create({85 yield* client.connections.create({
78 payload: {86 payload: {
@@ -84,24 +92,22 @@ scenario( · 5 unmodified lines
84 },92 },
85 });93 });
86 94
87 // Serve path (2nd former OOM site): tools/list rebuilds the catalog from95 // Serve path, second former OOM site: tools/list rebuilds the catalog
88 // the persisted bindings. The whole catalog must come back, with real96 // from persisted bindings, with real descriptions, and without
89 // descriptions, and without re-parsing the 37MB spec.97 // re-parsing the Graph spec.
90 const tools = yield* client.tools.list({98 const tools = yield* client.tools.list({
91 query: { integration: IntegrationSlug.make(integration), connection },99 query: { integration: IntegrationSlug.make(integration), connection },
92 });100 });
93 expect(101 expect(
94 tools.length,102 tools.length,
95 "the served catalog returns the whole set of operations, not a re-parse failure",103 "the served catalog returns the files operation subtree, not a re-parse failure",
96 ).toBeGreaterThan(5_000);104 ).toBeGreaterThan(10);
97 105
98 const names = tools.map((tool: ToolView) => tool.name);106 const names = tools.map((tool: ToolView) => tool.name);
99 const messageTools = names.filter((name) => name.toLowerCase().includes("message"));107 const driveTools = names.filter((name) => name.toLowerCase().includes("drive"));
100 const siteTools = names.filter((name) => name.toLowerCase().includes("site"));108 const shareTools = names.filter((name) => name.toLowerCase().includes("share"));
101 const userTools = names.filter((name) => name.toLowerCase().includes("user"));109 expect(driveTools, "the served catalog spans drive operations").not.toEqual([]);
102 expect(messageTools, "the served catalog spans mail operations").not.toEqual([]);110 expect(shareTools, "the served catalog spans sharing operations").not.toEqual([]);
103 expect(siteTools, "the served catalog spans SharePoint site operations").not.toEqual([]);
104 expect(userTools, "the served catalog spans directory user operations").not.toEqual([]);
105 }),111 }),
106 Effect.gen(function* () {112 Effect.gen(function* () {
107 yield* client.connections113 yield* client.connections
@@ -113,8 +119,8 @@ scenario( · 5 unmodified lines
113 },119 },
114 })120 })
115 .pipe(Effect.ignore);121 .pipe(Effect.ignore);
116 yield* client.microsoft122 yield* client.openapi
117 .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })123 .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
118 .pipe(Effect.ignore);124 .pipe(Effect.ignore);
119 }),125 }),
120 );126 );
e2e/scenarios/oauth-client-handoff.test.ts+24−21
@@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines
25import { AccountHttpApi } from "@executor-js/api";25import { AccountHttpApi } from "@executor-js/api";
26import { composePluginApi } from "@executor-js/api/server";26import { composePluginApi } from "@executor-js/api/server";
27import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";27import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";28import {
29import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";29 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
30 microsoftCatalog,
31 microsoftGraphAdapter,
32} from "@executor-js/plugin-openapi/providers/microsoft";
33import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
30import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";34import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
31 35
32import { scenario } from "../src/scenario";36import { scenario } from "../src/scenario";
33import { Api, Browser, Mcp, Target } from "../src/services";37import { Api, Browser, Mcp, Target } from "../src/services";
34import type { McpSession } from "../src/surfaces/mcp";38import type { McpSession } from "../src/surfaces/mcp";
35 39
36const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);40const microsoftApi = composePluginApi([
41 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
42] as const);
37 43
38const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;44const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
39 45
@@ -310,18 +316,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 270 unmodified lines
310 316
311scenario(317scenario(
312 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",318 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313 {319 { timeout: 240_000 },
314 // Blocked (pre-existing, not this PR): this scenario drives the handoff
315 // through `microsoft.addGraph`, which only accepts the canonical Graph spec
316 // in the streamable block-YAML profile (structural split to avoid OOMing the
317 // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/
318 // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec
319 // outside that profile, so addGraph hard-errors. The other two OAuth-client
320 // scenarios in this file (createHandoff, approval-gating) do not touch Graph
321 // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately.
322 skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve",
323 timeout: 240_000,
324 },
325 Effect.gen(function* () {320 Effect.gen(function* () {
326 const target = yield* Target;321 const target = yield* Target;
327 const { client: makeApiClient } = yield* Api;322 const { client: makeApiClient } = yield* Api;
@@ -356,14 +351,22 @@ scenario( · 28 unmodified lines
356 Effect.gen(function* () {351 Effect.gen(function* () {
357 // Register the Microsoft Graph integration so the console has an OAuth352 // Register the Microsoft Graph integration so the console has an OAuth
358 // method to register a client against.353 // method to register a client against.
359 yield* client.microsoft.addGraph({354 yield* client.openapi.addSpec({
360 payload: {355 payload: {
361 presetIds: ["users"],356 spec: { kind: "url", url: emulator.openapiUrl },
362 customScopes: [],
363 slug: integration,357 slug: integration,
364 name: "Microsoft Graph Emulator",358 name: "Microsoft Graph Emulator",
365 baseUrl: emulator.baseUrl,359 baseUrl: emulator.baseUrl,
366 specUrl: emulator.openapiUrl,360 family: "microsoft",
361 authenticationTemplate: [
362 {
363 slug: template,
364 kind: "oauth2",
365 authorizationUrl: oauth.authorizationUrl,
366 tokenUrl: oauth.tokenUrl,
367 scopes: ["https://graph.microsoft.com/.default"],
368 },
369 ],
367 },370 },
368 });371 });
369 372
@@ -422,7 +425,7 @@ scenario( · 52 unmodified lines
422 });425 });
423 426
424 await step("Register the app", async () => {427 await step("Register the app", async () => {
425 await page.getByRole("button", { name: "Register app" }).click();428 await page.getByRole("button", { name: "Register app", exact: true }).click();
426 // onCreated returns to the Add-connection view — the register form closes.429 // onCreated returns to the Add-connection view — the register form closes.
427 await page430 await page
428 .getByRole("heading", { name: "Register OAuth app" })431 .getByRole("heading", { name: "Register OAuth app" })
@@ -482,7 +485,7 @@ scenario( · 53 unmodified lines
482 payload: { owner: "org" },485 payload: { owner: "org" },
483 })486 })
484 .pipe(Effect.ignore);487 .pipe(Effect.ignore);
485 yield* client.microsoft.removeGraph({ params: { slug: integration } }).pipe(Effect.ignore);488 yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
486 }).pipe(Effect.ignore),489 }).pipe(Effect.ignore),
487 );490 );
488 }),491 }),
e2e/scenarios/provider-plugins-ui.test.ts+29−39
@@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6 6
7scenario(7scenario(
8 "Provider plugins · Google and Microsoft own their add flows outside OpenAPI",8 "Provider catalog · Google and Microsoft services are OpenAPI presets",
9 {},9 {},
10 Effect.gen(function* () {10 Effect.gen(function* () {
11 const target = yield* Target;11 const target = yield* Target;
@@ -19,56 +19,46 @@ scenario( · 7 unmodified lines
19 await page.getByRole("dialog", { name: "Connect an integration" }).waitFor();19 await page.getByRole("dialog", { name: "Connect an integration" }).waitFor();
20 });20 });
21 21
22 await step(22 await step("The picker exposes OpenAPI plus provider service presets", async () => {
23 "The provider list exposes OpenAPI, Google, and Microsoft separately",23 const dialog = page.getByRole("dialog", {
24 async () => {24 name: "Connect an integration",
25 const dialog = page.getByRole("dialog", {25 });
26 name: "Connect an integration",26 const search = dialog.getByPlaceholder(/Search or paste a URL/);
27 });27 await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
28 await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();28
29 await dialog.getByRole("link", { name: "Google", exact: true }).waitFor();29 await search.fill("gmail");
30 await dialog.getByRole("link", { name: "Microsoft", exact: true }).waitFor();30 await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor();
31 },31
32 );32 await search.fill("onedrive");
33 await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor();
34 });
33 35
34 await step("OpenAPI add remains generic", async () => {36 await step("OpenAPI add remains generic", async () => {
35 await page.goto("/integrations/add/openapi", {37 await page.goto("/integrations/add/openapi", {
36 waitUntil: "domcontentloaded",38 waitUntil: "domcontentloaded",
37 });39 });
38 await page.getByRole("heading", { name: "Add OpenAPI Integration" }).waitFor();40 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
39 await page.getByText("OpenAPI Spec").waitFor();41 await page.getByText("OpenAPI Spec").waitFor();
40 expect(await page.getByText("Customize your Google connection").count()).toBe(0);42 expect(await page.getByText("Customize your Google connection").count()).toBe(0);
41 expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);43 expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);
42 });44 });
43 45
44 await step("Google has its own product picker", async () => {46 await step("A Google service preset opens the OpenAPI add flow", async () => {
45 await page.goto("/integrations/add/google", {47 await page.goto(
46 waitUntil: "domcontentloaded",48 "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest",
47 });49 { waitUntil: "domcontentloaded" },
48 await page.getByRole("heading", { name: "Add Google integration" }).waitFor();50 );
49 await page.getByText("Customize your Google connection").waitFor();51 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
50 await page.getByText("Gmail").first().waitFor();52 await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail");
51 await page.getByText("Google Calendar").first().waitFor();
52 });53 });
53 54
54 await step("Microsoft has its own Graph scope picker", async () => {55 await step("A Microsoft service preset opens the OpenAPI add flow", async () => {
55 await page.goto("/integrations/add/microsoft", {56 await page.goto(
56 waitUntil: "domcontentloaded",57 "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoftgraph%2Fmsgraph-metadata%2Fmaster%2Fopenapi%2Fv1.0%2Fopenapi.yaml%23preset%3Dfiles",
57 });58 { waitUntil: "domcontentloaded" },
58 await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor();59 );
59 await page.getByText("Customize Microsoft Graph").waitFor();60 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
60 expect(await page.getByText("All Microsoft Graph", { exact: true }).count()).toBe(0);61 await expect.poll(() => page.locator("textarea").inputValue()).toContain("preset=files");
61 await page.getByText("Productivity").waitFor();
62 await page.getByText("Directory and identity").waitFor();
63 await page.getByText("Outlook Mail").waitFor();
64 await page.getByText("OneDrive Files").waitFor();
65 await page.getByText("OneNote", { exact: true }).waitFor();
66 await page.getByText("Teams Channels").waitFor();
67 await page.locator('img[src*="svgl.app/library/microsoft-outlook.svg"]').first().waitFor();
68 await page.locator('img[src*="svgl.app/library/microsoft-onedrive.svg"]').waitFor();
69 await page.getByRole("button", { name: /View scopes/ }).click();
70 await page.getByText("offline_access").waitFor();
71 await page.getByText("Mail.ReadWrite").waitFor();
72 });62 });
73 });63 });
74 }),64 }),
examples/all-plugins/package.json+0−1
@@ -10,7 +10,6 @@ · 9 unmodified lines
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@executor-js/plugin-file-secrets": "workspace:*",12 "@executor-js/plugin-file-secrets": "workspace:*",
13 "@executor-js/plugin-google": "workspace:*",
14 "@executor-js/plugin-graphql": "workspace:*",13 "@executor-js/plugin-graphql": "workspace:*",
15 "@executor-js/plugin-keychain": "workspace:*",14 "@executor-js/plugin-keychain": "workspace:*",
16 "@executor-js/plugin-mcp": "workspace:*",15 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/src/main.ts+13−4
@@ -33,12 +33,19 @@ import { · 32 unmodified lines
33} from "@executor-js/sdk";33} from "@executor-js/sdk";
34 34
35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36import { googlePlugin } from "@executor-js/plugin-google";36import {
37 googleCatalog,
38 googleDiscoveryAdapter,
39} from "@executor-js/plugin-openapi/providers/google";
37import { graphqlPlugin } from "@executor-js/plugin-graphql";40import { graphqlPlugin } from "@executor-js/plugin-graphql";
38import { keychainPlugin } from "@executor-js/plugin-keychain";41import { keychainPlugin } from "@executor-js/plugin-keychain";
39import { mcpPlugin } from "@executor-js/plugin-mcp";42import { mcpPlugin } from "@executor-js/plugin-mcp";
40import { onepasswordPlugin } from "@executor-js/plugin-onepassword";43import { onepasswordPlugin } from "@executor-js/plugin-onepassword";
41import { openApiPlugin, variable } from "@executor-js/plugin-openapi";44import { openApiPlugin, variable } from "@executor-js/plugin-openapi";
45import {
46 microsoftCatalog,
47 microsoftGraphAdapter,
48} from "@executor-js/plugin-openapi/providers/microsoft";
42import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";49import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
43 50
44// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
@@ -78,10 +85,12 @@ const plugins = [ · 33 unmodified lines
78 // Integration plugins — these declare their own schemas (tables), register85 // Integration plugins — these declare their own schemas (tables), register
79 // integrations via their extension methods (`addSpec` / `addServer` /86 // integrations via their extension methods (`addSpec` / `addServer` /
80 // `addIntegration`), and produce tools per connection.87 // `addIntegration`), and produce tools per connection.
81 googlePlugin(),
82 graphqlPlugin(),88 graphqlPlugin(),
83 mcpPlugin({ dangerouslyAllowStdioMCP: false }),89 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
84 openApiPlugin(),90 openApiPlugin({
91 presets: [...googleCatalog, ...microsoftCatalog],
92 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
93 }),
85 94
86 // workos-vault is a cloud-hosted credential provider. It would contribute a95 // workos-vault is a cloud-hosted credential provider. It would contribute a
87 // "workos-vault" provider if credentials were available. We skip it here96 // "workos-vault" provider if credentials were available. We skip it here
@@ -418,7 +427,7 @@ const program = Effect.gen(function* () { · 330 unmodified lines
418 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);427 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
419 428
420 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });429 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });
421 // executor.google.addBundle({ urls: ["..."], slug: "google" });430 // Google catalog rows are added through executor.openapi.addSpec with specFormat "google-discovery".
422 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });431 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
423 432
424 // -------------------------------------------------------------------------433 // -------------------------------------------------------------------------
knip.config.ts+0−3
@@ -41,9 +41,6 @@ const config: KnipConfig = { · 40 unmodified lines
41 "packages/plugins/file-secrets": {41 "packages/plugins/file-secrets": {
42 includeEntryExports: true,42 includeEntryExports: true,
43 },43 },
44 "packages/plugins/google": {
45 includeEntryExports: true,
46 },
47 "packages/plugins/onepassword": {44 "packages/plugins/onepassword": {
48 includeEntryExports: true,45 includeEntryExports: true,
49 ignoreDependencies: ["@executor-js/react"],46 ignoreDependencies: ["@executor-js/react"],
package.json+1−0
@@ -75,6 +75,7 @@ · 74 unmodified lines
75 "@effect/language-service": "^0.85.1",75 "@effect/language-service": "^0.85.1",
76 "@effect/tsgo": "^0.5.2",76 "@effect/tsgo": "^0.5.2",
77 "@effect/vitest": "catalog:",77 "@effect/vitest": "catalog:",
78 "@executor-js/plugin-provider-service-split": "workspace:*",
78 "@typescript/native-preview": "^7.0.0-dev.20260410.1",79 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
79 "@vitest/expect": "catalog:",80 "@vitest/expect": "catalog:",
80 "@vitest/mocker": "catalog:",81 "@vitest/mocker": "catalog:",
packages/core/api/src/connections/api.ts+1−0
@@ -58,6 +58,7 @@ const ConnectionResponse = Schema.Struct({ · 57 unmodified lines
58 oauthClient: Schema.NullOr(OAuthClientSlug),58 oauthClient: Schema.NullOr(OAuthClientSlug),
59 oauthClientOwner: Schema.NullOr(Owner),59 oauthClientOwner: Schema.NullOr(Owner),
60 oauthScope: Schema.NullOr(Schema.String),60 oauthScope: Schema.NullOr(Schema.String),
61 missingOAuthScopes: Schema.Array(Schema.String),
61 // Last persisted health-check verdict (written by every checkHealth run),62 // Last persisted health-check verdict (written by every checkHealth run),
62 // so the list can show alive/expired at a glance without probing.63 // so the list can show alive/expired at a glance without probing.
63 lastHealth: Schema.NullOr(HealthCheckResult),64 lastHealth: Schema.NullOr(HealthCheckResult),
packages/core/api/src/handlers/connections.ts+1−0
@@ -28,6 +28,7 @@ const toResponse = (c: Connection) => ({ · 27 unmodified lines
28 oauthClient: c.oauthClient ?? null,28 oauthClient: c.oauthClient ?? null,
29 oauthClientOwner: c.oauthClientOwner ?? null,29 oauthClientOwner: c.oauthClientOwner ?? null,
30 oauthScope: c.oauthScope ?? null,30 oauthScope: c.oauthScope ?? null,
31 missingOAuthScopes: c.missingOAuthScopes ?? [],
31 lastHealth: c.lastHealth ?? null,32 lastHealth: c.lastHealth ?? null,
32});33});
33 34
packages/core/api/src/handlers/integrations.ts+1−0
@@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines
15 canRefresh: i.canRefresh,15 canRefresh: i.canRefresh,
16 authMethods: i.authMethods,16 authMethods: i.authMethods,
17 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),17 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),
18 ...(i.family ? { family: i.family } : {}),
18});19});
19 20
20export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) =>21export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) =>
packages/core/api/src/handlers/oauth.ts+1−0
@@ -45,6 +45,7 @@ const connectionToResponse = (c: Connection) => ({ · 44 unmodified lines
45 oauthClient: c.oauthClient ?? null,45 oauthClient: c.oauthClient ?? null,
46 oauthClientOwner: c.oauthClientOwner ?? null,46 oauthClientOwner: c.oauthClientOwner ?? null,
47 oauthScope: c.oauthScope ?? null,47 oauthScope: c.oauthScope ?? null,
48 missingOAuthScopes: c.missingOAuthScopes ?? [],
48});49});
49 50
50const startResultToResponse = (result: ConnectResult) =>51const startResultToResponse = (result: ConnectResult) =>
packages/core/api/src/integrations/api.ts+2−0
@@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines
81 authMethods: Schema.Array(AuthMethodDescriptorSchema),81 authMethods: Schema.Array(AuthMethodDescriptorSchema),
82 /** Non-secret URL derived from opaque integration config for favicons. */82 /** Non-secret URL derived from opaque integration config for favicons. */
83 displayUrl: Schema.optional(Schema.String),83 displayUrl: Schema.optional(Schema.String),
84 /** Catalog family derived from opaque integration config for grouped display. */
85 family: Schema.optional(Schema.String),
84});86});
85 87
86const UpdateIntegrationPayload = Schema.Struct({88const UpdateIntegrationPayload = Schema.Struct({
packages/core/api/src/oauth/api.ts+1−0
@@ -50,6 +50,7 @@ const ConnectionResponse = Schema.Struct({ · 49 unmodified lines
50 oauthClient: Schema.NullOr(OAuthClientSlug),50 oauthClient: Schema.NullOr(OAuthClientSlug),
51 oauthClientOwner: Schema.NullOr(Owner),51 oauthClientOwner: Schema.NullOr(Owner),
52 oauthScope: Schema.NullOr(Schema.String),52 oauthScope: Schema.NullOr(Schema.String),
53 missingOAuthScopes: Schema.Array(Schema.String),
53});54});
54 55
55// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
packages/core/sdk/src/client.ts+26−0
@@ -22,6 +22,7 @@ import { HttpApi } from "effect/unstable/httpapi"; · 21 unmodified lines
22import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";22import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
23import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";23import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
24import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";24import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
25import type { HealthCheckSpec } from "./health-check";
25 26
26// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
27// Re-exports — the curated set of primitives a plugin author needs to28// Re-exports — the curated set of primitives a plugin author needs to
@@ -112,8 +113,33 @@ export interface IntegrationPreset { · 84 unmodified lines
112 readonly icon?: string;113 readonly icon?: string;
113 /** Shown in the top-level grid on the integrations page when true. */114 /** Shown in the top-level grid on the integrations page when true. */
114 readonly featured?: boolean;115 readonly featured?: boolean;
116 readonly family?: string;
117 readonly specFormat?: string;
118 readonly defaultSlug?: string;
119 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
120 readonly healthCheck?: HealthCheckSpec;
115}121}
116 122
123export 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
117export interface IntegrationAccountHandoff {143export interface IntegrationAccountHandoff {
118 /** Changes on each handoff URL, so the accounts UI can open once per link. */144 /** Changes on each handoff URL, so the accounts UI can open once per link. */
119 readonly key: string;145 readonly key: string;
packages/core/sdk/src/connection.ts+3−0
@@ -54,6 +54,9 @@ export interface Connection { · 53 unmodified lines
54 * declared scopes to decide whether this connection must reconnect to grant54 * declared scopes to decide whether this connection must reconnect to grant
55 * newly-needed access. Null for static creds / when the AS omitted `scope`. */55 * newly-needed access. Null for static creds / when the AS omitted `scope`. */
56 readonly oauthScope?: string | null;56 readonly oauthScope?: string | null;
57 /** Requested authorization-code scopes the provider did not grant, excluding
58 * identity/refresh scopes. Informational only: connect still succeeds. */
59 readonly missingOAuthScopes?: readonly string[];
57 /** Last health-check verdict, persisted by every `checkHealth` run. Answers60 /** Last health-check verdict, persisted by every `checkHealth` run. Answers
58 * "has this expired?" at a glance in the connections list without probing.61 * "has this expired?" at a glance in the connections list without probing.
59 * Null/absent = never checked. */62 * Null/absent = never checked. */
packages/core/sdk/src/connections.test.ts+23−0
@@ -618,6 +618,29 @@ describe("tool catalog sync safety", () => { · 617 unmodified lines
618 );618 );
619});619});
620 620
621describe("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
621describe("execute over a connection", () => {644describe("execute over a connection", () => {
622 it.effect("resolves the credential value and hands it to invokeTool", () =>645 it.effect("resolves the credential value and hands it to invokeTool", () =>
623 Effect.gen(function* () {646 Effect.gen(function* () {
packages/core/sdk/src/executor.ts+74−11
@@ -86,6 +86,7 @@ import type { · 85 unmodified lines
86 AuthMethodDescriptor,86 AuthMethodDescriptor,
87 Integration,87 Integration,
88 IntegrationConfig,88 IntegrationConfig,
89 IntegrationDisplayDescriptor,
89 RegisterIntegrationInput,90 RegisterIntegrationInput,
90} from "./integration";91} from "./integration";
91import {92import {
@@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines
522const rowToIntegration = (523const rowToIntegration = (
523 row: IntegrationRow,524 row: IntegrationRow,
524 authMethods: readonly AuthMethodDescriptor[] = [],525 authMethods: readonly AuthMethodDescriptor[] = [],
525 displayUrl?: string,526 display?: IntegrationDisplayDescriptor,
526): Integration => ({527): Integration => ({
527 slug: IntegrationSlug.make(row.slug),528 slug: IntegrationSlug.make(row.slug),
528 // Pre-split rows have no `name`; their description WAS the display name.529 // Pre-split rows have no `name`; their description WAS the display name.
@@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines
534 canRemove: Boolean(row.can_remove),535 canRemove: Boolean(row.can_remove),
535 canRefresh: Boolean(row.can_refresh),536 canRefresh: Boolean(row.can_refresh),
536 authMethods,537 authMethods,
537 ...(displayUrl ? { displayUrl } : {}),538 ...(display?.url ? { displayUrl: display.url } : {}),
539 ...(display?.family ? { family: display.family } : {}),
538});540});
539 541
540const rowToIntegrationRecord = (542const rowToIntegrationRecord = (
@@ -548,6 +550,15 @@ const rowToIntegrationRecord = ( · 7 unmodified lines
548const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult);550const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult);
549const decodeHealthCheckSpec = Schema.decodeUnknownOption(HealthCheckSpec);551const decodeHealthCheckSpec = Schema.decodeUnknownOption(HealthCheckSpec);
550 552
553const 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
551const rowToConnection = (row: ConnectionRow): Connection => {562const rowToConnection = (row: ConnectionRow): Connection => {
552 const owner = row.owner as Owner;563 const owner = row.owner as Owner;
553 const integration = IntegrationSlug.make(row.integration);564 const integration = IntegrationSlug.make(row.integration);
@@ -566,6 +577,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 12 unmodified lines
566 oauthClientOwner:577 oauthClientOwner:
567 row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner),578 row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner),
568 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),579 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
580 missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state),
569 lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),581 lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
570 };582 };
571};583};
@@ -1759,17 +1771,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1187 unmodified lines
1759 }1771 }
1760 };1772 };
1761 1773
1762 const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => {1774 const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => {
1763 const runtime = runtimes.get(row.plugin_id);1775 const runtime = runtimes.get(row.plugin_id);
1764 const describe = runtime?.plugin.describeIntegrationDisplay;1776 const describe = runtime?.plugin.describeIntegrationDisplay;
1765 if (!describe) return undefined;1777 if (!describe) return {};
1766 const record = rowToIntegrationRecord(row);1778 const record = rowToIntegrationRecord(row);
1767 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read1779 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read
1768 try {1780 try {
1769 const display = describe(record);1781 const display = describe(record);
1770 return display.url && display.url.length > 0 ? display.url : undefined;1782 return {
1783 ...(display.url && display.url.length > 0 ? { url: display.url } : {}),
1784 ...(display.family && display.family.length > 0 ? { family: display.family } : {}),
1785 };
1771 } catch {1786 } catch {
1772 return undefined;1787 return {};
1773 }1788 }
1774 };1789 };
1775 1790
@@ -1801,7 +1816,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 25 unmodified lines
1801 const rows = yield* core.findMany("integration", {});1816 const rows = yield* core.findMany("integration", {});
1802 const staticIntegrations = staticSources().map(staticSourceToIntegration);1817 const staticIntegrations = staticSources().map(staticSourceToIntegration);
1803 const dbIntegrations = rows.map((row) =>1818 const dbIntegrations = rows.map((row) =>
1804 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),1819 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)),
1805 );1820 );
1806 // A scoped toolkit must not advertise providers it grants no tools from1821 // A scoped toolkit must not advertise providers it grants no tools from
1807 // (mirrors `connectionsList`). Static sources are system namespaces, not1822 // (mirrors `connectionsList`). Static sources are system namespaces, not
@@ -1828,7 +1843,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
1828 if (staticSource) return staticSourceToIntegration(staticSource);1843 if (staticSource) return staticSourceToIntegration(staticSource);
1829 const row = yield* findIntegrationRow(slug);1844 const row = yield* findIntegrationRow(slug);
1830 return row1845 return row
1831 ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row))1846 ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row))
1832 : null;1847 : null;
1833 });1848 });
1834 1849
@@ -2442,6 +2457,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 607 unmodified lines
2442 expires_at: input.expiresAt,2457 expires_at: input.expiresAt,
2443 oauth_scope: input.oauthScope,2458 oauth_scope: input.oauthScope,
2444 oauth_token_url: input.oauthTokenUrl ?? null,2459 oauth_token_url: input.oauthTokenUrl ?? null,
2460 provider_state:
2461 input.missingOAuthScopes && input.missingOAuthScopes.length > 0
2462 ? { missingOAuthScopes: input.missingOAuthScopes }
2463 : null,
2445 updated_at: now,2464 updated_at: now,
2446 };2465 };
2447 if (existing) {2466 if (existing) {
@@ -2474,7 +2493,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
2474 expires_at: input.expiresAt,2493 expires_at: input.expiresAt,
2475 oauth_scope: input.oauthScope,2494 oauth_scope: input.oauthScope,
2476 oauth_token_url: input.oauthTokenUrl ?? null,2495 oauth_token_url: input.oauthTokenUrl ?? null,
2477 provider_state: null,2496 provider_state:
2497 input.missingOAuthScopes && input.missingOAuthScopes.length > 0
2498 ? { missingOAuthScopes: input.missingOAuthScopes }
2499 : null,
2478 created_at: now,2500 created_at: now,
2479 updated_at: now,2501 updated_at: now,
2480 });2502 });
@@ -2508,7 +2530,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 27 unmodified lines
2508 expires_at: input.expiresAt,2530 expires_at: input.expiresAt,
2509 oauth_scope: input.oauthScope,2531 oauth_scope: input.oauthScope,
2510 oauth_token_url: input.oauthTokenUrl ?? null,2532 oauth_token_url: input.oauthTokenUrl ?? null,
2511 provider_state: null,2533 provider_state:
2534 input.missingOAuthScopes && input.missingOAuthScopes.length > 0
2535 ? { missingOAuthScopes: input.missingOAuthScopes }
2536 : null,
2512 created_at: now,2537 created_at: now,
2513 updated_at: now,2538 updated_at: now,
2514 } as ConnectionRow);2539 } as ConnectionRow);
@@ -2683,6 +2708,39 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 168 unmodified lines
2683 }),2708 }),
2684 );2709 );
2685 2710
2711 const healthFromCredentialResolutionFailure = (
2712 failure: CredentialResolutionError,
2713 ): HealthCheckResult =>
2714 failure.reauthRequired === true
2715 ? {
2716 status: "expired",
2717 checkedAt: Date.now(),
2718 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
2719 detail: failure.message,
2720 }
2721 : {
2722 status: "degraded",
2723 checkedAt: Date.now(),
2724 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
2725 detail: failure.message,
2726 };
2727
2728 // Genuine storage failures propagate: an infra blip must fail the request,
2729 // not persist as a "degraded" verdict on the connection.
2730 const oauthCredentialHealthWithoutProbe = (
2731 row: ConnectionRow,
2732 ): Effect.Effect<HealthCheckResult, StorageFailure> =>
2733 resolveConnectionValues(row).pipe(
2734 Effect.as({
2735 status: "healthy" as const,
2736 checkedAt: Date.now(),
2737 detail: "Credential resolved (no probe configured).",
2738 }),
2739 Effect.catchTag("CredentialResolutionError", (failure) =>
2740 Effect.succeed(healthFromCredentialResolutionFailure(failure)),
2741 ),
2742 );
2743
2686 // Resolve an in-flight credential's value map (key-first validation) without2744 // Resolve an in-flight credential's value map (key-first validation) without
2687 // saving anything. Mirrors `resolveConnectionValues` for the saved-row path:2745 // saving anything. Mirrors `resolveConnectionValues` for the saved-row path:
2688 // pasted `value`/`values` are used directly; `from` origins resolve through2746 // pasted `value`/`values` are used directly; `from` origins resolve through
@@ -2742,6 +2800,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 53 unmodified lines
2742 const runtime = runtimes.get(integrationRow.plugin_id);2800 const runtime = runtimes.get(integrationRow.plugin_id);
2743 const check = runtime?.plugin.checkHealth;2801 const check = runtime?.plugin.checkHealth;
2744 if (!runtime || !check) return unknownHealth();2802 if (!runtime || !check) return unknownHealth();
2803 const spec = describeHealthCheckForRow(integrationRow) ?? undefined;
2804 if (spec === undefined && connectionRow.oauth_client != null) {
2805 const result = yield* oauthCredentialHealthWithoutProbe(connectionRow);
2806 yield* persistHealthResult(ref, result);
2807 return result;
2808 }
2745 2809
2746 const result = yield* Effect.gen(function* () {2810 const result = yield* Effect.gen(function* () {
2747 const values = yield* resolveConnectionValues(connectionRow);2811 const values = yield* resolveConnectionValues(connectionRow);
@@ -2760,7 +2824,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines
2760 };2824 };
2761 // Core resolves the declared spec (its own column) and hands it to the2825 // Core resolves the declared spec (its own column) and hands it to the
2762 // plugin; plugins no longer read it out of their config.2826 // plugin; plugins no longer read it out of their config.
2763 const spec = describeHealthCheckForRow(integrationRow) ?? undefined;
2764 return yield* foldPluginFailure(2827 return yield* foldPluginFailure(
2765 check({ ctx: runtime.ctx, integration: record, credential, spec }),2828 check({ ctx: runtime.ctx, integration: record, credential, spec }),
2766 `Health check for connection "${ref.name}" failed.`,2829 `Health check for connection "${ref.name}" failed.`,
packages/core/sdk/src/index.ts+1−0
@@ -316,6 +316,7 @@ export { · 315 unmodified lines
316 type IntegrationConfigureDecl,316 type IntegrationConfigureDecl,
317 type IntegrationConfigureSchema,317 type IntegrationConfigureSchema,
318 type IntegrationPreset,318 type IntegrationPreset,
319 type IntegrationPresetAuthentication,
319 type IntegrationPresetCatalogEntry,320 type IntegrationPresetCatalogEntry,
320 type ResolveToolsInput,321 type ResolveToolsInput,
321 type ResolveToolsResult,322 type ResolveToolsResult,
packages/core/sdk/src/integration.ts+4−0
@@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines
22export interface IntegrationDisplayDescriptor {22export interface IntegrationDisplayDescriptor {
23 /** Non-secret URL suitable for display metadata such as favicons. */23 /** Non-secret URL suitable for display metadata such as favicons. */
24 readonly url?: string;24 readonly url?: string;
25 /** Catalog family derived from plugin config for grouped display. */
26 readonly family?: string;
25}27}
26 28
27/** Where a credential value is carried. `header`/`query` place it on an29/** Where a credential value is carried. `header`/`query` place it on an
@@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines
100 /** Non-secret display URL derived by the owning plugin from opaque config.102 /** Non-secret display URL derived by the owning plugin from opaque config.
101 * Used for catalog favicons; never includes credentials or plugin config. */103 * Used for catalog favicons; never includes credentials or plugin config. */
102 readonly displayUrl?: string;104 readonly displayUrl?: string;
105 /** Catalog family derived by the owning plugin from opaque config. */
106 readonly family?: string;
103}107}
104 108
105/** Plugin-owned, opaque-to-core configuration stored on the integration row. The109/** Plugin-owned, opaque-to-core configuration stored on the integration row. The
packages/core/sdk/src/oauth-flow.test.ts+354−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12} from "./ids";12} from "./ids";
13import { decodeOAuthCallbackState } from "./oauth";13import { decodeOAuthCallbackState } from "./oauth";
14import { OAuthStartError } from "./oauth-client";14import { OAuthStartError } from "./oauth-client";
15import { missingGrantedOAuthScopes } from "./oauth-service";
15import { definePlugin } from "./plugin";16import { definePlugin } from "./plugin";
16import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";17import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
17import { serveOAuthTestServer } from "./testing/oauth-test-server";18import { serveOAuthTestServer } from "./testing/oauth-test-server";
@@ -294,6 +295,153 @@ describe("oauth.start / oauth.complete", () => { · 276 unmodified lines
294 ),295 ),
295 );296 );
296 297
298 it.effect("persists connection identity from id_token claims on authorization-code grant", () =>
299 Effect.scoped(
300 Effect.gen(function* () {
301 const server = yield* serveOAuthTestServer({
302 scopes: ["openid", "email", "profile", "read"],
303 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
304 });
305 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
306 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
307
308 yield* executor.oauth.createClient({
309 owner: "org",
310 slug: CLIENT,
311 authorizationUrl: server.authorizationEndpoint,
312 tokenUrl: server.tokenEndpoint,
313 grant: "authorization_code",
314 clientId: "test-client",
315 clientSecret: "test-secret",
316 });
317
318 const started = yield* executor.oauth.start({
319 owner: "org",
320 client: CLIENT,
321 clientOwner: "org",
322 name: ConnectionName.make("main"),
323 integration: INTEG,
324 template: TEMPLATE,
325 });
326 expect(started.status).toBe("redirect");
327 if (started.status !== "redirect") return;
328
329 const callback = yield* server.completeAuthorizationCodeFlow({
330 authorizationUrl: started.authorizationUrl,
331 });
332 const connection = yield* executor.oauth.complete({
333 state: started.state,
334 code: callback.code,
335 });
336
337 expect(connection.identityLabel).toBe("alice@example.com");
338 }),
339 ),
340 );
341
342 it.effect("keeps an explicit OAuth session identity label over id_token claims", () =>
343 Effect.scoped(
344 Effect.gen(function* () {
345 const server = yield* serveOAuthTestServer({
346 scopes: ["openid", "email", "profile", "read"],
347 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
348 });
349 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
350 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
351
352 yield* executor.oauth.createClient({
353 owner: "org",
354 slug: CLIENT,
355 authorizationUrl: server.authorizationEndpoint,
356 tokenUrl: server.tokenEndpoint,
357 grant: "authorization_code",
358 clientId: "test-client",
359 clientSecret: "test-secret",
360 });
361
362 const started = yield* executor.oauth.start({
363 owner: "org",
364 client: CLIENT,
365 clientOwner: "org",
366 name: ConnectionName.make("main"),
367 integration: INTEG,
368 template: TEMPLATE,
369 identityLabel: "Work account",
370 });
371 expect(started.status).toBe("redirect");
372 if (started.status !== "redirect") return;
373
374 const callback = yield* server.completeAuthorizationCodeFlow({
375 authorizationUrl: started.authorizationUrl,
376 });
377 const connection = yield* executor.oauth.complete({
378 state: started.state,
379 code: callback.code,
380 });
381
382 expect(connection.identityLabel).toBe("Work account");
383 }),
384 ),
385 );
386
387 it.effect("does not overwrite connection identity from id_token claims on refresh", () =>
388 Effect.scoped(
389 Effect.gen(function* () {
390 const server = yield* serveOAuthTestServer({
391 scopes: ["openid", "email", "profile", "read"],
392 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
393 refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" },
394 });
395 const harness = yield* makeTestWorkspaceHarness({ plugins });
396 const { executor, config } = harness;
397 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
398
399 yield* executor.oauth.createClient({
400 owner: "org",
401 slug: CLIENT,
402 authorizationUrl: server.authorizationEndpoint,
403 tokenUrl: server.tokenEndpoint,
404 grant: "authorization_code",
405 clientId: "test-client",
406 clientSecret: "test-secret",
407 });
408
409 const started = yield* executor.oauth.start({
410 owner: "org",
411 client: CLIENT,
412 clientOwner: "org",
413 name: ConnectionName.make("main"),
414 integration: INTEG,
415 template: TEMPLATE,
416 });
417 expect(started.status).toBe("redirect");
418 if (started.status !== "redirect") return;
419 const callback = yield* server.completeAuthorizationCodeFlow({
420 authorizationUrl: started.authorizationUrl,
421 });
422 yield* executor.oauth.complete({
423 state: started.state,
424 code: callback.code,
425 });
426
427 yield* Effect.promise(() =>
428 config.db.updateMany("connection", {
429 where: (b) => b("name", "=", "main"),
430 set: { expires_at: Date.now() - 60_000 },
431 }),
432 );
433
434 yield* executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {});
435 const refreshed = yield* executor.connections.get({
436 owner: "org",
437 integration: INTEG,
438 name: ConnectionName.make("main"),
439 });
440 expect(refreshed?.identityLabel).toBe("alice@example.com");
441 }),
442 ),
443 );
444
297 it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () =>445 it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () =>
298 Effect.scoped(446 Effect.scoped(
299 Effect.gen(function* () {447 Effect.gen(function* () {
@@ -702,6 +850,170 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 402 unmodified lines
702 }),850 }),
703 ),851 ),
704 );852 );
853
854 it.effect(
855 "checkHealth reports healthy from OAuth credential resolution when no probe is configured",
856 () =>
857 Effect.scoped(
858 Effect.gen(function* () {
859 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
860 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
861 yield* executor.acme.seed();
862
863 yield* executor.oauth.createClient({
864 owner: "org",
865 slug: CLIENT,
866 authorizationUrl: server.authorizationEndpoint,
867 tokenUrl: server.tokenEndpoint,
868 grant: "authorization_code",
869 clientId: "test-client",
870 clientSecret: "test-secret",
871 resource: server.mcpResourceUrl,
872 });
873
874 const started = yield* executor.oauth.start({
875 owner: "org",
876 client: CLIENT,
877 clientOwner: "org",
878 name: ConnectionName.make("main"),
879 integration: INTEG,
880 template: TEMPLATE,
881 });
882 expect(started.status).toBe("redirect");
883 if (started.status !== "redirect") return;
884 const callback = yield* server.completeAuthorizationCodeFlow({
885 authorizationUrl: started.authorizationUrl,
886 });
887 yield* executor.oauth.complete({
888 state: started.state,
889 code: callback.code,
890 });
891 yield* server.clearRequests;
892
893 const result = yield* executor.connections.checkHealth({
894 owner: "org",
895 integration: INTEG,
896 name: ConnectionName.make("main"),
897 });
898
899 expect(result).toMatchObject({
900 status: "healthy",
901 detail: "Credential resolved (no probe configured).",
902 });
903 expect(result.identity).toBeUndefined();
904 const requests = yield* server.requests;
905 expect(requests).toEqual([]);
906 }),
907 ),
908 );
909
910 it.effect("records missing authorization-code scopes without blocking the connection", () =>
911 Effect.scoped(
912 Effect.gen(function* () {
913 const server = yield* serveOAuthTestServer({
914 scopes: ["openid", "email", "profile", "offline_access", "read", "write"],
915 omitTokenResponseScopes: ["email", "profile", "write"],
916 });
917 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
918 yield* executor.acme.seed([
919 "openid",
920 "email",
921 "profile",
922 "offline_access",
923 "read",
924 "write",
925 ]);
926
927 yield* executor.oauth.createClient({
928 owner: "org",
929 slug: CLIENT,
930 authorizationUrl: server.authorizationEndpoint,
931 tokenUrl: server.tokenEndpoint,
932 grant: "authorization_code",
933 clientId: "test-client",
934 clientSecret: "test-secret",
935 resource: server.mcpResourceUrl,
936 });
937
938 const started = yield* executor.oauth.start({
939 owner: "org",
940 client: CLIENT,
941 clientOwner: "org",
942 name: ConnectionName.make("main"),
943 integration: INTEG,
944 template: TEMPLATE,
945 });
946 expect(started.status).toBe("redirect");
947 if (started.status !== "redirect") return;
948 const callback = yield* server.completeAuthorizationCodeFlow({
949 authorizationUrl: started.authorizationUrl,
950 });
951 const connection = yield* executor.oauth.complete({
952 state: started.state,
953 code: callback.code,
954 });
955
956 expect(connection.missingOAuthScopes).toEqual(["write"]);
957 const row = yield* Effect.promise(() =>
958 config.db.findFirst("connection", {
959 where: (b) => b("name", "=", "main"),
960 }),
961 );
962 expect(row?.provider_state).toEqual({ missingOAuthScopes: ["write"] });
963 const listed = yield* executor.connections.list({ integration: INTEG });
964 expect(listed[0]?.missingOAuthScopes).toEqual(["write"]);
965 }),
966 ),
967 );
968
969 it.effect(
970 "does not persist missing scopes when the authorization-code grant covers requested scopes",
971 () =>
972 Effect.scoped(
973 Effect.gen(function* () {
974 const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });
975 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
976 yield* executor.acme.seed(["read", "write"]);
977
978 yield* executor.oauth.createClient({
979 owner: "org",
980 slug: CLIENT,
981 authorizationUrl: server.authorizationEndpoint,
982 tokenUrl: server.tokenEndpoint,
983 grant: "authorization_code",
984 clientId: "test-client",
985 clientSecret: "test-secret",
986 resource: server.mcpResourceUrl,
987 });
988
989 const started = yield* executor.oauth.start({
990 owner: "org",
991 client: CLIENT,
992 clientOwner: "org",
993 name: ConnectionName.make("main"),
994 integration: INTEG,
995 template: TEMPLATE,
996 });
997 expect(started.status).toBe("redirect");
998 if (started.status !== "redirect") return;
999 const callback = yield* server.completeAuthorizationCodeFlow({
1000 authorizationUrl: started.authorizationUrl,
1001 });
1002 const connection = yield* executor.oauth.complete({
1003 state: started.state,
1004 code: callback.code,
1005 });
1006
1007 expect(connection.missingOAuthScopes).toEqual([]);
1008 const row = yield* Effect.promise(() =>
1009 config.db.findFirst("connection", {
1010 where: (b) => b("name", "=", "main"),
1011 }),
1012 );
1013 expect(row?.provider_state).toBeNull();
1014 }),
1015 ),
1016 );
705});1017});
706 1018
707// Multi-site providers (Datadog) statically advertise one region's token1019// 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
1182describe("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});
packages/core/sdk/src/oauth-helpers.test.ts+77−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 createPkceCodeVerifier,19 createPkceCodeVerifier,
20 exchangeAuthorizationCode,20 exchangeAuthorizationCode,
21 exchangeClientCredentials,21 exchangeClientCredentials,
22 idTokenIdentityLabel,
22 refreshAccessToken,23 refreshAccessToken,
23 shouldRefreshToken,24 shouldRefreshToken,
24} from "./oauth-helpers";25} from "./oauth-helpers";
@@ -362,6 +363,81 @@ describe("exchangeAuthorizationCode", () => { · 337 unmodified lines
362 });363 });
363 expect(result.access_token).toBe("tok");364 expect(result.access_token).toBe("tok");
364 expect(result.refresh_token).toBe("rtok");365 expect(result.refresh_token).toBe("rtok");
366 expect(result.idTokenIdentityLabel).toBe("user-1");
367 }),
368 ),
369 );
370
371 it.effect("extracts id_token email as the identity label", () =>
372 withTokenEndpoint(
373 tokenResponse({
374 ...validCodeBody,
375 id_token: unsignedJwt({
376 email: "alice@example.com",
377 preferred_username: "alice",
378 sub: "user-1",
379 }),
380 }),
381 ({ tokenUrl }) =>
382 Effect.gen(function* () {
383 const result = yield* exchangeAuthorizationCode({
384 tokenUrl,
385 clientId: "cid",
386 redirectUrl: "https://app.example.com/cb",
387 codeVerifier: "verifier",
388 code: "abc",
389 });
390 expect(result.idTokenIdentityLabel).toBe("alice@example.com");
391 }),
392 ),
393 );
394
395 it.effect("falls back from id_token email to preferred_username then sub", () =>
396 withTokenEndpoint(
397 tokenResponse({
398 ...validCodeBody,
399 id_token: unsignedJwt({
400 preferred_username: "alice",
401 sub: "user-1",
402 }),
403 }),
404 ({ tokenUrl }) =>
405 Effect.gen(function* () {
406 const preferred = yield* exchangeAuthorizationCode({
407 tokenUrl,
408 clientId: "cid",
409 redirectUrl: "https://app.example.com/cb",
410 codeVerifier: "verifier",
411 code: "abc",
412 });
413 expect(preferred.idTokenIdentityLabel).toBe("alice");
414 }),
415 ),
416 );
417
418 it("falls back to sub and ignores malformed id_tokens", () => {
419 expect(idTokenIdentityLabel(unsignedJwt({ sub: "user-1" }))).toBe("user-1");
420 expect(idTokenIdentityLabel("not-a-jwt")).toBeUndefined();
421 expect(idTokenIdentityLabel(undefined)).toBeUndefined();
422 });
423
424 it.effect("ignores malformed id_tokens without failing the exchange", () =>
425 withTokenEndpoint(
426 tokenResponse({
427 ...validCodeBody,
428 id_token: "not-a-jwt",
429 }),
430 ({ tokenUrl }) =>
431 Effect.gen(function* () {
432 const result = yield* exchangeAuthorizationCode({
433 tokenUrl,
434 clientId: "cid",
435 redirectUrl: "https://app.example.com/cb",
436 codeVerifier: "verifier",
437 code: "abc",
438 });
439 expect(result.access_token).toBe("tok");
440 expect(result.idTokenIdentityLabel).toBeUndefined();
365 }),441 }),
366 ),442 ),
367 );443 );
@@ -406,6 +482,7 @@ describe("exchangeAuthorizationCode", () => { · 38 unmodified lines
406 expect(result.access_token).toBe("tok");482 expect(result.access_token).toBe("tok");
407 expect(result.refresh_token).toBe("rtok");483 expect(result.refresh_token).toBe("rtok");
408 expect(result.expires_in).toBe(3600);484 expect(result.expires_in).toBe(3600);
485 expect(result.idTokenIdentityLabel).toBeUndefined();
409 }),486 }),
410 ),487 ),
411 );488 );
packages/core/sdk/src/oauth-helpers.ts+72−18
@@ -16,7 +16,7 @@ · 15 unmodified lines
16// construction keeps the call sync and lets callers opt out of PAR16// construction keeps the call sync and lets callers opt out of PAR
17// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
18 18
19import { Data, Effect, Predicate } from "effect";19import { Data, Effect, Option, Predicate, Schema } from "effect";
20import * as oauth from "oauth4webapi";20import * as oauth from "oauth4webapi";
21 21
22// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
@@ -45,6 +45,7 @@ export type OAuth2TokenResponse = { · 22 unmodified lines
45 readonly refresh_token?: string;45 readonly refresh_token?: string;
46 readonly expires_in?: number;46 readonly expires_in?: number;
47 readonly scope?: string;47 readonly scope?: string;
48 readonly idTokenIdentityLabel?: string;
48};49};
49 50
50// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
@@ -461,12 +462,56 @@ const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse · 410 unmodified lines
461 scope: r.scope,462 scope: r.scope,
462});463});
463 464
464// MCP source connections are pure OAuth 2.0 — we never request `openid` and465const JwtClaims = Schema.Record(Schema.String, Schema.Unknown);
465// never consume `id_token`. Some providers (PostHog, etc.) front an OIDC466const decodeJwtClaims = Schema.decodeUnknownOption(Schema.fromJsonString(JwtClaims));
466// backend and emit an `id_token` anyway; oauth4webapi then strict-validates467
467// its claims against the AS metadata and rejects mismatches we don't care468const stringClaim = (
468// about. Strip the field before delegation.469 claims: Readonly<Record<string, unknown>>,
469const stripIdToken = async (response: Response): Promise<Response> => {470 key: string,
471): string | undefined => {
472 const value = claims[key];
473 if (typeof value !== "string") return undefined;
474 const trimmed = value.trim();
475 return trimmed.length > 0 ? trimmed : undefined;
476};
477
478const 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
493export 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
504type 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.
514const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> => {
470 const body = await response515 const body = await response
471 .clone()516 .clone()
472 .json()517 .json()
@@ -475,24 +520,33 @@ const stripIdToken = async (response: Response): Promise<Response> => { · 2 unmodified lines
475 () => null,520 () => null,
476 );521 );
477 if (!body || typeof body !== "object" || !("id_token" in (body as Record<string, unknown>))) {522 if (!body || typeof body !== "object" || !("id_token" in (body as Record<string, unknown>))) {
478 return response;523 return { response };
479 }524 }
480 const { id_token: _ignored, ...rest } = body as Record<string, unknown>;525 const { id_token: idToken, ...rest } = body as Record<string, unknown>;
481 return new Response(JSON.stringify(rest), {526 const label = typeof idToken === "string" ? idTokenIdentityLabel(idToken) : undefined;
482 status: response.status,527 return {
483 statusText: response.statusText,528 response: new Response(JSON.stringify(rest), {
484 headers: response.headers,529 status: response.status,
485 });530 statusText: response.statusText,
531 headers: response.headers,
532 }),
533 ...(label ? { idTokenIdentityLabel: label } : {}),
534 };
486};535};
487 536
488const processTokenEndpointResponse = async (537const processTokenEndpointResponse = async (
489 as: oauth.AuthorizationServer,538 as: oauth.AuthorizationServer,
490 client: oauth.Client,539 client: oauth.Client,
491 response: Response,540 response: Response,
492): Promise<OAuth2TokenResponse> =>541): Promise<OAuth2TokenResponse> => {
493 tokenResponseFrom(542 const stripped = await stripIdToken(response);
494 await oauth.processGenericTokenEndpointResponse(as, client, await stripIdToken(response)),543 const token = tokenResponseFrom(
544 await oauth.processGenericTokenEndpointResponse(as, client, stripped.response),
495 );545 );
546 return stripped.idTokenIdentityLabel
547 ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel }
548 : token;
549};
496 550
497// ---------------------------------------------------------------------------551// ---------------------------------------------------------------------------
498// Exchange authorization code → tokens552// Exchange authorization code → tokens
@@ -681,7 +735,7 @@ export const refreshAccessToken = ( · 182 unmodified lines
681 const result = await oauth.processRefreshTokenResponse(735 const result = await oauth.processRefreshTokenResponse(
682 as,736 as,
683 client,737 client,
684 await stripIdToken(response),738 (await stripIdToken(response)).response,
685 );739 );
686 return tokenResponseFrom(result);740 return tokenResponseFrom(result);
687 },741 },
packages/core/sdk/src/oauth-service.ts+54−2
@@ -91,6 +91,7 @@ export interface MintOAuthConnectionInput { · 90 unmodified lines
91 readonly refreshItemId: string | null;91 readonly refreshItemId: string | null;
92 readonly expiresAt: number | null;92 readonly expiresAt: number | null;
93 readonly oauthScope: string | null;93 readonly oauthScope: string | null;
94 readonly missingOAuthScopes?: readonly string[];
94 /** Per-connection override for the token endpoint, persisted only when the95 /** Per-connection override for the token endpoint, persisted only when the
95 * code was redeemed at a region other than the client's configured token96 * code was redeemed at a region other than the client's configured token
96 * host (Datadog multi-site). Null means refresh uses the client's token URL. */97 * host (Datadog multi-site). Null means refresh uses the client's token URL. */
@@ -203,6 +204,52 @@ const recordedOAuthScope = ( · 106 unmodified lines
203 return recorded.join(" ") || null;204 return recorded.join(" ") || null;
204};205};
205 206
207const 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
212const 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. */
219const 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". */
230const isMetaOAuthScope = (scope: string): boolean => scope.toLowerCase().endsWith("/.default");
231
232const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> =>
233 new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean));
234
235export 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
206const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);253const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
207 254
208/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The255/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The
@@ -1209,7 +1256,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1000 unmodified lines
1209 name: session.name,1256 name: session.name,
1210 integration: session.integration,1257 integration: session.integration,
1211 template: session.template,1258 template: session.template,
1212 identityLabel: session.identityLabel,1259 identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null,
1213 },1260 },
1214 client,1261 client,
1215 token,1262 token,
@@ -1278,6 +1325,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 62 unmodified lines
1278 yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token);1325 yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token);
1279 }1326 }
1280 1327
1328 const oauthScope = recordedOAuthScope(token, requestedScopes);
1281 return yield* deps.mintOAuthConnection({1329 return yield* deps.mintOAuthConnection({
1282 owner: target.owner,1330 owner: target.owner,
1283 name: target.name,1331 name: target.name,
@@ -1294,7 +1342,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines
1294 // Microsoft, issue a refresh token for `offline_access` but omit that1342 // Microsoft, issue a refresh token for `offline_access` but omit that
1295 // non-resource scope from the token `scope` string, so preserve it when1343 // non-resource scope from the token `scope` string, so preserve it when
1296 // the refresh token proves it was granted.1344 // the refresh token proves it was granted.
1297 oauthScope: recordedOAuthScope(token, requestedScopes),1345 oauthScope,
1346 missingOAuthScopes:
1347 client.grant === "authorization_code"
1348 ? missingGrantedOAuthScopes(requestedScopes, oauthScope)
1349 : [],
1298 oauthTokenUrl,1350 oauthTokenUrl,
1299 });1351 });
1300 });1352 });
packages/core/sdk/src/plugin.ts+25−0
@@ -505,12 +505,37 @@ export interface IntegrationPreset { · 504 unmodified lines
505 readonly endpoint?: string;505 readonly endpoint?: string;
506 readonly icon?: string;506 readonly icon?: string;
507 readonly featured?: boolean;507 readonly featured?: boolean;
508 readonly family?: string;
509 readonly specFormat?: string;
510 readonly defaultSlug?: string;
511 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
512 readonly healthCheck?: HealthCheckSpec;
508 readonly transport?: "remote" | "stdio";513 readonly transport?: "remote" | "stdio";
509 readonly command?: string;514 readonly command?: string;
510 readonly args?: readonly string[];515 readonly args?: readonly string[];
511 readonly env?: Readonly<Record<string, string>>;516 readonly env?: Readonly<Record<string, string>>;
512}517}
513 518
519export 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
514export interface IntegrationPresetCatalogEntry extends IntegrationPreset {539export interface IntegrationPresetCatalogEntry extends IntegrationPreset {
515 readonly pluginId: string;540 readonly pluginId: string;
516}541}
packages/core/sdk/src/testing/oauth-test-server.ts+13−0
@@ -55,6 +55,8 @@ export interface OAuthTestServerOptions { · 54 unmodified lines
55 readonly supportRefresh?: boolean;55 readonly supportRefresh?: boolean;
56 readonly tokenExpiresInSeconds?: number;56 readonly tokenExpiresInSeconds?: number;
57 readonly invalidRefreshTokenDescription?: string;57 readonly invalidRefreshTokenDescription?: string;
58 readonly idTokenClaims?: Readonly<Record<string, unknown>>;
59 readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>;
58 /** Gate Dynamic Client Registration on the requested redirect URIs. When set,60 /** Gate Dynamic Client Registration on the requested redirect URIs. When set,
59 * `/register` returns `400 invalid_redirect_uri` unless every requested61 * `/register` returns `400 invalid_redirect_uri` unless every requested
60 * `redirect_uris` entry is approved. Mirrors authorization servers (e.g.62 * `redirect_uris` entry is approved. Mirrors authorization servers (e.g.
@@ -127,6 +129,7 @@ const TokenResponse = Schema.Struct({ · 66 unmodified lines
127 token_type: Schema.String,129 token_type: Schema.String,
128 expires_in: Schema.optional(Schema.Number),130 expires_in: Schema.optional(Schema.Number),
129 scope: Schema.optional(Schema.String),131 scope: Schema.optional(Schema.String),
132 id_token: Schema.optional(Schema.String),
130});133});
131const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);134const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
132 135
@@ -179,6 +182,12 @@ const decodeBasicAuthorization = ( · 46 unmodified lines
179const codeChallengeForVerifier = (verifier: string): string =>182const codeChallengeForVerifier = (verifier: string): string =>
180 createHash("sha256").update(verifier).digest("base64url");183 createHash("sha256").update(verifier).digest("base64url");
181 184
185const jwtPart = (value: unknown): string =>
186 Buffer.from(JSON.stringify(value)).toString("base64url");
187
188const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string =>
189 `${jwtPart({ alg: "RS256", typ: "JWT" })}.${jwtPart(claims)}.sig`;
190
182const oauthError = (status: number, error: string, errorDescription: string) =>191const oauthError = (status: number, error: string, errorDescription: string) =>
183 jsonResponse(192 jsonResponse(
184 status,193 status,
@@ -652,6 +661,7 @@ export const serveOAuthTestServer = ( · 467 unmodified lines
652 token_type: "Bearer",661 token_type: "Bearer",
653 expires_in: tokenExpiresInSeconds,662 expires_in: tokenExpiresInSeconds,
654 ...(scope ? { scope } : {}),663 ...(scope ? { scope } : {}),
664 ...(options.idTokenClaims ? { id_token: unsignedJwt(options.idTokenClaims) } : {}),
655 },665 },
656 { "cache-control": "no-store" },666 { "cache-control": "no-store" },
657 );667 );
@@ -680,6 +690,9 @@ export const serveOAuthTestServer = ( · 22 unmodified lines
680 token_type: "Bearer",690 token_type: "Bearer",
681 expires_in: tokenExpiresInSeconds,691 expires_in: tokenExpiresInSeconds,
682 ...(scope ? { scope } : {}),692 ...(scope ? { scope } : {}),
693 ...(options.refreshIdTokenClaims
694 ? { id_token: unsignedJwt(options.refreshIdTokenClaims) }
695 : {}),
683 },696 },
684 { "cache-control": "no-store" },697 { "cache-control": "no-store" },
685 );698 );
packages/plugins/google/CHANGELOG.md+0−143
@@ -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
packages/plugins/google/package.json+0−98
@@ -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}
packages/plugins/google/src/api/group.ts+0−142
@@ -1,142 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {
5 InternalError,
6 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,
8 IntegrationSlug,
9} from "@executor-js/sdk/shared";
10import {
11 OpenApiExtractionError,
12 OpenApiOAuthError,
13 OpenApiParseError,
14} from "@executor-js/plugin-openapi";
15
16const DomainErrors = [
17 InternalError,
18 OpenApiParseError,
19 OpenApiExtractionError,
20 OpenApiOAuthError,
21 IntegrationAlreadyExistsError,
22] as const;
23
24const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25
26const UpdateErrors = [
27 InternalError,
28 OpenApiParseError,
29 OpenApiExtractionError,
30 OpenApiOAuthError,
31 IntegrationNotFound,
32] as const;
33
34const SlugParams = {
35 slug: Schema.String,
36};
37
38const 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
46const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48
49const 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
57const AddBundleResponse = Schema.Struct({
58 slug: IntegrationSlug,
59 toolCount: Schema.Number,
60});
61
62const UpdateBundlePayload = Schema.Struct({
63 urls: Schema.optional(Schema.Array(Schema.String)),
64});
65
66const UpdateBundleResponse = Schema.Struct({
67 slug: IntegrationSlug,
68 toolCount: Schema.Number,
69 addedTools: Schema.Array(Schema.String),
70 removedTools: Schema.Array(Schema.String),
71});
72
73const IntegrationView = Schema.Struct({
74 slug: IntegrationSlug,
75 description: Schema.String,
76 kind: Schema.String,
77 canRemove: Schema.Boolean,
78 canRefresh: Schema.Boolean,
79});
80
81const 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
89const ConfigurePayload = Schema.Struct({
90 authenticationTemplate: Schema.Array(AuthenticationPayload),
91 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
92});
93
94const ConfigureResponse = Schema.Struct({
95 authenticationTemplate: Schema.Array(AuthenticationResponse),
96});
97
98export 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 );
packages/plugins/google/src/api/handlers.ts+0−105
@@ -1,105 +0,0 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { Context, Effect } from "effect";
3
4import { addGroup, capture } from "@executor-js/api";
5import type { GooglePluginExtension } from "../sdk/plugin";
6import { GoogleGroup } from "./group";
7
8export class GoogleExtensionService extends Context.Service<
9 GoogleExtensionService,
10 GooglePluginExtension
11>()("GoogleExtensionService") {}
12
13const ExecutorApiWithGoogle = addGroup(GoogleGroup);
14
15export 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);
packages/plugins/google/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1import { definePlugin } from "@executor-js/sdk/core";
2
3import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin";
4import { GoogleGroup } from "./group";
5import { GoogleHandlers, GoogleExtensionService } from "./handlers";
6
7export { GoogleGroup } from "./group";
8export { GoogleHandlers, GoogleExtensionService } from "./handlers";
9
10export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({
11 ...googlePlugin(options),
12 routes: () => GoogleGroup,
13 handlers: () => GoogleHandlers,
14 extensionService: GoogleExtensionService,
15}));
packages/plugins/google/src/promise.ts+0−1
@@ -1 +0,0 @@
1export { googlePlugin } from "./sdk/plugin";
packages/plugins/google/src/react/AddGoogleSource.tsx+0−178
@@ -1,178 +0,0 @@
1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4
5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import {
7 slugifyNamespace,
8 useIntegrationIdentity,
9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";
12import {
13 addIntegrationErrorMessage,
14 FormErrorAlert,
15 SlugCollisionAlert,
16 useSlugAlreadyExists,
17} from "@executor-js/react/lib/integration-add";
18import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19
20import { addGoogleBundle } from "./atoms";
21import { GoogleProductPicker } from "./GoogleProductPicker";
22import {
23 GOOGLE_PHOTOS_PRESET_ID,
24 googleOpenApiPresets,
25 googlePhotosPresetIds,
26 type GoogleOpenApiPreset,
27} from "../sdk/presets";
28
29const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
30
31const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set(
32 googleOpenApiPresets
33 .filter((preset: GoogleOpenApiPreset) => preset.featured)
34 .map((preset: GoogleOpenApiPreset) => preset.id),
35);
36
37const 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
47export 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}
packages/plugins/google/src/react/GoogleAccountsPanel.tsx+0−148
@@ -1,148 +0,0 @@
1import { useCallback, useMemo } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7import { TriangleAlert } from "lucide-react";
8
9import { AccountsSection } from "@executor-js/react/components/accounts-section";
10import {
11 HealthCheckEditor,
12 type HealthCheckLivePreview,
13} from "@executor-js/react/components/health-check-editor";
14import { useOrganizationId } from "@executor-js/react/api/organization-context";
15import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
16import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
17import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
18import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
19import {
20 useCustomMethodActions,
21 type AuthMethodsCodec,
22 type ConfigureAuthMethods,
23} from "@executor-js/react/lib/custom-auth-methods";
24import {
25 authMethodsFromConfig,
26 openApiWireAuthInput,
27 templateFromPlacements,
28} from "@executor-js/plugin-openapi/react";
29import type { Authentication } from "@executor-js/plugin-openapi";
30
31import { googleConfigAtom, googleConfigure } from "./atoms";
32import { googleAudienceWarningsForUrls } from "../sdk/presets";
33
34const 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
41const 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
50export 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}
packages/plugins/google/src/react/GoogleProductPicker.tsx+0−363
@@ -1,363 +0,0 @@
1import { useMemo, useState } from "react";
2import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react";
3
4import { cn } from "@executor-js/react/lib/utils";
5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
7import { Checkbox } from "@executor-js/react/components/checkbox";
8import {
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12} from "@executor-js/react/components/collapsible";
13import { FieldLabel } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";
15import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16
17import {
18 googleOAuthConsentScopesForPreset,
19 googleOpenApiPresets,
20 type GoogleOpenApiOAuthAudience,
21 type GoogleOpenApiPreset,
22} from "../sdk/presets";
23import { googleOAuthConsentBatches } from "../sdk/oauth-batches";
24import { 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.
41const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [
42 "standard-user",
43 "advanced-user",
44 "workspace-admin",
45 "unsupported-user",
46];
47
48const 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
55const 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
62const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean =>
63 audience === "workspace-admin" || audience === "unsupported-user";
64
65type 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
73const 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.
90const 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
122const 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
202export 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
363export default GoogleProductPicker;
packages/plugins/google/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1import type { IntegrationSlug } from "@executor-js/sdk/shared";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4import { GoogleClient } from "./client";
5
6export const googleIntegrationAtom = (slug: IntegrationSlug) =>
7 GoogleClient.query("google", "getIntegration", {
8 params: { slug },
9 timeToLive: "15 seconds",
10 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11 });
12
13export const googleConfigAtom = (slug: IntegrationSlug) =>
14 GoogleClient.query("google", "getConfig", {
15 params: { slug },
16 timeToLive: "15 seconds",
17 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18 });
19
20export const addGoogleBundle = GoogleClient.mutation("google", "addBundle");
21
22export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle");
23
24export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle");
25
26export const googleConfigure = GoogleClient.mutation("google", "configure");
27
28export const googleIntegrationFamily = Atom.family(googleIntegrationAtom);
29
30export const googleConfigFamily = Atom.family(googleConfigAtom);
packages/plugins/google/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1import { createPluginAtomClient } from "@executor-js/sdk/client";
2import {
3 getExecutorOrganizationHeaders,
4 getExecutorApiBaseUrl,
5 getExecutorServerAuthorizationHeader,
6} from "@executor-js/react/api/server-connection";
7import { GoogleGroup } from "../api/group";
8
9export const GoogleClient = createPluginAtomClient(GoogleGroup, {
10 baseUrl: getExecutorApiBaseUrl,
11 authorizationHeader: getExecutorServerAuthorizationHeader,
12 headers: getExecutorOrganizationHeaders,
13});
packages/plugins/google/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1export { googleIntegrationPlugin } from "./source-plugin";
2export { GoogleClient } from "./client";
3export {
4 addGoogleBundle,
5 googleConfigAtom,
6 googleConfigFamily,
7 googleConfigure,
8 googleIntegrationAtom,
9 googleIntegrationFamily,
10 removeGoogleBundle,
11 updateGoogleBundle,
12} from "./atoms";
packages/plugins/google/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { googleIntegrationPlugin } from "./source-plugin";
4
5export default defineClientPlugin({
6 id: "google" as const,
7 integrationPlugin: googleIntegrationPlugin,
8});
packages/plugins/google/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";
4
5const importAdd = () => import("./AddGoogleSource");
6const importAccounts = () => import("./GoogleAccountsPanel");
7
8export 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};
packages/plugins/google/src/sdk/oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import { googleOAuthConsentBatches } from "./oauth-batches";
4
5it("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
33it("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});
packages/plugins/google/src/sdk/oauth-batches.ts+0−69
@@ -1,69 +0,0 @@
1import type { GoogleOpenApiOAuthAudience } from "./presets";
2import { compactGoogleOAuthScopes } from "./oauth-scopes";
3
4export type GoogleOAuthBatchInput = {
5 readonly id: string;
6 readonly name: string;
7 readonly oauthAudience: GoogleOpenApiOAuthAudience;
8 readonly scopes: readonly string[];
9};
10
11export type GoogleOAuthConsentBatch = {
12 readonly id: string;
13 readonly label: string;
14 readonly apiScopes: readonly string[];
15};
16
17const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]);
18
19export 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};
packages/plugins/google/src/sdk/plugin.test.ts+0−799
@@ -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
14import { describe, expect, it } from "@effect/vitest";
15import { Effect, Exit, Layer } from "effect";
16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17
18import {
19 ConnectionName,
20 IntegrationSlug,
21 createExecutor,
22 AuthTemplateSlug,
23} from "@executor-js/sdk";
24import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
25
26import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27import { 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
35const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";
36const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
37const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
38const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
39const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
40const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
41const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
42const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
43const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
44const 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
50const 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
84const 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
124const 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
156const 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
189const 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
222const 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
270const 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
291const 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
334const 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
420const toJson = (value: unknown): string => JSON.stringify(value);
421
422const 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.
438const 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
457const bundlePlugins = () =>
458 [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
459
460describe("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
704describe("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});
packages/plugins/google/src/sdk/plugin.ts+0−469
@@ -1,469 +0,0 @@
1import { Effect } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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";
20import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21import {
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
37import {
38 convertGoogleDiscoveryBundleToOpenApi,
39 fetchGoogleDiscoveryDocument,
40 normalizeGoogleDiscoveryUrl,
41} from "./discovery";
42import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
43import {
44 googleOAuthConsentScopesForPreset,
45 googleOpenApiBundlePreset,
46 googlePhotosOpenApiBundlePreset,
47 googlePhotosOpenApiPresets,
48} from "./presets";
49
50const 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. */
55export 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
92export 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
100export interface GoogleConfigureInput {
101 readonly authenticationTemplate: readonly AuthenticationInput[];
102 readonly mode?: "merge" | "replace";
103}
104
105export interface GoogleUpdateInput {
106 readonly urls?: readonly string[];
107}
108
109export interface GoogleUpdateResult {
110 readonly slug: IntegrationSlug;
111 readonly toolCount: number;
112 readonly addedTools: readonly string[];
113 readonly removedTools: readonly string[];
114}
115
116export interface GooglePluginOptions {
117 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
118}
119
120const DEFAULT_GOOGLE_SLUG = "google";
121
122const googlePhotosBundlePresetIdByUrl = new Map(
123 googlePhotosOpenApiPresets.flatMap((preset) =>
124 preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
125 ),
126);
127
128const 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
140const 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
162const uniqueUrls = (urls: readonly string[]): readonly string[] => [
163 ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])),
164];
165
166const 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
184const 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
207const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
208 const config = decodeGoogleIntegrationConfig(record.config);
209 return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
210};
211
212const 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
395export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
396
397export 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}));
packages/plugins/google/src/sdk/presets.test.ts+0−29
@@ -1,29 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
4
5it("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
22it("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});
packages/plugins/google/src/sdk/product-picker-scopes.test.ts+0−122
@@ -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
11import { expect, it } from "@effect/vitest";
12
13import {
14 googleAudienceWarningsForUrls,
15 googleOAuthConsentScopes,
16 googleOAuthConsentScopesForPreset,
17 googleOpenApiPresets,
18 googlePresetForDiscoveryUrl,
19 type GoogleOpenApiPreset,
20} from "./presets";
21import { googleOAuthConsentBatches } from "./oauth-batches";
22import { compactGoogleOAuthScopes } from "./oauth-scopes";
23
24const 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
34it("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
45it("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
63it("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
90it("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
103it("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});
packages/plugins/google/tsconfig.json+0−24
@@ -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}
packages/plugins/google/tsup.config.ts+0−14
@@ -1,14 +0,0 @@
1import { defineConfig } from "tsup";
2
3export 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});
packages/plugins/google/vitest.config.ts+0−12
@@ -1,12 +0,0 @@
1import { defineConfig } from "vitest/config";
2
3export 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});
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
23 useSlugAlreadyExists,23 useSlugAlreadyExists,
24} from "@executor-js/react/lib/integration-add";24} from "@executor-js/react/lib/integration-add";
25 25
26import { addGraphqlIntegrationOptimistic } from "./atoms";26import { createGraphqlIntegrationOptimistic } from "./atoms";
27import { GraphqlSourceFields } from "./GraphqlSourceFields";27import { GraphqlSourceFields } from "./GraphqlSourceFields";
28import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";28import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
29import type { GraphqlAuthMethodInput } from "../sdk/types";29import type { GraphqlAuthMethodInput } from "../sdk/types";
@@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
52 const [adding, setAdding] = useState(false);52 const [adding, setAdding] = useState(false);
53 const [addError, setAddError] = useState<string | null>(null);53 const [addError, setAddError] = useState<string | null>(null);
54 54
55 const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, {55 const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, {
56 mode: "promiseExit",56 mode: "promiseExit",
57 });57 });
58 58
packages/plugins/graphql/src/react/atoms.ts+3−3
@@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines
33// Mutation atoms33// Mutation atoms
34// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
35 35
36export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");36export const createGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
37 37
38// Merge-append custom auth methods onto an integration's `authenticationTemplate`.38// Merge-append custom auth methods onto an integration's `authenticationTemplate`.
39export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");39export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");
@@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line
41// Optimistically slot a pending row into the shared integration catalog so the41// Optimistically slot a pending row into the shared integration catalog so the
42// integrations list reflects the new GraphQL integration before the server42// integrations list reflects the new GraphQL integration before the server
43// round-trips. The reducer mirrors the catalog `Integration` shape.43// round-trips. The reducer mirrors the catalog `Integration` shape.
44export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(44export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
45 Atom.optimisticFn({45 Atom.optimisticFn({
46 reducer: (46 reducer: (
47 current,47 current,
@@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines
69 a.name.localeCompare(b.name),69 a.name.localeCompare(b.name),
70 );70 );
71 }),71 }),
72 fn: addGraphqlIntegration,72 fn: createGraphqlIntegration,
73 }),73 }),
74);74);
packages/plugins/graphql/src/react/index.ts+1−1
@@ -1,3 +1,3 @@
1export { graphqlIntegrationPlugin } from "./source-plugin";1export { graphqlIntegrationPlugin } from "./source-plugin";
2export { GraphqlClient } from "./client";2export { GraphqlClient } from "./client";
3export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";3export { createGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";
packages/plugins/microsoft/CHANGELOG.md+0−141
@@ -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
packages/plugins/microsoft/package.json+0−98
@@ -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}
packages/plugins/microsoft/src/api/group.ts+0−163
@@ -1,163 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {
5 InternalError,
6 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,
8 IntegrationSlug,
9} from "@executor-js/sdk/shared";
10import {
11 OpenApiExtractionError,
12 OpenApiOAuthError,
13 OpenApiParseError,
14} from "@executor-js/plugin-openapi";
15
16const DomainErrors = [
17 InternalError,
18 OpenApiParseError,
19 OpenApiExtractionError,
20 OpenApiOAuthError,
21 IntegrationAlreadyExistsError,
22] as const;
23
24const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25
26const UpdateErrors = [
27 InternalError,
28 OpenApiParseError,
29 OpenApiExtractionError,
30 OpenApiOAuthError,
31 IntegrationNotFound,
32] as const;
33
34const SlugParams = {
35 slug: Schema.String,
36};
37
38const 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
46const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48
49const 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
62const AddGraphResponse = Schema.Struct({
63 slug: IntegrationSlug,
64 toolCount: Schema.Number,
65});
66
67const 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
77const UpdateGraphResponse = Schema.Struct({
78 slug: IntegrationSlug,
79 toolCount: Schema.Number,
80 addedTools: Schema.Array(Schema.String),
81 removedTools: Schema.Array(Schema.String),
82});
83
84const IntegrationView = Schema.Struct({
85 slug: IntegrationSlug,
86 description: Schema.String,
87 kind: Schema.String,
88 canRemove: Schema.Boolean,
89 canRefresh: Schema.Boolean,
90});
91
92const 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
110const ConfigurePayload = Schema.Struct({
111 authenticationTemplate: Schema.Array(AuthenticationPayload),
112 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
113});
114
115const ConfigureResponse = Schema.Struct({
116 authenticationTemplate: Schema.Array(AuthenticationResponse),
117});
118
119export 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 );
packages/plugins/microsoft/src/api/handlers.ts+0−144
@@ -1,144 +0,0 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { Context, Effect } from "effect";
3
4import { addGroup, capture } from "@executor-js/api";
5import type { MicrosoftPluginExtension } from "../sdk/plugin";
6import { MicrosoftGroup } from "./group";
7
8export class MicrosoftExtensionService extends Context.Service<
9 MicrosoftExtensionService,
10 MicrosoftPluginExtension
11>()("MicrosoftExtensionService") {}
12
13const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup);
14
15export 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);
packages/plugins/microsoft/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1import { definePlugin } from "@executor-js/sdk/core";
2
3import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin";
4import { MicrosoftGroup } from "./group";
5import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
6
7export { MicrosoftGroup } from "./group";
8export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
9
10export const microsoftHttpPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({
11 ...microsoftPlugin(options),
12 routes: () => MicrosoftGroup,
13 handlers: () => MicrosoftHandlers,
14 extensionService: MicrosoftExtensionService,
15}));
packages/plugins/microsoft/src/promise.ts+0−1
@@ -1 +0,0 @@
1export { microsoftPlugin } from "./sdk/plugin";
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+0−155
@@ -1,155 +0,0 @@
1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4
5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import {
7 slugifyNamespace,
8 useIntegrationIdentity,
9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";
12import {
13 addIntegrationErrorMessage,
14 FormErrorAlert,
15 SlugCollisionAlert,
16 useSlugAlreadyExists,
17} from "@executor-js/react/lib/integration-add";
18import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19
20import { addMicrosoftGraph } from "./atoms";
21import { MicrosoftScopePicker } from "./MicrosoftScopePicker";
22import {
23 MICROSOFT_GRAPH_BASE_URL,
24 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
25 microsoftGraphScopesForPresetIds,
26} from "../sdk/presets";
27
28const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico";
29
30const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
31
32export 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}
packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx+0−118
@@ -1,118 +0,0 @@
1import { useCallback, useMemo } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7
8import { AccountsSection } from "@executor-js/react/components/accounts-section";
9import {
10 HealthCheckEditor,
11 type HealthCheckLivePreview,
12} from "@executor-js/react/components/health-check-editor";
13import { useOrganizationId } from "@executor-js/react/api/organization-context";
14import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
15import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
16import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
17import {
18 useCustomMethodActions,
19 type AuthMethodsCodec,
20 type ConfigureAuthMethods,
21} from "@executor-js/react/lib/custom-auth-methods";
22import {
23 authMethodsFromConfig,
24 openApiWireAuthInput,
25 templateFromPlacements,
26} from "@executor-js/plugin-openapi/react";
27import type { Authentication } from "@executor-js/plugin-openapi";
28
29import { microsoftConfigAtom, microsoftConfigure } from "./atoms";
30
31const 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
40export 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}
packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx+0−286
@@ -1,286 +0,0 @@
1import { useMemo, useState } from "react";
2import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react";
3
4import { cn } from "@executor-js/react/lib/utils";
5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
7import { Checkbox } from "@executor-js/react/components/checkbox";
8import {
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12} from "@executor-js/react/components/collapsible";
13import { FieldLabel } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";
15import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16
17import {
18 microsoftGraphScopePresets,
19 microsoftGraphScopesForPresetIds,
20 type MicrosoftGraphScopeAudience,
21 type MicrosoftGraphScopePreset,
22} from "../sdk/presets";
23
24type 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
32const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [
33 "productivity",
34 "files-content",
35 "collaboration",
36 "directory-identity",
37 "admin-security",
38 "platform-business",
39];
40
41const 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
50const 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
59const 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
87const 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
165export 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
286export default MicrosoftScopePicker;
packages/plugins/microsoft/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1import type { IntegrationSlug } from "@executor-js/sdk/shared";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4import { MicrosoftClient } from "./client";
5
6export const microsoftIntegrationAtom = (slug: IntegrationSlug) =>
7 MicrosoftClient.query("microsoft", "getIntegration", {
8 params: { slug },
9 timeToLive: "15 seconds",
10 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11 });
12
13export const microsoftConfigAtom = (slug: IntegrationSlug) =>
14 MicrosoftClient.query("microsoft", "getConfig", {
15 params: { slug },
16 timeToLive: "15 seconds",
17 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18 });
19
20export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph");
21
22export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph");
23
24export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph");
25
26export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure");
27
28export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom);
29
30export const microsoftConfigFamily = Atom.family(microsoftConfigAtom);
packages/plugins/microsoft/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1import { createPluginAtomClient } from "@executor-js/sdk/client";
2import {
3 getExecutorOrganizationHeaders,
4 getExecutorApiBaseUrl,
5 getExecutorServerAuthorizationHeader,
6} from "@executor-js/react/api/server-connection";
7import { MicrosoftGroup } from "../api/group";
8
9export const MicrosoftClient = createPluginAtomClient(MicrosoftGroup, {
10 baseUrl: getExecutorApiBaseUrl,
11 authorizationHeader: getExecutorServerAuthorizationHeader,
12 headers: getExecutorOrganizationHeaders,
13});
packages/plugins/microsoft/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1export { microsoftIntegrationPlugin } from "./source-plugin";
2export { MicrosoftClient } from "./client";
3export {
4 addMicrosoftGraph,
5 microsoftConfigAtom,
6 microsoftConfigFamily,
7 microsoftConfigure,
8 microsoftIntegrationAtom,
9 microsoftIntegrationFamily,
10 removeMicrosoftGraph,
11 updateMicrosoftGraph,
12} from "./atoms";
packages/plugins/microsoft/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { microsoftIntegrationPlugin } from "./source-plugin";
4
5export default defineClientPlugin({
6 id: "microsoft" as const,
7 integrationPlugin: microsoftIntegrationPlugin,
8});
packages/plugins/microsoft/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { microsoftGraphPreset } from "../sdk/presets";
4
5const importAdd = () => import("./AddMicrosoftSource");
6const importAccounts = () => import("./MicrosoftAccountsPanel");
7
8export 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};
packages/plugins/microsoft/src/sdk/plugin.test.ts+0−692
@@ -1,692 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Exit, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
5import {
6 AuthTemplateSlug,
7 ConnectionName,
8 IntegrationSlug,
9 createExecutor,
10} from "@executor-js/sdk";
11import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
12
13import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
14import {
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
25const graphFixture = `
26openapi: 3.0.4
27info:
28 title: Microsoft Graph Fixture
29 version: v1.0
30servers:
31 - url: https://graph.microsoft.com/v1.0
32paths:
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
76components:
77 schemas:
78 user:
79 type: object
80`;
81
82const 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
122const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi";
123const EMULATOR_BASE_URL = "https://microsoft.emulators.dev";
124const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi";
125const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123";
126const emulatorGraphFixture = `
127openapi: 3.0.3
128info:
129 title: Microsoft Graph Emulator
130 version: 1.0.0
131servers:
132 - url: ${EMULATOR_BASE_URL}
133paths:
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
146components:
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
163const localEmulatorGraphFixture = `
164openapi: 3.0.3
165info:
166 title: Microsoft Graph Local Emulator
167 version: 1.0.0
168servers:
169 - url: ${LOCAL_EMULATOR_BASE_URL}
170paths:
171 /v1.0/users:
172 get:
173 operationId: graphUser_List
174 responses:
175 "200":
176 description: OK
177components:
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
188const 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
224const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
225 [
226 microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
227 memoryCredentialsPlugin(),
228 ] as const;
229
230describe("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
623describe("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});
packages/plugins/microsoft/src/sdk/plugin.ts+0−436
@@ -1,436 +0,0 @@
1import { Effect } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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";
20import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21import {
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
38import {
39 buildMicrosoftGraphOpenApiSpec,
40 decodeMicrosoftGraphIntegrationConfig,
41 microsoftGraphKeepPathItem,
42 type MicrosoftGraphUrlPolicy,
43 type MicrosoftGraphIntegrationConfig,
44 type MicrosoftGraphSpecBuild,
45} from "./graph";
46import {
47 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
48 MICROSOFT_GRAPH_BASE_URL,
49 microsoftGraphPreset,
50} from "./presets";
51
52export 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
65export interface MicrosoftConfigureInput {
66 readonly authenticationTemplate: readonly AuthenticationInput[];
67 readonly mode?: "merge" | "replace";
68}
69
70export 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
80export interface MicrosoftUpdateResult {
81 readonly slug: IntegrationSlug;
82 readonly toolCount: number;
83 readonly addedTools: readonly string[];
84 readonly removedTools: readonly string[];
85}
86
87export 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
99const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
100
101const 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
128const 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
135const 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
364export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>;
365
366export 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}));
packages/plugins/microsoft/src/sdk/presets.test.ts+0−169
@@ -1,169 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
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
15describe("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});
packages/plugins/microsoft/vitest.config.ts+0−12
@@ -1,12 +0,0 @@
1import { defineConfig } from "vitest/config";
2
3export 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});
packages/plugins/openapi/package.json+14−0
@@ -22,6 +22,8 @@ · 21 unmodified lines
22 "./react": "./src/react/index.ts",22 "./react": "./src/react/index.ts",
23 "./client": "./src/react/plugin-client.tsx",23 "./client": "./src/react/plugin-client.tsx",
24 "./presets": "./src/sdk/presets.ts",24 "./presets": "./src/sdk/presets.ts",
25 "./providers/google": "./src/providers/google/index.ts",
26 "./providers/microsoft": "./src/providers/microsoft/index.ts",
25 "./testing": "./src/testing/index.ts"27 "./testing": "./src/testing/index.ts"
26 },28 },
27 "publishConfig": {29 "publishConfig": {
@@ -45,6 +47,18 @@ · 17 unmodified lines
45 "default": "./dist/client.js"47 "default": "./dist/client.js"
46 }48 }
47 },49 },
50 "./providers/google": {
51 "import": {
52 "types": "./dist/providers/google/index.d.ts",
53 "default": "./dist/providers/google.js"
54 }
55 },
56 "./providers/microsoft": {
57 "import": {
58 "types": "./dist/providers/microsoft/index.d.ts",
59 "default": "./dist/providers/microsoft.js"
60 }
61 },
48 "./testing": {62 "./testing": {
49 "import": {63 "import": {
50 "types": "./dist/testing/index.d.ts",64 "types": "./dist/testing/index.d.ts",
packages/plugins/openapi/src/api/group.ts+11−4
@@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {4import {
5 HealthCheckSpec,
5 InternalError,6 InternalError,
6 IntegrationAlreadyExistsError,7 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,8 IntegrationNotFoundError,
@@ -68,25 +69,31 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 60 unmodified lines
68 69
69const AddSpecPayload = Schema.Struct({70const AddSpecPayload = Schema.Struct({
70 spec: OpenApiSpecInputPayload,71 spec: OpenApiSpecInputPayload,
71 slug: Schema.String,72 slug: Schema.optional(Schema.String),
72 name: Schema.optional(Schema.String),73 name: Schema.optional(Schema.String),
73 description: Schema.optional(Schema.String),74 description: Schema.optional(Schema.String),
74 baseUrl: Schema.optional(Schema.String),75 baseUrl: Schema.optional(Schema.String),
75 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),76 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
76 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),77 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
78 specFormat: Schema.optional(Schema.String),
79 family: Schema.optional(Schema.String),
80 healthCheck: Schema.optional(HealthCheckSpec),
77 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),81 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
78});82});
79 83
80const PreviewSpecPayload = Schema.Struct({84const PreviewSpecPayload = Schema.Struct({
81 spec: Schema.String,85 spec: Schema.String,
86 specFormat: Schema.optional(Schema.String),
82});87});
83 88
84// The `configure` payload — the new/updated auth methods to merge onto the89// The `configure` payload — the new/updated auth methods to merge onto the
85// integration's `authenticationTemplate`. Reuses the same `AuthenticationPayload`90// integration's `authenticationTemplate`, plus request routing metadata.
86// schema as `addSpec` so a custom apiKey method round-trips identically.91// Reuses the same `AuthenticationPayload` schema as `addSpec` so a custom apiKey
92// method round-trips identically.
87const ConfigurePayload = Schema.Struct({93const ConfigurePayload = Schema.Struct({
88 authenticationTemplate: Schema.Array(AuthenticationPayload),94 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
89 mode: Schema.optional(Schema.Literals(["merge", "replace"])),95 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
96 baseUrl: Schema.optional(Schema.String),
90});97});
91 98
92// ---------------------------------------------------------------------------99// ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+8−1
@@ -44,7 +44,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
44 capture(44 capture(
45 Effect.gen(function* () {45 Effect.gen(function* () {
46 const ext = yield* OpenApiExtensionService;46 const ext = yield* OpenApiExtensionService;
47 const preview = yield* ext.previewSpec({ spec: payload.spec });47 const preview = yield* ext.previewSpec({
48 spec: payload.spec,
49 specFormat: payload.specFormat,
50 });
48 return specPreviewSummary(preview);51 return specPreviewSummary(preview);
49 }),52 }),
50 ),53 ),
@@ -61,6 +64,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines
61 baseUrl: payload.baseUrl,64 baseUrl: payload.baseUrl,
62 headers: payload.headers ? { ...payload.headers } : undefined,65 headers: payload.headers ? { ...payload.headers } : undefined,
63 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,66 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
67 specFormat: payload.specFormat,
68 family: payload.family,
69 healthCheck: payload.healthCheck,
64 authenticationTemplate: payload.authenticationTemplate,70 authenticationTemplate: payload.authenticationTemplate,
65 });71 });
66 }),72 }),
@@ -109,6 +115,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines
109 const authenticationTemplate = yield* ext.configure(params.slug, {115 const authenticationTemplate = yield* ext.configure(params.slug, {
110 authenticationTemplate: payload.authenticationTemplate,116 authenticationTemplate: payload.authenticationTemplate,
111 mode: payload.mode ?? "merge",117 mode: payload.mode ?? "merge",
118 baseUrl: payload.baseUrl,
112 });119 });
113 return { authenticationTemplate: [...authenticationTemplate] };120 return { authenticationTemplate: [...authenticationTemplate] };
114 }),121 }),
packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snaprenamed from packages/plugins/google/src/sdk/__snapshots__/presets.test.ts.snap+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/config.tsrenamed from packages/plugins/google/src/sdk/config.ts+3−1
@@ -1,5 +1,7 @@
1import { Option, Schema } from "effect";1import { Option, Schema } from "effect";
2import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";2
3import { AuthenticationSchema } from "../../sdk/config";
4import type { Authentication } from "../../sdk/types";
3 5
4export const GoogleIntegrationConfigSchema = Schema.Struct({6export const GoogleIntegrationConfigSchema = Schema.Struct({
5 specHash: Schema.optional(Schema.String),7 specHash: Schema.optional(Schema.String),
packages/plugins/openapi/src/providers/google/discovery.test.tsrenamed from packages/plugins/google/src/sdk/discovery.test.ts+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/discovery.tsrenamed from packages/plugins/google/src/sdk/discovery.ts+2−1
@@ -5,7 +5,8 @@ · 4 unmodified lines
5import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";5import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";
6import { HttpClient, HttpClientRequest } from "effect/unstable/http";6import { HttpClient, HttpClientRequest } from "effect/unstable/http";
7 7
8import { OpenApiParseError, type Authentication } from "@executor-js/plugin-openapi";8import { OpenApiParseError } from "../../sdk/errors";
9import type { Authentication } from "../../sdk/types";
9import { compactGoogleOAuthScopes } from "./oauth-scopes";10import { compactGoogleOAuthScopes } from "./oauth-scopes";
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";11import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11 12
packages/plugins/openapi/src/providers/google/index.tsrenamed from packages/plugins/google/src/sdk/index.ts+3−14
@@ -7,6 +7,7 @@ export { · 6 unmodified lines
7} from "./discovery";7} from "./discovery";
8export {8export {
9 googleOpenApiBundlePreset,9 googleOpenApiBundlePreset,
10 googleCatalog,
10 googleOpenApiPresets,11 googleOpenApiPresets,
11 googlePhotosOpenApiBundlePreset,12 googlePhotosOpenApiBundlePreset,
12 googlePhotosOpenApiPresets,13 googlePhotosOpenApiPresets,
@@ -16,6 +17,7 @@ export { · 3 unmodified lines
16 googleStandardUserOAuthPresets,17 googleStandardUserOAuthPresets,
17 googleOAuthConsentScopes,18 googleOAuthConsentScopes,
18 googleOAuthConsentScopesForPreset,19 googleOAuthConsentScopesForPreset,
20 googleServiceSlug,
19 googleAudienceWarningsForUrls,21 googleAudienceWarningsForUrls,
20 googlePresetForDiscoveryUrl,22 googlePresetForDiscoveryUrl,
21 type GoogleOpenApiOAuthAudience,23 type GoogleOpenApiOAuthAudience,
@@ -27,20 +29,7 @@ export { · 5 unmodified lines
27 filterGoogleUserConsentOAuthScopes,29 filterGoogleUserConsentOAuthScopes,
28 isGoogleUserConsentOAuthScope,30 isGoogleUserConsentOAuthScope,
29} from "./oauth-scopes";31} from "./oauth-scopes";
30export {32export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
31 googleOAuthConsentBatches,
32 type GoogleOAuthBatchInput,
33 type GoogleOAuthConsentBatch,
34} from "./oauth-batches";
35export {
36 googlePlugin,
37 type GoogleBundleConfig,
38 type GoogleConfigureInput,
39 type GooglePluginExtension,
40 type GooglePluginOptions,
41 type GoogleUpdateInput,
42 type GoogleUpdateResult,
43} from "./plugin";
44export {33export {
45 googleOpenApiOwnershipDataMigration,34 googleOpenApiOwnershipDataMigration,
46 runSqliteGoogleOpenApiOwnershipMigration,35 runSqliteGoogleOpenApiOwnershipMigration,
packages/plugins/openapi/src/providers/google/oauth-scopes.test.tsrenamed from packages/plugins/google/src/sdk/oauth-scopes.test.ts+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/oauth-scopes.tsrenamed from packages/plugins/google/src/sdk/oauth-scopes.ts+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/openapi-ownership-migration.test.tsrenamed from packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/openapi-ownership-migration.tsrenamed from packages/plugins/google/src/sdk/openapi-ownership-migration.ts+0−0

File renamed without changes.

packages/plugins/openapi/src/providers/google/presets.test.ts+283−0
@@ -0,0 +1,283 @@
1import { expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { compileOpenApiSpec } from "@executor-js/plugin-openapi";
4
5import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery";
6import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
7
8const 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
175const 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
199it("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
216it("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
225it("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
242it.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
260it("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
269it("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});
packages/plugins/openapi/src/providers/google/presets.tsrenamed from packages/plugins/google/src/sdk/presets.ts+69−1
@@ -1,4 +1,6 @@
1import { normalizeGoogleDiscoveryUrl } from "./discovery";1import { normalizeGoogleDiscoveryUrl } from "./discovery";
2import { compactGoogleOAuthScopes } from "./oauth-scopes";
3import type { HealthCheckSpec, IntegrationPreset } from "@executor-js/sdk/core";
2 4
3export interface GooglePreset {5export interface GooglePreset {
4 readonly id: string;6 readonly id: string;
@@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines
238// The picker shows the OAuth consent a user is about to grant BEFORE connecting240// The picker shows the OAuth consent a user is about to grant BEFORE connecting
239// (the "View scopes" panel), but the authoritative scope list only exists in241// (the "View scopes" panel), but the authoritative scope list only exists in
240// each API's live Discovery document, which the add flow fetches lazily at242// 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 preset243// provider add time. To preview consent without N network round-trips, each preset
242// declares the broad top-level scope(s) a full integration grants. These flow244// declares the broad top-level scope(s) a full integration grants. These flow
243// through `googleOAuthConsentBatches` (which compacts sub-scopes under their245// through `googleOAuthConsentBatches` (which compacts sub-scopes under their
244// broad parent), so the previewed grant matches the unioned scopes the bundle246// 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
276export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>278export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
277 googleOAuthConsentScopes[presetId] ?? [];279 googleOAuthConsentScopes[presetId] ?? [];
278 280
281export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_");
282
283const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
284const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
285const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
286const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"];
287const 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
320const 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
333export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({
334 id: preset.id,
335 name: preset.name,
336 summary: preset.summary,
337 ...(preset.url ? { url: preset.url } : {}),
338 ...(preset.icon ? { icon: preset.icon } : {}),
339 ...(preset.featured ? { featured: preset.featured } : {}),
340 family: "google",
341 specFormat: "google-discovery",
342 defaultSlug: googleServiceSlug(preset.id),
343 authTemplate: googleCatalogAuthTemplate(preset.id),
344 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
345}));
346
279// ---------------------------------------------------------------------------347// ---------------------------------------------------------------------------
280// Resolve a stored/normalized Discovery URL back to its preset, so a bundled348// Resolve a stored/normalized Discovery URL back to its preset, so a bundled
281// `google` integration can surface each selected API's `oauthAudience` (e.g. a349// `google` integration can surface each selected API's `oauthAudience` (e.g. a
packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts+167−0
@@ -0,0 +1,167 @@
1import { expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7import type { AuthenticationInput } from "@executor-js/plugin-openapi";
8
9import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
10import { googleCatalog } from "./presets";
11
12const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
13
14const 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
59const 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
73it.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
87it("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
95it.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
121it.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);
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+85−0
@@ -0,0 +1,85 @@
1import { Effect, Schema } from "effect";
2
3import { OpenApiParseError } from "../../sdk/errors";
4import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-format";
5
6import {
7 convertGoogleDiscoveryBundleToOpenApi,
8 fetchGoogleDiscoveryDocument,
9 normalizeGoogleDiscoveryUrl,
10} from "./discovery";
11
12const isRecord = (value: unknown): value is Record<string, unknown> =>
13 typeof value === "object" && value !== null && !Array.isArray(value);
14
15const 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
25const googleDiscoverySlug = (service: string): string =>
26 `google_${service
27 .trim()
28 .toLowerCase()
29 .replace(/[^a-z0-9]+/g, "_")
30 .replace(/^_+|_+$/g, "")}`;
31
32export 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
48export 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};
packages/plugins/openapi/src/providers/microsoft/graph.test.tsrenamed from packages/plugins/microsoft/src/sdk/graph.test.ts+68−0
@@ -8,6 +8,11 @@ import { · 7 unmodified lines
8} from "@executor-js/plugin-openapi";8} from "@executor-js/plugin-openapi";
9 9
10import { microsoftGraphKeepPathItem } from "./graph";10import { microsoftGraphKeepPathItem } from "./graph";
11import {
12 microsoftGraphExactPathsForPresetIds,
13 microsoftGraphPathPrefixesForPresetIds,
14 microsoftGraphTagPrefixesForPresetIds,
15} from "./presets";
11 16
12// Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every17// Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every
13// success response uses the OpenAPI wildcard status key `2XX` (the real spec18// success response uses the OpenAPI wildcard status key `2XX` (the real spec
@@ -220,6 +225,29 @@ paths: · 206 unmodified lines
220components: {}225components: {}
221`;226`;
222 227
228const driveAndWorkbookFixture = `
229openapi: 3.0.4
230info:
231 title: Microsoft Graph Fixture
232 version: v1.0
233servers:
234 - url: https://graph.microsoft.com/v1.0
235paths:
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
248components: {}
249`;
250
223const fullGraphSelection = {251const fullGraphSelection = {
224 coversFullGraph: true,252 coversFullGraph: true,
225 presetIds: [],253 presetIds: [],
@@ -268,6 +296,36 @@ const keptIdentityFixturePaths = ( · 42 unmodified lines
268 return kept;296 return kept;
269};297};
270 298
299const 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
308const 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
271const keptPathItem = (fixture: string): Record<string, unknown> => {329const keptPathItem = (fixture: string): Record<string, unknown> => {
272 const structure = structuralSplit(fixture);330 const structure = structuralSplit(fixture);
273 expect(structure).not.toBeNull();331 expect(structure).not.toBeNull();
@@ -338,6 +396,16 @@ it("keeps all /me operations when profile selects the path", () => { · 64 unmodified lines
338 expect(kept.get("/me")?.patch).toMatchObject({ operationId: "me.UpdateUser" });396 expect(kept.get("/me")?.patch).toMatchObject({ operationId: "me.UpdateUser" });
339});397});
340 398
399it("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
341it("keeps already-binary drive content responses untouched", () => {409it("keeps already-binary drive content responses untouched", () => {
342 const pathItem = keptPathItem(driveContentFixture);410 const pathItem = keptPathItem(driveContentFixture);
343 411
packages/plugins/openapi/src/providers/microsoft/graph.tsrenamed from packages/plugins/microsoft/src/sdk/graph.ts+4−5
@@ -3,18 +3,17 @@ import type { Layer } from "effect"; · 2 unmodified lines
3import { HttpClient, HttpClientRequest } from "effect/unstable/http";3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4 4
5import { AuthTemplateSlug } from "@executor-js/sdk/core";5import { AuthTemplateSlug } from "@executor-js/sdk/core";
6import { AuthenticationSchema, type OpenApiIntegrationConfig } from "../../sdk/config";
7import { OpenApiParseError } from "../../sdk/errors";
6import {8import {
7 AuthenticationSchema,
8 OpenApiParseError,
9 parseEntry,9 parseEntry,
10 parseHead,10 parseHead,
11 parseSmallComponents,11 parseSmallComponents,
12 structuralSplit,12 structuralSplit,
13 type Authentication,
14 type KeepPathItem,13 type KeepPathItem,
15 type OpenApiIntegrationConfig,
16 type SpecStructure,14 type SpecStructure,
17} from "@executor-js/plugin-openapi";15} from "../../sdk/split";
16import type { Authentication } from "../../sdk/types";
18 17
19import {18import {
20 MICROSOFT_AUTHORIZATION_URL,19 MICROSOFT_AUTHORIZATION_URL,
packages/plugins/openapi/src/providers/microsoft/index.tsrenamed from packages/plugins/microsoft/src/sdk/index.ts+3−9
@@ -13,6 +13,7 @@ export { · 12 unmodified lines
13 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,13 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
14 MICROSOFT_GRAPH_PRESET_ID,14 MICROSOFT_GRAPH_PRESET_ID,
15 MICROSOFT_TOKEN_URL,15 MICROSOFT_TOKEN_URL,
16 microsoftCatalog,
16 microsoftGraphExactPathsForPresetIds,17 microsoftGraphExactPathsForPresetIds,
17 microsoftGraphPathPrefixesForPresetIds,18 microsoftGraphPathPrefixesForPresetIds,
18 microsoftGraphPreset,19 microsoftGraphPreset,
@@ -21,6 +22,7 @@ export { · 2 unmodified lines
21 microsoftGraphScopePresets,22 microsoftGraphScopePresets,
22 microsoftGraphScopesForPresetIds,23 microsoftGraphScopesForPresetIds,
23 microsoftGraphTagPrefixesForPresetIds,24 microsoftGraphTagPrefixesForPresetIds,
25 microsoftServiceSlug,
24 type MicrosoftGraphPreset,26 type MicrosoftGraphPreset,
25 type MicrosoftGraphScopeAudience,27 type MicrosoftGraphScopeAudience,
26 type MicrosoftGraphScopePreset,28 type MicrosoftGraphScopePreset,
@@ -36,12 +38,4 @@ export { · 9 unmodified lines
36 type MicrosoftGraphSelectionInput,38 type MicrosoftGraphSelectionInput,
37 type MicrosoftGraphSpecBuild,39 type MicrosoftGraphSpecBuild,
38} from "./graph";40} from "./graph";
39export {41export { microsoftGraphAdapter } from "./spec-format-adapter";
40 microsoftPlugin,
41 type MicrosoftConfigureInput,
42 type MicrosoftGraphConfig,
43 type MicrosoftPluginExtension,
44 type MicrosoftPluginOptions,
45 type MicrosoftUpdateInput,
46 type MicrosoftUpdateResult,
47} from "./plugin";
packages/plugins/openapi/src/providers/microsoft/presets.test.ts+332−0
@@ -0,0 +1,332 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4import { parseEntry, structuralSplit, type KeepPathItem } from "@executor-js/plugin-openapi";
5
6import { microsoftGraphAdapter } from "./spec-format-adapter";
7import {
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
21const graphHealthCheckFixture = `
22openapi: 3.0.4
23info:
24 title: Microsoft Graph Fixture
25 version: v1.0
26servers:
27 - url: https://graph.microsoft.com/v1.0
28paths:
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
51components:
52 schemas:
53 microsoft.graph.user:
54 type: object
55 properties:
56 mail:
57 type: string
58`;
59
60const 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
77const 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
96const 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
126describe("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});
packages/plugins/openapi/src/providers/microsoft/presets.tsrenamed from packages/plugins/microsoft/src/sdk/presets.ts+41−0
@@ -1,3 +1,5 @@
1import type { IntegrationPreset } from "@executor-js/sdk/core";
2
1export interface MicrosoftGraphPreset {3export interface MicrosoftGraphPreset {
2 readonly id: string;4 readonly id: string;
3 readonly name: string;5 readonly name: string;
@@ -522,3 +524,42 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines
522 orderedUnique(524 orderedUnique(
523 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),525 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),
524 );526 );
527
528export const microsoftServiceSlug = (presetId: string): string =>
529 `microsoft_${presetId.replaceAll("-", "_")}`;
530
531const microsoftGraphCatalogUrl = (presetId: string): string =>
532 `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`;
533
534const 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
551export 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);
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+92−0
@@ -0,0 +1,92 @@
1import { expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4import { structuralSplit } from "@executor-js/plugin-openapi";
5
6import { microsoftGraphAdapter } from "./spec-format-adapter";
7import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
8
9const graphFixture = `
10openapi: 3.0.4
11info:
12 title: Microsoft Graph Fixture
13 version: v1.0
14servers:
15 - url: https://graph.microsoft.com/v1.0
16paths:
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
35components:
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
47const 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
61it.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
79it.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);
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+52−0
@@ -0,0 +1,52 @@
1import { Effect } from "effect";
2
3import type { SpecFormatAdapter } from "../../sdk/spec-format";
4
5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6
7const 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
22export 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};
packages/plugins/openapi/src/react/AddOpenApiSource.test.ts+68−1
@@ -1,6 +1,20 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as React from "react";
2 3
3import { baseUrlFromSpecInput, openApiPreviewFailureMessage } from "./AddOpenApiSource";4import {
5 AddOpenApiHealthCheckSection,
6 baseUrlFromSpecInput,
7 openApiPreviewFailureMessage,
8} from "./AddOpenApiSource";
9
10const 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
5describe("baseUrlFromSpecInput", () => {19describe("baseUrlFromSpecInput", () => {
6 it("defaults URL-hosted specs to their origin", () => {20 it("defaults URL-hosted specs to their origin", () => {
@@ -27,3 +41,56 @@ describe("openApiPreviewFailureMessage", () => { · 20 unmodified lines
27 );41 );
28 });42 });
29});43});
44
45describe("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});
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+105−37
@@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6 6
7import {7import {
8 AuthTemplateSlug,
8 IntegrationSlug,9 IntegrationSlug,
9 type HealthCheckCandidate,10 type HealthCheckCandidate,
10 type HealthCheckSpec,11 type HealthCheckSpec,
11} from "@executor-js/sdk/shared";12} from "@executor-js/sdk/shared";
13import { useIntegrationPlugins } from "@executor-js/sdk/client";
12import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";14import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";
13import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";15import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";
14import {16import {
@@ -43,7 +45,6 @@ import { · 28 unmodified lines
43} from "./auth-method-config";45} from "./auth-method-config";
44import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";46import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
45import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";47import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
46import { openApiPresets } from "../sdk/presets";
47import type { SpecPreviewSummary } from "../sdk/preview";48import type { SpecPreviewSummary } from "../sdk/preview";
48import { type Authentication } from "../sdk/types";49import { type Authentication } from "../sdk/types";
49import { resolveServerUrl } from "../sdk/openapi-utils";50import { resolveServerUrl } from "../sdk/openapi-utils";
@@ -82,6 +83,58 @@ export const baseUrlFromSpecInput = (input: string): string => { · 32 unmodified lines
82export const openApiPreviewFailureMessage = (message: string | null | undefined): string =>83export const openApiPreviewFailureMessage = (message: string | null | undefined): string =>
83 `Couldn't load or parse this spec: ${message && message.trim().length > 0 ? message : "unknown error"}`;84 `Couldn't load or parse this spec: ${message && message.trim().length > 0 ? message : "unknown error"}`;
84 85
86export function AddOpenApiHealthCheckSection(props: {
87 readonly presetHealthCheck?: HealthCheckSpec;
88 readonly candidates: readonly HealthCheckCandidate[];
89 readonly selected: HealthCheckCandidate | null;
90 readonly operation: string;
91 readonly onOperationChange: (operation: string) => void;
92 readonly identityField: string;
93 readonly onIdentityFieldChange: (field: string) => void;
94 readonly args: Record<string, string>;
95 readonly onArgChange: (name: string, value: string) => void;
96 readonly disabled: boolean;
97}) {
98 if (props.presetHealthCheck) {
99 return (
100 <section className="space-y-2">
101 <h3 className="text-sm font-medium text-foreground">Health check</h3>
102 <p className="text-[11px] text-muted-foreground">
103 <span className="font-mono text-foreground">{props.presetHealthCheck.operation}</span>{" "}
104 From the catalog. Change it later from the integration page.
105 </p>
106 </section>
107 );
108 }
109
110 if (props.candidates.length === 0) return null;
111
112 return (
113 <section className="space-y-4">
114 <div className="space-y-1">
115 <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3>
116 <p className="text-[11px] text-muted-foreground">
117 An optional read-only probe adds live upstream depth. For OAuth connections, validity and
118 identity come from the OAuth grant itself. For non-OAuth methods, the probe can validate
119 the credential and identify the account.
120 </p>
121 </div>
122 <HealthCheckConfigFields
123 candidates={props.candidates}
124 selected={props.selected}
125 operation={props.operation}
126 onOperationChange={props.onOperationChange}
127 identityField={props.identityField}
128 onIdentityFieldChange={props.onIdentityFieldChange}
129 args={props.args}
130 onArgChange={props.onArgChange}
131 disabled={props.disabled}
132 idPrefix="add-health-check"
133 />
134 </section>
135 );
136}
137
85// ---------------------------------------------------------------------------138// ---------------------------------------------------------------------------
86// Component: single progressive form. Post-redesign: preview -> addSpec139// Component: single progressive form. Post-redesign: preview -> addSpec
87// (register the integration catalog entry with ALL detected auth methods) →140// (register the integration catalog entry with ALL detected auth methods) →
@@ -96,6 +149,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
96 initialPreset?: string;149 initialPreset?: string;
97 initialNamespace?: string;150 initialNamespace?: string;
98}) {151}) {
152 const integrationPlugins = useIntegrationPlugins();
153 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
154 const openApiPresets = openApiPlugin?.presets;
155 const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
99 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");156 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
100 const [analyzing, setAnalyzing] = useState(false);157 const [analyzing, setAnalyzing] = useState(false);
101 const [analyzeError, setAnalyzeError] = useState<string | null>(null);158 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
@@ -106,7 +163,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
106 // Agent-visible description: prefilled from the spec's `info.description`163 // Agent-visible description: prefilled from the spec's `info.description`
107 // until the user types (null = untouched, keep deriving from the preview).164 // until the user types (null = untouched, keep deriving from the preview).
108 const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);165 const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
109 const identityFallbackName = preview ? Option.getOrElse(preview.title, () => "") : "";166 const identityFallbackName =
167 initialPreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : "");
110 const identity = useIntegrationIdentity({168 const identity = useIntegrationIdentity({
111 fallbackName: identityFallbackName,169 fallbackName: identityFallbackName,
112 fallbackNamespace: props.initialNamespace,170 fallbackNamespace: props.initialNamespace,
@@ -159,12 +217,13 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines
159 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})217 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
160 : "";218 : "";
161 const previewPresetIcon =219 const previewPresetIcon =
162 openApiPresets.find(220 openApiPresets?.find(
163 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),221 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
164 )?.icon ?? null;222 )?.icon ?? null;
165 223
166 const resolvedBaseUrl = baseUrl.trim();224 const resolvedBaseUrl = baseUrl.trim();
167 const resolvedSourceId =225 const resolvedSourceId =
226 initialPreset?.defaultSlug ||
168 slugifyNamespace(identity.namespace) ||227 slugifyNamespace(identity.namespace) ||
169 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");228 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
170 const resolvedDisplayName =229 const resolvedDisplayName =
@@ -176,15 +235,26 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
176 // Register EVERY spec-detected auth method, not just a single selected one.235 // Register EVERY spec-detected auth method, not just a single selected one.
177 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the236 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
178 // freshly-allocated `?? []` fallback arrays.237 // freshly-allocated `?? []` fallback arrays.
179 const authenticationTemplate: readonly Authentication[] = useMemo(238 const authenticationTemplate: readonly Authentication[] = useMemo(() => {
180 () =>239 if (initialPreset?.authTemplate) {
181 detectedAuthenticationTemplates(240 return initialPreset.authTemplate.flatMap((template): readonly Authentication[] =>
182 preview?.headerPresets ?? [],241 template.kind === "oauth2"
183 preview?.oauth2Presets ?? [],242 ? [
184 resolvedBaseUrl,243 {
185 ),244 ...template,
186 [preview, resolvedBaseUrl],245 slug: AuthTemplateSlug.make(template.slug),
187 );246 resource: template.resource ?? undefined,
247 },
248 ]
249 : [],
250 );
251 }
252 return detectedAuthenticationTemplates(
253 preview?.headerPresets ?? [],
254 preview?.oauth2Presets ?? [],
255 resolvedBaseUrl,
256 );
257 }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]);
188 258
189 // Editable auth methods, seeded from the spec-detected templates. The add flow259 // Editable auth methods, seeded from the spec-detected templates. The add flow
190 // registers EVERY method (P6), so this is a LIST, preserving multi-method260 // registers EVERY method (P6), so this is a LIST, preserving multi-method
@@ -271,7 +341,9 @@ export default function AddOpenApiSource(props: { · 80 unmodified lines
271 setAnalyzing(true);341 setAnalyzing(true);
272 setAnalyzeError(null);342 setAnalyzeError(null);
273 setAddError(null);343 setAddError(null);
274 const exit = await doPreview({ payload: { spec: specUrl } });344 const exit = await doPreview({
345 payload: { spec: specUrl, specFormat: initialPreset?.specFormat },
346 });
275 if (Exit.isFailure(exit)) {347 if (Exit.isFailure(exit)) {
276 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));348 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));
277 setAnalyzing(false);349 setAnalyzing(false);
@@ -298,6 +370,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
298 ? { description: resolvedDescription.trim() }370 ? { description: resolvedDescription.trim() }
299 : {}),371 : {}),
300 baseUrl: resolvedBaseUrl,372 baseUrl: resolvedBaseUrl,
373 specFormat: initialPreset?.specFormat,
374 family: initialPreset?.family,
375 healthCheck: initialPreset?.healthCheck,
301 // Always send the edited method list (even empty) when the user has376 // Always send the edited method list (even empty) when the user has
302 // inspected a preview: an explicit [] means "no auth methods", while377 // inspected a preview: an explicit [] means "no auth methods", while
303 // OMITTING the field tells the server to derive defaults from the378 // OMITTING the field tells the server to derive defaults from the
@@ -320,6 +395,9 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
320 resolvedDescription,395 resolvedDescription,
321 resolvedBaseUrl,396 resolvedBaseUrl,
322 editedAuthenticationTemplate,397 editedAuthenticationTemplate,
398 initialPreset?.family,
399 initialPreset?.healthCheck,
400 initialPreset?.specFormat,
323 ]);401 ]);
324 402
325 const handleAdd = async () => {403 const handleAdd = async () => {
@@ -465,30 +543,20 @@ export default function AddOpenApiSource(props: { · 139 unmodified lines
465 />543 />
466 )}544 )}
467 545
468 {preview && healthCheckCandidates.length > 0 && (546 {preview ? (
469 <section className="space-y-4">547 <AddOpenApiHealthCheckSection
470 <div className="space-y-1">548 presetHealthCheck={initialPreset?.healthCheck}
471 <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3>549 candidates={healthCheckCandidates}
472 <p className="text-[11px] text-muted-foreground">550 selected={hcSelected}
473 One read-only call Executor runs to tell whether a connection's credential is still551 operation={hcOperation}
474 alive and, optionally, whose account it is. You can change this later, and run a live552 onOperationChange={onHcOperationChange}
475 preview against a key, from the integration page.553 identityField={hcIdentityField}
476 </p>554 onIdentityFieldChange={setHcIdentityField}
477 </div>555 args={hcArgs}
478 <HealthCheckConfigFields556 onArgChange={onHcArgChange}
479 candidates={healthCheckCandidates}557 disabled={adding}
480 selected={hcSelected}558 />
481 operation={hcOperation}559 ) : null}
482 onOperationChange={onHcOperationChange}
483 identityField={hcIdentityField}
484 onIdentityFieldChange={setHcIdentityField}
485 args={hcArgs}
486 onArgChange={onHcArgChange}
487 disabled={adding}
488 idPrefix="add-health-check"
489 />
490 </section>
491 )}
492 560
493 {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}561 {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
494 562
packages/plugins/openapi/src/react/plugin-client.tsx+7−5
@@ -12,9 +12,11 @@ · 11 unmodified lines
12 12
13import { defineClientPlugin } from "@executor-js/sdk/client";13import { defineClientPlugin } from "@executor-js/sdk/client";
14 14
15import { openApiIntegrationPlugin } from "./source-plugin";15import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin";
16 16
17export default defineClientPlugin({17export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) {
18 id: "openapi" as const,18 return defineClientPlugin({
19 integrationPlugin: openApiIntegrationPlugin,19 id: "openapi" as const,
20});20 integrationPlugin: createOpenApiIntegrationPlugin(config),
21 });
22}
packages/plugins/openapi/src/react/source-plugin.ts+12−4
@@ -1,21 +1,29 @@
1import { lazy } from "react";1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";
3import { openApiPresets } from "../sdk/presets";3import { openApiPresets } from "../sdk/presets";
4 4
5const importAdd = () => import("./AddOpenApiSource");5const importAdd = () => import("./AddOpenApiSource");
6const importEditSheet = () => import("./UpdateSpecSection");6const importEditSheet = () => import("./UpdateSpecSection");
7const importAccounts = () => import("./OpenApiAccountsPanel");7const importAccounts = () => import("./OpenApiAccountsPanel");
8 8
9export const openApiIntegrationPlugin: IntegrationPlugin = {9export interface OpenApiClientConfig {
10 readonly presets?: readonly IntegrationPreset[];
11}
12
13export const createOpenApiIntegrationPlugin = (
14 config?: OpenApiClientConfig,
15): IntegrationPlugin => ({
10 key: "openapi",16 key: "openapi",
11 label: "OpenAPI",17 label: "OpenAPI",
12 add: lazy(importAdd),18 add: lazy(importAdd),
13 editSheet: lazy(importEditSheet),19 editSheet: lazy(importEditSheet),
14 accounts: lazy(importAccounts),20 accounts: lazy(importAccounts),
15 presets: openApiPresets,21 presets: [...openApiPresets, ...(config?.presets ?? [])],
16 preload: () => {22 preload: () => {
17 void importAdd();23 void importAdd();
18 void importEditSheet();24 void importEditSheet();
19 void importAccounts();25 void importAccounts();
20 },26 },
21};27});
28
29export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/config.ts+4−0
@@ -51,6 +51,10 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 50 unmodified lines
51 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),51 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
52 /** Static query params applied to every request (no secret material). */52 /** Static query params applied to every request (no secret material). */
53 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),53 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
54 /** Adapter id used to fetch/convert the source format, when not plain OpenAPI. */
55 specFormat: Schema.optional(Schema.String),
56 /** Catalog family for grouped integration display. */
57 family: Schema.optional(Schema.String),
54 /** The auth methods a connection's value can be applied through. */58 /** The auth methods a connection's value can be applied through. */
55 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),59 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
56});60});
packages/plugins/openapi/src/sdk/index.ts+8−0
@@ -51,6 +51,14 @@ export { · 50 unmodified lines
51 type OpenApiPluginExtension,51 type OpenApiPluginExtension,
52 type OpenApiPluginOptions,52 type OpenApiPluginOptions,
53} from "./plugin";53} from "./plugin";
54export {
55 resolveSpecFormatAdapter,
56 type ConvertedSpec,
57 type DerivedIdentity,
58 type SpecFetchCredentials,
59 type SpecFetchInput,
60 type SpecFormatAdapter,
61} from "./spec-format";
54export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store";62export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store";
55export {63export {
56 decodeOpenApiIntegrationConfig,64 decodeOpenApiIntegrationConfig,
packages/plugins/openapi/src/sdk/plugin.ts+197−60
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 IntegrationSlug,9 IntegrationSlug,
10 ToolResult,10 ToolResult,
11 definePlugin,11 definePlugin,
12 HealthCheckSpec,
12 mergeAuthTemplates,13 mergeAuthTemplates,
13 sha256Hex,14 sha256Hex,
14 tool,15 tool,
@@ -33,13 +34,19 @@ import { · 18 unmodified lines
33 type SpecPreview,34 type SpecPreview,
34} from "./preview";35} from "./preview";
35import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";36import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
36import { openApiPresets } from "./presets";37import { openApiPresets, type OpenApiPreset } from "./presets";
37import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";38import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
39import {
40 resolveSpecFormatAdapter,
41 type ConvertedSpec,
42 type SpecFormatAdapter,
43} from "./spec-format";
38import type { Authentication } from "./types";44import type { Authentication } from "./types";
39import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";45import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
40import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";46import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
41import {47import {
42 checkHealthOpenApi,48 checkHealthOpenApi,
49 compileAndPersistOpenApiSpecStreaming,
43 compileOpenApiSpec,50 compileOpenApiSpec,
44 invokeOpenApiBackedTool,51 invokeOpenApiBackedTool,
45 listHealthCheckCandidatesOpenApi,52 listHealthCheckCandidatesOpenApi,
@@ -59,6 +66,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 13 unmodified lines
59 66
60export interface OpenApiPreviewInput {67export interface OpenApiPreviewInput {
61 readonly spec: string;68 readonly spec: string;
69 readonly specFormat?: string;
62}70}
63 71
64/** Add an OpenAPI integration to the catalog. The integration is the API72/** Add an OpenAPI integration to the catalog. The integration is the API
@@ -67,7 +75,7 @@ export interface OpenApiPreviewInput { · 2 unmodified lines
67export interface OpenApiSpecConfig {75export interface OpenApiSpecConfig {
68 readonly spec: OpenApiSpecInput;76 readonly spec: OpenApiSpecInput;
69 /** The catalog slug for the new integration (the `<integration>` segment). */77 /** The catalog slug for the new integration (the `<integration>` segment). */
70 readonly slug: string;78 readonly slug?: string;
71 /** Display name (defaults to the spec title). */79 /** Display name (defaults to the spec title). */
72 readonly name?: string;80 readonly name?: string;
73 /** Agent-visible description (defaults to the spec's `info.description`,81 /** Agent-visible description (defaults to the spec's `info.description`,
@@ -78,6 +86,9 @@ export interface OpenApiSpecConfig { · 4 unmodified lines
78 readonly headers?: Record<string, string>;86 readonly headers?: Record<string, string>;
79 /** Static query params applied to every request. */87 /** Static query params applied to every request. */
80 readonly queryParams?: Record<string, string>;88 readonly queryParams?: Record<string, string>;
89 readonly specFormat?: string;
90 readonly family?: string;
91 readonly healthCheck?: HealthCheckSpec;
81 /** Auth methods a connection's value renders through - canonical92 /** Auth methods a connection's value renders through - canonical
82 * placements or the request-shaped authoring dialect. */93 * placements or the request-shaped authoring dialect. */
83 readonly authenticationTemplate?: readonly AuthenticationInput[];94 readonly authenticationTemplate?: readonly AuthenticationInput[];
@@ -88,14 +99,16 @@ export interface OpenApiExtensionFailure { · 4 unmodified lines
88}99}
89 100
90/** Add / merge custom auth methods onto an existing OpenAPI integration's101/** 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`. */
92export interface OpenApiConfigureInput {104export interface OpenApiConfigureInput {
93 /** The auth methods to add. Each entry is appended to (or, when its `slug`105 /** The auth methods to add. Each entry is appended to (or, when its `slug`
94 * already exists, replaces) the integration's existing template array. A106 * already exists, replaces) the integration's existing template array. A
95 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`107 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
96 * slug that is collision-checked against the existing template. */108 * slug that is collision-checked against the existing template. */
97 readonly authenticationTemplate: readonly AuthenticationInput[];109 readonly authenticationTemplate?: readonly AuthenticationInput[];
98 readonly mode?: "merge" | "replace";110 readonly mode?: "merge" | "replace";
111 readonly baseUrl?: string;
99}112}
100 113
101/** What changed in the tool catalog when a spec was updated in place. Tool114/** 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
166const PreviewSpecInputSchema = Schema.Struct({179const PreviewSpecInputSchema = Schema.Struct({
167 spec: Schema.String,180 spec: Schema.String,
181 specFormat: Schema.optional(Schema.String),
168});182});
169 183
170const StaticPreviewServerVariableSchema = Schema.Struct({184const StaticPreviewServerVariableSchema = Schema.Struct({
@@ -261,11 +275,15 @@ const AuthenticationSchema = Schema.Union([ · 90 unmodified lines
261 275
262const AddSourceInputSchema = Schema.Struct({276const AddSourceInputSchema = Schema.Struct({
263 spec: OpenApiSpecInputSchema,277 spec: OpenApiSpecInputSchema,
264 slug: Schema.String,278 slug: Schema.optional(Schema.String),
279 name: Schema.optional(Schema.String),
265 description: Schema.optional(Schema.String),280 description: Schema.optional(Schema.String),
266 baseUrl: Schema.optional(Schema.String),281 baseUrl: Schema.optional(Schema.String),
267 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),282 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
268 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),283 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
284 specFormat: Schema.optional(Schema.String),
285 family: Schema.optional(Schema.String),
286 healthCheck: Schema.optional(HealthCheckSpec),
269 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),287 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
270});288});
271 289
@@ -553,9 +571,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines
553 571
554export const describeOpenApiIntegrationDisplay = (572export const describeOpenApiIntegrationDisplay = (
555 record: IntegrationRecord,573 record: IntegrationRecord,
556): { readonly url?: string } => {574): { readonly url?: string; readonly family?: string } => {
557 const config = decodeOpenApiIntegrationConfig(record.config);575 const config = decodeOpenApiIntegrationConfig(record.config);
558 return { url: config?.baseUrl ?? config?.sourceUrl };576 return {
577 url: config?.baseUrl ?? config?.sourceUrl,
578 ...(config?.family ? { family: config.family } : {}),
579 };
559};580};
560 581
561// ---------------------------------------------------------------------------582// ---------------------------------------------------------------------------
@@ -565,36 +586,65 @@ export const describeOpenApiIntegrationDisplay = ( · 3 unmodified lines
565export interface OpenApiPluginOptions {586export interface OpenApiPluginOptions {
566 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;587 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
567 readonly invokeOptions?: InvokeOptions;588 readonly invokeOptions?: InvokeOptions;
589 readonly specFormats?: readonly SpecFormatAdapter[];
590 readonly presets?: readonly OpenApiPreset[];
568}591}
569 592
570export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {593export const openApiPlugin = definePlugin<
594 "openapi",
595 OpenApiPluginExtension,
596 OpenapiStore,
597 OpenApiPluginOptions
598>((options?: OpenApiPluginOptions) => {
571 const resolveSpecForInput = (599 const resolveSpecForInput = (
572 spec: OpenApiSpecInput,600 config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">,
573 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,601 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
574 ): Effect.Effect<602 ): Effect.Effect<ConvertedSpec, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError> =>
575 {
576 readonly specText: string;
577 },
578 OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError
579 > =>
580 Effect.gen(function* () {603 Effect.gen(function* () {
581 if (spec.kind === "url") {604 const adapter = yield* resolveSpecFormatAdapter(
582 const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));605 options?.specFormats ?? [],
583 return { specText };606 config.specFormat,
607 );
608 if (adapter) {
609 if (config.spec.kind !== "url") {
610 return yield* new OpenApiParseError({
611 message: "Spec format adapters require a URL spec input",
612 });
613 }
614 return yield* adapter.fetch({
615 urls: [config.spec.url],
616 credentials: {
617 ...(config.headers ? { headers: config.headers } : {}),
618 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
619 },
620 httpClientLayer,
621 });
622 }
623 if (config.spec.kind === "url") {
624 const specText = yield* resolveSpecText(config.spec.url).pipe(
625 Effect.provide(httpClientLayer),
626 );
627 return { specText, sourceUrl: config.spec.url };
584 }628 }
585 return { specText: spec.value };629 return { specText: config.spec.value };
586 });630 });
587 631
588 return {632 return {
589 id: "openapi" as const,633 id: "openapi" as const,
590 packageName: "@executor-js/plugin-openapi",634 packageName: "@executor-js/plugin-openapi",
591 integrationPresets: openApiPresets.map((preset) => ({635 clientConfig: options?.presets ? { presets: options.presets } : undefined,
636 integrationPresets: [...openApiPresets, ...(options?.presets ?? [])].map((preset) => ({
592 id: preset.id,637 id: preset.id,
593 name: preset.name,638 name: preset.name,
594 summary: preset.summary,639 summary: preset.summary,
595 ...(preset.url ? { url: preset.url } : {}),640 ...(preset.url ? { url: preset.url } : {}),
596 ...(preset.icon ? { icon: preset.icon } : {}),641 ...(preset.icon ? { icon: preset.icon } : {}),
597 ...(preset.featured ? { featured: preset.featured } : {}),642 ...(preset.featured ? { featured: preset.featured } : {}),
643 ...(preset.family ? { family: preset.family } : {}),
644 ...(preset.specFormat ? { specFormat: preset.specFormat } : {}),
645 ...(preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}),
646 ...(preset.authTemplate ? { authTemplate: preset.authTemplate } : {}),
647 ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}),
598 })),648 })),
599 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),649 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
600 650
@@ -650,8 +700,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 49 unmodified lines
650 Effect.gen(function* () {700 Effect.gen(function* () {
651 // Resolve URL → text and parse BEFORE opening a transaction. Holding701 // Resolve URL → text and parse BEFORE opening a transaction. Holding
652 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.702 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.
653 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);703 const resolved = yield* resolveSpecForInput(config, httpClientLayer);
654 const compiled = yield* compileOpenApiSpec(resolved.specText);704 const compiled = resolved.keepPathItem
705 ? undefined
706 : yield* compileOpenApiSpec(resolved.specText);
707 const adapter = yield* resolveSpecFormatAdapter(
708 options?.specFormats ?? [],
709 config.specFormat,
710 );
711 const derivedIdentity =
712 adapter?.deriveIdentity && resolved.document
713 ? adapter.deriveIdentity(resolved.document)
714 : null;
715 const resolvedSlug = config.slug?.trim() || derivedIdentity?.slug;
716 if (!resolvedSlug) {
717 return yield* new OpenApiParseError({
718 message: "OpenAPI integration slug is required",
719 });
720 }
655 721
656 // Defaults the add page derives from its preview, applied here so722 // Defaults the add page derives from its preview, applied here so
657 // headless callers (MCP, API) get the same integration the UI's723 // headless callers (MCP, API) get the same integration the UI's
@@ -665,7 +731,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines
665 // end with nowhere to paste a credential)731 // end with nowhere to paste a credential)
666 // An explicit input always wins; for auth, an explicit EMPTY array732 // An explicit input always wins; for auth, an explicit EMPTY array
667 // means "no auth methods" and suppresses the derivation.733 // means "no auth methods" and suppresses the derivation.
668 const explicitBaseUrl = config.baseUrl;734 const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
669 const needsDerivedBaseUrl = explicitBaseUrl == null;735 const needsDerivedBaseUrl = explicitBaseUrl == null;
670 const needsDerivedAuth = config.authenticationTemplate == null;736 const needsDerivedAuth = config.authenticationTemplate == null;
671 const preview =737 const preview =
@@ -675,8 +741,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines
675 enrichPreviewWithDiscoveredOAuth({741 enrichPreviewWithDiscoveredOAuth({
676 specText: resolved.specText,742 specText: resolved.specText,
677 preview: rawPreview,743 preview: rawPreview,
678 sourceUrl: specInputToSourceUrl(config.spec),744 sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec),
679 baseUrl: config.baseUrl,745 baseUrl: explicitBaseUrl,
680 }),746 }),
681 ),747 ),
682 )748 )
@@ -689,7 +755,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 6 unmodified lines
689 ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)755 ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)
690 : undefined;756 : undefined;
691 757
692 const slug = IntegrationSlug.make(config.slug);758 const slug = IntegrationSlug.make(resolvedSlug);
693 759
694 // Block re-adding an existing slug. The core `integrations.register`760 // Block re-adding an existing slug. The core `integrations.register`
695 // primitive upserts (so boot re-registration is idempotent), but an761 // primitive upserts (so boot re-registration is idempotent), but an
@@ -704,9 +770,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 8 unmodified lines
704 const specHash = yield* sha256Hex(resolved.specText);770 const specHash = yield* sha256Hex(resolved.specText);
705 771
706 const integrationConfig: OpenApiIntegrationConfig = {772 const integrationConfig: OpenApiIntegrationConfig = {
773 ...(resolved.config ?? {}),
707 specHash,774 specHash,
708 ...(specInputToSourceUrl(config.spec) !== undefined775 ...((resolved.sourceUrl ?? specInputToSourceUrl(config.spec)) !== undefined
709 ? { sourceUrl: specInputToSourceUrl(config.spec) }776 ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec) }
710 : {}),777 : {}),
711 // baseUrl is an optional override only. The host is otherwise778 // baseUrl is an optional override only. The host is otherwise
712 // resolved per call from the operation's `servers` (extracted from779 // resolved per call from the operation's `servers` (extracted from
@@ -714,15 +781,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
714 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),781 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
715 ...(config.headers ? { headers: config.headers } : {}),782 ...(config.headers ? { headers: config.headers } : {}),
716 ...(config.queryParams ? { queryParams: config.queryParams } : {}),783 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
784 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
785 ...(config.family ? { family: config.family } : {}),
717 // Prefer the caller's explicit template; otherwise derive from the786 // Prefer the caller's explicit template; otherwise derive from the
718 // spec's declared security schemes.787 // spec's declared security schemes.
719 ...(config.authenticationTemplate788 ...(config.authenticationTemplate
720 ? {789 ? {
721 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),790 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
722 }791 }
723 : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0792 : resolved.authenticationTemplate && resolved.authenticationTemplate.length > 0
724 ? { authenticationTemplate: derivedAuthenticationTemplate }793 ? { authenticationTemplate: resolved.authenticationTemplate }
725 : {}),794 : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
795 ? { authenticationTemplate: derivedAuthenticationTemplate }
796 : {}),
726 };797 };
727 798
728 // The spec blob is written OUTSIDE the transaction: it's799 // The spec blob is written OUTSIDE the transaction: it's
@@ -733,27 +804,53 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
733 // The content-addressed defs blob lets the serve path resolve the804 // The content-addressed defs blob lets the serve path resolve the
734 // shared `definitions` without re-parsing the spec. Same idempotent,805 // shared `definitions` without re-parsing the spec. Same idempotent,
735 // outside-the-transaction rationale as the spec blob.806 // outside-the-transaction rationale as the spec blob.
736 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));807 if (compiled) {
808 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
809 }
737 810
738 yield* ctx.transaction(811 yield* ctx.transaction(
739 Effect.gen(function* () {812 Effect.gen(function* () {
740 yield* ctx.core.integrations.register({813 yield* ctx.core.integrations.register({
741 slug,814 slug,
742 name: config.name?.trim() || compiled.title || config.slug,815 name:
816 config.name?.trim() || derivedIdentity?.name || compiled?.title || resolvedSlug,
743 description:817 description:
744 config.description ?? compiled.description ?? compiled.title ?? config.slug,818 config.description ??
819 derivedIdentity?.description ??
820 compiled?.description ??
821 compiled?.title ??
822 resolvedSlug,
745 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,823 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
746 canRemove: true,824 canRemove: true,
747 canRefresh: specInputToSourceUrl(config.spec) != null,825 canRefresh: integrationConfig.sourceUrl != null,
826 });
827 if (config.healthCheck) {
828 yield* ctx.core.integrations.setHealthCheck(slug, config.healthCheck);
829 }
830 if (compiled) {
831 yield* ctx.storage.putOperations(
832 resolvedSlug,
833 openApiStoredOperationsFromCompiled(resolvedSlug, compiled),
834 );
835 return compiled.definitions.length;
836 }
837 const persisted = yield* compileAndPersistOpenApiSpecStreaming({
838 specText: resolved.specText,
839 integration: resolvedSlug,
840 storage: ctx.storage,
841 specHash,
842 keepPathItem: resolved.keepPathItem,
748 });843 });
749 yield* ctx.storage.putOperations(844 return persisted.toolCount;
750 config.slug,
751 openApiStoredOperationsFromCompiled(config.slug, compiled),
752 );
753 }),845 }),
754 );846 );
755 847
756 return { slug, toolCount: compiled.definitions.length };848 const toolCount = compiled
849 ? compiled.definitions.length
850 : yield* ctx.storage
851 .listOperations(resolvedSlug)
852 .pipe(Effect.map((operations) => operations.length));
853 return { slug, toolCount };
757 });854 });
758 855
759 // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a856 // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a
@@ -784,12 +881,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 24 unmodified lines
784 881
785 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock882 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock
786 // rule as addSpec: never hold BEGIN across a network fetch).883 // rule as addSpec: never hold BEGIN across a network fetch).
787 const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);884 const resolved = yield* resolveSpecForInput(
788 const compiled = yield* compileOpenApiSpec(resolved.specText);885 {
886 spec: specInput,
887 specFormat: current.specFormat,
888 headers: current.headers,
889 queryParams: current.queryParams,
890 baseUrl: current.baseUrl,
891 },
892 httpClientLayer,
893 );
894 const compiled = resolved.keepPathItem
895 ? undefined
896 : yield* compileOpenApiSpec(resolved.specText);
789 897
790 const previousOperations = yield* ctx.storage.listOperations(rawSlug);898 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
791 const previousNames = new Set(previousOperations.map((op) => op.toolName));899 const previousNames = new Set(previousOperations.map((op) => op.toolName));
792 const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
793 900
794 // The resolved spec text lives in the plugin blob store keyed by its901 // The resolved spec text lives in the plugin blob store keyed by its
795 // content hash (`spec/<hash>`); the config carries only the hash. Put902 // content hash (`spec/<hash>`); the config carries only the hash. Put
@@ -797,13 +904,15 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
797 // aborted config update just leaves an unreferenced blob.904 // aborted config update just leaves an unreferenced blob.
798 const specHash = yield* sha256Hex(resolved.specText);905 const specHash = yield* sha256Hex(resolved.specText);
799 yield* ctx.storage.putSpec(specHash, resolved.specText);906 yield* ctx.storage.putSpec(specHash, resolved.specText);
800 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));907 if (compiled) {
908 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
909 }
801 910
802 const nextConfig: OpenApiIntegrationConfig = {911 const nextConfig: OpenApiIntegrationConfig = {
803 ...current,912 ...current,
804 specHash,913 specHash,
805 ...(specInputToSourceUrl(specInput) !== undefined914 ...((resolved.sourceUrl ?? specInputToSourceUrl(specInput)) !== undefined
806 ? { sourceUrl: specInputToSourceUrl(specInput) }915 ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(specInput) }
807 : {}),916 : {}),
808 };917 };
809 918
@@ -812,13 +921,28 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines
812 yield* ctx.core.integrations.update(slug, {921 yield* ctx.core.integrations.update(slug, {
813 config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,922 config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
814 });923 });
815 yield* ctx.storage.putOperations(924 if (compiled) {
816 rawSlug,925 yield* ctx.storage.putOperations(
817 openApiStoredOperationsFromCompiled(rawSlug, compiled),926 rawSlug,
818 );927 openApiStoredOperationsFromCompiled(rawSlug, compiled),
928 );
929 } else {
930 yield* compileAndPersistOpenApiSpecStreaming({
931 specText: resolved.specText,
932 integration: rawSlug,
933 storage: ctx.storage,
934 specHash,
935 keepPathItem: resolved.keepPathItem,
936 });
937 }
819 }),938 }),
820 );939 );
821 940
941 const nextOperations = compiled
942 ? openApiStoredOperationsFromCompiled(rawSlug, compiled)
943 : yield* ctx.storage.listOperations(rawSlug);
944 const nextNames = new Set(nextOperations.map((op) => op.toolName));
945
822 // Rebuild each connection's tool rows from the new spec. Outside the946 // Rebuild each connection's tool rows from the new spec. Outside the
823 // transaction: refresh opens its own, and a half-refreshed catalog947 // transaction: refresh opens its own, and a half-refreshed catalog
824 // self-heals on the next refresh anyway.948 // self-heals on the next refresh anyway.
@@ -843,7 +967,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 18 unmodified lines
843 967
844 return {968 return {
845 slug,969 slug,
846 toolCount: compiled.definitions.length,970 toolCount: nextNames.size,
847 addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),971 addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
848 removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),972 removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
849 } satisfies UpdateSpecResult;973 } satisfies UpdateSpecResult;
@@ -857,14 +981,18 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines
857 previewSpec: (input: string | OpenApiPreviewInput) =>981 previewSpec: (input: string | OpenApiPreviewInput) =>
858 Effect.gen(function* () {982 Effect.gen(function* () {
859 const previewInput = typeof input === "string" ? { spec: input } : input;983 const previewInput = typeof input === "string" ? { spec: input } : input;
860 const specText = yield* resolveSpecText(previewInput.spec).pipe(984 const spec = maybeUrl(previewInput.spec.trim())
861 Effect.provide(httpClientLayer),985 ? { kind: "url" as const, url: previewInput.spec.trim() }
986 : { kind: "blob" as const, value: previewInput.spec };
987 const resolved = yield* resolveSpecForInput(
988 { spec, specFormat: previewInput.specFormat },
989 httpClientLayer,
862 );990 );
863 const preview = yield* previewSpecText(specText);991 const preview = yield* previewSpecText(resolved.specText);
864 return yield* enrichPreviewWithDiscoveredOAuth({992 return yield* enrichPreviewWithDiscoveredOAuth({
865 specText,993 specText: resolved.specText,
866 preview,994 preview,
867 sourceUrl: maybeUrl(previewInput.spec.trim()) ? previewInput.spec.trim() : undefined,995 sourceUrl: resolved.sourceUrl ?? (spec.kind === "url" ? spec.url : undefined),
868 });996 });
869 }),997 }),
870 998
@@ -917,14 +1045,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 46 unmodified lines
917 const current = decodeOpenApiIntegrationConfig(record.config);1045 const current = decodeOpenApiIntegrationConfig(record.config);
918 if (!current) return [] as readonly Authentication[];1046 if (!current) return [] as readonly Authentication[];
919 1047
920 const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
921 const merged =1048 const merged =
922 input.mode === "replace"1049 input.authenticationTemplate === undefined
923 ? incoming1050 ? (current.authenticationTemplate ?? [])
924 : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);1051 : input.mode === "replace"
1052 ? normalizeOpenApiAuthInputs(input.authenticationTemplate)
1053 : mergeAuthTemplates(
1054 current.authenticationTemplate ?? [],
1055 normalizeOpenApiAuthInputs(input.authenticationTemplate),
1056 );
925 1057
926 const next: OpenApiIntegrationConfig = {1058 const next: OpenApiIntegrationConfig = {
927 ...current,1059 ...current,
1060 ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
928 authenticationTemplate: merged,1061 authenticationTemplate: merged,
929 };1062 };
930 1063
@@ -978,10 +1111,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 47 unmodified lines
978 .addSpec({1111 .addSpec({
979 spec: input.spec,1112 spec: input.spec,
980 slug: input.slug,1113 slug: input.slug,
1114 name: input.name,
981 description: input.description,1115 description: input.description,
982 baseUrl: input.baseUrl,1116 baseUrl: input.baseUrl,
983 headers: input.headers,1117 headers: input.headers,
984 queryParams: input.queryParams,1118 queryParams: input.queryParams,
1119 specFormat: input.specFormat,
1120 family: input.family,
1121 healthCheck: input.healthCheck,
985 authenticationTemplate: input.authenticationTemplate as1122 authenticationTemplate: input.authenticationTemplate as
986 | readonly AuthenticationInput[]1123 | readonly AuthenticationInput[]
987 | undefined,1124 | undefined,
packages/plugins/openapi/src/sdk/presets.ts+7−0
@@ -1,3 +1,5 @@
1import type { HealthCheckSpec, IntegrationPresetAuthentication } from "@executor-js/sdk/core";
2
1export interface OpenApiPreset {3export interface OpenApiPreset {
2 readonly id: string;4 readonly id: string;
3 readonly name: string;5 readonly name: string;
@@ -5,6 +7,11 @@ export interface OpenApiPreset { · 1 unmodified line
5 readonly url?: string;7 readonly url?: string;
6 readonly icon?: string;8 readonly icon?: string;
7 readonly featured?: boolean;9 readonly featured?: boolean;
10 readonly family?: string;
11 readonly specFormat?: string;
12 readonly defaultSlug?: string;
13 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
14 readonly healthCheck?: HealthCheckSpec;
8}15}
9 16
10const openApiOnlyPresets: readonly OpenApiPreset[] = [17const openApiOnlyPresets: readonly OpenApiPreset[] = [
packages/plugins/openapi/src/sdk/spec-format.test.ts+32−0
@@ -0,0 +1,32 @@
1import { expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { OpenApiParseError } from "./errors";
5import { resolveSpecFormatAdapter, type SpecFormatAdapter } from "./spec-format";
6
7const adapter: SpecFormatAdapter = {
8 id: "known",
9 fetch: () => Effect.succeed({ specText: "{}" }),
10};
11
12it.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
19it.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
26it.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);
packages/plugins/openapi/src/sdk/spec-format.ts+51−0
@@ -0,0 +1,51 @@
1import type { Layer } from "effect";
2import { Effect } from "effect";
3import type { HttpClient } from "effect/unstable/http";
4
5import type { Authentication } from "./types";
6import type { OpenApiIntegrationConfig } from "./config";
7import type { KeepPathItem } from "./split";
8import { OpenApiParseError } from "./errors";
9
10export interface SpecFetchCredentials {
11 readonly headers?: Record<string, string>;
12 readonly queryParams?: Record<string, string>;
13}
14
15export interface SpecFetchInput {
16 readonly urls: readonly string[];
17 readonly credentials?: SpecFetchCredentials;
18 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
19}
20
21export interface DerivedIdentity {
22 readonly slug: string;
23 readonly name: string;
24 readonly description?: string;
25}
26
27export 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
37export interface SpecFormatAdapter {
38 readonly id: string;
39 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
40 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;
41}
42
43export 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};
packages/plugins/openapi/tsup.config.ts+2−0
@@ -5,6 +5,8 @@ export default defineConfig({ · 4 unmodified lines
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",7 client: "src/react/plugin-client.tsx",
8 "providers/google": "src/providers/google/index.ts",
9 "providers/microsoft": "src/providers/microsoft/index.ts",
8 testing: "src/testing/index.ts",10 testing: "src/testing/index.ts",
9 },11 },
10 format: ["esm"],12 format: ["esm"],
packages/plugins/provider-service-split/CHANGELOG.md+7−0
@@ -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.
packages/plugins/provider-service-split/package.json+31−0
@@ -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}
packages/plugins/provider-service-split/src/index.ts+2−0
@@ -0,0 +1,2 @@
1export * from "./planner";
2export * from "./sqlite";
packages/plugins/provider-service-split/src/planner.test.ts+1119−0
@@ -0,0 +1,1119 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { streamOperationBindingsFromStructure, structuralSplit } from "@executor-js/plugin-openapi";
4
5import {
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";
20import { googleCatalog } from "@executor-js/plugin-openapi/providers/google";
21
22const now = "2026-01-01T00:00:00.000Z";
23
24const 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
45const 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
71const 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
89const 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
113const blob = (key: string, overrides: Partial<BlobRow> = {}): BlobRow => ({
114 id: `blob_${key}`,
115 namespace: "o:org_1/google",
116 key,
117 ...overrides,
118});
119
120const 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
134const googleDiscoveryConfig = (urls: readonly string[], specHash = "mono-hash") => ({
135 googleDiscoveryUrls: urls,
136 specHash,
137});
138
139const youtubeDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest";
140
141const 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
168const 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
183const microsoftFixtureTagForFirstSegment = (firstSegment: string): string =>
184 firstSegment.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
185
186const 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
201info:
202 title: Microsoft Graph Tool Ownership Fixture
203 version: v1.0
204paths:
205${paths}
206components: {}
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
218const 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
228describe("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});
packages/plugins/provider-service-split/src/planner.ts+1335−0
@@ -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 */
2import { createHash } from "node:crypto";
3
4import {
5 googleCatalog,
6 googlePresetForDiscoveryUrl,
7} from "@executor-js/plugin-openapi/providers/google";
8import {
9 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
10 microsoftCatalog,
11} from "@executor-js/plugin-openapi/providers/microsoft";
12import type { OpenApiPreset } from "@executor-js/plugin-openapi/presets";
13
14type MonolithPluginId = "google" | "microsoft";
15
16const 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
25const googleCatalogByDiscoveryUrl = new Map(
26 googleCatalog.flatMap((preset) =>
27 preset.url ? [[normalizeUrl(preset.url), preset] as const] : [],
28 ),
29);
30
31const 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
47export type PluginId = "openapi";
48
49export 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
65export 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
90export 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
107export 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
120export interface BlobRow {
121 readonly id: string;
122 readonly namespace: string;
123 readonly key: string;
124}
125
126export 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
139export 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
155export 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
168export 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
194export 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
207export 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
214export 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
224export 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
241export 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
263const GOOGLE_IDENTITY_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
264
265const 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
290const unique = <T>(values: Iterable<T>): readonly T[] => [...new Set(values)];
291
292export const tenantHash = (tenant: string): string =>
293 createHash("sha256").update(tenant).digest("hex").slice(0, 12);
294
295const isRecord = (value: unknown): value is Record<string, unknown> =>
296 typeof value === "object" && value !== null && !Array.isArray(value);
297
298const 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
309const stringArray = (value: unknown): readonly string[] =>
310 Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
311
312const configRecord = (integration: IntegrationRow): Record<string, unknown> =>
313 recordFromJsonLike(integration.config);
314
315const toolAddress = (
316 tool: Pick<ToolRow, "integration" | "owner" | "connection" | "name">,
317): string => `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`;
318
319const withoutToolsPrefix = (pattern: string): string =>
320 pattern.startsWith("tools.") ? pattern.slice("tools.".length) : pattern;
321
322const withOriginalToolsPrefix = (original: string, rewrittenTail: string): string =>
323 original.startsWith("tools.") ? `tools.${rewrittenTail}` : rewrittenTail;
324
325const 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
333const googleCatalogById: ReadonlyMap<string, OpenApiPreset> = new Map(
334 googleCatalog.map((preset) => [preset.id, preset]),
335);
336
337const microsoftCatalogByMonolithPresetId: ReadonlyMap<string, OpenApiPreset> = new Map(
338 microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]),
339);
340
341const MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS = new Set(["me-surface", "users", "groups"]);
342const MICROSOFT_TOOL_IMPLIED_PRESET_IDS = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
343const microsoftCatalogBackedPresetIds = new Set(
344 microsoftCatalog
345 .filter((preset) => preset.defaultSlug && preset.defaultSlug.length > 0)
346 .map((preset) => preset.id.replace(/^microsoft-/, "")),
347);
348
349export 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
444const MICROSOFT_WORKBOOK_FIRST_SEGMENTS = new Set([
445 "drivesDriveItem",
446 "groupsDrive",
447 "meDrive",
448 "usersDrive",
449]);
450
451const 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
461const 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
469const 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
505const 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
517const 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
529const 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
536export 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
544export 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
560const 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
565const 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
631const 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
646const rowKey = (...parts: readonly string[]): string => parts.join("\u0000");
647
648const 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
659export const operationStorageKey = (integration: string, toolName: string): string =>
660 `op.${stableKeyHash(integration)}.${stableKeyHash(toolName)}`;
661
662export const storageDataRecord = (row: Pick<PluginStorageRow, "data">): Record<string, unknown> =>
663 recordFromJsonLike(row.data);
664
665const operationToolName = (row: PluginStorageRow): string | undefined => {
666 const value = storageDataRecord(row).toolName;
667 return typeof value === "string" && value.length > 0 ? value : undefined;
668};
669
670const operationIntegration = (row: PluginStorageRow): string | undefined => {
671 const value = storageDataRecord(row).integration;
672 return typeof value === "string" && value.length > 0 ? value : undefined;
673};
674
675const 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
683const pluginBlobNamespace = (tenant: string, pluginId: string): string => `o:${tenant}/${pluginId}`;
684
685const blobObjectName = (namespace: string, key: string): string => `${namespace}/${key}`;
686
687const 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
705const 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
716const 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
723const isIntentionallyDroppedTool = (pluginId: MonolithPluginId, toolName: string): boolean =>
724 pluginId === "google" && toolName.startsWith("oauth2.");
725
726const 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
738const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] =>
739 services.map((service) => service.slug);
740
741const 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
753const 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
778const 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
792const 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
815const 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
823const policyMatches = (pattern: string, tool: ToolRow): boolean => {
824 const normalized = withoutToolsPrefix(pattern);
825 return matchPattern(normalized, toolAddress(tool));
826};
827
828const 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
863export 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
879export 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
978export 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
1243const printableJson = (value: unknown): string =>
1244 JSON.stringify(value, (_key, inner) => (inner === undefined ? null : inner), 2);
1245
1246export 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
1315export 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};
packages/plugins/provider-service-split/src/sqlite.test.ts+337−0
@@ -0,0 +1,337 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3import {
4 collectTables,
5 DataMigrationError,
6 runSqliteDataMigrations,
7 type SqliteDataMigrationClient,
8} from "@executor-js/sdk";
9import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
10
11import { operationStorageKey } from "./planner";
12import {
13 providerServiceSplitDataMigration,
14 runSqliteProviderServiceSplitMigration,
15} from "./sqlite";
16
17const now = 1_780_000_000_000;
18const parseJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
19
20const 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
50const 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
82const 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
91const 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
111const 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
123const 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
131const 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
163describe("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});
packages/plugins/provider-service-split/src/sqlite.ts+549−0
@@ -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 */
2import { Effect } from "effect";
3import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk";
4
5import {
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
20const MIGRATION_NAME = "2026-07-08-provider-service-split";
21const LEDGER_TABLE = "provider_service_split_org_migration";
22
23const operationKeyPrefix = (integration: string): string =>
24 `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`;
25
26const 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
35const 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
44const 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
56const 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
65const 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
71const 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
80const 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
85const 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
93const 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
286const 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
510export 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
545export const providerServiceSplitDataMigration = {
546 name: MIGRATION_NAME,
547 run: (client: SqliteDataMigrationClient) =>
548 runSqliteProviderServiceSplitMigration(client).pipe(Effect.asVoid),
549};
packages/plugins/provider-service-split/tsconfig.jsonrenamed from packages/plugins/microsoft/tsconfig.json+1−2
@@ -19,6 +19,5 @@ · 18 unmodified lines
19 }19 }
20 ]20 ]
21 },21 },
22 "include": ["src/**/*.ts", "src/**/*.tsx"],22 "include": ["src/**/*.ts"]
23 "exclude": ["src/promise.ts"]
24}23}
packages/plugins/provider-service-split/tsup.config.tsrenamed from packages/plugins/microsoft/tsup.config.ts+1−3
@@ -2,9 +2,7 @@ import { defineConfig } from "tsup"; · 1 unmodified line
2 2
3export default defineConfig({3export default defineConfig({
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/index.ts",
6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
8 },6 },
9 format: ["esm"],7 format: ["esm"],
10 dts: false,8 dts: false,
packages/plugins/provider-service-split/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 testTimeout: 15_000,
7 },
8});
packages/react/src/api/atoms.tsx+1−0
@@ -366,6 +366,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 365 unmodified lines
366 oauthClient: null,366 oauthClient: null,
367 oauthClientOwner: null,367 oauthClientOwner: null,
368 oauthScope: null,368 oauthScope: null,
369 missingOAuthScopes: [],
369 lastHealth: null,370 lastHealth: null,
370 };371 };
371 return [optimistic, ...rows];372 return [optimistic, ...rows];
packages/react/src/components/accounts-section.tsx+10−4
@@ -85,16 +85,17 @@ function AccountRow(props: { · 84 unmodified lines
85 const { probe, status, runCheck } = useConnectionHealth(connection);85 const { probe, status, runCheck } = useConnectionHealth(connection);
86 const indicator = HEALTH_INDICATOR_COLOR[status];86 const indicator = HEALTH_INDICATOR_COLOR[status];
87 87
88 // Prefer a probed identity (the live account), then the stored label, then the88 // Prefer the stored label from the connection row, then a probed identity,
89 // connection name. The probe is the whole point: it shows WHICH account this is.89 // then the connection name. OAuth labels come from the grant's OIDC claims,
90 // while health identities remain useful for non-OAuth probes.
90 const identity =91 const identity =
91 (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ??
92 (connection.identityLabel && connection.identityLabel.length > 092 (connection.identityLabel && connection.identityLabel.length > 0
93 ? connection.identityLabel93 ? connection.identityLabel
94 : null);94 : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null);
95 const displayLabel = identity ?? String(connection.name);95 const displayLabel = identity ?? String(connection.name);
96 96
97 const expired = status === "expired";97 const expired = status === "expired";
98 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
98 99
99 const handleCheck = async () => {100 const handleCheck = async () => {
100 if (checking) return;101 if (checking) return;
@@ -150,6 +151,11 @@ function AccountRow(props: { · 49 unmodified lines
150 This connection wasn't granted all the access this integration now needs.151 This connection wasn't granted all the access this integration now needs.
151 </CardStackEntryDescription>152 </CardStackEntryDescription>
152 ) : null}153 ) : null}
154 {missingOAuthScopes.length > 0 ? (
155 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
156 Missing scopes: {missingOAuthScopes.join(", ")}
157 </CardStackEntryDescription>
158 ) : null}
153 </CardStackEntryContent>159 </CardStackEntryContent>
154 <CardStackEntryActions className="self-start pt-0.5">160 <CardStackEntryActions className="self-start pt-0.5">
155 {props.showOwnerLabel ? (161 {props.showOwnerLabel ? (
packages/react/src/components/health-check-editor.tsx+4−4
@@ -477,8 +477,8 @@ function HealthCheckEditorSheet(props: { · 476 unmodified lines
477 <SheetHeader>477 <SheetHeader>
478 <SheetTitle>Health check</SheetTitle>478 <SheetTitle>Health check</SheetTitle>
479 <SheetDescription>479 <SheetDescription>
480 One read-only call Executor runs to tell whether a connection's credential is still480 Optional read-only depth for live upstream checks. OAuth validity and identity come from
481 alive and, optionally, whose account it is. A 401 or 403 marks the connection expired.481 the grant itself. For non-OAuth methods, a 401 or 403 marks the connection expired.
482 </SheetDescription>482 </SheetDescription>
483 </SheetHeader>483 </SheetHeader>
484 484
@@ -553,8 +553,8 @@ export function HealthCheckEditor(props: { · 68 unmodified lines
553 <div className="space-y-1">553 <div className="space-y-1">
554 <h3 className="text-sm font-medium">Health check</h3>554 <h3 className="text-sm font-medium">Health check</h3>
555 <p className="text-xs text-muted-foreground">555 <p className="text-xs text-muted-foreground">
556 A read-only call Executor runs to tell whether a connection's credential is still alive556 Optional read-only depth for live upstream checks. OAuth identity comes from the account
557 and, optionally, whose account it is.557 grant, while probe identity fields are useful for non-OAuth methods.
558 </p>558 </p>
559 </div>559 </div>
560 <div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5">560 <div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5">
packages/react/src/components/integration-favicon.test.tsx+22−0
@@ -224,6 +224,28 @@ describe("IntegrationFavicon", () => { · 223 unmodified lines
224 ).toBe("https://example.com/stripe.png");224 ).toBe("https://example.com/stripe.png");
225 });225 });
226 226
227 it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => {
228 expect(
229 integrationPresetIconUrl({ id: "google_gmail", kind: "openapi", name: "Gmail" }, [
230 {
231 key: "openapi",
232 label: "OpenAPI",
233 add: () => null,
234 edit: () => null,
235 presets: [
236 {
237 id: "google-gmail",
238 name: "Gmail",
239 summary: "Messages.",
240 defaultSlug: "google_gmail",
241 icon: "https://example.com/gmail.png",
242 },
243 ],
244 },
245 ]),
246 ).toBe("https://example.com/gmail.png");
247 });
248
227 it("does not split generic words into brand matches", () => {249 it("does not split generic words into brand matches", () => {
228 expect(250 expect(
229 integrationPresetIconUrl(251 integrationPresetIconUrl(
packages/react/src/components/integration-favicon.tsx+3−0
@@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines
157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
159 const presets = plugin?.presets ?? [];159 const presets = plugin?.presets ?? [];
160 const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon;
161 if (exactSlugIcon) return exactSlugIcon;
162
160 const sourceUrl = normalizeUrl(source.url);163 const sourceUrl = normalizeUrl(source.url);
161 const sourceGoogleService = googleApiServiceFromUrl(source.url);164 const sourceGoogleService = googleApiServiceFromUrl(source.url);
162 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];165 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];
packages/react/src/lib/integration-grouping.test.ts+80−0
@@ -0,0 +1,80 @@
1import { describe, expect, it } from "@effect/vitest";
2import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared";
3
4import {
5 groupIntegrations,
6 integrationFamily,
7 type IntegrationFamilyGroup,
8} from "./integration-grouping";
9
10const 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
21describe("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});
packages/react/src/lib/integration-grouping.ts+69−0
@@ -0,0 +1,69 @@
1import type { Integration } from "@executor-js/sdk/shared";
2
3export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4
5const FAMILY_LABELS: Record<string, string> = {
6 google: "Google",
7 microsoft: "Microsoft",
8};
9
10export const familyLabel = (family: string): string =>
11 FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1);
12
13export const integrationFamily = (integration: Integration): string | null => {
14 const family = integration.family?.trim();
15 return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;
16};
17
18export interface IntegrationFamilyGroup {
19 readonly type: "group";
20 readonly family: string;
21 readonly label: string;
22 readonly members: readonly Integration[];
23}
24
25export interface IntegrationSingle {
26 readonly type: "single";
27 readonly integration: Integration;
28}
29
30export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle;
31
32export 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}
packages/react/src/pages/integrations.tsx+100−42
@@ -1,4 +1,4 @@
1import { Suspense, useCallback, useMemo, useState } from "react";1import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -33,11 +33,14 @@ import { · 28 unmodified lines
33 CardStackEntryDescription,33 CardStackEntryDescription,
34 CardStackEntryMedia,34 CardStackEntryMedia,
35 CardStackEntryTitle,35 CardStackEntryTitle,
36 CardStackHeader,
36} from "../components/card-stack";37} from "../components/card-stack";
37import {38import {
39 IntegrationFavicon,
38 integrationInferredUrl,40 integrationInferredUrl,
39 integrationPresetIconUrl,41 integrationPresetIconUrl,
40} from "../components/integration-favicon";42} from "../components/integration-favicon";
43import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
41import { IntegrationHealthSummary } from "../components/integration-health-summary";44import { IntegrationHealthSummary } from "../components/integration-health-summary";
42import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";45import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
43import { Skeleton } from "../components/skeleton";46import { Skeleton } from "../components/skeleton";
@@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines
349 const q = (props.searchQuery ?? "").trim().toLowerCase();352 const q = (props.searchQuery ?? "").trim().toLowerCase();
350 if (q.length === 0) return allPresets;353 if (q.length === 0) return allPresets;
351 return allPresets.filter(({ preset, pluginLabel }) => {354 return allPresets.filter(({ preset, pluginLabel }) => {
352 const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();355 const corpus =
356 `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase();
353 return corpus.includes(q);357 return corpus.includes(q);
354 });358 });
355 }, [allPresets, props.searchQuery]);359 }, [allPresets, props.searchQuery]);
@@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines
434 return out;438 return out;
435 }, [integrationPlugins]);439 }, [integrationPlugins]);
436 440
441 const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]);
442
443 const renderEntry = (integration: Integration) => {
444 const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;
445 const plugin = pluginByKind.get(pluginKey);
446 const SummaryComponent = plugin?.summary;
447 const slug = String(integration.slug);
448 const name = integration.name || slug;
449 return (
450 <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
451 <Link
452 to="/{-$orgSlug}/integrations/$namespace"
453 params={{ namespace: slug }}
454 data-testid={`integration-entry-${slug}`}
455 >
456 <IntegrationIconWithAccount
457 icon={integrationPresetIconUrl(
458 { id: slug, kind: integration.kind, name, url: integration.displayUrl },
459 integrationPlugins,
460 )}
461 sourceId={slug}
462 url={integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined}
463 />
464 <CardStackEntryContent>
465 <CardStackEntryTitle>{name}</CardStackEntryTitle>
466 <CardStackEntryDescription>{slug}</CardStackEntryDescription>
467 </CardStackEntryContent>
468 <CardStackEntryActions>
469 {SummaryComponent && (
470 <Suspense fallback={null}>
471 <SummaryComponent sourceId={slug} />
472 </Suspense>
473 )}
474 <IntegrationHealthSummary integration={integration.slug} />
475 </CardStackEntryActions>
476 </Link>
477 </CardStackEntry>
478 );
479 };
480
481 const rendered: ReactNode[] = [];
482 let flatRun: Integration[] = [];
483 const flushFlat = () => {
484 if (flatRun.length === 0) return;
485 const run = flatRun;
486 flatRun = [];
487 rendered.push(
488 <CardStack key={`flat-${String(run[0]!.slug)}`} searchable>
489 <CardStackContent>{run.map(renderEntry)}</CardStackContent>
490 </CardStack>,
491 );
492 };
493
494 for (const item of items) {
495 if (item.type === "single") {
496 flatRun.push(item.integration);
497 continue;
498 }
499 flushFlat();
500 rendered.push(
501 <IntegrationFamilyGroupCard
502 key={`group-${item.family}`}
503 group={item}
504 plugin={pluginByKind.get("openapi")}
505 renderEntry={renderEntry}
506 />,
507 );
508 }
509 flushFlat();
510
511 return <div className="space-y-3">{rendered}</div>;
512}
513
514function IntegrationFamilyGroupCard(props: {
515 group: IntegrationFamilyGroup;
516 plugin: IntegrationPlugin | undefined;
517 renderEntry: (integration: Integration) => ReactNode;
518}) {
519 const { group, plugin, renderEntry } = props;
520 const headerIcon =
521 plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null;
437 return (522 return (
438 <CardStack searchable>523 <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}>
439 <CardStackContent>524 <CardStackHeader>
440 {props.integrations.map((integration) => {525 <span className="flex min-w-0 items-center gap-2">
441 const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;526 <span className="flex size-5 shrink-0 items-center justify-center">
442 const plugin = pluginByKind.get(pluginKey);527 <IntegrationFavicon icon={headerIcon} size={16} />
443 const SummaryComponent = plugin?.summary;528 </span>
444 const slug = String(integration.slug);529 <span className="truncate">{group.label}</span>
445 const name = integration.name || slug;530 <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">
446 return (531 {group.members.length}
447 <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>532 </span>
448 <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}>533 </span>
449 <IntegrationIconWithAccount534 </CardStackHeader>
450 icon={integrationPresetIconUrl(535 <CardStackContent>{group.members.map(renderEntry)}</CardStackContent>
451 { id: slug, kind: integration.kind, name, url: integration.displayUrl },
452 integrationPlugins,
453 )}
454 sourceId={slug}
455 url={
456 integration.displayUrl ??
457 integrationInferredUrl({ id: slug, name }) ??
458 undefined
459 }
460 />
461 <CardStackEntryContent>
462 <CardStackEntryTitle>{name}</CardStackEntryTitle>
463 <CardStackEntryDescription>{slug}</CardStackEntryDescription>
464 </CardStackEntryContent>
465 <CardStackEntryActions>
466 {SummaryComponent && (
467 <Suspense fallback={null}>
468 <SummaryComponent sourceId={slug} />
469 </Suspense>
470 )}
471 <IntegrationHealthSummary integration={integration.slug} />
472 </CardStackEntryActions>
473 </Link>
474 </CardStackEntry>
475 );
476 })}
477 </CardStackContent>
478 </CardStack>536 </CardStack>
479 );537 );
480}538}
plans/openapi-provider-catalog-design.md+139−0
@@ -0,0 +1,139 @@
1# Providers as OpenAPI catalog entries
2
3Decision (Rhys, 2026-07-07): dissolve `plugin-google` / `plugin-microsoft` into
4the openapi plugin. No bulk-select picker — per-service OAuth killed the shared
5consent that justified it; every service is added and authed individually, like
6any other openapi integration. The dedicated Google/Microsoft add pages go away.
7
8## Why this is small
9
10Serving is already 100% openapi: both provider plugins' `resolveTools` /
11`invokeTool` / `storage` are pass-throughs to `@executor-js/plugin-openapi`.
12What the provider plugins actually own is add-time (spec acquisition/conversion,
13scope selection UI) and identity (presets, icons, OAuth templates). The add-time
14UI 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
20Each service/workload is one `IntegrationPreset` on the openapi plugin:
21
22```ts
23// packages/core/sdk — IntegrationPreset gains:
24readonly family?: string; // "google" | "microsoft" — grid grouping + umbrella label
25readonly specFormat?: string; // "google-discovery" | "microsoft-graph" (default: openapi)
26readonly defaultSlug?: string; // "google_gmail" — stable slug for policy/migration identity
27readonly authTemplate?: readonly Authentication[]; // per-service OAuth: ONLY this service's scopes
28readonly healthCheck?: HealthCheckSpec;
29```
30
31Google Calendar, Gmail, Drive, Docs, Sheets, YouTube, Photos (picker scope is
32just this entry's scopes), Search Console, … and Microsoft Mail, Calendar,
33Teams, Files, … are rows in this catalog. `featured` controls grid presence;
34search finds the rest. The "single Google page" is gone — Google appears in the
35picker as N entries (grid groups them under one umbrella, see §4).
36
37### 2. Spec-format adapters (the only real new interface)
38
39The openapi plugin accepts registered converters:
40
41```ts
42export 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
53Two 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
61Custom Discovery URL = the normal "add openapi spec" flow with format
62`google-discovery`; identity derived from the doc; `google_custom` remains only
63as 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
69fold into plugin-openapi outright — decide by size after deletion; bias: fold
70in, packages are cheap to resurrect). They export `googleCatalog:
71IntegrationPreset[]`, `googleDiscoveryAdapter`, `microsoftCatalog`,
72`microsoftGraphAdapter`. Apps compose:
73
74```ts
75openapiPlugin({
76 presets: [...googleCatalog, ...microsoftCatalog],
77 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
78});
79```
80
81Deleted entirely: AddGoogleSource.tsx, AddMicrosoftSource.tsx,
82GoogleProductPicker, MicrosoftScopePicker, oauth-batches (scope batching served
83the union-consent flow), the fan-out add APIs (addServices/addWorkloads,
84added/skipped/failed result model, per-slug dedup locks, concurrency knobs),
85both plugins' api/ groups and handlers, source-plugin.ts registrations.
86
87### 4. Grid
88
89Grouping (#1336) is harvested nearly as-is, with one change: key off
90`preset.family` / integration config instead of `integration.kind` (everything
91is kind "openapi" now). The family tag is stamped into the integration config
92at add time so grouping works without a preset lookup. Umbrella card, entry
93testids, slug-keyed icons all carry over.
94
95### 5. OAuth / accounts
96
97Each catalog entry's `authTemplate` carries exactly its service's scopes —
98the consent screen asks for one service's access, which is the behavior that
99motivated killing shared auth. The account-entity/login_hint work (#1311,
100merged) is unaffected: connections still group by provider account, so adding
101Gmail then Calendar with the same account is two consents but one visible
102account. Connection cloning in the migration hands every derived service a
103copy of the monolith token (union scopes) — grandfathered; converges as users
104reconnect.
105
106## Migration retarget
107
108The planner from #1337 (policy fan-out with never-widen, connection cloning,
109serving-state carry, ledger/resume, hard-error gating) survives with a new
110target 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
1291. Core: preset fields + openapi `specFormats` option + adapter interface
1302. Move discovery.ts / graph.ts behind adapters; catalog data; delete provider
131 add UI/APIs/plugins; grid family-tag grouping
1323. e2e: adapt parked-stack specs (add google_calendar via catalog; custom
133 discovery URL derives google_tasks; grid umbrella)
1344. Migration planner refactor + both runners + dry-run v3
1355. Park→close stack PRs #1308/1309/1315/1334/1336/1337 once the branch is up
136
137Open items to settle during build, not before: exact `ConvertedSpec` shape
138(reuse existing `OpenApiIntegrationConfig` plumbing), whether photos picker
139needs any UI affordance beyond its scope set, fold-in vs data-only packages.
scripts/migration/service-split-postgres-cli.ts+639−0
@@ -0,0 +1,639 @@
1/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: one-shot migration CLI */
2
3import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4import { resolve } from "node:path";
5import { pathToFileURL } from "node:url";
6
7import postgres from "../../apps/cloud/node_modules/postgres/src/index.js";
8
9import {
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
27export 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
36const args = process.argv.slice(2);
37const hasArg = (name: string): boolean => args.includes(name);
38const argValue = (name: string): string | undefined => {
39 const index = args.indexOf(name);
40 return index >= 0 ? args[index + 1] : undefined;
41};
42
43const apply = hasArg("--apply");
44const inputJson = argValue("--input-json");
45const outputDir = resolve(
46 argValue("--output-dir") ?? "~/agent-workspace/scratch/service-split-migration-dryrun",
47).replace(/^~(?=\/)/, process.env.HOME ?? "~");
48const orgFilter = argValue("--org");
49const trafficLastTenant = argValue("--traffic-last-tenant");
50const databaseUrl = argValue("--database-url") ?? process.env.DATABASE_URL;
51const r2CopiesDone = hasArg("--r2-copies-done");
52export const LEDGER_TABLE = "provider_service_split_org_migration";
53
54const parseInputJson = (path: string): MigrationInput =>
55 JSON.parse(readFileSync(path, "utf8")) as MigrationInput;
56
57const readRows = async <T extends readonly object[]>(sql: SqlClient, query: string): Promise<T> =>
58 sql.unsafe<T>(query);
59
60const 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
83const 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
107export 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
211const 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
223const 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
232const operationKeyPrefix = (integration: string): string =>
233 `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`;
234
235export 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
490const 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
555const 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
637if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
638 await main();
639}
tests/migration/service-split-postgres-cli.test.ts+468−0
@@ -0,0 +1,468 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Schema } from "effect";
3
4import { PGlite } from "../../apps/cloud/node_modules/@electric-sql/pglite";
5import { PGLiteSocketServer } from "../../apps/cloud/node_modules/@electric-sql/pglite-socket";
6import postgres from "../../apps/cloud/node_modules/postgres/src/index.js";
7import {
8 applyOrg,
9 LEDGER_TABLE,
10 readDatabaseInput,
11 type SqlClient,
12} from "../../scripts/migration/service-split-postgres-cli";
13import {
14 operationStorageKey,
15 planMigration,
16} from "@executor-js/plugin-provider-service-split/planner";
17
18const now = "2026-01-01T00:00:00.000Z";
19const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
20
21const parseJson = (value: unknown): unknown =>
22 typeof value === "string" ? decodeJson(value) : value;
23
24const 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
129const 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
219const 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
293describe("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});
tests/presets-reachable.test.ts+1−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10 convertGoogleDiscoveryToOpenApi,10 convertGoogleDiscoveryToOpenApi,
11 fetchGoogleDiscoveryDocument,11 fetchGoogleDiscoveryDocument,
12 isGoogleDiscoveryUrl,12 isGoogleDiscoveryUrl,
13} from "../packages/plugins/google/src/sdk/discovery";13} from "../packages/plugins/openapi/src/providers/google/discovery";
14import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin";14import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin";
15import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin";15import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin";
16import { introspect } from "../packages/plugins/graphql/src/sdk/introspect";16import { introspect } from "../packages/plugins/graphql/src/sdk/introspect";