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 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import {
4+ googleCatalog,
5+ googleDiscoveryAdapter,
6+} from "@executor-js/plugin-openapi/providers/google";
7+import {
8+ microsoftCatalog,
9+ microsoftGraphAdapter,
10+} from "@executor-js/plugin-openapi/providers/microsoft";
511 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
612 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
713 import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines
4248 * falls back to the credential-driven default. */
4349 readonly workosVaultClient?: WorkOSVaultClient;
4450 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;
4951 }
5052
5153 export default defineExecutorConfig({
52− plugins: ({
53− workosCredentials,
54− workosVaultClient,
55− activeToolkitSlug,
56− allowLocalNetwork,
57− }: CloudPluginDeps = {}) =>
54+ plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
5855 [
59− openApiHttpPlugin(),
60− googleHttpPlugin(),
61− microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),
56+ openApiHttpPlugin({
57+ presets: [...googleCatalog, ...microsoftCatalog],
58+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
59+ }),
6260 mcpHttpPlugin({
6361 dangerouslyAllowStdioMCP: false,
6462 }),
apps/cloud/package.json+0−2
@@ -41,10 +41,8 @@ · 40 unmodified lines
4141 "@executor-js/execution": "workspace:*",
4242 "@executor-js/fumadb": "workspace:*",
4343 "@executor-js/host-mcp": "workspace:*",
44− "@executor-js/plugin-google": "workspace:*",
4544 "@executor-js/plugin-graphql": "workspace:*",
4645 "@executor-js/plugin-mcp": "workspace:*",
47− "@executor-js/plugin-microsoft": "workspace:*",
4846 "@executor-js/plugin-openapi": "workspace:*",
4947 "@executor-js/plugin-toolkits": "workspace:*",
5048 "@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
6666 },
6767 activeToolkitSlug:
6868 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
69− allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
7069 }),
7170 });
7271
apps/desktop/package.json+0−2
@@ -35,11 +35,9 @@ · 34 unmodified lines
3535 "@executor-js/local": "workspace:*",
3636 "@executor-js/plugin-desktop-settings": "workspace:*",
3737 "@executor-js/plugin-file-secrets": "workspace:*",
38− "@executor-js/plugin-google": "workspace:*",
3938 "@executor-js/plugin-graphql": "workspace:*",
4039 "@executor-js/plugin-keychain": "workspace:*",
4140 "@executor-js/plugin-mcp": "workspace:*",
42− "@executor-js/plugin-microsoft": "workspace:*",
4341 "@executor-js/plugin-onepassword": "workspace:*",
4442 "@executor-js/plugin-openapi": "workspace:*",
4543 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-cloudflare/executor.config.ts+12−5
@@ -1,7 +1,13 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import {
4+ googleCatalog,
5+ googleDiscoveryAdapter,
6+} from "@executor-js/plugin-openapi/providers/google";
7+import {
8+ microsoftCatalog,
9+ microsoftGraphAdapter,
10+} from "@executor-js/plugin-openapi/providers/microsoft";
511 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
612 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
713 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -19,9 +25,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines
1925 export default defineExecutorConfig({
2026 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
2127 [
22− openApiHttpPlugin(),
23− googleHttpPlugin(),
24− microsoftHttpPlugin(),
28+ openApiHttpPlugin({
29+ presets: [...googleCatalog, ...microsoftCatalog],
30+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
31+ }),
2532 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2633 graphqlHttpPlugin(),
2734 toolkitsPlugin({ activeToolkitSlug }),
apps/host-cloudflare/package.json+1−2
@@ -24,11 +24,10 @@ · 23 unmodified lines
2424 "@executor-js/fumadb": "workspace:*",
2525 "@executor-js/host-mcp": "workspace:*",
2626 "@executor-js/plugin-encrypted-secrets": "workspace:*",
27− "@executor-js/plugin-google": "workspace:*",
2827 "@executor-js/plugin-graphql": "workspace:*",
2928 "@executor-js/plugin-mcp": "workspace:*",
30− "@executor-js/plugin-microsoft": "workspace:*",
3129 "@executor-js/plugin-openapi": "workspace:*",
30+ "@executor-js/plugin-provider-service-split": "workspace:*",
3231 "@executor-js/plugin-toolkits": "workspace:*",
3332 "@executor-js/react": "workspace:*",
3433 "@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
110110 args: [
111111 row.tenant,
112112 row.pluginId,
113− `${row.integration}.items.list`,
113+ `${row.integration}.calendar.events.list`,
114114 JSON.stringify({
115115 integration: row.integration,
116− toolName: "items.list",
117− binding: { method: "get", pathTemplate: "/items" },
116+ toolName: "calendar.events.list",
117+ binding: { method: "get", pathTemplate: "/calendar/events" },
118118 }),
119119 now,
120120 now,
@@ -239,6 +239,7 @@ describe("runCloudflareDataMigrations", () => { · 118 unmodified lines
239239 const d1 = makeFakeD1(db.client);
240240 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
241241 "2026-06-20-google-openapi-ownership",
242+ "2026-07-08-provider-service-split",
242243 ]);
243244 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
244245
@@ -276,10 +277,12 @@ describe("runCloudflareDataMigrations", () => { · 31 unmodified lines
276277 }),
277278 );
278279 objects.set("o:org_1/openapi/spec/googlehash", "google spec");
280+ objects.set("o:org_1/google/defs/googlehash", "google defs");
279281
280282 const d1 = makeFakeD1(db.client);
281283 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
282284 "2026-06-20-google-openapi-ownership",
285+ "2026-07-08-provider-service-split",
283286 ]);
284287 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
285288
@@ -288,12 +291,58 @@ describe("runCloudflareDataMigrations", () => { · 2 unmodified lines
288291 const integrations = yield* Effect.promise(() =>
289292 db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
290293 );
291− expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]);
294+ expect(integrations.rows).toEqual([{ slug: "google_calendar", plugin_id: "openapi" }]);
292295
293296 const storage = yield* Effect.promise(() =>
294297 db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"),
295298 );
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" }]);
297346
298347 yield* Effect.promise(() => db.close());
299348 }),
apps/host-cloudflare/src/db/data-migrations.ts+73−1
@@ -7,7 +7,13 @@ import { · 6 unmodified lines
77 type SqliteDataMigration,
88 type SqliteDataMigrationClient,
99 } from "@executor-js/sdk";
10−import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
10+import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
11+
12+import {
13+ providerServiceSplitDataMigration,
14+ runSqliteProviderServiceSplitMigration,
15+ type BlobRow,
16+} from "@executor-js/plugin-provider-service-split";
1117
1218 const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]);
1319
@@ -161,6 +167,61 @@ const copyGoogleOpenApiSpecBlobsToR2 = ( · 147 unmodified lines
161167 }),
162168 });
163169
170+const copyProviderServiceSplitBlobsToR2 = (
171+ client: SqliteDataMigrationClient,
172+ bucket: R2Bucket,
173+): Effect.Effect<readonly BlobRow[], DataMigrationError> =>
174+ Effect.tryPromise({
175+ try: async () => {
176+ const present: BlobRow[] = [];
177+ const result = await client.execute(
178+ `SELECT tenant, plugin_id, json_extract(config, '$.specHash') AS spec_hash
179+ FROM integration
180+ WHERE ((plugin_id = 'google' AND slug = 'google')
181+ OR (plugin_id = 'microsoft' AND slug = 'microsoft'))
182+ AND config IS NOT NULL
183+ AND json_valid(config)
184+ AND json_extract(config, '$.specHash') IS NOT NULL
185+ AND json_extract(config, '$.specHash') <> ''`,
186+ );
187+ const seen = new Set<string>();
188+ for (const row of result.rows) {
189+ if (
190+ typeof row.tenant !== "string" ||
191+ typeof row.plugin_id !== "string" ||
192+ typeof row.spec_hash !== "string"
193+ ) {
194+ continue;
195+ }
196+ for (const key of [`spec/${row.spec_hash}`, `defs/${row.spec_hash}`]) {
197+ const sourceNamespace = `o:${row.tenant}/${row.plugin_id}`;
198+ const sourceKey = r2ObjectName(row.tenant, row.plugin_id, key);
199+ const targetKey = r2ObjectName(row.tenant, "openapi", key);
200+ const source = await bucket.get(sourceKey);
201+ if (source == null) continue;
202+ const presenceKey = `${sourceNamespace}/${key}`;
203+ if (!seen.has(presenceKey)) {
204+ seen.add(presenceKey);
205+ present.push({
206+ id: JSON.stringify([sourceNamespace, key]),
207+ namespace: sourceNamespace,
208+ key,
209+ });
210+ }
211+ if ((await bucket.head(targetKey)) == null) {
212+ await bucket.put(targetKey, await source.text());
213+ }
214+ }
215+ }
216+ return present;
217+ },
218+ catch: (cause) =>
219+ new DataMigrationError({
220+ migration: providerServiceSplitDataMigration.name,
221+ cause,
222+ }),
223+ });
224+
164225 const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
165226 {
166227 name: googleOpenApiOwnershipDataMigration.name,
@@ -170,6 +231,17 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite · 3 unmodified lines
170231 yield* googleOpenApiOwnershipDataMigration.run(client);
171232 }),
172233 },
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+ },
173245 ];
174246
175247 export const runCloudflareDataMigrations = (
apps/host-cloudflare/src/plugins.ts+12−5
@@ -1,6 +1,12 @@
11 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
3−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
2+import {
3+ googleCatalog,
4+ googleDiscoveryAdapter,
5+} from "@executor-js/plugin-openapi/providers/google";
6+import {
7+ microsoftCatalog,
8+ microsoftGraphAdapter,
9+} from "@executor-js/plugin-openapi/providers/microsoft";
410 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
511 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
612 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -22,9 +28,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines
2228 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},
2329 ) =>
2430 [
25− openApiHttpPlugin(),
26− googleHttpPlugin(),
27− microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }),
31+ openApiHttpPlugin({
32+ presets: [...googleCatalog, ...microsoftCatalog],
33+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
34+ }),
2835 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2936 graphqlHttpPlugin(),
3037 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
66 import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
77 import { Schema } from "effect";
88 import { unstable_dev, type Unstable_DevWorker } from "wrangler";
9+import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft";
910
1011 // ---------------------------------------------------------------------------
1112 // 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
187188 expect(integration?.slug).toBe(slug);
188189 }, 60_000);
189190
190− it("registers the Microsoft Graph API route in the runtime plugin set", async () => {
191− const res = await worker.fetch("/api/microsoft/graph", {
191+ it("exposes Microsoft catalog presets through the OpenAPI integration catalog", async () => {
192+ const microsoftFilesPreset = microsoftCatalog.find(
193+ (preset) => preset.defaultSlug === "microsoft_files",
194+ );
195+ expect(microsoftFilesPreset).toBeTruthy();
196+
197+ const removedProviderRoute = await worker.fetch("/api/microsoft/graph", {
192198 method: "POST",
193199 headers: { "content-type": "application/json" },
194200 body: JSON.stringify({}),
195201 });
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+ );
197233 });
198234
199235 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 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import {
4+ googleCatalog,
5+ googleDiscoveryAdapter,
6+} from "@executor-js/plugin-openapi/providers/google";
7+import {
8+ microsoftCatalog,
9+ microsoftGraphAdapter,
10+} from "@executor-js/plugin-openapi/providers/microsoft";
511 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
612 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
713 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -21,18 +27,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines
2127
2228 interface SelfHostPluginDeps {
2329 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;
2830 }
2931
3032 export default defineExecutorConfig({
31− plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) =>
33+ plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
3234 [
33− openApiHttpPlugin(),
34− googleHttpPlugin(),
35− microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),
35+ openApiHttpPlugin({
36+ presets: [...googleCatalog, ...microsoftCatalog],
37+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
38+ }),
3639 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3740 graphqlHttpPlugin(),
3841 toolkitsPlugin({ activeToolkitSlug }),
apps/host-selfhost/package.json+1−2
@@ -27,11 +27,10 @@ · 26 unmodified lines
2727 "@executor-js/fumadb": "workspace:*",
2828 "@executor-js/host-mcp": "workspace:*",
2929 "@executor-js/plugin-encrypted-secrets": "workspace:*",
30− "@executor-js/plugin-google": "workspace:*",
3130 "@executor-js/plugin-graphql": "workspace:*",
3231 "@executor-js/plugin-mcp": "workspace:*",
33− "@executor-js/plugin-microsoft": "workspace:*",
3432 "@executor-js/plugin-openapi": "workspace:*",
33+ "@executor-js/plugin-provider-service-split": "workspace:*",
3534 "@executor-js/plugin-toolkits": "workspace:*",
3635 "@executor-js/react": "workspace:*",
3736 "@executor-js/runtime-quickjs": "workspace:*",
apps/host-selfhost/src/db/data-migrations.ts+3−1
@@ -12,8 +12,9 @@ import { · 11 unmodified lines
1212 openApiSpecBlobDataMigration,
1313 } from "@executor-js/plugin-openapi";
1414 import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
15−import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
15+import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
1616
17+import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
1718 import { authConfigTransforms } from "./auth-config-migration";
1819
1920 export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
@@ -31,4 +32,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines
3132 openApiSpecBlobDataMigration,
3233 graphqlIntrospectionBlobDataMigration,
3334 googleOpenApiOwnershipDataMigration,
35+ providerServiceSplitDataMigration,
3436 ];
apps/host-selfhost/src/execution.ts+0−4
@@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines
4444 executorConfig.plugins({
4545 activeToolkitSlug:
4646 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",
5147 }),
5248 },
5349 );
apps/local/executor.config.ts+12−5
@@ -1,7 +1,13 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import {
4+ googleCatalog,
5+ googleDiscoveryAdapter,
6+} from "@executor-js/plugin-openapi/providers/google";
7+import {
8+ microsoftCatalog,
9+ microsoftGraphAdapter,
10+} from "@executor-js/plugin-openapi/providers/microsoft";
511 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
612 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
713 import { keychainPlugin } from "@executor-js/plugin-keychain";
@@ -26,9 +32,10 @@ interface LocalPluginDeps { · 18 unmodified lines
2632 export default defineExecutorConfig({
2733 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
2834 [
29− openApiHttpPlugin(),
30− googleHttpPlugin(),
31− microsoftHttpPlugin(),
35+ openApiHttpPlugin({
36+ presets: [...googleCatalog, ...microsoftCatalog],
37+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
38+ }),
3239 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
3340 graphqlHttpPlugin(),
3441 toolkitsPlugin({ activeToolkitSlug }),
apps/local/package.json+1−2
@@ -32,13 +32,12 @@ · 31 unmodified lines
3232 "@executor-js/plugin-desktop-settings": "workspace:*",
3333 "@executor-js/plugin-example": "workspace:*",
3434 "@executor-js/plugin-file-secrets": "workspace:*",
35− "@executor-js/plugin-google": "workspace:*",
3635 "@executor-js/plugin-graphql": "workspace:*",
3736 "@executor-js/plugin-keychain": "workspace:*",
3837 "@executor-js/plugin-mcp": "workspace:*",
39− "@executor-js/plugin-microsoft": "workspace:*",
4038 "@executor-js/plugin-onepassword": "workspace:*",
4139 "@executor-js/plugin-openapi": "workspace:*",
40+ "@executor-js/plugin-provider-service-split": "workspace:*",
4241 "@executor-js/plugin-toolkits": "workspace:*",
4342 "@executor-js/react": "workspace:*",
4443 "@executor-js/runtime-quickjs": "workspace:*",
apps/local/src/db/data-migrations.ts+3−1
@@ -17,8 +17,9 @@ import { · 16 unmodified lines
1717 openApiSpecBlobDataMigration,
1818 } from "@executor-js/plugin-openapi";
1919 import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
20−import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
20+import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
2121
22+import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
2223 import { authConfigTransforms } from "./auth-config-migration";
2324 import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";
2425
@@ -42,6 +43,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines
4243 openApiSpecBlobDataMigration,
4344 graphqlIntrospectionBlobDataMigration,
4445 googleOpenApiOwnershipDataMigration,
46+ providerServiceSplitDataMigration,
4547 // GC dead DCR oauth_client rows (old always-register duplicates) and backfill
4648 // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C).
4749 oauthClientGcSqliteMigration,
bun.lock+65−93
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 "@effect/language-service": "^0.85.1",
1111 "@effect/tsgo": "^0.5.2",
1212 "@effect/vitest": "catalog:",
13+ "@executor-js/plugin-provider-service-split": "workspace:*",
1314 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
1415 "@vitest/expect": "catalog:",
1516 "@vitest/mocker": "catalog:",
@@ -66,10 +67,8 @@ · 50 unmodified lines
6667 "@executor-js/execution": "workspace:*",
6768 "@executor-js/fumadb": "workspace:*",
6869 "@executor-js/host-mcp": "workspace:*",
69− "@executor-js/plugin-google": "workspace:*",
7070 "@executor-js/plugin-graphql": "workspace:*",
7171 "@executor-js/plugin-mcp": "workspace:*",
72− "@executor-js/plugin-microsoft": "workspace:*",
7372 "@executor-js/plugin-openapi": "workspace:*",
7473 "@executor-js/plugin-toolkits": "workspace:*",
7574 "@executor-js/plugin-workos-vault": "workspace:*",
@@ -142,11 +141,9 @@ · 66 unmodified lines
142141 "@executor-js/local": "workspace:*",
143142 "@executor-js/plugin-desktop-settings": "workspace:*",
144143 "@executor-js/plugin-file-secrets": "workspace:*",
145− "@executor-js/plugin-google": "workspace:*",
146144 "@executor-js/plugin-graphql": "workspace:*",
147145 "@executor-js/plugin-keychain": "workspace:*",
148146 "@executor-js/plugin-mcp": "workspace:*",
149− "@executor-js/plugin-microsoft": "workspace:*",
150147 "@executor-js/plugin-onepassword": "workspace:*",
151148 "@executor-js/plugin-openapi": "workspace:*",
152149 "@executor-js/runtime-quickjs": "workspace:*",
@@ -176,11 +173,10 @@ · 23 unmodified lines
176173 "@executor-js/fumadb": "workspace:*",
177174 "@executor-js/host-mcp": "workspace:*",
178175 "@executor-js/plugin-encrypted-secrets": "workspace:*",
179− "@executor-js/plugin-google": "workspace:*",
180176 "@executor-js/plugin-graphql": "workspace:*",
181177 "@executor-js/plugin-mcp": "workspace:*",
182− "@executor-js/plugin-microsoft": "workspace:*",
183178 "@executor-js/plugin-openapi": "workspace:*",
179+ "@executor-js/plugin-provider-service-split": "workspace:*",
184180 "@executor-js/plugin-toolkits": "workspace:*",
185181 "@executor-js/react": "workspace:*",
186182 "@executor-js/runtime-quickjs": "workspace:*",
@@ -225,11 +221,10 @@ · 38 unmodified lines
225221 "@executor-js/fumadb": "workspace:*",
226222 "@executor-js/host-mcp": "workspace:*",
227223 "@executor-js/plugin-encrypted-secrets": "workspace:*",
228− "@executor-js/plugin-google": "workspace:*",
229224 "@executor-js/plugin-graphql": "workspace:*",
230225 "@executor-js/plugin-mcp": "workspace:*",
231− "@executor-js/plugin-microsoft": "workspace:*",
232226 "@executor-js/plugin-openapi": "workspace:*",
227+ "@executor-js/plugin-provider-service-split": "workspace:*",
233228 "@executor-js/plugin-toolkits": "workspace:*",
234229 "@executor-js/react": "workspace:*",
235230 "@executor-js/runtime-quickjs": "workspace:*",
@@ -276,13 +271,12 @@ · 40 unmodified lines
276271 "@executor-js/plugin-desktop-settings": "workspace:*",
277272 "@executor-js/plugin-example": "workspace:*",
278273 "@executor-js/plugin-file-secrets": "workspace:*",
279− "@executor-js/plugin-google": "workspace:*",
280274 "@executor-js/plugin-graphql": "workspace:*",
281275 "@executor-js/plugin-keychain": "workspace:*",
282276 "@executor-js/plugin-mcp": "workspace:*",
283− "@executor-js/plugin-microsoft": "workspace:*",
284277 "@executor-js/plugin-onepassword": "workspace:*",
285278 "@executor-js/plugin-openapi": "workspace:*",
279+ "@executor-js/plugin-provider-service-split": "workspace:*",
286280 "@executor-js/plugin-toolkits": "workspace:*",
287281 "@executor-js/react": "workspace:*",
288282 "@executor-js/runtime-quickjs": "workspace:*",
@@ -346,10 +340,8 @@ · 57 unmodified lines
346340 "@executor-js/api": "workspace:*",
347341 "@executor-js/emulate": "^0.13.3",
348342 "@executor-js/mcporter": "^0.11.4",
349− "@executor-js/plugin-google": "workspace:*",
350343 "@executor-js/plugin-graphql": "workspace:*",
351344 "@executor-js/plugin-mcp": "workspace:*",
352− "@executor-js/plugin-microsoft": "workspace:*",
353345 "@executor-js/plugin-openapi": "workspace:*",
354346 "@executor-js/plugin-toolkits": "workspace:*",
355347 "@executor-js/sdk": "workspace:*",
@@ -381,7 +373,6 @@ · 25 unmodified lines
381373 "version": "0.0.47",
382374 "dependencies": {
383375 "@executor-js/plugin-file-secrets": "workspace:*",
384− "@executor-js/plugin-google": "workspace:*",
385376 "@executor-js/plugin-graphql": "workspace:*",
386377 "@executor-js/plugin-keychain": "workspace:*",
387378 "@executor-js/plugin-mcp": "workspace:*",
@@ -849,43 +840,6 @@ · 461 unmodified lines
849840 "effect": "catalog:",
850841 },
851842 },
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− },
889843 "packages/plugins/graphql": {
890844 "name": "@executor-js/plugin-graphql",
891845 "version": "1.5.29",
@@ -984,43 +938,6 @@ · 92 unmodified lines
984938 "react",
985939 ],
986940 },
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− },
1024941 "packages/plugins/onepassword": {
1025942 "name": "@executor-js/plugin-onepassword",
1026943 "version": "1.5.29",
@@ -1096,6 +1013,23 @@ · 69 unmodified lines
10961013 "react",
10971014 ],
10981015 },
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+ },
10991033 "packages/plugins/toolkits": {
11001034 "name": "@executor-js/plugin-toolkits",
11011035 "version": "1.5.21",
@@ -1784,20 +1718,18 @@ · 682 unmodified lines
17841718
17851719 "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"],
17861720
1787− "@executor-js/plugin-google": ["@executor-js/plugin-google@workspace:packages/plugins/google"],
1788−
17891721 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],
17901722
17911723 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],
17921724
17931725 "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"],
17941726
1795− "@executor-js/plugin-microsoft": ["@executor-js/plugin-microsoft@workspace:packages/plugins/microsoft"],
1796−
17971727 "@executor-js/plugin-onepassword": ["@executor-js/plugin-onepassword@workspace:packages/plugins/onepassword"],
17981728
17991729 "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"],
18001730
1731+ "@executor-js/plugin-provider-service-split": ["@executor-js/plugin-provider-service-split@workspace:packages/plugins/provider-service-split"],
1732+
18011733 "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"],
18021734
18031735 "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"],
@@ -3172,6 +3104,46 @@ · 1368 unmodified lines
31723104
31733105 "@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=="],
31743106
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+
31753147 "@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=="],
31763148
31773149 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -5920,9 +5892,9 @@ · 2742 unmodified lines
59205892
59215893 "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
59225894
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=="],
59245896
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=="],
59265898
59275899 "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
59285900
e2e/package.json+0−2
@@ -24,10 +24,8 @@ · 23 unmodified lines
2424 "@executor-js/api": "workspace:*",
2525 "@executor-js/emulate": "^0.13.3",
2626 "@executor-js/mcporter": "^0.11.4",
27− "@executor-js/plugin-google": "workspace:*",
2827 "@executor-js/plugin-graphql": "workspace:*",
2928 "@executor-js/plugin-mcp": "workspace:*",
30− "@executor-js/plugin-microsoft": "workspace:*",
3129 "@executor-js/plugin-openapi": "workspace:*",
3230 "@executor-js/plugin-toolkits": "workspace:*",
3331 "@executor-js/sdk": "workspace:*",
e2e/scenarios/google-health-checks.test.ts+351−0
@@ -0,0 +1,351 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { Effect } from "effect";
5+import type { HttpApiClient } from "effect/unstable/httpapi";
6+import { composePluginApi } from "@executor-js/api/server";
7+import { connectEmulator, type EmulatorClient } from "@executor-js/emulate";
8+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
9+import {
10+ AuthTemplateSlug,
11+ ConnectionName,
12+ IntegrationSlug,
13+ OAuthClientSlug,
14+} from "@executor-js/sdk/shared";
15+
16+import { scenario } from "../src/scenario";
17+import { Api, Browser, Target } from "../src/services";
18+import type { Identity, Target as TargetShape } from "../src/target";
19+import type { BrowserSurface } from "../src/surfaces/browser";
20+
21+const api = composePluginApi([openApiHttpPlugin()] as const);
22+type Client = HttpApiClient.ForApi<typeof api>;
23+const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
24+const CONNECTION = ConnectionName.make("main");
25+const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";
26+
27+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
28+
29+const decodeHtml = (value: string): string =>
30+ value
31+ .replaceAll("&amp;", "&")
32+ .replaceAll("&quot;", '"')
33+ .replaceAll("&#x27;", "'")
34+ .replaceAll("&lt;", "<")
35+ .replaceAll("&gt;", ">");
36+
37+const inputFields = (html: string): Record<string, string> => {
38+ const fields: Record<string, string> = {};
39+ for (const input of html.matchAll(/<input\b[^>]*>/gi)) {
40+ const tag = input[0];
41+ const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1];
42+ const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? "";
43+ if (name) fields[decodeHtml(name)] = decodeHtml(value);
44+ }
45+ return fields;
46+};
47+
48+const formAction = (html: string, fallback: string): string => {
49+ const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1];
50+ return action ? decodeHtml(action) : fallback;
51+};
52+
53+const completeGoogleConsent = (authorizationUrl: string) =>
54+ Effect.promise(async () => {
55+ const page = await fetch(authorizationUrl);
56+ if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`);
57+ const html = await page.text();
58+ const callback = await fetch(formAction(html, authorizationUrl), {
59+ method: "POST",
60+ body: new URLSearchParams(inputFields(html)),
61+ redirect: "manual",
62+ });
63+ const location = callback.headers.get("location");
64+ if (callback.status !== 302 || !location) {
65+ throw new Error(`Google emulator consent did not redirect: ${callback.status}`);
66+ }
67+ const code = new URL(location).searchParams.get("code");
68+ if (!code) throw new Error("Google emulator callback did not include a code");
69+ return code;
70+ });
71+
72+const createGoogleEmulator = Effect.promise(async () => {
73+ const response = await fetch("https://google.emulators.dev/_emulate/instances", {
74+ method: "POST",
75+ });
76+ if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`);
77+ const instance = (await response.json()) as { readonly providerBaseUrl: string };
78+ const client = await connectEmulator({ baseUrl: instance.providerBaseUrl });
79+ return { client, baseUrl: instance.providerBaseUrl };
80+});
81+
82+const addGooglePresetFromCatalog = (
83+ browser: BrowserSurface,
84+ identity: Identity,
85+ presetName: string,
86+ slug: string,
87+) =>
88+ browser.session(identity, async ({ page, step }) => {
89+ await step(`Open ${presetName} from the connect catalog`, async () => {
90+ await page.goto("/integrations", { waitUntil: "networkidle" });
91+ await page
92+ .getByRole("button", { name: /Connect/ })
93+ .first()
94+ .click();
95+ const dialog = page.getByRole("dialog", { name: "Connect an integration" });
96+ await dialog.waitFor();
97+ await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
98+ await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
99+ });
100+
101+ await step(`Add the ${presetName} integration`, async () => {
102+ await page.waitForURL(/\/integrations\/add\/openapi/);
103+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
104+ const button = page.getByRole("button", { name: "Add integration" });
105+ await button.waitFor({ timeout: 120_000 });
106+ await button.click({ timeout: 120_000 });
107+ await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 });
108+ });
109+ });
110+
111+const connectGoogleAccount = (input: {
112+ readonly client: Client;
113+ readonly emulator: EmulatorClient;
114+ readonly emulatorBaseUrl: string;
115+ readonly integrationBaseUrl?: string;
116+ readonly target: TargetShape;
117+ readonly integration: IntegrationSlug;
118+ readonly oauthClient: OAuthClientSlug;
119+}) =>
120+ Effect.gen(function* () {
121+ const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
122+ const credential = yield* Effect.promise(() =>
123+ input.emulator.credentials.mint({
124+ type: "oauth-authorization-code",
125+ redirect_uris: [redirectUri],
126+ }),
127+ );
128+ if (!credential.client_id || !credential.client_secret) {
129+ return yield* Effect.die(`Google emulator did not mint an OAuth client`);
130+ }
131+
132+ yield* input.client.openapi.configure({
133+ params: { slug: input.integration },
134+ payload: { baseUrl: input.integrationBaseUrl ?? input.emulatorBaseUrl },
135+ });
136+ yield* input.client.oauth.createClient({
137+ payload: {
138+ owner: "org",
139+ slug: input.oauthClient,
140+ grant: "authorization_code",
141+ authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`,
142+ tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`,
143+ clientId: credential.client_id,
144+ clientSecret: credential.client_secret,
145+ originIntegration: input.integration,
146+ },
147+ });
148+
149+ const started = yield* input.client.oauth.start({
150+ payload: {
151+ client: input.oauthClient,
152+ clientOwner: "org",
153+ owner: "org",
154+ name: CONNECTION,
155+ integration: input.integration,
156+ template: GOOGLE_AUTH_TEMPLATE,
157+ redirectUri,
158+ },
159+ });
160+ expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
161+ if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected");
162+
163+ const code = yield* completeGoogleConsent(started.authorizationUrl);
164+ const completed = yield* input.client.oauth.complete({
165+ payload: { state: started.state, code },
166+ });
167+ expect(completed.integration, "OAuth completion creates the connection").toBe(
168+ input.integration,
169+ );
170+ });
171+
172+scenario(
173+ "Google · Calendar and Gmail catalog health checks run against the emulator",
174+ { timeout: 300_000 },
175+ Effect.gen(function* () {
176+ const target = yield* Target;
177+ const browser = yield* Browser;
178+ const { client: makeClient } = yield* Api;
179+ const identity = yield* target.newIdentity();
180+ const client = yield* makeClient(api, identity);
181+ const emulator = yield* createGoogleEmulator;
182+
183+ const rows = [
184+ {
185+ presetName: "Google Calendar",
186+ slug: IntegrationSlug.make("google_calendar"),
187+ oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),
188+ expectedHealthOperation: "calendar.calendarList.list",
189+ expectedLedgerOperation: "calendar.calendarList.list",
190+ emulatorPathPrefix: "/calendar/v3",
191+ },
192+ {
193+ presetName: "Gmail",
194+ slug: IntegrationSlug.make("google_gmail"),
195+ oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),
196+ expectedHealthOperation: "gmail.users.labels.list",
197+ expectedLedgerOperation: "gmail.users.labels.list",
198+ emulatorPathPrefix: "",
199+ },
200+ ] as const;
201+
202+ yield* Effect.ensuring(
203+ Effect.gen(function* () {
204+ for (const row of rows) {
205+ yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug));
206+
207+ const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });
208+ expect(stored?.operation, `${row.presetName} stored health check`).toBe(
209+ row.expectedHealthOperation,
210+ );
211+
212+ yield* connectGoogleAccount({
213+ client,
214+ emulator: emulator.client,
215+ emulatorBaseUrl: emulator.baseUrl,
216+ integrationBaseUrl: `${emulator.baseUrl}${row.emulatorPathPrefix ?? ""}`,
217+ target,
218+ integration: row.slug,
219+ oauthClient: row.oauthClient,
220+ });
221+
222+ const connections = yield* client.connections.list({
223+ query: { owner: "org", integration: row.slug },
224+ });
225+ const connected = connections.find((connection) => connection.name === CONNECTION);
226+ expect(
227+ connected?.identityLabel,
228+ `${row.presetName} stores OAuth identity from the id_token before health checks`,
229+ ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);
230+ expect(
231+ connected?.lastHealth,
232+ `${row.presetName} has not run a health check before the explicit probe`,
233+ ).toBeNull();
234+
235+ const tools = yield* client.tools.list({
236+ query: { integration: row.slug, connection: CONNECTION },
237+ });
238+ expect(
239+ tools.length,
240+ `${row.presetName} exposes tools for the connection`,
241+ ).toBeGreaterThan(0);
242+
243+ const health = yield* client.connections.checkHealth({
244+ params: { owner: "org", integration: row.slug, name: CONNECTION },
245+ query: { ifStaleMs: 0 },
246+ });
247+ expect(
248+ health.status,
249+ `${row.presetName} health check is healthy: ${JSON.stringify(health)}`,
250+ ).toBe("healthy");
251+ }
252+
253+ const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100));
254+ for (const row of rows) {
255+ expect(
256+ ledger.some((entry) => entry.operationId === row.expectedLedgerOperation),
257+ `${row.presetName} health check reached the Google emulator`,
258+ ).toBe(true);
259+ }
260+ }),
261+ Effect.gen(function* () {
262+ for (const row of rows) {
263+ yield* client.connections
264+ .remove({
265+ params: { owner: "org", integration: row.slug, name: CONNECTION },
266+ })
267+ .pipe(Effect.ignore);
268+ yield* client.oauth
269+ .removeClient({ params: { slug: row.oauthClient }, payload: { owner: "org" } })
270+ .pipe(Effect.ignore);
271+ yield* client.openapi.removeSpec({ params: { slug: row.slug } }).pipe(Effect.ignore);
272+ }
273+ }),
274+ );
275+ }),
276+);
277+
278+scenario(
279+ "Google · OAuth catalog connection without a health check is healthy from the grant",
280+ { timeout: 300_000 },
281+ Effect.gen(function* () {
282+ const target = yield* Target;
283+ const browser = yield* Browser;
284+ const { client: makeClient } = yield* Api;
285+ const identity = yield* target.newIdentity();
286+ const client = yield* makeClient(api, identity);
287+ const emulator = yield* createGoogleEmulator;
288+ const slug = IntegrationSlug.make("google_sheets");
289+ const oauthClient = OAuthClientSlug.make(unique("google_sheets_oauth"));
290+
291+ yield* Effect.ensuring(
292+ Effect.gen(function* () {
293+ yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug));
294+
295+ const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
296+ expect(stored, "Google Sheets catalog preset declares no health check").toBeNull();
297+
298+ yield* connectGoogleAccount({
299+ client,
300+ emulator: emulator.client,
301+ emulatorBaseUrl: emulator.baseUrl,
302+ target,
303+ integration: slug,
304+ oauthClient,
305+ });
306+
307+ const connections = yield* client.connections.list({
308+ query: { owner: "org", integration: slug },
309+ });
310+ const connected = connections.find((connection) => connection.name === CONNECTION);
311+ expect(
312+ connected?.identityLabel,
313+ "Google Sheets stores grant identity before any probe is configured",
314+ ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);
315+ expect(
316+ connected?.lastHealth,
317+ "Google Sheets has not run a health check before the explicit check",
318+ ).toBeNull();
319+
320+ const health = yield* client.connections.checkHealth({
321+ params: { owner: "org", integration: slug, name: CONNECTION },
322+ query: { ifStaleMs: 0 },
323+ });
324+ expect(
325+ health.status,
326+ `Google Sheets no-probe health is healthy: ${JSON.stringify(health)}`,
327+ ).toBe("healthy");
328+ expect(health.detail).toBe("Credential resolved (no probe configured).");
329+
330+ const refreshed = yield* client.connections.list({
331+ query: { owner: "org", integration: slug },
332+ });
333+ expect(
334+ refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel,
335+ "Google Sheets still shows the OAuth grant identity after no-probe health",
336+ ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);
337+ }),
338+ Effect.gen(function* () {
339+ yield* client.connections
340+ .remove({
341+ params: { owner: "org", integration: slug, name: CONNECTION },
342+ })
343+ .pipe(Effect.ignore);
344+ yield* client.oauth
345+ .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
346+ .pipe(Effect.ignore);
347+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
348+ }),
349+ );
350+ }),
351+);
e2e/scenarios/google-photos-preset-ui.test.ts+22−21
@@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
55 import { Browser, Target } from "../src/services";
66
77 scenario(
8− "Google Photos: the focused preset opens a Photos-scoped add flow",
8+ "Google Photos: separated catalog presets open a Photos service add flow",
99 {},
1010 Effect.gen(function* () {
1111 const target = yield* Target;
@@ -13,30 +13,31 @@ scenario( · 1 unmodified line
1313 const identity = yield* target.newIdentity();
1414
1515 yield* browser.session(identity, async ({ page, step }) => {
16− await step("Find the Google Photos preset from the integrations picker", async () => {
17− await page.goto("/integrations", { waitUntil: "networkidle" });
18− await page.getByRole("button", { name: "Connect" }).click();
19− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
20− await dialog.waitFor();
21− await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");
22− await dialog.getByRole("link", { name: /^Google Photos\b/ }).waitFor();
23− });
16+ await step(
17+ "Find the separated Google Photos presets from the integrations picker",
18+ async () => {
19+ await page.goto("/integrations", { waitUntil: "networkidle" });
20+ await page.getByRole("button", { name: "Connect" }).click();
21+ const dialog = page.getByRole("dialog", { name: "Connect an integration" });
22+ await dialog.waitFor();
23+ await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");
24+ await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor();
25+ await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor();
26+ },
27+ );
2428
25− await step("Open the Google Photos scoped add flow", async () => {
29+ await step("Open the Google Photos Library add flow", async () => {
2630 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
27− await dialog.getByRole("link", { name: /^Google Photos\b/ }).click();
28− await page.waitForURL(/\/integrations\/add\/google/);
29− await page.getByRole("heading", { name: "Add Google integration" }).waitFor();
31+ await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click();
32+ await page.waitForURL(/\/integrations\/add\/openapi/);
33+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
3034 });
3135
32− await step("The Photos preset defaults to the focused namespace and products", async () => {
33− await page.locator('input[value="Google Photos"]').waitFor();
34− await page.locator('input[value="google_photos"]').waitFor();
35− await page.getByText("Google Photos Library").first().waitFor();
36− await page.getByText("Google Photos Picker").first().waitFor();
37− await page.getByText("2 Google APIs").waitFor();
38− expect(await page.locator('input[value="Google"]').count()).toBe(0);
39− expect(await page.locator('input[value="google"]').count()).toBe(0);
36+ await step("The Library link carries the focused OpenAPI preset", async () => {
37+ const url = new URL(page.url());
38+ expect(url.searchParams.get("preset")).toBe("google-photos-library");
39+ expect(url.searchParams.get("url")).toContain("photoslibrary");
40+ await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary");
4041 });
4142 });
4243 }),
e2e/scenarios/microsoft-emulator.test.ts+24−21
@@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines
55 import { Effect } from "effect";
66 import { composePluginApi } from "@executor-js/api/server";
77 import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";
8−import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
9−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
8+import {
9+ MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
10+ microsoftCatalog,
11+ microsoftGraphAdapter,
12+} from "@executor-js/plugin-openapi/providers/microsoft";
13+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1014 import {
1115 AuthTemplateSlug,
1216 ConnectionName,
@@ -17,7 +21,9 @@ import { · 4 unmodified lines
1721 import { scenario } from "../src/scenario";
1822 import { Api, Target } from "../src/services";
1923
20−const api = composePluginApi([microsoftHttpPlugin()] as const);
24+const api = composePluginApi([
25+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
26+] as const);
2127
2228 type OAuthTemplateView = {
2329 readonly slug: string;
@@ -80,18 +86,7 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul · 56 unmodified lines
8086
8187 scenario(
8288 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",
83− {
84− // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the
85− // canonical Graph spec in the streamable block-YAML profile — it structurally
86− // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB
87− // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on
88− // anything else. The @executor-js/emulate Microsoft emulator serves a small
89− // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix
90− // needs the emulator to serve a block-YAML-profile Graph spec (or a
91− // non-Workers compile path); tracked separately.
92− skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile",
93− timeout: 180_000,
94− },
89+ { timeout: 180_000 },
9590 Effect.scoped(
9691 Effect.gen(function* () {
9792 const target = yield* Target;
@@ -110,18 +105,26 @@ scenario( · 12 unmodified lines
110105
111106 yield* Effect.ensuring(
112107 Effect.gen(function* () {
113− yield* client.microsoft.addGraph({
108+ yield* client.openapi.addSpec({
114109 payload: {
115− presetIds: ["users"],
116− customScopes: [],
110+ spec: { kind: "url", url: emulator.openapiUrl },
117111 slug: integration,
118112 name: "Microsoft Graph Emulator",
119113 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+ ],
121124 },
122125 });
123126
124− const config = yield* client.microsoft.getConfig({
127+ const config = yield* client.openapi.getConfig({
125128 params: { slug: integration },
126129 });
127130 const appOnlyTemplate = config?.authenticationTemplate?.find(
@@ -213,7 +216,7 @@ scenario( · 85 unmodified lines
213216 params: { slug: oauthClient },
214217 payload: { owner: "org" },
215218 });
216− yield* client.microsoft.removeGraph({ params: { slug: integration } });
219+ yield* client.openapi.removeSpec({ params: { slug: integration } });
217220 }).pipe(Effect.ignore),
218221 );
219222 }),
e2e/scenarios/microsoft-graph-default.test.ts+44−58
@@ -6,10 +6,12 @@ import { composePluginApi } from "@executor-js/api/server"; · 5 unmodified lines
66 import {
77 MICROSOFT_AUTH_TEMPLATE_SLUG,
88 MICROSOFT_AUTHORIZATION_URL,
9− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
9+ MICROSOFT_GRAPH_OPENAPI_URL,
1010 MICROSOFT_TOKEN_URL,
11−} from "@executor-js/plugin-microsoft";
12−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
11+ microsoftCatalog,
12+ microsoftGraphAdapter,
13+} from "@executor-js/plugin-openapi/providers/microsoft";
14+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1315 import {
1416 AuthTemplateSlug,
1517 ConnectionName,
@@ -20,24 +22,26 @@ import { · 4 unmodified lines
2022 import { scenario } from "../src/scenario";
2123 import { Api, Target } from "../src/services";
2224
23−const api = composePluginApi([microsoftHttpPlugin()] as const);
25+const api = composePluginApi([
26+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
27+] as const);
2428
2529 type ToolView = {
2630 readonly name: string;
2731 };
2832
2933 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
34+const MICROSOFT_FILES_PRESET_ID = "files";
3035
31−// Compiling the ~37MB Graph spec inside dev workerd needs more headroom than
32−// GitHub's 2-core runners have: /api/microsoft/graph 500s and the dev stack is
33−// dead for every scenario after it in the shard. Local runs (and the
34−// production Workers streaming path) are unaffected — CI-only quarantine.
36+// Compiling the Graph spec inside dev workerd needs more headroom than GitHub's
37+// 2-core runners have. Local runs and the production Workers streaming path are
38+// unaffected, so CI keeps this narrow catalog scenario quarantined.
3539 const CI_GRAPH_SPEC_SKIP = process.env.CI
3640 ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard"
3741 : undefined;
3842
3943 scenario(
40− "Microsoft Graph: default add stores common Microsoft 365 workloads",
44+ "Microsoft Graph: catalog add stores the OneDrive files service",
4145 { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP },
4246 Effect.gen(function* () {
4347 const target = yield* Target;
@@ -45,59 +49,41 @@ scenario( · 1 unmodified line
4549 const identity = yield* target.newIdentity();
4650 const client = yield* makeApiClient(api, identity);
4751
48− const integration = unique("msgraph_default");
52+ const integration = unique("msgraph_files");
4953 const connection = ConnectionName.make("main");
5054 const oauthClient = OAuthClientSlug.make(unique("msgraph_app"));
5155
5256 yield* Effect.ensuring(
5357 Effect.gen(function* () {
54− const added = yield* client.microsoft.addGraph({
58+ const added = yield* client.openapi.addSpec({
5559 payload: {
56− presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS],
57− customScopes: [],
60+ spec: {
61+ kind: "url",
62+ url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`,
63+ },
5864 slug: integration,
59− name: "Microsoft Graph Defaults",
65+ name: "Microsoft Graph Files",
66+ family: "microsoft",
67+ specFormat: "microsoft-graph",
6068 },
6169 });
6270 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);
6371 expect(
6472 added.toolCount,
65− "the default Microsoft Graph add extracts common user-facing operations",
66− ).toBeGreaterThan(100);
73+ "the Microsoft files catalog preset extracts a focused operation subtree",
74+ ).toBeGreaterThan(10);
6775
68− const config = yield* client.microsoft.getConfig({
76+ const config = yield* client.openapi.getConfig({
6977 params: { slug: integration },
7078 });
71− expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([
72− ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
73− ]);
74− expect(
75− config?.microsoftGraphCoversFullGraph,
76− "the default selection is not full Graph",
77− ).toBe(false);
78− expect(
79− config?.microsoftGraphScopes,
80− "default delegated OAuth requests the selected common workload scopes",
81− ).toEqual([
82− "offline_access",
83− "User.Read",
84− "Mail.ReadWrite",
85− "Mail.Send",
86− "MailboxSettings.ReadWrite",
87− "Calendars.ReadWrite",
88− "Contacts.ReadWrite",
89− "People.Read.All",
90− "Tasks.ReadWrite",
79+ const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
80+ template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
81+ ? [...template.scopes]
82+ : [],
83+ );
84+ expect(delegatedScopes, "the files delegated OAuth asks for file scopes").toContain(
9185 "Files.ReadWrite.All",
92− "Sites.ReadWrite.All",
93− "Notes.ReadWrite",
94− "Chat.ReadWrite",
95− "Team.ReadBasic.All",
96− "Channel.ReadBasic.All",
97− "ChannelMessage.Read.All",
98− "ChannelMessage.Send",
99− "OnlineMeetings.ReadWrite",
100− ]);
86+ );
10187
10288 yield* client.oauth.createClient({
10389 payload: {
@@ -128,12 +114,12 @@ scenario( · 24 unmodified lines
128114 const authorizeUrl = new URL(authorizationUrl || "https://invalid.example");
129115 expect(
130116 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",
132118 ).toBeLessThan(2_000);
133119 expect(
134120 authorizeUrl.searchParams.get("scope"),
135− "default Microsoft Graph OAuth asks for common workload scopes",
136− ).toBe(config?.microsoftGraphScopes?.join(" "));
121+ "Microsoft files OAuth asks for the focused scope set",
122+ ).toBe(delegatedScopes?.join(" "));
137123
138124 yield* client.connections.create({
139125 payload: {
@@ -149,15 +135,15 @@ scenario( · 9 unmodified lines
149135 query: { integration: IntegrationSlug.make(integration), connection },
150136 });
151137 const names = tools.map((tool: ToolView) => tool.name);
152− const messageTools = names.filter((name) => name.toLowerCase().includes("message"));
153− const siteTools = names.filter((name) => name.toLowerCase().includes("site"));
154− expect(
155− messageTools,
156− "the retrieved catalog includes Microsoft message operations",
157− ).not.toEqual([]);
158− expect(siteTools, "the retrieved catalog includes SharePoint site operations").not.toEqual(
138+ const driveTools = names.filter((name) => name.toLowerCase().includes("drive"));
139+ const shareTools = names.filter((name) => name.toLowerCase().includes("share"));
140+ expect(driveTools, "the retrieved catalog includes Microsoft drive operations").not.toEqual(
159141 [],
160142 );
143+ expect(
144+ shareTools,
145+ "the retrieved catalog includes Microsoft sharing operations",
146+ ).not.toEqual([]);
161147 }),
162148 Effect.gen(function* () {
163149 yield* client.connections
@@ -169,8 +155,8 @@ scenario( · 5 unmodified lines
169155 },
170156 })
171157 .pipe(Effect.ignore);
172− yield* client.microsoft
173− .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })
158+ yield* client.openapi
159+ .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
174160 .pipe(Effect.ignore);
175161 yield* client.oauth
176162 .removeClient({
e2e/scenarios/microsoft-graph-full.test.ts+54−48
@@ -5,34 +5,40 @@ import { Effect } from "effect"; · 4 unmodified lines
55 import { composePluginApi } from "@executor-js/api/server";
66 import {
77 MICROSOFT_AUTH_TEMPLATE_SLUG,
8− MICROSOFT_GRAPH_ALL_PRESET_IDS,
9− MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
10−} from "@executor-js/plugin-microsoft";
11−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
8+ MICROSOFT_GRAPH_OPENAPI_URL,
9+ microsoftCatalog,
10+ microsoftGraphAdapter,
11+} from "@executor-js/plugin-openapi/providers/microsoft";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1213 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
1314
1415 import { scenario } from "../src/scenario";
1516 import { Api, Target } from "../src/services";
1617
17−const api = composePluginApi([microsoftHttpPlugin()] as const);
18+const api = composePluginApi([
19+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
20+] as const);
1821
1922 type ToolView = {
2023 readonly name: string;
2124 };
2225
2326 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
27+const MICROSOFT_FILES_PRESET_ID = "files";
28+const MICROSOFT_FILES_DELEGATED_SCOPES = [
29+ "offline_access",
30+ "User.Read",
31+ "Files.ReadWrite.All",
32+ "Sites.ReadWrite.All",
33+] as const;
2434
25−// Adding *every* Graph workload pulls the full Microsoft Graph OpenAPI document
26−// (~37MB, ~16.5k operations) and persists a binding per operation. That whole-
27−// document path used to 503 on the Cloudflare worker: parsing the spec, and
28−// then re-parsing it on every tools/list, each rebuilt a ~300MB JS tree that
29−// blew the 128MB isolate. This scenario is the regression guard for both sites
30−// at real scale: the add streams the compile + persist, and tools/list serves
31−// the catalog back from the persisted bindings (+ the content-addressed defs
32−// blob) without ever re-parsing the spec. It drives only the public API, so a
33−// green run is evidence the full catalog lands and serves end to end.
35+// Adding a catalog service extracts only that service's Microsoft Graph subtree
36+// and persists a binding per operation. This is the regression guard for both
37+// former worker pressure sites: the add streams compile and persist, and
38+// tools/list serves from persisted bindings plus the content-addressed defs blob
39+// without re-parsing the Graph spec.
3440 scenario(
35− "Microsoft Graph: the full catalog adds and serves without re-parsing the spec",
41+ "Microsoft Graph: the files catalog service adds and serves without re-parsing the spec",
3642 { timeout: 300_000 },
3743 Effect.gen(function* () {
3844 const target = yield* Target;
@@ -40,39 +46,41 @@ scenario( · 1 unmodified line
4046 const identity = yield* target.newIdentity();
4147 const client = yield* makeApiClient(api, identity);
4248
43− const integration = unique("msgraph_full");
49+ const integration = unique("msgraph_files");
4450 const connection = ConnectionName.make("main");
4551
4652 yield* Effect.ensuring(
4753 Effect.gen(function* () {
48− // Add path (1st former OOM site): the full spec is fetched and
49− // stream-compiled into one persisted binding per operation.
50− const added = yield* client.microsoft.addGraph({
54+ // Add path, first former OOM site: the Graph spec is fetched and
55+ // stream-compiled into one persisted binding per selected operation.
56+ const added = yield* client.openapi.addSpec({
5157 payload: {
52− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
53− customScopes: [],
58+ spec: {
59+ kind: "url",
60+ url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`,
61+ },
5462 slug: integration,
55− name: "Microsoft Graph (full)",
63+ name: "Microsoft Graph Files",
64+ family: "microsoft",
65+ specFormat: "microsoft-graph",
5666 },
5767 });
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);
5969 expect(
6070 added.toolCount,
61− "adding every Graph workload extracts the whole catalog (thousands of operations)",
62− ).toBeGreaterThan(5_000);
71+ "adding the files catalog service extracts a focused Graph operation subtree",
72+ ).toBeGreaterThan(10);
6373
64− const config = yield* client.microsoft.getConfig({ params: { slug: integration } });
65− expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual(
66− [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
74+ const config = yield* client.openapi.getConfig({ params: { slug: integration } });
75+ const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
76+ template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
77+ ? [...template.scopes]
78+ : [],
6779 );
6880 expect(
69− config?.microsoftGraphCoversFullGraph,
70− "selecting every workload is recognized as full Graph",
71− ).toBe(true);
72− expect(
73− config?.microsoftGraphScopes,
74− "full Graph delegates the app-registration default scope set",
75− ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]);
81+ delegatedScopes,
82+ "the files service delegates only the file-service scope set",
83+ ).toEqual([...MICROSOFT_FILES_DELEGATED_SCOPES]);
7684
7785 yield* client.connections.create({
7886 payload: {
@@ -84,24 +92,22 @@ scenario( · 5 unmodified lines
8492 },
8593 });
8694
87− // Serve path (2nd former OOM site): tools/list rebuilds the catalog from
88− // the persisted bindings. The whole catalog must come back, with real
89− // descriptions, and without re-parsing the 37MB spec.
95+ // Serve path, second former OOM site: tools/list rebuilds the catalog
96+ // from persisted bindings, with real descriptions, and without
97+ // re-parsing the Graph spec.
9098 const tools = yield* client.tools.list({
9199 query: { integration: IntegrationSlug.make(integration), connection },
92100 });
93101 expect(
94102 tools.length,
95− "the served catalog returns the whole set of operations, not a re-parse failure",
96− ).toBeGreaterThan(5_000);
103+ "the served catalog returns the files operation subtree, not a re-parse failure",
104+ ).toBeGreaterThan(10);
97105
98106 const names = tools.map((tool: ToolView) => tool.name);
99− const messageTools = names.filter((name) => name.toLowerCase().includes("message"));
100− const siteTools = names.filter((name) => name.toLowerCase().includes("site"));
101− const userTools = names.filter((name) => name.toLowerCase().includes("user"));
102− expect(messageTools, "the served catalog spans mail operations").not.toEqual([]);
103− expect(siteTools, "the served catalog spans SharePoint site operations").not.toEqual([]);
104− expect(userTools, "the served catalog spans directory user operations").not.toEqual([]);
107+ const driveTools = names.filter((name) => name.toLowerCase().includes("drive"));
108+ const shareTools = names.filter((name) => name.toLowerCase().includes("share"));
109+ expect(driveTools, "the served catalog spans drive operations").not.toEqual([]);
110+ expect(shareTools, "the served catalog spans sharing operations").not.toEqual([]);
105111 }),
106112 Effect.gen(function* () {
107113 yield* client.connections
@@ -113,8 +119,8 @@ scenario( · 5 unmodified lines
113119 },
114120 })
115121 .pipe(Effect.ignore);
116− yield* client.microsoft
117− .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })
122+ yield* client.openapi
123+ .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
118124 .pipe(Effect.ignore);
119125 }),
120126 );
e2e/scenarios/oauth-client-handoff.test.ts+24−21
@@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines
2525 import { AccountHttpApi } from "@executor-js/api";
2626 import { composePluginApi } from "@executor-js/api/server";
2727 import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28−import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
29−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
28+import {
29+ MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
30+ microsoftCatalog,
31+ microsoftGraphAdapter,
32+} from "@executor-js/plugin-openapi/providers/microsoft";
33+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3034 import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
3135
3236 import { scenario } from "../src/scenario";
3337 import { Api, Browser, Mcp, Target } from "../src/services";
3438 import type { McpSession } from "../src/surfaces/mcp";
3539
36−const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);
40+const microsoftApi = composePluginApi([
41+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
42+] as const);
3743
3844 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
3945
@@ -310,18 +316,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 270 unmodified lines
310316
311317 scenario(
312318 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313− {
314− // Blocked (pre-existing, not this PR): this scenario drives the handoff
315− // through `microsoft.addGraph`, which only accepts the canonical Graph spec
316− // in the streamable block-YAML profile (structural split to avoid OOMing the
317− // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/
318− // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec
319− // outside that profile, so addGraph hard-errors. The other two OAuth-client
320− // scenarios in this file (createHandoff, approval-gating) do not touch Graph
321− // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately.
322− skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve",
323− timeout: 240_000,
324− },
319+ { timeout: 240_000 },
325320 Effect.gen(function* () {
326321 const target = yield* Target;
327322 const { client: makeApiClient } = yield* Api;
@@ -356,14 +351,22 @@ scenario( · 28 unmodified lines
356351 Effect.gen(function* () {
357352 // Register the Microsoft Graph integration so the console has an OAuth
358353 // method to register a client against.
359− yield* client.microsoft.addGraph({
354+ yield* client.openapi.addSpec({
360355 payload: {
361− presetIds: ["users"],
362− customScopes: [],
356+ spec: { kind: "url", url: emulator.openapiUrl },
363357 slug: integration,
364358 name: "Microsoft Graph Emulator",
365359 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+ ],
367370 },
368371 });
369372
@@ -422,7 +425,7 @@ scenario( · 52 unmodified lines
422425 });
423426
424427 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();
426429 // onCreated returns to the Add-connection view — the register form closes.
427430 await page
428431 .getByRole("heading", { name: "Register OAuth app" })
@@ -482,7 +485,7 @@ scenario( · 53 unmodified lines
482485 payload: { owner: "org" },
483486 })
484487 .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);
486489 }).pipe(Effect.ignore),
487490 );
488491 }),
e2e/scenarios/provider-plugins-ui.test.ts+29−39
@@ -5,7 +5,7 @@ import { scenario } from "../src/scenario"; · 4 unmodified lines
55 import { Browser, Target } from "../src/services";
66
77 scenario(
8− "Provider plugins · Google and Microsoft own their add flows outside OpenAPI",
8+ "Provider catalog · Google and Microsoft services are OpenAPI presets",
99 {},
1010 Effect.gen(function* () {
1111 const target = yield* Target;
@@ -19,56 +19,46 @@ scenario( · 7 unmodified lines
1919 await page.getByRole("dialog", { name: "Connect an integration" }).waitFor();
2020 });
2121
22− await step(
23− "The provider list exposes OpenAPI, Google, and Microsoft separately",
24− async () => {
25− const dialog = page.getByRole("dialog", {
26− name: "Connect an integration",
27− });
28− await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
29− await dialog.getByRole("link", { name: "Google", exact: true }).waitFor();
30− await dialog.getByRole("link", { name: "Microsoft", exact: true }).waitFor();
31− },
32− );
22+ await step("The picker exposes OpenAPI plus provider service presets", async () => {
23+ const dialog = page.getByRole("dialog", {
24+ name: "Connect an integration",
25+ });
26+ const search = dialog.getByPlaceholder(/Search or paste a URL/);
27+ await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
28+
29+ await search.fill("gmail");
30+ await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor();
31+
32+ await search.fill("onedrive");
33+ await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor();
34+ });
3335
3436 await step("OpenAPI add remains generic", async () => {
3537 await page.goto("/integrations/add/openapi", {
3638 waitUntil: "domcontentloaded",
3739 });
38− await page.getByRole("heading", { name: "Add OpenAPI Integration" }).waitFor();
40+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
3941 await page.getByText("OpenAPI Spec").waitFor();
4042 expect(await page.getByText("Customize your Google connection").count()).toBe(0);
4143 expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);
4244 });
4345
44− await step("Google has its own product picker", async () => {
45− await page.goto("/integrations/add/google", {
46− waitUntil: "domcontentloaded",
47− });
48− await page.getByRole("heading", { name: "Add Google integration" }).waitFor();
49− await page.getByText("Customize your Google connection").waitFor();
50− await page.getByText("Gmail").first().waitFor();
51− await page.getByText("Google Calendar").first().waitFor();
46+ await step("A Google service preset opens the OpenAPI add flow", async () => {
47+ await page.goto(
48+ "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest",
49+ { waitUntil: "domcontentloaded" },
50+ );
51+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
52+ await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail");
5253 });
5354
54− await step("Microsoft has its own Graph scope picker", async () => {
55− await page.goto("/integrations/add/microsoft", {
56− waitUntil: "domcontentloaded",
57− });
58− await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor();
59− await page.getByText("Customize Microsoft Graph").waitFor();
60− expect(await page.getByText("All Microsoft Graph", { exact: true }).count()).toBe(0);
61− await page.getByText("Productivity").waitFor();
62− await page.getByText("Directory and identity").waitFor();
63− await page.getByText("Outlook Mail").waitFor();
64− await page.getByText("OneDrive Files").waitFor();
65− await page.getByText("OneNote", { exact: true }).waitFor();
66− await page.getByText("Teams Channels").waitFor();
67− await page.locator('img[src*="svgl.app/library/microsoft-outlook.svg"]').first().waitFor();
68− await page.locator('img[src*="svgl.app/library/microsoft-onedrive.svg"]').waitFor();
69− await page.getByRole("button", { name: /View scopes/ }).click();
70− await page.getByText("offline_access").waitFor();
71− await page.getByText("Mail.ReadWrite").waitFor();
55+ await step("A Microsoft service preset opens the OpenAPI add flow", async () => {
56+ await page.goto(
57+ "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoftgraph%2Fmsgraph-metadata%2Fmaster%2Fopenapi%2Fv1.0%2Fopenapi.yaml%23preset%3Dfiles",
58+ { waitUntil: "domcontentloaded" },
59+ );
60+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
61+ await expect.poll(() => page.locator("textarea").inputValue()).toContain("preset=files");
7262 });
7363 });
7464 }),
examples/all-plugins/package.json+0−1
@@ -10,7 +10,6 @@ · 9 unmodified lines
1010 },
1111 "dependencies": {
1212 "@executor-js/plugin-file-secrets": "workspace:*",
13− "@executor-js/plugin-google": "workspace:*",
1413 "@executor-js/plugin-graphql": "workspace:*",
1514 "@executor-js/plugin-keychain": "workspace:*",
1615 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/src/main.ts+13−4
@@ -33,12 +33,19 @@ import { · 32 unmodified lines
3333 } from "@executor-js/sdk";
3434
3535 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36−import { googlePlugin } from "@executor-js/plugin-google";
36+import {
37+ googleCatalog,
38+ googleDiscoveryAdapter,
39+} from "@executor-js/plugin-openapi/providers/google";
3740 import { graphqlPlugin } from "@executor-js/plugin-graphql";
3841 import { keychainPlugin } from "@executor-js/plugin-keychain";
3942 import { mcpPlugin } from "@executor-js/plugin-mcp";
4043 import { onepasswordPlugin } from "@executor-js/plugin-onepassword";
4144 import { openApiPlugin, variable } from "@executor-js/plugin-openapi";
45+import {
46+ microsoftCatalog,
47+ microsoftGraphAdapter,
48+} from "@executor-js/plugin-openapi/providers/microsoft";
4249 import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
4350
4451 // ---------------------------------------------------------------------------
@@ -78,10 +85,12 @@ const plugins = [ · 33 unmodified lines
7885 // Integration plugins — these declare their own schemas (tables), register
7986 // integrations via their extension methods (`addSpec` / `addServer` /
8087 // `addIntegration`), and produce tools per connection.
81− googlePlugin(),
8288 graphqlPlugin(),
8389 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
84− openApiPlugin(),
90+ openApiPlugin({
91+ presets: [...googleCatalog, ...microsoftCatalog],
92+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
93+ }),
8594
8695 // workos-vault is a cloud-hosted credential provider. It would contribute a
8796 // "workos-vault" provider if credentials were available. We skip it here
@@ -418,7 +427,7 @@ const program = Effect.gen(function* () { · 330 unmodified lines
418427 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
419428
420429 // 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".
422431 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
423432
424433 // -------------------------------------------------------------------------
knip.config.ts+0−3
@@ -41,9 +41,6 @@ const config: KnipConfig = { · 40 unmodified lines
4141 "packages/plugins/file-secrets": {
4242 includeEntryExports: true,
4343 },
44− "packages/plugins/google": {
45− includeEntryExports: true,
46− },
4744 "packages/plugins/onepassword": {
4845 includeEntryExports: true,
4946 ignoreDependencies: ["@executor-js/react"],
package.json+1−0
@@ -75,6 +75,7 @@ · 74 unmodified lines
7575 "@effect/language-service": "^0.85.1",
7676 "@effect/tsgo": "^0.5.2",
7777 "@effect/vitest": "catalog:",
78+ "@executor-js/plugin-provider-service-split": "workspace:*",
7879 "@typescript/native-preview": "^7.0.0-dev.20260410.1",
7980 "@vitest/expect": "catalog:",
8081 "@vitest/mocker": "catalog:",
packages/core/api/src/connections/api.ts+1−0
@@ -58,6 +58,7 @@ const ConnectionResponse = Schema.Struct({ · 57 unmodified lines
5858 oauthClient: Schema.NullOr(OAuthClientSlug),
5959 oauthClientOwner: Schema.NullOr(Owner),
6060 oauthScope: Schema.NullOr(Schema.String),
61+ missingOAuthScopes: Schema.Array(Schema.String),
6162 // Last persisted health-check verdict (written by every checkHealth run),
6263 // so the list can show alive/expired at a glance without probing.
6364 lastHealth: Schema.NullOr(HealthCheckResult),
packages/core/api/src/handlers/connections.ts+1−0
@@ -28,6 +28,7 @@ const toResponse = (c: Connection) => ({ · 27 unmodified lines
2828 oauthClient: c.oauthClient ?? null,
2929 oauthClientOwner: c.oauthClientOwner ?? null,
3030 oauthScope: c.oauthScope ?? null,
31+ missingOAuthScopes: c.missingOAuthScopes ?? [],
3132 lastHealth: c.lastHealth ?? null,
3233 });
3334
packages/core/api/src/handlers/integrations.ts+1−0
@@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines
1515 canRefresh: i.canRefresh,
1616 authMethods: i.authMethods,
1717 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),
18+ ...(i.family ? { family: i.family } : {}),
1819 });
1920
2021 export 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
4545 oauthClient: c.oauthClient ?? null,
4646 oauthClientOwner: c.oauthClientOwner ?? null,
4747 oauthScope: c.oauthScope ?? null,
48+ missingOAuthScopes: c.missingOAuthScopes ?? [],
4849 });
4950
5051 const startResultToResponse = (result: ConnectResult) =>
packages/core/api/src/integrations/api.ts+2−0
@@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines
8181 authMethods: Schema.Array(AuthMethodDescriptorSchema),
8282 /** Non-secret URL derived from opaque integration config for favicons. */
8383 displayUrl: Schema.optional(Schema.String),
84+ /** Catalog family derived from opaque integration config for grouped display. */
85+ family: Schema.optional(Schema.String),
8486 });
8587
8688 const UpdateIntegrationPayload = Schema.Struct({
packages/core/api/src/oauth/api.ts+1−0
@@ -50,6 +50,7 @@ const ConnectionResponse = Schema.Struct({ · 49 unmodified lines
5050 oauthClient: Schema.NullOr(OAuthClientSlug),
5151 oauthClientOwner: Schema.NullOr(Owner),
5252 oauthScope: Schema.NullOr(Schema.String),
53+ missingOAuthScopes: Schema.Array(Schema.String),
5354 });
5455
5556 // ---------------------------------------------------------------------------
packages/core/sdk/src/client.ts+26−0
@@ -22,6 +22,7 @@ import { HttpApi } from "effect/unstable/httpapi"; · 21 unmodified lines
2222 import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2323 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
2424 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
25+import type { HealthCheckSpec } from "./health-check";
2526
2627 // ---------------------------------------------------------------------------
2728 // Re-exports — the curated set of primitives a plugin author needs to
@@ -112,8 +113,33 @@ export interface IntegrationPreset { · 84 unmodified lines
112113 readonly icon?: string;
113114 /** Shown in the top-level grid on the integrations page when true. */
114115 readonly featured?: boolean;
116+ readonly family?: string;
117+ readonly specFormat?: string;
118+ readonly defaultSlug?: string;
119+ readonly authTemplate?: readonly IntegrationPresetAuthentication[];
120+ readonly healthCheck?: HealthCheckSpec;
115121 }
116122
123+export type IntegrationPresetAuthentication =
124+ | {
125+ readonly slug: string;
126+ readonly kind: "oauth2";
127+ readonly authorizationUrl: string;
128+ readonly tokenUrl: string;
129+ readonly resource?: string | null;
130+ readonly scopes: readonly string[];
131+ readonly supportsClientIdMetadataDocument?: boolean;
132+ }
133+ | {
134+ readonly kind: "apiKey";
135+ readonly slug?: string;
136+ readonly name?: string;
137+ readonly placements?: readonly unknown[];
138+ readonly headers?: Readonly<Record<string, readonly unknown[]>>;
139+ readonly queryParams?: Readonly<Record<string, readonly unknown[]>>;
140+ readonly cookies?: Readonly<Record<string, readonly unknown[]>>;
141+ };
142+
117143 export interface IntegrationAccountHandoff {
118144 /** Changes on each handoff URL, so the accounts UI can open once per link. */
119145 readonly key: string;
packages/core/sdk/src/connection.ts+3−0
@@ -54,6 +54,9 @@ export interface Connection { · 53 unmodified lines
5454 * declared scopes to decide whether this connection must reconnect to grant
5555 * newly-needed access. Null for static creds / when the AS omitted `scope`. */
5656 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[];
5760 /** Last health-check verdict, persisted by every `checkHealth` run. Answers
5861 * "has this expired?" at a glance in the connections list without probing.
5962 * 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
618618 );
619619 });
620620
621+describe("connections.checkHealth", () => {
622+ it.effect("keeps API-key connections without a probe unknown", () =>
623+ Effect.gen(function* () {
624+ const executor = yield* setup();
625+ yield* executor.connections.create({
626+ owner: "org",
627+ name: ConnectionName.make("main"),
628+ integration: INTEG,
629+ template: TEMPLATE,
630+ value: "secret-token",
631+ });
632+
633+ const result = yield* executor.connections.checkHealth({
634+ owner: "org",
635+ integration: INTEG,
636+ name: ConnectionName.make("main"),
637+ });
638+
639+ expect(result.status).toBe("unknown");
640+ }),
641+ );
642+});
643+
621644 describe("execute over a connection", () => {
622645 it.effect("resolves the credential value and hands it to invokeTool", () =>
623646 Effect.gen(function* () {
packages/core/sdk/src/executor.ts+74−11
@@ -86,6 +86,7 @@ import type { · 85 unmodified lines
8686 AuthMethodDescriptor,
8787 Integration,
8888 IntegrationConfig,
89+ IntegrationDisplayDescriptor,
8990 RegisterIntegrationInput,
9091 } from "./integration";
9192 import {
@@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines
522523 const rowToIntegration = (
523524 row: IntegrationRow,
524525 authMethods: readonly AuthMethodDescriptor[] = [],
525− displayUrl?: string,
526+ display?: IntegrationDisplayDescriptor,
526527 ): Integration => ({
527528 slug: IntegrationSlug.make(row.slug),
528529 // Pre-split rows have no `name`; their description WAS the display name.
@@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines
534535 canRemove: Boolean(row.can_remove),
535536 canRefresh: Boolean(row.can_refresh),
536537 authMethods,
537− ...(displayUrl ? { displayUrl } : {}),
538+ ...(display?.url ? { displayUrl: display.url } : {}),
539+ ...(display?.family ? { family: display.family } : {}),
538540 });
539541
540542 const rowToIntegrationRecord = (
@@ -548,6 +550,15 @@ const rowToIntegrationRecord = ( · 7 unmodified lines
548550 const decodeLastHealth = Schema.decodeUnknownOption(HealthCheckResult);
549551 const decodeHealthCheckSpec = Schema.decodeUnknownOption(HealthCheckSpec);
550552
553+const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] => {
554+ const decoded = decodeJsonColumn(value);
555+ if (decoded == null || typeof decoded !== "object" || Array.isArray(decoded)) return [];
556+ const scopes = (decoded as Record<string, unknown>).missingOAuthScopes;
557+ return Array.isArray(scopes)
558+ ? scopes.filter((scope): scope is string => typeof scope === "string")
559+ : [];
560+};
561+
551562 const rowToConnection = (row: ConnectionRow): Connection => {
552563 const owner = row.owner as Owner;
553564 const integration = IntegrationSlug.make(row.integration);
@@ -566,6 +577,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 12 unmodified lines
566577 oauthClientOwner:
567578 row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner),
568579 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
580+ missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state),
569581 lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
570582 };
571583 };
@@ -1759,17 +1771,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1187 unmodified lines
17591771 }
17601772 };
17611773
1762− const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => {
1774+ const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => {
17631775 const runtime = runtimes.get(row.plugin_id);
17641776 const describe = runtime?.plugin.describeIntegrationDisplay;
1765− if (!describe) return undefined;
1777+ if (!describe) return {};
17661778 const record = rowToIntegrationRecord(row);
17671779 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read
17681780 try {
17691781 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+ };
17711786 } catch {
1772− return undefined;
1787+ return {};
17731788 }
17741789 };
17751790
@@ -1801,7 +1816,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 25 unmodified lines
18011816 const rows = yield* core.findMany("integration", {});
18021817 const staticIntegrations = staticSources().map(staticSourceToIntegration);
18031818 const dbIntegrations = rows.map((row) =>
1804− rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
1819+ rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)),
18051820 );
18061821 // A scoped toolkit must not advertise providers it grants no tools from
18071822 // (mirrors `connectionsList`). Static sources are system namespaces, not
@@ -1828,7 +1843,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
18281843 if (staticSource) return staticSourceToIntegration(staticSource);
18291844 const row = yield* findIntegrationRow(slug);
18301845 return row
1831− ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row))
1846+ ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row))
18321847 : null;
18331848 });
18341849
@@ -2442,6 +2457,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 607 unmodified lines
24422457 expires_at: input.expiresAt,
24432458 oauth_scope: input.oauthScope,
24442459 oauth_token_url: input.oauthTokenUrl ?? null,
2460+ provider_state:
2461+ input.missingOAuthScopes && input.missingOAuthScopes.length > 0
2462+ ? { missingOAuthScopes: input.missingOAuthScopes }
2463+ : null,
24452464 updated_at: now,
24462465 };
24472466 if (existing) {
@@ -2474,7 +2493,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
24742493 expires_at: input.expiresAt,
24752494 oauth_scope: input.oauthScope,
24762495 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,
24782500 created_at: now,
24792501 updated_at: now,
24802502 });
@@ -2508,7 +2530,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 27 unmodified lines
25082530 expires_at: input.expiresAt,
25092531 oauth_scope: input.oauthScope,
25102532 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,
25122537 created_at: now,
25132538 updated_at: now,
25142539 } as ConnectionRow);
@@ -2683,6 +2708,39 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 168 unmodified lines
26832708 }),
26842709 );
26852710
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+
26862744 // Resolve an in-flight credential's value map (key-first validation) without
26872745 // saving anything. Mirrors `resolveConnectionValues` for the saved-row path:
26882746 // 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
27422800 const runtime = runtimes.get(integrationRow.plugin_id);
27432801 const check = runtime?.plugin.checkHealth;
27442802 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+ }
27452809
27462810 const result = yield* Effect.gen(function* () {
27472811 const values = yield* resolveConnectionValues(connectionRow);
@@ -2760,7 +2824,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines
27602824 };
27612825 // Core resolves the declared spec (its own column) and hands it to the
27622826 // plugin; plugins no longer read it out of their config.
2763− const spec = describeHealthCheckForRow(integrationRow) ?? undefined;
27642827 return yield* foldPluginFailure(
27652828 check({ ctx: runtime.ctx, integration: record, credential, spec }),
27662829 `Health check for connection "${ref.name}" failed.`,
packages/core/sdk/src/index.ts+1−0
@@ -316,6 +316,7 @@ export { · 315 unmodified lines
316316 type IntegrationConfigureDecl,
317317 type IntegrationConfigureSchema,
318318 type IntegrationPreset,
319+ type IntegrationPresetAuthentication,
319320 type IntegrationPresetCatalogEntry,
320321 type ResolveToolsInput,
321322 type ResolveToolsResult,
packages/core/sdk/src/integration.ts+4−0
@@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines
2222 export interface IntegrationDisplayDescriptor {
2323 /** Non-secret URL suitable for display metadata such as favicons. */
2424 readonly url?: string;
25+ /** Catalog family derived from plugin config for grouped display. */
26+ readonly family?: string;
2527 }
2628
2729 /** Where a credential value is carried. `header`/`query` place it on an
@@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines
100102 /** Non-secret display URL derived by the owning plugin from opaque config.
101103 * Used for catalog favicons; never includes credentials or plugin config. */
102104 readonly displayUrl?: string;
105+ /** Catalog family derived by the owning plugin from opaque config. */
106+ readonly family?: string;
103107 }
104108
105109 /** 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
1212 } from "./ids";
1313 import { decodeOAuthCallbackState } from "./oauth";
1414 import { OAuthStartError } from "./oauth-client";
15+import { missingGrantedOAuthScopes } from "./oauth-service";
1516 import { definePlugin } from "./plugin";
1617 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1718 import { serveOAuthTestServer } from "./testing/oauth-test-server";
@@ -294,6 +295,153 @@ describe("oauth.start / oauth.complete", () => { · 276 unmodified lines
294295 ),
295296 );
296297
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+
297445 it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () =>
298446 Effect.scoped(
299447 Effect.gen(function* () {
@@ -702,6 +850,170 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 402 unmodified lines
702850 }),
703851 ),
704852 );
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+ );
7051017 });
7061018
7071019 // 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
8661178 ),
8671179 );
8681180 });
1181+
1182+describe("missingGrantedOAuthScopes canonicalization", () => {
1183+ it("treats Microsoft fully-qualified granted scopes as covering short-form requests", () => {
1184+ const missing = missingGrantedOAuthScopes(
1185+ [
1186+ "offline_access",
1187+ "User.Read",
1188+ "https://graph.microsoft.com/.default",
1189+ "Mail.ReadWrite",
1190+ "Mail.Send",
1191+ "MailboxSettings.ReadWrite",
1192+ ],
1193+ [
1194+ "https://graph.microsoft.com/Mail.ReadWrite",
1195+ "https://graph.microsoft.com/Mail.Send",
1196+ "https://graph.microsoft.com/MailboxSettings.ReadWrite",
1197+ "https://graph.microsoft.com/User.Read",
1198+ ].join(" "),
1199+ );
1200+ expect(missing).toEqual([]);
1201+ });
1202+
1203+ it("still reports genuinely ungranted scopes under the qualified Microsoft shape", () => {
1204+ const missing = missingGrantedOAuthScopes(
1205+ ["User.Read", "Mail.ReadWrite", "Mail.Send"],
1206+ "https://graph.microsoft.com/User.Read https://graph.microsoft.com/Mail.ReadWrite",
1207+ );
1208+ expect(missing).toEqual(["Mail.Send"]);
1209+ });
1210+
1211+ it("maps Google userinfo aliases and ignores identity meta-scopes", () => {
1212+ const missing = missingGrantedOAuthScopes(
1213+ ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"],
1214+ [
1215+ "https://www.googleapis.com/auth/userinfo.email",
1216+ "https://www.googleapis.com/auth/userinfo.profile",
1217+ "https://www.googleapis.com/auth/calendar",
1218+ ].join(" "),
1219+ );
1220+ expect(missing).toEqual([]);
1221+ });
1222+});
packages/core/sdk/src/oauth-helpers.test.ts+77−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 createPkceCodeVerifier,
2020 exchangeAuthorizationCode,
2121 exchangeClientCredentials,
22+ idTokenIdentityLabel,
2223 refreshAccessToken,
2324 shouldRefreshToken,
2425 } from "./oauth-helpers";
@@ -362,6 +363,81 @@ describe("exchangeAuthorizationCode", () => { · 337 unmodified lines
362363 });
363364 expect(result.access_token).toBe("tok");
364365 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();
365441 }),
366442 ),
367443 );
@@ -406,6 +482,7 @@ describe("exchangeAuthorizationCode", () => { · 38 unmodified lines
406482 expect(result.access_token).toBe("tok");
407483 expect(result.refresh_token).toBe("rtok");
408484 expect(result.expires_in).toBe(3600);
485+ expect(result.idTokenIdentityLabel).toBeUndefined();
409486 }),
410487 ),
411488 );
packages/core/sdk/src/oauth-helpers.ts+72−18
@@ -16,7 +16,7 @@ · 15 unmodified lines
1616 // construction keeps the call sync and lets callers opt out of PAR
1717 // ---------------------------------------------------------------------------
1818
19−import { Data, Effect, Predicate } from "effect";
19+import { Data, Effect, Option, Predicate, Schema } from "effect";
2020 import * as oauth from "oauth4webapi";
2121
2222 // ---------------------------------------------------------------------------
@@ -45,6 +45,7 @@ export type OAuth2TokenResponse = { · 22 unmodified lines
4545 readonly refresh_token?: string;
4646 readonly expires_in?: number;
4747 readonly scope?: string;
48+ readonly idTokenIdentityLabel?: string;
4849 };
4950
5051 // ---------------------------------------------------------------------------
@@ -461,12 +462,56 @@ const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse · 410 unmodified lines
461462 scope: r.scope,
462463 });
463464
464−// MCP source connections are pure OAuth 2.0 — we never request `openid` and
465−// never consume `id_token`. Some providers (PostHog, etc.) front an OIDC
466−// backend and emit an `id_token` anyway; oauth4webapi then strict-validates
467−// its claims against the AS metadata and rejects mismatches we don't care
468−// about. Strip the field before delegation.
469−const stripIdToken = async (response: Response): Promise<Response> => {
465+const JwtClaims = Schema.Record(Schema.String, Schema.Unknown);
466+const decodeJwtClaims = Schema.decodeUnknownOption(Schema.fromJsonString(JwtClaims));
467+
468+const stringClaim = (
469+ claims: Readonly<Record<string, unknown>>,
470+ key: string,
471+): string | undefined => {
472+ const value = claims[key];
473+ if (typeof value !== "string") return undefined;
474+ const trimmed = value.trim();
475+ return trimmed.length > 0 ? trimmed : undefined;
476+};
477+
478+const decodeJwtPayload = (token: string): Readonly<Record<string, unknown>> | null => {
479+ const payload = token.split(".")[1];
480+ if (!payload) return null;
481+ if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) return null;
482+ const base64 = payload.replaceAll("-", "+").replaceAll("_", "/");
483+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
484+ // atob yields latin1 code units; JWT payloads are UTF-8 bytes, so re-decode
485+ // them properly or non-ASCII claim values (accented emails, names) garble.
486+ const utf8 = new TextDecoder().decode(
487+ Uint8Array.from(globalThis.atob(padded), (char) => char.charCodeAt(0)),
488+ );
489+ const decoded = decodeJwtClaims(utf8);
490+ return Option.isSome(decoded) ? decoded.value : null;
491+};
492+
493+export const idTokenIdentityLabel = (idToken: string | undefined): string | undefined => {
494+ if (!idToken) return undefined;
495+ const claims = decodeJwtPayload(idToken);
496+ if (!claims) return undefined;
497+ return (
498+ stringClaim(claims, "email") ??
499+ stringClaim(claims, "preferred_username") ??
500+ stringClaim(claims, "sub")
501+ );
502+};
503+
504+type StrippedTokenResponse = {
505+ readonly response: Response;
506+ readonly idTokenIdentityLabel?: string;
507+};
508+
509+// MCP source connections are pure OAuth 2.0. Some providers (PostHog, etc.)
510+// front an OIDC backend and emit an `id_token` anyway; oauth4webapi then
511+// strict-validates its claims against the AS metadata and rejects mismatches we
512+// don't care about. Strip the field before delegation, after extracting the
513+// optional display label when the token endpoint returned OIDC account claims.
514+const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> => {
470515 const body = await response
471516 .clone()
472517 .json()
@@ -475,24 +520,33 @@ const stripIdToken = async (response: Response): Promise<Response> => { · 2 unmodified lines
475520 () => null,
476521 );
477522 if (!body || typeof body !== "object" || !("id_token" in (body as Record<string, unknown>))) {
478− return response;
523+ return { response };
479524 }
480− const { id_token: _ignored, ...rest } = body as Record<string, unknown>;
481− return new Response(JSON.stringify(rest), {
482− status: response.status,
483− statusText: response.statusText,
484− headers: response.headers,
485− });
525+ const { id_token: idToken, ...rest } = body as Record<string, unknown>;
526+ const label = typeof idToken === "string" ? idTokenIdentityLabel(idToken) : undefined;
527+ return {
528+ response: new Response(JSON.stringify(rest), {
529+ status: response.status,
530+ statusText: response.statusText,
531+ headers: response.headers,
532+ }),
533+ ...(label ? { idTokenIdentityLabel: label } : {}),
534+ };
486535 };
487536
488537 const processTokenEndpointResponse = async (
489538 as: oauth.AuthorizationServer,
490539 client: oauth.Client,
491540 response: Response,
492−): Promise<OAuth2TokenResponse> =>
493− tokenResponseFrom(
494− await oauth.processGenericTokenEndpointResponse(as, client, await stripIdToken(response)),
541+): Promise<OAuth2TokenResponse> => {
542+ const stripped = await stripIdToken(response);
543+ const token = tokenResponseFrom(
544+ await oauth.processGenericTokenEndpointResponse(as, client, stripped.response),
495545 );
546+ return stripped.idTokenIdentityLabel
547+ ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel }
548+ : token;
549+};
496550
497551 // ---------------------------------------------------------------------------
498552 // Exchange authorization code → tokens
@@ -681,7 +735,7 @@ export const refreshAccessToken = ( · 182 unmodified lines
681735 const result = await oauth.processRefreshTokenResponse(
682736 as,
683737 client,
684− await stripIdToken(response),
738+ (await stripIdToken(response)).response,
685739 );
686740 return tokenResponseFrom(result);
687741 },
packages/core/sdk/src/oauth-service.ts+54−2
@@ -91,6 +91,7 @@ export interface MintOAuthConnectionInput { · 90 unmodified lines
9191 readonly refreshItemId: string | null;
9292 readonly expiresAt: number | null;
9393 readonly oauthScope: string | null;
94+ readonly missingOAuthScopes?: readonly string[];
9495 /** Per-connection override for the token endpoint, persisted only when the
9596 * code was redeemed at a region other than the client's configured token
9697 * host (Datadog multi-site). Null means refresh uses the client's token URL. */
@@ -203,6 +204,52 @@ const recordedOAuthScope = ( · 106 unmodified lines
203204 return recorded.join(" ") || null;
204205 };
205206
207+const OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = {
208+ "https://www.googleapis.com/auth/userinfo.email": "email",
209+ "https://www.googleapis.com/auth/userinfo.profile": "profile",
210+};
211+
212+const informationalOAuthScopes = new Set(["openid", "email", "profile", "offline_access"]);
213+
214+/** Canonicalize a scope for granted-vs-requested comparison. Microsoft's token
215+ * endpoint returns Graph scopes fully qualified
216+ * (`https://graph.microsoft.com/Mail.ReadWrite`) even when the request used
217+ * the short form, so resource-URI prefixes are stripped down to the scope's
218+ * final path segment before comparing. */
219+const canonicalOAuthScope = (scope: string): string => {
220+ const aliased = OAUTH_SCOPE_ALIASES[scope];
221+ if (aliased) return aliased;
222+ if (/^https?:\/\/graph\.microsoft\.(com|us|de)\//i.test(scope)) {
223+ return scope.slice(scope.lastIndexOf("/") + 1);
224+ }
225+ return scope;
226+};
227+
228+/** `.default` is a request-time meta-scope (Microsoft expands it server-side
229+ * and never echoes it in the granted scope), so it can never be "missing". */
230+const isMetaOAuthScope = (scope: string): boolean => scope.toLowerCase().endsWith("/.default");
231+
232+const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> =>
233+ new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean));
234+
235+export const missingGrantedOAuthScopes = (
236+ requestedScopes: readonly string[],
237+ recordedScope: string | null,
238+): readonly string[] => {
239+ const granted = normalizedOAuthScopeSet(recordedScope?.split(/\s+/).filter(Boolean) ?? []);
240+ const seen = new Set<string>();
241+ const out: string[] = [];
242+ for (const raw of requestedScopes) {
243+ const trimmed = raw.trim();
244+ if (isMetaOAuthScope(trimmed)) continue;
245+ const scope = canonicalOAuthScope(trimmed);
246+ if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue;
247+ seen.add(scope);
248+ if (!granted.has(scope)) out.push(scope);
249+ }
250+ return out;
251+};
252+
206253 const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
207254
208255 /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The
@@ -1209,7 +1256,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1000 unmodified lines
12091256 name: session.name,
12101257 integration: session.integration,
12111258 template: session.template,
1212− identityLabel: session.identityLabel,
1259+ identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null,
12131260 },
12141261 client,
12151262 token,
@@ -1278,6 +1325,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 62 unmodified lines
12781325 yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token);
12791326 }
12801327
1328+ const oauthScope = recordedOAuthScope(token, requestedScopes);
12811329 return yield* deps.mintOAuthConnection({
12821330 owner: target.owner,
12831331 name: target.name,
@@ -1294,7 +1342,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines
12941342 // Microsoft, issue a refresh token for `offline_access` but omit that
12951343 // non-resource scope from the token `scope` string, so preserve it when
12961344 // 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+ : [],
12981350 oauthTokenUrl,
12991351 });
13001352 });
packages/core/sdk/src/plugin.ts+25−0
@@ -505,12 +505,37 @@ export interface IntegrationPreset { · 504 unmodified lines
505505 readonly endpoint?: string;
506506 readonly icon?: string;
507507 readonly featured?: boolean;
508+ readonly family?: string;
509+ readonly specFormat?: string;
510+ readonly defaultSlug?: string;
511+ readonly authTemplate?: readonly IntegrationPresetAuthentication[];
512+ readonly healthCheck?: HealthCheckSpec;
508513 readonly transport?: "remote" | "stdio";
509514 readonly command?: string;
510515 readonly args?: readonly string[];
511516 readonly env?: Readonly<Record<string, string>>;
512517 }
513518
519+export type IntegrationPresetAuthentication =
520+ | {
521+ readonly slug: string;
522+ readonly kind: "oauth2";
523+ readonly authorizationUrl: string;
524+ readonly tokenUrl: string;
525+ readonly resource?: string | null;
526+ readonly scopes: readonly string[];
527+ readonly supportsClientIdMetadataDocument?: boolean;
528+ }
529+ | {
530+ readonly kind: "apiKey";
531+ readonly slug?: string;
532+ readonly name?: string;
533+ readonly placements?: readonly unknown[];
534+ readonly headers?: Readonly<Record<string, readonly unknown[]>>;
535+ readonly queryParams?: Readonly<Record<string, readonly unknown[]>>;
536+ readonly cookies?: Readonly<Record<string, readonly unknown[]>>;
537+ };
538+
514539 export interface IntegrationPresetCatalogEntry extends IntegrationPreset {
515540 readonly pluginId: string;
516541 }
packages/core/sdk/src/testing/oauth-test-server.ts+13−0
@@ -55,6 +55,8 @@ export interface OAuthTestServerOptions { · 54 unmodified lines
5555 readonly supportRefresh?: boolean;
5656 readonly tokenExpiresInSeconds?: number;
5757 readonly invalidRefreshTokenDescription?: string;
58+ readonly idTokenClaims?: Readonly<Record<string, unknown>>;
59+ readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>;
5860 /** Gate Dynamic Client Registration on the requested redirect URIs. When set,
5961 * `/register` returns `400 invalid_redirect_uri` unless every requested
6062 * `redirect_uris` entry is approved. Mirrors authorization servers (e.g.
@@ -127,6 +129,7 @@ const TokenResponse = Schema.Struct({ · 66 unmodified lines
127129 token_type: Schema.String,
128130 expires_in: Schema.optional(Schema.Number),
129131 scope: Schema.optional(Schema.String),
132+ id_token: Schema.optional(Schema.String),
130133 });
131134 const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
132135
@@ -179,6 +182,12 @@ const decodeBasicAuthorization = ( · 46 unmodified lines
179182 const codeChallengeForVerifier = (verifier: string): string =>
180183 createHash("sha256").update(verifier).digest("base64url");
181184
185+const jwtPart = (value: unknown): string =>
186+ Buffer.from(JSON.stringify(value)).toString("base64url");
187+
188+const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string =>
189+ `${jwtPart({ alg: "RS256", typ: "JWT" })}.${jwtPart(claims)}.sig`;
190+
182191 const oauthError = (status: number, error: string, errorDescription: string) =>
183192 jsonResponse(
184193 status,
@@ -652,6 +661,7 @@ export const serveOAuthTestServer = ( · 467 unmodified lines
652661 token_type: "Bearer",
653662 expires_in: tokenExpiresInSeconds,
654663 ...(scope ? { scope } : {}),
664+ ...(options.idTokenClaims ? { id_token: unsignedJwt(options.idTokenClaims) } : {}),
655665 },
656666 { "cache-control": "no-store" },
657667 );
@@ -680,6 +690,9 @@ export const serveOAuthTestServer = ( · 22 unmodified lines
680690 token_type: "Bearer",
681691 expires_in: tokenExpiresInSeconds,
682692 ...(scope ? { scope } : {}),
693+ ...(options.refreshIdTokenClaims
694+ ? { id_token: unsignedJwt(options.refreshIdTokenClaims) }
695+ : {}),
683696 },
684697 { "cache-control": "no-store" },
685698 );
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 @@
1−import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { Schema } from "effect";
3−import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4−import {
5− InternalError,
6− IntegrationAlreadyExistsError,
7− IntegrationNotFoundError,
8− IntegrationSlug,
9−} from "@executor-js/sdk/shared";
10−import {
11− OpenApiExtractionError,
12− OpenApiOAuthError,
13− OpenApiParseError,
14−} from "@executor-js/plugin-openapi";
15−
16−const DomainErrors = [
17− InternalError,
18− OpenApiParseError,
19− OpenApiExtractionError,
20− OpenApiOAuthError,
21− IntegrationAlreadyExistsError,
22−] as const;
23−
24−const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25−
26−const UpdateErrors = [
27− InternalError,
28− OpenApiParseError,
29− OpenApiExtractionError,
30− OpenApiOAuthError,
31− IntegrationNotFound,
32−] as const;
33−
34−const SlugParams = {
35− slug: Schema.String,
36−};
37−
38−const OAuthTemplatePayload = Schema.Struct({
39− slug: Schema.String,
40− kind: Schema.Literal("oauth2"),
41− authorizationUrl: Schema.String,
42− tokenUrl: Schema.String,
43− scopes: Schema.Array(Schema.String),
44−});
45−
46−const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47−const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48−
49−const AddBundlePayload = Schema.Struct({
50− urls: Schema.Array(Schema.String),
51− slug: Schema.optional(Schema.String),
52− name: Schema.optional(Schema.String),
53− description: Schema.optional(Schema.String),
54− baseUrl: Schema.optional(Schema.String),
55−});
56−
57−const AddBundleResponse = Schema.Struct({
58− slug: IntegrationSlug,
59− toolCount: Schema.Number,
60−});
61−
62−const UpdateBundlePayload = Schema.Struct({
63− urls: Schema.optional(Schema.Array(Schema.String)),
64−});
65−
66−const UpdateBundleResponse = Schema.Struct({
67− slug: IntegrationSlug,
68− toolCount: Schema.Number,
69− addedTools: Schema.Array(Schema.String),
70− removedTools: Schema.Array(Schema.String),
71−});
72−
73−const IntegrationView = Schema.Struct({
74− slug: IntegrationSlug,
75− description: Schema.String,
76− kind: Schema.String,
77− canRemove: Schema.Boolean,
78− canRefresh: Schema.Boolean,
79−});
80−
81−const GoogleConfigView = Schema.Struct({
82− googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
83− baseUrl: Schema.optional(Schema.String),
84− headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
85− queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
87−});
88−
89−const ConfigurePayload = Schema.Struct({
90− authenticationTemplate: Schema.Array(AuthenticationPayload),
91− mode: Schema.optional(Schema.Literals(["merge", "replace"])),
92−});
93−
94−const ConfigureResponse = Schema.Struct({
95− authenticationTemplate: Schema.Array(AuthenticationResponse),
96−});
97−
98−export const GoogleGroup = HttpApiGroup.make("google")
99− .add(
100− HttpApiEndpoint.post("addBundle", "/google/bundles", {
101− payload: AddBundlePayload,
102− success: AddBundleResponse,
103− error: DomainErrors,
104− }),
105− )
106− .add(
107− HttpApiEndpoint.get("getIntegration", "/google/integrations/:slug", {
108− params: SlugParams,
109− success: Schema.NullOr(IntegrationView),
110− error: DomainErrors,
111− }),
112− )
113− .add(
114− HttpApiEndpoint.get("getConfig", "/google/integrations/:slug/config", {
115− params: SlugParams,
116− success: Schema.NullOr(GoogleConfigView),
117− error: DomainErrors,
118− }),
119− )
120− .add(
121− HttpApiEndpoint.post("configure", "/google/integrations/:slug/config", {
122− params: SlugParams,
123− payload: ConfigurePayload,
124− success: ConfigureResponse,
125− error: DomainErrors,
126− }),
127− )
128− .add(
129− HttpApiEndpoint.post("updateBundle", "/google/integrations/:slug/bundle", {
130− params: SlugParams,
131− payload: UpdateBundlePayload,
132− success: UpdateBundleResponse,
133− error: UpdateErrors,
134− }),
135− )
136− .add(
137− HttpApiEndpoint.delete("removeBundle", "/google/integrations/:slug", {
138− params: SlugParams,
139− success: Schema.Void,
140− error: DomainErrors,
141− }),
142− );
packages/plugins/google/src/api/handlers.ts+0−105
@@ -1,105 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { Context, Effect } from "effect";
3−
4−import { addGroup, capture } from "@executor-js/api";
5−import type { GooglePluginExtension } from "../sdk/plugin";
6−import { GoogleGroup } from "./group";
7−
8−export class GoogleExtensionService extends Context.Service<
9− GoogleExtensionService,
10− GooglePluginExtension
11−>()("GoogleExtensionService") {}
12−
13−const ExecutorApiWithGoogle = addGroup(GoogleGroup);
14−
15−export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) =>
16− handlers
17− .handle("addBundle", ({ payload }) =>
18− capture(
19− Effect.gen(function* () {
20− const ext = yield* GoogleExtensionService;
21− return yield* ext.addBundle({
22− urls: payload.urls,
23− slug: payload.slug,
24− name: payload.name,
25− description: payload.description,
26− baseUrl: payload.baseUrl,
27− });
28− }),
29− ),
30− )
31− .handle("getIntegration", ({ params }) =>
32− capture(
33− Effect.gen(function* () {
34− const ext = yield* GoogleExtensionService;
35− const integration = yield* ext.getIntegration(params.slug);
36− return integration
37− ? {
38− slug: integration.slug,
39− description: integration.description,
40− kind: integration.kind,
41− canRemove: integration.canRemove,
42− canRefresh: integration.canRefresh,
43− }
44− : null;
45− }),
46− ),
47− )
48− .handle("getConfig", ({ params }) =>
49− capture(
50− Effect.gen(function* () {
51− const ext = yield* GoogleExtensionService;
52− const config = yield* ext.getConfig(params.slug);
53− return config
54− ? {
55− googleDiscoveryUrls: config.googleDiscoveryUrls
56− ? [...config.googleDiscoveryUrls]
57− : undefined,
58− baseUrl: config.baseUrl,
59− headers: config.headers ? { ...config.headers } : undefined,
60− queryParams: config.queryParams ? { ...config.queryParams } : undefined,
61− authenticationTemplate: config.authenticationTemplate
62− ? [...config.authenticationTemplate]
63− : undefined,
64− }
65− : null;
66− }),
67− ),
68− )
69− .handle("configure", ({ params, payload }) =>
70− capture(
71− Effect.gen(function* () {
72− const ext = yield* GoogleExtensionService;
73− const authenticationTemplate = yield* ext.configure(params.slug, {
74− authenticationTemplate: payload.authenticationTemplate,
75− mode: payload.mode ?? "merge",
76− });
77− return { authenticationTemplate: [...authenticationTemplate] };
78− }),
79− ),
80− )
81− .handle("updateBundle", ({ params, payload }) =>
82− capture(
83− Effect.gen(function* () {
84− const ext = yield* GoogleExtensionService;
85− const result = yield* ext.updateBundle(params.slug, {
86− ...(payload.urls !== undefined ? { urls: payload.urls } : {}),
87− });
88− return {
89− slug: result.slug,
90− toolCount: result.toolCount,
91− addedTools: [...result.addedTools],
92− removedTools: [...result.removedTools],
93− };
94− }),
95− ),
96− )
97− .handle("removeBundle", ({ params }) =>
98− capture(
99− Effect.gen(function* () {
100− const ext = yield* GoogleExtensionService;
101− yield* ext.removeBundle(params.slug);
102− }),
103− ),
104− ),
105−);
packages/plugins/google/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1−import { definePlugin } from "@executor-js/sdk/core";
2−
3−import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin";
4−import { GoogleGroup } from "./group";
5−import { GoogleHandlers, GoogleExtensionService } from "./handlers";
6−
7−export { GoogleGroup } from "./group";
8−export { GoogleHandlers, GoogleExtensionService } from "./handlers";
9−
10−export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({
11− ...googlePlugin(options),
12− routes: () => GoogleGroup,
13− handlers: () => GoogleHandlers,
14− extensionService: GoogleExtensionService,
15−}));
packages/plugins/google/src/promise.ts+0−1
@@ -1 +0,0 @@
1−export { googlePlugin } from "./sdk/plugin";
packages/plugins/google/src/react/AddGoogleSource.tsx+0−178
@@ -1,178 +0,0 @@
1−import { useCallback, useMemo, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−
5−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6−import {
7− slugifyNamespace,
8− useIntegrationIdentity,
9−} from "@executor-js/react/plugins/integration-identity";
10−import { Button } from "@executor-js/react/components/button";
11−import { FloatActions } from "@executor-js/react/components/float-actions";
12−import {
13− addIntegrationErrorMessage,
14− FormErrorAlert,
15− SlugCollisionAlert,
16− useSlugAlreadyExists,
17−} from "@executor-js/react/lib/integration-add";
18−import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19−
20−import { addGoogleBundle } from "./atoms";
21−import { GoogleProductPicker } from "./GoogleProductPicker";
22−import {
23− GOOGLE_PHOTOS_PRESET_ID,
24− googleOpenApiPresets,
25− googlePhotosPresetIds,
26− type GoogleOpenApiPreset,
27−} from "../sdk/presets";
28−
29−const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
30−
31−const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set(
32− googleOpenApiPresets
33− .filter((preset: GoogleOpenApiPreset) => preset.featured)
34− .map((preset: GoogleOpenApiPreset) => preset.id),
35−);
36−
37−const googleBundleUrls = (
38− selectedPresetIds: ReadonlySet<string>,
39− customUrls: readonly string[],
40−): readonly string[] => {
41− const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) =>
42− preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [],
43− );
44− return [...new Set([...fromPresets, ...customUrls])];
45−};
46−
47−export default function AddGoogleSource(props: {
48− onComplete: (slug?: string) => void;
49− onCancel: () => void;
50− initialPreset?: string;
51− initialNamespace?: string;
52−}) {
53− const isGooglePhotosPreset = props.initialPreset === GOOGLE_PHOTOS_PRESET_ID;
54− const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(
55− isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds,
56− );
57− const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);
58− const [baseUrl, setBaseUrl] = useState("");
59− const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
60− const [adding, setAdding] = useState(false);
61− const [addError, setAddError] = useState<string | null>(null);
62−
63− const identity = useIntegrationIdentity({
64− fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google",
65− fallbackNamespace:
66− props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"),
67− });
68−
69− const bundleDiscoveryUrls = useMemo(
70− () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls),
71− [selectedPresetIds, customDiscoveryUrls],
72− );
73−
74− const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => {
75− setSelectedPresetIds((current: ReadonlySet<string>) => {
76− const next = new Set(current);
77− if (checked) next.add(presetId);
78− else next.delete(presetId);
79− return next;
80− });
81− }, []);
82−
83− const addCustomDiscoveryUrl = useCallback((url: string) => {
84− setCustomDiscoveryUrls((current: readonly string[]) =>
85− current.includes(url) ? current : [...current, url],
86− );
87− }, []);
88−
89− const removeCustomDiscoveryUrl = useCallback((url: string) => {
90− setCustomDiscoveryUrls((current: readonly string[]) =>
91− current.filter((entry: string) => entry !== url),
92− );
93− }, []);
94−
95− const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" });
96−
97− const resolvedSourceId =
98− slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google");
99− const resolvedDisplayName =
100− identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google");
101− const resolvedDescription =
102− descriptionDraft ??
103− (isGooglePhotosPreset
104− ? "Google Photos albums, uploads, app-created media, and selected picker media."
105− : "Google APIs");
106− const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
107− const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists;
108−
109− const handleAdd = async () => {
110− setAdding(true);
111− setAddError(null);
112− const exit = await doAdd({
113− payload: {
114− urls: [...bundleDiscoveryUrls],
115− slug: resolvedSourceId,
116− name: resolvedDisplayName,
117− ...(resolvedDescription.trim().length > 0
118− ? { description: resolvedDescription.trim() }
119− : {}),
120− ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}),
121− },
122− reactivityKeys: integrationWriteKeys,
123− });
124− if (Exit.isFailure(exit)) {
125− setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google"));
126− setAdding(false);
127− return;
128− }
129− props.onComplete(String(exit.value.slug));
130− };
131−
132− return (
133− <div className="flex flex-1 flex-col gap-6">
134− <div>
135− <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1>
136− <p className="mt-1 text-[13px] text-muted-foreground">
137− Bundle Google APIs into one integration with a shared OAuth consent.
138− </p>
139− </div>
140−
141− <GoogleProductPicker
142− selectedPresetIds={selectedPresetIds}
143− onToggle={toggleBundlePreset}
144− customUrls={customDiscoveryUrls}
145− onAddCustomUrl={addCustomDiscoveryUrl}
146− onRemoveCustomUrl={removeCustomDiscoveryUrl}
147− />
148−
149− <OpenApiSourceDetailsFields
150− title="Google"
151− subtitle={`${bundleDiscoveryUrls.length} Google API${
152− bundleDiscoveryUrls.length !== 1 ? "s" : ""
153− } · one shared OAuth consent`}
154− identity={identity}
155− description={resolvedDescription}
156− onDescriptionChange={setDescriptionDraft}
157− baseUrl={baseUrl}
158− onBaseUrlChange={setBaseUrl}
159− baseUrlLabel="Base URL override (optional)"
160− faviconIcon={GOOGLE_BUNDLE_FAVICON}
161− faviconUrl={baseUrl}
162− />
163−
164− {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
165−
166− {addError && <FormErrorAlert message={addError} />}
167−
168− <FloatActions>
169− <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
170− Cancel
171− </Button>
172− <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>
173− Connect Google
174− </Button>
175− </FloatActions>
176− </div>
177− );
178−}
packages/plugins/google/src/react/GoogleAccountsPanel.tsx+0−148
@@ -1,148 +0,0 @@
1−import { useCallback, useMemo } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6−import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7−import { TriangleAlert } from "lucide-react";
8−
9−import { AccountsSection } from "@executor-js/react/components/accounts-section";
10−import {
11− HealthCheckEditor,
12− type HealthCheckLivePreview,
13−} from "@executor-js/react/components/health-check-editor";
14−import { useOrganizationId } from "@executor-js/react/api/organization-context";
15−import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
16−import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
17−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
18−import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
19−import {
20− useCustomMethodActions,
21− type AuthMethodsCodec,
22− type ConfigureAuthMethods,
23−} from "@executor-js/react/lib/custom-auth-methods";
24−import {
25− authMethodsFromConfig,
26− openApiWireAuthInput,
27− templateFromPlacements,
28−} from "@executor-js/plugin-openapi/react";
29−import type { Authentication } from "@executor-js/plugin-openapi";
30−
31−import { googleConfigAtom, googleConfigure } from "./atoms";
32−import { googleAudienceWarningsForUrls } from "../sdk/presets";
33−
34−const GOOGLE_AUDIENCE_WARNING: Readonly<Record<string, string>> = {
35− "workspace-admin":
36− "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.",
37− "unsupported-user":
38− "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.",
39−};
40−
41−const NO_AUTH_METHOD: AuthMethod = {
42− id: "none",
43− label: "No authentication",
44− kind: "none",
45− source: "spec",
46− template: AuthTemplateSlug.make("none"),
47− placements: [],
48−};
49−
50−export default function GoogleAccountsPanel(props: {
51− readonly sourceId: string;
52− readonly integrationName: string;
53− readonly accountHandoff?: IntegrationAccountHandoff | null;
54−}) {
55− const { sourceId, integrationName, accountHandoff } = props;
56− const slug = IntegrationSlug.make(sourceId);
57− const configResult = useAtomValue(googleConfigAtom(slug));
58− const doConfigure = useAtomSet(googleConfigure, { mode: "promiseExit" });
59−
60− const existingTemplate = useMemo<readonly Authentication[]>(() => {
61− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
62− return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[];
63− }, [configResult]);
64−
65− const methods = useMemo<readonly AuthMethod[]>(() => {
66− const declared = authMethodsFromConfig(existingTemplate);
67− return declared.length > 0 ? declared : [NO_AUTH_METHOD];
68− }, [existingTemplate]);
69−
70− const configure = useCallback<ConfigureAuthMethods<Authentication>>(
71− async (input) => {
72− const exit = await doConfigure({
73− params: { slug },
74− payload: {
75− authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
76− ...(input.mode ? { mode: input.mode } : {}),
77− },
78− reactivityKeys: integrationWriteKeys,
79− });
80− return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
81− },
82− [doConfigure, slug],
83− );
84−
85− const codec = useMemo<AuthMethodsCodec<Authentication>>(
86− () => ({
87− toAuthMethods: authMethodsFromConfig,
88− templatesFromPlacements: (placements: readonly Placement[]) => [
89− templateFromPlacements(placements),
90− ],
91− slugOf: (template: Authentication) => String(template.slug),
92− }),
93− [],
94− );
95−
96− const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
97− existing: existingTemplate,
98− codec,
99− configure,
100− });
101−
102− const audienceWarnings = useMemo<readonly string[]>(() => {
103− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
104− const urls = configResult.value.googleDiscoveryUrls ?? [];
105− return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => {
106− const message = GOOGLE_AUDIENCE_WARNING[audience];
107− return message ? [message] : [];
108− });
109− }, [configResult]);
110−
111− // Live-preview context for the health-check edit sheet (apiKey methods only;
112− // OAuth methods can't be previewed with a pasted key).
113− const organizationId = useOrganizationId();
114− const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => {
115− const templates = methods
116− .filter((m) => m.kind === "apikey")
117− .map((m) => ({ template: m.template, label: m.label }));
118− if (templates.length === 0) return undefined;
119− return { owner: defaultConnectionOwnerForHost(organizationId), templates };
120− }, [methods, organizationId]);
121−
122− return (
123− <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
124− {audienceWarnings.length > 0 && (
125− <Alert variant="destructive">
126− <TriangleAlert />
127− <AlertTitle>Some Google APIs need special consent</AlertTitle>
128− <AlertDescription>
129− {audienceWarnings.map((message: string) => (
130− <p key={message}>{message}</p>
131− ))}
132− </AlertDescription>
133− </Alert>
134− )}
135− <AccountsSection
136− integration={slug}
137− integrationName={integrationName}
138− methods={methods}
139− accountHandoff={accountHandoff}
140− createCustomMethod={createCustomMethod}
141− removeCustomMethod={removeCustomMethod}
142− />
143− {/* Google auto-configures the People identity probe at add time; this is
144− where an admin sees, adjusts, or clears it. */}
145− <HealthCheckEditor integration={slug} livePreview={livePreview} />
146− </div>
147− );
148−}
packages/plugins/google/src/react/GoogleProductPicker.tsx+0−363
@@ -1,363 +0,0 @@
1−import { useMemo, useState } from "react";
2−import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react";
3−
4−import { cn } from "@executor-js/react/lib/utils";
5−import { Badge } from "@executor-js/react/components/badge";
6−import { Button } from "@executor-js/react/components/button";
7−import { Checkbox } from "@executor-js/react/components/checkbox";
8−import {
9− Collapsible,
10− CollapsibleContent,
11− CollapsibleTrigger,
12−} from "@executor-js/react/components/collapsible";
13−import { FieldLabel } from "@executor-js/react/components/field";
14−import { Input } from "@executor-js/react/components/input";
15−import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16−
17−import {
18− googleOAuthConsentScopesForPreset,
19− googleOpenApiPresets,
20− type GoogleOpenApiOAuthAudience,
21− type GoogleOpenApiPreset,
22−} from "../sdk/presets";
23−import { googleOAuthConsentBatches } from "../sdk/oauth-batches";
24−import { isGoogleDiscoveryUrl } from "../sdk/discovery";
25−
26−// ---------------------------------------------------------------------------
27−// GoogleProductPicker - the "customize your Google connection" surface.
28−//
29−// A checkable card grid over `googleOpenApiPresets`, grouped/annotated by
30−// `oauthAudience`. The user picks which Google APIs to bundle into the single
31−// `google` integration; the parent turns the selected discovery URLs into a
32−// `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews
33−// the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting,
34−// and a custom-URL escape hatch lets advanced users paste any Google Discovery
35−// document the preset list doesn't cover.
36−// ---------------------------------------------------------------------------
37−
38−// Audience groups, ordered from least- to most-privileged. The warning tiers
39−// (`workspace-admin`, `unsupported-user`) carry a caution chip so the user sees
40−// the consent risk before selecting.
41−const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [
42− "standard-user",
43− "advanced-user",
44− "workspace-admin",
45− "unsupported-user",
46−];
47−
48−const AUDIENCE_LABEL: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = {
49− "standard-user": "Core Google services",
50− "advanced-user": "Advanced services",
51− "workspace-admin": "Workspace admin",
52− "unsupported-user": "Limited user consent",
53−};
54−
55−const AUDIENCE_DESCRIPTION: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = {
56− "standard-user": "Connect with a normal Google account - one consent screen.",
57− "advanced-user": "Broader scopes that may need an unverified-app warning to be accepted.",
58− "workspace-admin": "Requires a Google Workspace admin account; not available on personal Gmail.",
59− "unsupported-user": "Google does not grant these scopes through standard user OAuth consent.",
60−};
61−
62−const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean =>
63− audience === "workspace-admin" || audience === "unsupported-user";
64−
65−type GoogleProductPickerProps = {
66− readonly selectedPresetIds: ReadonlySet<string>;
67− readonly onToggle: (presetId: string, checked: boolean) => void;
68− readonly customUrls: readonly string[];
69− readonly onAddCustomUrl: (url: string) => void;
70− readonly onRemoveCustomUrl: (url: string) => void;
71−};
72−
73−const AudienceWarningChip = ({ audience }: { audience: GoogleOpenApiOAuthAudience }) =>
74− audience === "workspace-admin" ? (
75− <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
76− <TriangleAlert className="size-3" />
77− Admin only
78− </Badge>
79− ) : audience === "unsupported-user" ? (
80− <Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive">
81− <TriangleAlert className="size-3" />
82− Limited consent
83− </Badge>
84− ) : null;
85−
86−// A Google API row - borderless and single-line, leaning on hover/selected
87−// fills instead of per-item card chrome (lightest separation that still reads).
88−// Name + truncated summary share one baseline for a dense, scannable two-column
89−// list; the audience warning chip trails on the right.
90−const ProductRow = ({
91− preset,
92− checked,
93− onToggle,
94−}: {
95− readonly preset: GoogleOpenApiPreset;
96− readonly checked: boolean;
97− readonly onToggle: (checked: boolean) => void;
98−}) => (
99− <FieldLabel
100− className={cn(
101− // `w-full` overrides FieldLabel's base `w-fit` (which would size the row to
102− // its content and overflow the column); `min-w-0` then lets the cell shrink
103− // to its track so the name/summary truncates instead of spilling over.
104− "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
105− checked ? "bg-primary/5" : "hover:bg-muted/40",
106− )}
107− >
108− <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} />
109− <div className="shrink-0">
110− <IntegrationFavicon icon={preset.icon} url={preset.url} size={16} />
111− </div>
112− {/* One truncating line - the name + summary clip to the cell with an
113− ellipsis instead of overflowing into the neighbouring column. */}
114− <div className="min-w-0 flex-1 truncate text-sm">
115− <span className="font-medium text-foreground">{preset.name}</span>{" "}
116− <span className="text-[11px] text-muted-foreground">{preset.summary}</span>
117− </div>
118− <AudienceWarningChip audience={preset.oauthAudience} />
119− </FieldLabel>
120−);
121−
122−const CustomUrlEscapeHatch = ({
123− customUrls,
124− onAddCustomUrl,
125− onRemoveCustomUrl,
126−}: {
127− readonly customUrls: readonly string[];
128− readonly onAddCustomUrl: (url: string) => void;
129− readonly onRemoveCustomUrl: (url: string) => void;
130−}) => {
131− const [draft, setDraft] = useState("");
132− const trimmed = draft.trim();
133− const isValid = isGoogleDiscoveryUrl(trimmed);
134− const isDuplicate = customUrls.includes(trimmed);
135−
136− const commit = () => {
137− if (!isValid || isDuplicate) return;
138− onAddCustomUrl(trimmed);
139− setDraft("");
140− };
141−
142− return (
143− <div className="space-y-2">
144− <FieldLabel className="text-[11px] font-medium text-muted-foreground">
145− Add a custom Google Discovery URL
146− </FieldLabel>
147− <div className="flex items-center gap-2">
148− <Input
149− value={draft}
150− onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)}
151− onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
152− if (event.key === "Enter") {
153− event.preventDefault();
154− commit();
155− }
156− }}
157− placeholder="https://www.googleapis.com/discovery/v1/apis/<service>/<version>/rest"
158− className="font-mono text-[11px]"
159− />
160− <Button
161− type="button"
162− variant="outline"
163− size="sm"
164− disabled={!isValid || isDuplicate}
165− onClick={commit}
166− >
167− <PlusIcon className="size-3.5" />
168− Add
169− </Button>
170− </div>
171− {trimmed.length > 0 && !isValid ? (
172− <p className="text-[11px] text-destructive">
173− Enter a Google Discovery document URL (a *.googleapis.com discovery/$discovery endpoint).
174− </p>
175− ) : null}
176− {customUrls.length > 0 ? (
177− <ul className="space-y-1">
178− {customUrls.map((url: string) => (
179− <li
180− key={url}
181− className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5"
182− >
183− <span className="truncate font-mono text-[11px] text-foreground">{url}</span>
184− <Button
185− type="button"
186− variant="ghost"
187− size="icon"
188− className="size-6 shrink-0"
189− onClick={() => onRemoveCustomUrl(url)}
190− aria-label={`Remove ${url}`}
191− >
192− <XIcon className="size-3.5" />
193− </Button>
194− </li>
195− ))}
196− </ul>
197− ) : null}
198− </div>
199− );
200−};
201−
202−export function GoogleProductPicker({
203− selectedPresetIds,
204− onToggle,
205− customUrls,
206− onAddCustomUrl,
207− onRemoveCustomUrl,
208−}: GoogleProductPickerProps) {
209− const [scopesOpen, setScopesOpen] = useState(false);
210−
211− const groups = useMemo(
212− () =>
213− AUDIENCE_ORDER.flatMap((audience: GoogleOpenApiOAuthAudience) => {
214− const presets = googleOpenApiPresets.filter(
215− (preset: GoogleOpenApiPreset) => preset.oauthAudience === audience,
216− );
217− return presets.length > 0 ? [{ audience, presets }] : [];
218− }),
219− [],
220− );
221−
222− // The "View scopes" preview unions the selected presets' representative
223− // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned
224− // scopes the bundle converter ultimately stores on the integration.
225− const consentBatches = useMemo(
226− () =>
227− googleOAuthConsentBatches(
228− googleOpenApiPresets
229− .filter((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id))
230− .map((preset: GoogleOpenApiPreset) => ({
231− id: preset.id,
232− name: preset.name,
233− oauthAudience: preset.oauthAudience,
234− scopes: googleOAuthConsentScopesForPreset(preset.id),
235− })),
236− ),
237− [selectedPresetIds],
238− );
239−
240− const selectedCount = selectedPresetIds.size + customUrls.length;
241−
242− return (
243− <section className="space-y-4">
244− <div className="space-y-1">
245− <FieldLabel>Customize your Google connection</FieldLabel>
246− <p className="text-[11px] text-muted-foreground">
247− Pick the Google APIs to bundle into one connection. They share a single OAuth consent and
248− appear as merged tools under one Google integration.
249− </p>
250− </div>
251−
252− {groups.map(
253− ({
254− audience,
255− presets,
256− }: {
257− readonly audience: GoogleOpenApiOAuthAudience;
258− readonly presets: readonly GoogleOpenApiPreset[];
259− }) => (
260− <div key={audience} className="space-y-2">
261− <div className="flex items-center gap-2">
262− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
263− {AUDIENCE_LABEL[audience]}
264− </span>
265− {audienceNeedsWarning(audience) ? <AudienceWarningChip audience={audience} /> : null}
266− <Button
267− type="button"
268− variant="ghost"
269− size="sm"
270− className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
271− onClick={() => {
272− const allSelected = presets.every((preset: GoogleOpenApiPreset) =>
273− selectedPresetIds.has(preset.id),
274− );
275− presets.forEach((preset: GoogleOpenApiPreset) =>
276− onToggle(preset.id, !allSelected),
277− );
278− }}
279− >
280− {presets.every((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id))
281− ? "Clear"
282− : "Select all"}
283− </Button>
284− </div>
285− <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p>
286− <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2">
287− {presets.map((preset: GoogleOpenApiPreset) => (
288− <ProductRow
289− key={preset.id}
290− preset={preset}
291− checked={selectedPresetIds.has(preset.id)}
292− onToggle={(checked: boolean) => onToggle(preset.id, checked)}
293− />
294− ))}
295− </div>
296− </div>
297− ),
298− )}
299−
300− <CustomUrlEscapeHatch
301− customUrls={customUrls}
302− onAddCustomUrl={onAddCustomUrl}
303− onRemoveCustomUrl={onRemoveCustomUrl}
304− />
305−
306− <div className="space-y-1.5 rounded-lg border border-border bg-muted/10 px-3 py-2.5">
307− <div className="flex items-center gap-2">
308− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
309− Authentication
310− </span>
311− <Badge variant="secondary">OAuth</Badge>
312− </div>
313− <p className="text-[11px] text-muted-foreground">
314− The selected Google APIs share one OAuth consent. Review the scopes below, then connect a
315− Google account from the integration page after adding.
316− </p>
317− </div>
318−
319− <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}>
320− <CollapsibleTrigger asChild>
321− <Button type="button" variant="outline" size="sm" disabled={consentBatches.length === 0}>
322− <ChevronDownIcon
323− className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")}
324− />
325− View scopes
326− {selectedCount > 0 ? (
327− <Badge variant="secondary" className="ml-1">
328− {selectedCount}
329− </Badge>
330− ) : null}
331− </Button>
332− </CollapsibleTrigger>
333− <CollapsibleContent className="pt-3">
334− {consentBatches.length === 0 ? (
335− <p className="text-[11px] text-muted-foreground">
336− Select at least one Google API to preview the OAuth consent.
337− </p>
338− ) : (
339− <div className="space-y-3">
340− {consentBatches.map((batch) => (
341− <div key={batch.id} className="space-y-1.5">
342− <span className="text-[11px] font-semibold text-foreground">{batch.label}</span>
343− <ul className="space-y-1">
344− {batch.apiScopes.map((scope: string) => (
345− <li
346− key={scope}
347− className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
348− >
349− {scope}
350− </li>
351− ))}
352− </ul>
353− </div>
354− ))}
355− </div>
356− )}
357− </CollapsibleContent>
358− </Collapsible>
359− </section>
360− );
361−}
362−
363−export default GoogleProductPicker;
packages/plugins/google/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1−import type { IntegrationSlug } from "@executor-js/sdk/shared";
2−import * as Atom from "effect/unstable/reactivity/Atom";
3−import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4−import { GoogleClient } from "./client";
5−
6−export const googleIntegrationAtom = (slug: IntegrationSlug) =>
7− GoogleClient.query("google", "getIntegration", {
8− params: { slug },
9− timeToLive: "15 seconds",
10− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11− });
12−
13−export const googleConfigAtom = (slug: IntegrationSlug) =>
14− GoogleClient.query("google", "getConfig", {
15− params: { slug },
16− timeToLive: "15 seconds",
17− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18− });
19−
20−export const addGoogleBundle = GoogleClient.mutation("google", "addBundle");
21−
22−export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle");
23−
24−export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle");
25−
26−export const googleConfigure = GoogleClient.mutation("google", "configure");
27−
28−export const googleIntegrationFamily = Atom.family(googleIntegrationAtom);
29−
30−export const googleConfigFamily = Atom.family(googleConfigAtom);
packages/plugins/google/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1−import { createPluginAtomClient } from "@executor-js/sdk/client";
2−import {
3− getExecutorOrganizationHeaders,
4− getExecutorApiBaseUrl,
5− getExecutorServerAuthorizationHeader,
6−} from "@executor-js/react/api/server-connection";
7−import { GoogleGroup } from "../api/group";
8−
9−export const GoogleClient = createPluginAtomClient(GoogleGroup, {
10− baseUrl: getExecutorApiBaseUrl,
11− authorizationHeader: getExecutorServerAuthorizationHeader,
12− headers: getExecutorOrganizationHeaders,
13−});
packages/plugins/google/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1−export { googleIntegrationPlugin } from "./source-plugin";
2−export { GoogleClient } from "./client";
3−export {
4− addGoogleBundle,
5− googleConfigAtom,
6− googleConfigFamily,
7− googleConfigure,
8− googleIntegrationAtom,
9− googleIntegrationFamily,
10− removeGoogleBundle,
11− updateGoogleBundle,
12−} from "./atoms";
packages/plugins/google/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1−import { defineClientPlugin } from "@executor-js/sdk/client";
2−
3−import { googleIntegrationPlugin } from "./source-plugin";
4−
5−export default defineClientPlugin({
6− id: "google" as const,
7− integrationPlugin: googleIntegrationPlugin,
8−});
packages/plugins/google/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
3−import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";
4−
5−const importAdd = () => import("./AddGoogleSource");
6−const importAccounts = () => import("./GoogleAccountsPanel");
7−
8−export const googleIntegrationPlugin: IntegrationPlugin = {
9− key: "google",
10− label: "Google",
11− add: lazy(importAdd),
12− accounts: lazy(importAccounts),
13− presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],
14− preload: () => {
15− void importAdd();
16− void importAccounts();
17− },
18−};
packages/plugins/google/src/sdk/oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import { googleOAuthConsentBatches } from "./oauth-batches";
4−
5−it("keeps core Google OAuth in one consent batch", () => {
6− expect(
7− googleOAuthConsentBatches([
8− {
9− id: "google-gmail",
10− name: "Gmail",
11− oauthAudience: "standard-user",
12− scopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send"],
13− },
14− {
15− id: "google-calendar",
16− name: "Google Calendar",
17− oauthAudience: "standard-user",
18− scopes: [
19− "https://www.googleapis.com/auth/calendar",
20− "https://www.googleapis.com/auth/calendar.readonly",
21− ],
22− },
23− ]),
24− ).toEqual([
25− {
26− id: "google-core",
27− label: "Core Google services",
28− apiScopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/calendar"],
29− },
30− ]);
31−});
32−
33−it("splits advanced Google OAuth into smaller known-good consent batches", () => {
34− expect(
35− googleOAuthConsentBatches([
36− {
37− id: "google-youtube-data",
38− name: "YouTube Data",
39− oauthAudience: "advanced-user",
40− scopes: ["https://www.googleapis.com/auth/youtube"],
41− },
42− {
43− id: "google-classroom",
44− name: "Google Classroom",
45− oauthAudience: "advanced-user",
46− scopes: ["https://www.googleapis.com/auth/classroom.courses"],
47− },
48− {
49− id: "google-apps-script",
50− name: "Google Apps Script",
51− oauthAudience: "advanced-user",
52− scopes: [
53− "https://www.googleapis.com/auth/script.projects",
54− "https://www.googleapis.com/auth/drive",
55− ],
56− },
57− {
58− id: "google-bigquery",
59− name: "Google BigQuery",
60− oauthAudience: "advanced-user",
61− scopes: ["https://www.googleapis.com/auth/bigquery"],
62− },
63− {
64− id: "google-cloud-resource-manager",
65− name: "Google Cloud Resource Manager",
66− oauthAudience: "advanced-user",
67− scopes: ["https://www.googleapis.com/auth/cloud-platform"],
68− },
69− ]).map((batch) => ({ id: batch.id, label: batch.label, apiScopes: batch.apiScopes })),
70− ).toEqual([
71− {
72− id: "google-youtube-data",
73− label: "YouTube Data",
74− apiScopes: ["https://www.googleapis.com/auth/youtube"],
75− },
76− {
77− id: "google-classroom",
78− label: "Google Classroom",
79− apiScopes: ["https://www.googleapis.com/auth/classroom.courses"],
80− },
81− {
82− id: "google-apps-script",
83− label: "Google Apps Script",
84− apiScopes: [
85− "https://www.googleapis.com/auth/script.projects",
86− "https://www.googleapis.com/auth/drive",
87− ],
88− },
89− {
90− id: "google-cloud",
91− label: "Google Cloud services",
92− apiScopes: [
93− "https://www.googleapis.com/auth/bigquery",
94− "https://www.googleapis.com/auth/cloud-platform",
95− ],
96− },
97− ]);
98−});
packages/plugins/google/src/sdk/oauth-batches.ts+0−69
@@ -1,69 +0,0 @@
1−import type { GoogleOpenApiOAuthAudience } from "./presets";
2−import { compactGoogleOAuthScopes } from "./oauth-scopes";
3−
4−export type GoogleOAuthBatchInput = {
5− readonly id: string;
6− readonly name: string;
7− readonly oauthAudience: GoogleOpenApiOAuthAudience;
8− readonly scopes: readonly string[];
9−};
10−
11−export type GoogleOAuthConsentBatch = {
12− readonly id: string;
13− readonly label: string;
14− readonly apiScopes: readonly string[];
15−};
16−
17−const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]);
18−
19−export const googleOAuthConsentBatches = (
20− items: readonly GoogleOAuthBatchInput[],
21−): readonly GoogleOAuthConsentBatch[] => {
22− const standardScopes: string[] = [];
23− const cloudScopes: string[] = [];
24− const batches: GoogleOAuthConsentBatch[] = [];
25−
26− for (const item of items) {
27− if (item.scopes.length === 0) continue;
28− if (item.oauthAudience === "standard-user") {
29− standardScopes.push(...item.scopes);
30− continue;
31− }
32− if (GOOGLE_CLOUD_BATCH_IDS.has(item.id)) {
33− cloudScopes.push(...item.scopes);
34− continue;
35− }
36− batches.push({
37− id: item.id,
38− label: item.name,
39− apiScopes: item.scopes,
40− });
41− }
42−
43− const compactedStandardScopes = compactGoogleOAuthScopes(standardScopes);
44− const compactedCloudScopes = compactGoogleOAuthScopes(cloudScopes);
45− return [
46− ...(compactedStandardScopes.length > 0
47− ? [
48− {
49− id: "google-core",
50− label: "Core Google services",
51− apiScopes: compactedStandardScopes,
52− },
53− ]
54− : []),
55− ...batches.map((batch) => ({
56− ...batch,
57− apiScopes: compactGoogleOAuthScopes(batch.apiScopes),
58− })),
59− ...(compactedCloudScopes.length > 0
60− ? [
61− {
62− id: "google-cloud",
63− label: "Google Cloud services",
64− apiScopes: compactedCloudScopes,
65− },
66− ]
67− : []),
68− ].filter((batch) => batch.apiScopes.length > 0);
69−};
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−
14−import { describe, expect, it } from "@effect/vitest";
15−import { Effect, Exit, Layer } from "effect";
16−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17−
18−import {
19− ConnectionName,
20− IntegrationSlug,
21− createExecutor,
22− AuthTemplateSlug,
23−} from "@executor-js/sdk";
24−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
25−
26−import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27−import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets";
28−
29−// --- Canned Discovery documents -------------------------------------------
30−// Each carries one method. Calendar and Gmail BOTH expose a generic `list`
31−// method id segment, so a naive merge that keyed tools on the trailing method
32−// name would collide. The bundle converter keys on the full method id
33−// (`calendar.events.list`, `gmail.users.messages.list`, …), so they don't.
34−
35−const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";
36−const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
37−const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
38−const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
39−const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
40−const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
41−const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
42−const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
43−const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
44−const OAUTH2_USERINFO_SCOPES = [
45− "openid",
46− "https://www.googleapis.com/auth/userinfo.email",
47− "https://www.googleapis.com/auth/userinfo.profile",
48−] as const;
49−
50−const calendarDoc = {
51− name: "calendar",
52− version: "v3",
53− title: "Calendar API",
54− rootUrl: "https://www.googleapis.com/",
55− servicePath: "calendar/v3/",
56− auth: {
57− oauth2: {
58− scopes: {
59− "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" },
60− "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" },
61− },
62− },
63− },
64− resources: {
65− events: {
66− methods: {
67− list: {
68− id: "calendar.events.list",
69− httpMethod: "GET",
70− path: "calendars/{calendarId}/events",
71− scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
72− parameters: {
73− calendarId: { location: "path", required: true, type: "string" },
74− },
75− },
76− },
77− },
78− },
79− schemas: {
80− Event: { id: "Event", type: "object", properties: { id: { type: "string" } } },
81− },
82−};
83−
84−const gmailDoc = {
85− name: "gmail",
86− version: "v1",
87− title: "Gmail API",
88− rootUrl: "https://gmail.googleapis.com/",
89− servicePath: "",
90− auth: {
91− oauth2: {
92− scopes: {
93− "https://mail.google.com/": { description: "Full Gmail access" },
94− "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
95− },
96− },
97− },
98− resources: {
99− users: {
100− resources: {
101− messages: {
102− methods: {
103− // Same trailing `list` as calendar.events.list - would collide on a
104− // naive merge; service-prefixed method id keeps them distinct.
105− list: {
106− id: "gmail.users.messages.list",
107− httpMethod: "GET",
108− path: "gmail/v1/users/{userId}/messages",
109− scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
110− parameters: {
111− userId: { location: "path", required: true, type: "string" },
112− },
113− },
114− },
115− },
116− },
117− },
118− },
119− schemas: {
120− Message: { id: "Message", type: "object", properties: { id: { type: "string" } } },
121− },
122−};
123−
124−const driveDoc = {
125− name: "drive",
126− version: "v3",
127− title: "Drive API",
128− rootUrl: "https://www.googleapis.com/",
129− servicePath: "drive/v3/",
130− auth: {
131− oauth2: {
132− scopes: {
133− "https://www.googleapis.com/auth/drive": { description: "Manage Drive" },
134− },
135− },
136− },
137− resources: {
138− files: {
139− methods: {
140− // A third `list` - three generic method names across three APIs.
141− list: {
142− id: "drive.files.list",
143− httpMethod: "GET",
144− path: "files",
145− scopes: ["https://www.googleapis.com/auth/drive"],
146− parameters: {},
147− },
148− },
149− },
150− },
151− schemas: {
152− File: { id: "File", type: "object", properties: { id: { type: "string" } } },
153− },
154−};
155−
156−const sheetsDoc = {
157− name: "sheets",
158− version: "v4",
159− title: "Google Sheets API",
160− rootUrl: "https://sheets.googleapis.com/",
161− servicePath: "v4/",
162− auth: {
163− oauth2: {
164− scopes: {
165− "https://www.googleapis.com/auth/spreadsheets": { description: "Manage spreadsheets" },
166− },
167− },
168− },
169− resources: {
170− spreadsheets: {
171− methods: {
172− get: {
173− id: "sheets.spreadsheets.get",
174− httpMethod: "GET",
175− path: "spreadsheets/{spreadsheetId}",
176− scopes: ["https://www.googleapis.com/auth/spreadsheets"],
177− parameters: {
178− spreadsheetId: { location: "path", required: true, type: "string" },
179− },
180− },
181− },
182− },
183− },
184− schemas: {
185− Spreadsheet: { id: "Spreadsheet", type: "object", properties: { id: { type: "string" } } },
186− },
187−};
188−
189−const docsDoc = {
190− name: "docs",
191− version: "v1",
192− title: "Google Docs API",
193− rootUrl: "https://docs.googleapis.com/",
194− servicePath: "v1/",
195− auth: {
196− oauth2: {
197− scopes: {
198− "https://www.googleapis.com/auth/documents": { description: "Manage documents" },
199− },
200− },
201− },
202− resources: {
203− documents: {
204− methods: {
205− get: {
206− id: "docs.documents.get",
207− httpMethod: "GET",
208− path: "documents/{documentId}",
209− scopes: ["https://www.googleapis.com/auth/documents"],
210− parameters: {
211− documentId: { location: "path", required: true, type: "string" },
212− },
213− },
214− },
215− },
216− },
217− schemas: {
218− Document: { id: "Document", type: "object", properties: { documentId: { type: "string" } } },
219− },
220−};
221−
222−const photosLibraryDoc = {
223− name: "photoslibrary",
224− version: "v1",
225− title: "Google Photos Library API",
226− rootUrl: "https://photoslibrary.googleapis.com/",
227− servicePath: "v1/",
228− auth: {
229− oauth2: {
230− scopes: {
231− "https://www.googleapis.com/auth/photoslibrary": {
232− description: "Manage the full Google Photos library",
233− },
234− "https://www.googleapis.com/auth/photoslibrary.appendonly": {
235− description: "Upload to Google Photos",
236− },
237− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": {
238− description: "Read app-created Google Photos media",
239− },
240− },
241− },
242− },
243− resources: {
244− albums: {
245− methods: {
246− list: {
247− id: "photoslibrary.albums.list",
248− httpMethod: "GET",
249− path: "albums",
250− scopes: ["https://www.googleapis.com/auth/photoslibrary"],
251− parameters: {},
252− },
253− },
254− },
255− mediaItems: {
256− methods: {
257− search: {
258− id: "photoslibrary.mediaItems.search",
259− httpMethod: "POST",
260− path: "mediaItems:search",
261− scopes: ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"],
262− parameters: {},
263− },
264− },
265− },
266− },
267− schemas: {},
268−};
269−
270−const photosPickerDoc = {
271− name: "photospicker",
272− version: "v1",
273− title: "Google Photos Picker API",
274− rootUrl: "https://photospicker.googleapis.com/",
275− servicePath: "v1/",
276− resources: {
277− mediaItems: {
278− methods: {
279− list: {
280− id: "photospicker.mediaItems.list",
281− httpMethod: "GET",
282− path: "mediaItems",
283− parameters: {},
284− },
285− },
286− },
287− },
288− schemas: {},
289−};
290−
291−const peopleDoc = {
292− name: "people",
293− version: "v1",
294− title: "People API",
295− rootUrl: "https://people.googleapis.com/",
296− servicePath: "",
297− auth: {
298− oauth2: {
299− scopes: {
300− "https://www.googleapis.com/auth/userinfo.email": { description: "See your email" },
301− },
302− },
303− },
304− resources: {
305− people: {
306− methods: {
307− get: {
308− id: "people.people.get",
309− httpMethod: "GET",
310− path: "v1/{+resourceName}",
311− scopes: ["https://www.googleapis.com/auth/userinfo.email"],
312− parameters: {
313− resourceName: { location: "path", required: true, type: "string" },
314− personFields: { location: "query", type: "string" },
315− },
316− response: { $ref: "Person" },
317− },
318− },
319− },
320− },
321− schemas: {
322− Person: {
323− id: "Person",
324− type: "object",
325− properties: {
326− resourceName: { type: "string" },
327− emailAddresses: { type: "array", items: { $ref: "EmailAddress" } },
328− },
329− },
330− EmailAddress: { id: "EmailAddress", type: "object", properties: { value: { type: "string" } } },
331− },
332−};
333−
334−const oauth2Doc = {
335− name: "oauth2",
336− version: "v2",
337− title: "Google OAuth2 API",
338− rootUrl: "https://www.googleapis.com/",
339− servicePath: "",
340− auth: {
341− oauth2: {
342− scopes: {
343− openid: { description: "Associate you with your personal info on Google" },
344− "https://www.googleapis.com/auth/userinfo.email": {
345− description: "See your primary Google Account email address",
346− },
347− "https://www.googleapis.com/auth/userinfo.profile": {
348− description: "See your personal info",
349− },
350− },
351− },
352− },
353− methods: {
354− tokeninfo: {
355− id: "oauth2.tokeninfo",
356− httpMethod: "POST",
357− path: "oauth2/v2/tokeninfo",
358− response: { $ref: "Tokeninfo" },
359− },
360− },
361− resources: {
362− userinfo: {
363− methods: {
364− get: {
365− id: "oauth2.userinfo.get",
366− httpMethod: "GET",
367− path: "oauth2/v2/userinfo",
368− scopes: OAUTH2_USERINFO_SCOPES,
369− response: { $ref: "Userinfo" },
370− },
371− },
372− resources: {
373− v2: {
374− resources: {
375− me: {
376− methods: {
377− get: {
378− id: "oauth2.userinfo.v2.me.get",
379− httpMethod: "GET",
380− path: "userinfo/v2/me",
381− scopes: OAUTH2_USERINFO_SCOPES,
382− response: { $ref: "Userinfo" },
383− },
384− },
385− },
386− },
387− },
388− },
389− },
390− },
391− schemas: {
392− Tokeninfo: {
393− id: "Tokeninfo",
394− type: "object",
395− properties: {
396− audience: { type: "string" },
397− scope: { type: "string" },
398− },
399− },
400− Userinfo: {
401− id: "Userinfo",
402− type: "object",
403− properties: {
404− email: { type: "string" },
405− family_name: { type: "string" },
406− gender: { type: "string" },
407− given_name: { type: "string" },
408− hd: { type: "string" },
409− id: { type: "string" },
410− link: { type: "string" },
411− locale: { type: "string" },
412− name: { type: "string" },
413− picture: { type: "string" },
414− verified_email: { type: "boolean" },
415− },
416− },
417− },
418−};
419−
420−const toJson = (value: unknown): string => JSON.stringify(value);
421−
422−const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
423− [CALENDAR_URL]: toJson(calendarDoc),
424− [GMAIL_URL]: toJson(gmailDoc),
425− [SHEETS_URL]: toJson(sheetsDoc),
426− [DRIVE_URL]: toJson(driveDoc),
427− [DOCS_URL]: toJson(docsDoc),
428− [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc),
429− [PHOTOS_PICKER_URL]: toJson(photosPickerDoc),
430− [PEOPLE_URL]: toJson(peopleDoc),
431− [OAUTH2_URL]: toJson(oauth2Doc),
432−};
433−
434−// A stub HTTP client that serves the canned Discovery document for whichever
435−// URL the bundle converter fetches. Service-hosted Discovery URLs carry their
436−// version in the query string, so match the full URL before falling back to the
437−// path-only key used by central Discovery URLs.
438−const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
439− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
440− const url = new URL(request.url);
441− const key = `${url.origin}${url.pathname}`;
442− const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
443− return Effect.succeed(
444− HttpClientResponse.fromWeb(
445− request,
446− body === undefined
447− ? new Response("not found", { status: 404 })
448− : new Response(body, {
449− status: 200,
450− headers: { "content-type": "application/json" },
451− }),
452− ),
453− );
454− }),
455−);
456−
457−const bundlePlugins = () =>
458− [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
459−
460−describe("Google bundle add flow", () => {
461− it("SDK catalog includes the Google Photos focused preset", () => {
462− const presetIds =
463− googlePlugin({ httpClientLayer: discoveryHttpClientLayer }).integrationPresets?.map(
464− (preset) => preset.id,
465− ) ?? [];
466−
467− expect(presetIds).toContain("google");
468− expect(presetIds).toContain("google-photos");
469− });
470−
471− it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () =>
472− Effect.scoped(
473− Effect.gen(function* () {
474− let requests = 0;
475− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
476− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
477− Effect.sync(() => {
478− requests += 1;
479− return HttpClientResponse.fromWeb(
480− request,
481− new Response("unexpected request", { status: 500 }),
482− );
483− }),
484− ),
485− );
486− const executor = yield* createExecutor(
487− makeTestConfig({
488− plugins: [
489− googlePlugin({ httpClientLayer: blockedHttpClientLayer }),
490− memoryCredentialsPlugin(),
491− ],
492− }),
493− );
494−
495− const exit = yield* executor.google
496− .addBundle({
497− urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"],
498− slug: "bad_google",
499− })
500− .pipe(Effect.exit);
501−
502− expect(Exit.isFailure(exit)).toBe(true);
503− expect(requests).toBe(0);
504− }),
505− ),
506− );
507−
508− it.effect(
509− "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions",
510− () =>
511− Effect.scoped(
512− Effect.gen(function* () {
513− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
514−
515− const result = yield* executor.google.addBundle({
516− urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL],
517− slug: "google",
518− description: "Google",
519− });
520− expect(String(result.slug)).toBe("google");
521−
522− // ONE integration, not three.
523− const integration = yield* executor.google.getIntegration("google");
524− expect(integration?.slug).toBe(IntegrationSlug.make("google"));
525−
526− // The stored oauth template carries the COMPACTED union of every API's
527− // scopes plus the hidden OAuth2 identity scopes that `oauth.start`
528− // requests.
529− // `calendar.readonly` collapses under `calendar`, and `gmail.readonly`
530− // collapses under `https://mail.google.com/`, so the requested consent
531− // is clean rather than the raw per-method union.
532− const config = yield* executor.google.getConfig("google");
533− expect(config?.googleDiscoveryUrls).toEqual([
534− CALENDAR_URL,
535− GMAIL_URL,
536− DRIVE_URL,
537− OAUTH2_URL,
538− ]);
539− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
540− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
541− [
542− "email",
543− "https://mail.google.com/",
544− "https://www.googleapis.com/auth/calendar",
545− "https://www.googleapis.com/auth/drive",
546− "openid",
547− "profile",
548− ].sort(),
549− );
550−
551− // A connection stamps the merged tools; assert all three `list`s are
552− // present under distinct service-prefixed names (no collision).
553− yield* executor.connections.create({
554− owner: "org",
555− name: ConnectionName.make("main"),
556− integration: IntegrationSlug.make("google"),
557− template: AuthTemplateSlug.make("googleOAuth2"),
558− value: "token-xyz",
559− });
560−
561− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
562− expect(toolNames).toContain("calendar.events.list");
563− expect(toolNames).toContain("gmail.users.messages.list");
564− expect(toolNames).toContain("drive.files.list");
565−
566− // No duplicate tool names across the merged surface.
567− const googleTools = toolNames.filter((name) => name.endsWith(".list"));
568− expect(new Set(googleTools).size).toBe(googleTools.length);
569− expect(googleTools.length).toBe(3);
570− }),
571− ),
572− );
573−
574− it.effect("addBundle constrains Google Photos to the preset scopes and upload tool", () =>
575− Effect.scoped(
576− Effect.gen(function* () {
577− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
578−
579− yield* executor.google.addBundle({
580− urls: [
581− PHOTOS_LIBRARY_URL,
582− "https://photospicker.googleapis.com/$discovery/rest?version=v1",
583− ],
584− slug: "google_photos",
585− name: "Google Photos",
586− });
587−
588− const config = yield* executor.google.getConfig("google_photos");
589− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
590− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
591− [
592− "email",
593− "https://www.googleapis.com/auth/photoslibrary.appendonly",
594− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
595− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
596− "openid",
597− "profile",
598− ].sort(),
599− );
600−
601− yield* executor.connections.create({
602− owner: "org",
603− name: ConnectionName.make("main"),
604− integration: IntegrationSlug.make("google_photos"),
605− template: AuthTemplateSlug.make("googleOAuth2"),
606− value: "token-xyz",
607− });
608−
609− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
610− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
611− expect(toolNames).toContain("photoslibrary.mediaItems.search");
612− expect(toolNames).toContain("photospicker.mediaItems.list");
613− expect(toolNames).not.toContain("photoslibrary.albums.list");
614− }),
615− ),
616− );
617−
618− it.effect("addBundle keeps Google Photos scoped when combined with another API", () =>
619− Effect.scoped(
620− Effect.gen(function* () {
621− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
622−
623− yield* executor.google.addBundle({
624− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL],
625− slug: "google_photos_calendar",
626− name: "Google Photos and Calendar",
627− });
628−
629− const config = yield* executor.google.getConfig("google_photos_calendar");
630− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
631− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
632− [
633− "email",
634− "https://www.googleapis.com/auth/calendar",
635− "https://www.googleapis.com/auth/photoslibrary.appendonly",
636− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
637− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
638− "openid",
639− "profile",
640− ].sort(),
641− );
642−
643− yield* executor.connections.create({
644− owner: "org",
645− name: ConnectionName.make("main"),
646− integration: IntegrationSlug.make("google_photos_calendar"),
647− template: AuthTemplateSlug.make("googleOAuth2"),
648− value: "token-xyz",
649− });
650−
651− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
652− expect(toolNames).toContain("calendar.events.list");
653− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
654− expect(toolNames).toContain("photoslibrary.mediaItems.search");
655− expect(toolNames).toContain("photospicker.mediaItems.list");
656− expect(toolNames).not.toContain("photoslibrary.albums.list");
657− }),
658− ),
659− );
660−
661− it.effect("addBundle scopes a partial Google Photos bundle when mixed with another API", () =>
662− Effect.scoped(
663− Effect.gen(function* () {
664− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
665−
666− yield* executor.google.addBundle({
667− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL],
668− slug: "google_photos_library_calendar",
669− name: "Google Photos Library and Calendar",
670− });
671−
672− const config = yield* executor.google.getConfig("google_photos_library_calendar");
673− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
674− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
675− [
676− "email",
677− "https://www.googleapis.com/auth/calendar",
678− "https://www.googleapis.com/auth/photoslibrary.appendonly",
679− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
680− "openid",
681− "profile",
682− ].sort(),
683− );
684−
685− yield* executor.connections.create({
686− owner: "org",
687− name: ConnectionName.make("main"),
688− integration: IntegrationSlug.make("google_photos_library_calendar"),
689− template: AuthTemplateSlug.make("googleOAuth2"),
690− value: "token-xyz",
691− });
692−
693− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
694− expect(toolNames).toContain("calendar.events.list");
695− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
696− expect(toolNames).toContain("photoslibrary.mediaItems.search");
697− expect(toolNames).not.toContain("photospicker.mediaItems.list");
698− expect(toolNames).not.toContain("photoslibrary.albums.list");
699− }),
700− ),
701− );
702−});
703−
704−describe("Google health-check default", () => {
705− it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () =>
706− Effect.scoped(
707− Effect.gen(function* () {
708− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
709− const defaultUrls = googleOpenApiPresets
710− .filter((preset: GoogleOpenApiPreset) => preset.featured)
711− .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : []));
712−
713− expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]);
714−
715− yield* executor.google.addBundle({
716− urls: defaultUrls,
717− slug: "google_default",
718− description: "Google",
719− });
720−
721− const stored = yield* executor.integrations.healthCheck.get(
722− IntegrationSlug.make("google_default"),
723− );
724− expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe(
725− "oauth2.userinfo.get",
726− );
727− expect(stored?.args, "userinfo needs no args").toBeUndefined();
728− expect(stored?.identityField, "the default reads the account email").toBe("email");
729−
730− const config = yield* executor.google.getConfig("google_default");
731− expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]);
732− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
733− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
734− [
735− "email",
736− "https://mail.google.com/",
737− "https://www.googleapis.com/auth/calendar",
738− "https://www.googleapis.com/auth/documents",
739− "https://www.googleapis.com/auth/drive",
740− "https://www.googleapis.com/auth/spreadsheets",
741− "openid",
742− "profile",
743− ].sort(),
744− );
745−
746− const candidates = yield* executor.integrations.healthCheck.candidates(
747− IntegrationSlug.make("google_default"),
748− );
749− const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get");
750− expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined();
751− expect(
752− (userinfo?.responseFields ?? []).map((field) => field.path),
753− "the email identity field is projected from the response schema",
754− ).toContain("email");
755− }),
756− ),
757− );
758−
759− it.effect("addBundle with People still prefers OAuth2 userinfo", () =>
760− Effect.scoped(
761− Effect.gen(function* () {
762− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
763−
764− yield* executor.google.addBundle({
765− urls: [PEOPLE_URL, CALENDAR_URL],
766− slug: "google_people",
767− description: "Google",
768− });
769−
770− const stored = yield* executor.integrations.healthCheck.get(
771− IntegrationSlug.make("google_people"),
772− );
773− expect(stored?.operation, "OAuth2 userinfo beats the People identity call").toBe(
774− "oauth2.userinfo.get",
775− );
776− expect(stored?.args).toBeUndefined();
777− expect(stored?.identityField).toBe("email");
778− }),
779− ),
780− );
781−
782− it("falls back to People when OAuth2 userinfo is absent", () => {
783− expect(
784− defaultGoogleHealthCheck(
785− [PEOPLE_URL],
786− [
787− {
788− toolPath: "people.people.get",
789− operation: { method: "get", pathTemplate: "/v1/{+resourceName}" },
790− },
791− ],
792− ),
793− ).toEqual({
794− operation: "people.people.get",
795− args: { resourceName: "people/me", personFields: "names,emailAddresses" },
796− identityField: "emailAddresses.0.value",
797− });
798− });
799−});
packages/plugins/google/src/sdk/plugin.ts+0−469
@@ -1,469 +0,0 @@
1−import { Effect } from "effect";
2−import type { Layer } from "effect";
3−import { HttpClient } from "effect/unstable/http";
4−
5−import {
6− IntegrationAlreadyExistsError,
7− IntegrationDetectionResult,
8− IntegrationNotFoundError,
9− IntegrationSlug,
10− definePlugin,
11− mergeAuthTemplates,
12− sha256Hex,
13− type AuthMethodDescriptor,
14− type HealthCheckSpec,
15− type Integration,
16− type IntegrationConfig,
17− type IntegrationRecord,
18− type PluginCtx,
19−} from "@executor-js/sdk/core";
20−import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21−import {
22− checkHealthOpenApi,
23− compileOpenApiSpec,
24− invokeOpenApiBackedTool,
25− listHealthCheckCandidatesOpenApi,
26− makeDefaultOpenapiStore,
27− normalizeOpenApiAuthInputs,
28− OpenApiParseError,
29− openApiStoredOperationsFromCompiled,
30− resolveOpenApiBackedAnnotations,
31− resolveOpenApiBackedTools,
32− type Authentication,
33− type AuthenticationInput,
34− type OpenapiStore,
35−} from "@executor-js/plugin-openapi";
36−
37−import {
38− convertGoogleDiscoveryBundleToOpenApi,
39− fetchGoogleDiscoveryDocument,
40− normalizeGoogleDiscoveryUrl,
41−} from "./discovery";
42−import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
43−import {
44− googleOAuthConsentScopesForPreset,
45− googleOpenApiBundlePreset,
46− googlePhotosOpenApiBundlePreset,
47− googlePhotosOpenApiPresets,
48−} from "./presets";
49−
50−const GOOGLE_OAUTH2_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
51−
52−/** The default health check for a Google bundle: prefer the lightweight
53− * OAuth2 userinfo identity call, which every new Google bundle includes.
54− * Older bundles may only have People API, so keep that fallback. */
55−export const defaultGoogleHealthCheck = (
56− urls: readonly string[],
57− definitions: readonly {
58− readonly toolPath: string;
59− readonly operation: { readonly method: string; readonly pathTemplate: string };
60− }[],
61−): HealthCheckSpec | undefined => {
62− const userinfoGet = definitions.find(
63− (def) =>
64− def.operation.method.toLowerCase() === "get" &&
65− (def.toolPath === "oauth2.userinfo.get" ||
66− def.operation.pathTemplate === "/oauth2/v2/userinfo"),
67− );
68− if (userinfoGet) {
69− return {
70− operation: userinfoGet.toolPath,
71− identityField: "email",
72− };
73− }
74−
75− const hasPeopleApi = urls.some((url) => url.includes("/people/"));
76− if (!hasPeopleApi) return undefined;
77− const peopleGet = definitions.find(
78− (def) =>
79− def.operation.method.toLowerCase() === "get" &&
80− (def.toolPath === "people.people.get" ||
81− def.operation.pathTemplate === "/v1/{+resourceName}"),
82− );
83− return peopleGet
84− ? {
85− operation: peopleGet.toolPath,
86− args: { resourceName: "people/me", personFields: "names,emailAddresses" },
87− identityField: "emailAddresses.0.value",
88− }
89− : undefined;
90−};
91−
92−export interface GoogleBundleConfig {
93− readonly urls: readonly string[];
94− readonly slug?: string;
95− readonly name?: string;
96− readonly description?: string;
97− readonly baseUrl?: string;
98−}
99−
100−export interface GoogleConfigureInput {
101− readonly authenticationTemplate: readonly AuthenticationInput[];
102− readonly mode?: "merge" | "replace";
103−}
104−
105−export interface GoogleUpdateInput {
106− readonly urls?: readonly string[];
107−}
108−
109−export interface GoogleUpdateResult {
110− readonly slug: IntegrationSlug;
111− readonly toolCount: number;
112− readonly addedTools: readonly string[];
113− readonly removedTools: readonly string[];
114−}
115−
116−export interface GooglePluginOptions {
117− readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
118−}
119−
120−const DEFAULT_GOOGLE_SLUG = "google";
121−
122−const googlePhotosBundlePresetIdByUrl = new Map(
123− googlePhotosOpenApiPresets.flatMap((preset) =>
124− preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
125− ),
126−);
127−
128−const googlePhotosBundleConsentScopes = (
129− urls: readonly string[],
130−): readonly string[] | undefined => {
131− const normalized = new Set(urls);
132− const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) =>
133− normalized.has(url) ? [presetId] : [],
134− );
135− return presetIds.length > 0
136− ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId))
137− : undefined;
138−};
139−
140−const fetchGoogleBundleConversion = (
141− urls: readonly string[],
142− httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
143−) =>
144− Effect.forEach(
145− urls,
146− (url) =>
147− fetchGoogleDiscoveryDocument(url).pipe(
148− Effect.provide(httpClientLayer),
149− Effect.map((documentText) => ({ discoveryUrl: url, documentText })),
150− ),
151− { concurrency: 4 },
152− ).pipe(
153− Effect.flatMap((documents) => {
154− const consentScopes = googlePhotosBundleConsentScopes(urls);
155− return convertGoogleDiscoveryBundleToOpenApi({
156− documents,
157− ...(consentScopes ? { consentScopes } : {}),
158− });
159− }),
160− );
161−
162−const uniqueUrls = (urls: readonly string[]): readonly string[] => [
163− ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])),
164−];
165−
166−const googleBundleUrlsWithIdentity = (
167− urls: readonly string[],
168−): Effect.Effect<readonly string[], OpenApiParseError> =>
169− Effect.gen(function* () {
170− const normalized: string[] = [];
171− for (const url of urls) {
172− const discoveryUrl = normalizeGoogleDiscoveryUrl(url);
173− if (!discoveryUrl) {
174− return yield* new OpenApiParseError({
175− message:
176− "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",
177− });
178− }
179− normalized.push(discoveryUrl);
180− }
181− return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);
182− });
183−
184−const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
185− const config = decodeGoogleIntegrationConfig(record.config);
186− if (!config) return [];
187− return (config.authenticationTemplate ?? []).map(
188− (template: Authentication): AuthMethodDescriptor => {
189− if (template.kind === "oauth2") {
190− return {
191− id: String(template.slug),
192− label: "OAuth2",
193− kind: "oauth",
194− template: String(template.slug),
195− oauth: {
196− authorizationUrl: template.authorizationUrl,
197− tokenUrl: template.tokenUrl,
198− scopes: template.scopes,
199− },
200− };
201− }
202− return describeApiKeyAuthMethod(template);
203− },
204− );
205−};
206−
207−const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
208− const config = decodeGoogleIntegrationConfig(record.config);
209− return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
210−};
211−
212−const makeGooglePluginExtension = (
213− options: GooglePluginOptions | undefined,
214− ctx: PluginCtx<OpenapiStore>,
215−) => {
216− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
217−
218− const addBundle = (config: GoogleBundleConfig) =>
219− Effect.gen(function* () {
220− const urls = yield* googleBundleUrlsWithIdentity(config.urls);
221− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
222− const compiled = yield* compileOpenApiSpec(conversion.specText);
223− const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);
224−
225− const existing = yield* ctx.core.integrations.get(slug);
226− if (existing) {
227− return yield* new IntegrationAlreadyExistsError({ slug });
228− }
229−
230− const specHash = yield* sha256Hex(conversion.specText);
231− const integrationConfig: GoogleIntegrationConfig = {
232− specHash,
233− googleDiscoveryUrls: urls,
234− ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
235− ...(conversion.authenticationTemplate
236− ? { authenticationTemplate: conversion.authenticationTemplate }
237− : {}),
238− };
239−
240− yield* ctx.storage.putSpec(specHash, conversion.specText);
241− yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
242−
243− yield* ctx.transaction(
244− Effect.gen(function* () {
245− yield* ctx.core.integrations.register({
246− slug,
247− name: config.name?.trim() || "Google",
248− description: config.description ?? "Google APIs",
249− config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
250− canRemove: true,
251− canRefresh: true,
252− });
253− yield* ctx.storage.putOperations(
254− String(slug),
255− openApiStoredOperationsFromCompiled(String(slug), compiled),
256− );
257− }),
258− );
259−
260− // Default the health check to the light OAuth2 userinfo identity call
261− // added to every new bundle. Older bundles without oauth2/v2 can still
262− // fall back to the People API identity operation.
263− const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);
264− if (defaultHealthCheck) {
265− yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);
266− }
267−
268− return { slug, toolCount: compiled.definitions.length };
269− });
270−
271− const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>
272− Effect.gen(function* () {
273− const slug = IntegrationSlug.make(rawSlug);
274− const record = yield* ctx.core.integrations.get(slug);
275− const current = record ? decodeGoogleIntegrationConfig(record.config) : null;
276− if (!record || !current) {
277− return yield* new IntegrationNotFoundError({ slug });
278− }
279−
280− const urls = yield* googleBundleUrlsWithIdentity(
281− input?.urls ?? current.googleDiscoveryUrls ?? [],
282− );
283− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
284− const compiled = yield* compileOpenApiSpec(conversion.specText);
285−
286− const previousOperations = yield* ctx.storage.listOperations(rawSlug);
287− const previousNames = new Set(previousOperations.map((op) => op.toolName));
288− const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
289−
290− const specHash = yield* sha256Hex(conversion.specText);
291− yield* ctx.storage.putSpec(specHash, conversion.specText);
292− yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
293−
294− const nextConfig: GoogleIntegrationConfig = {
295− ...current,
296− specHash,
297− googleDiscoveryUrls: urls,
298− };
299−
300− yield* ctx.transaction(
301− Effect.gen(function* () {
302− yield* ctx.core.integrations.update(slug, {
303− config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
304− });
305− yield* ctx.storage.putOperations(
306− rawSlug,
307− openApiStoredOperationsFromCompiled(rawSlug, compiled),
308− );
309− }),
310− );
311−
312− const connections = yield* ctx.connections.list({ integration: slug });
313− yield* Effect.forEach(
314− connections,
315− (connection) =>
316− ctx.connections
317− .refresh({
318− owner: connection.owner,
319− integration: connection.integration,
320− name: connection.name,
321− })
322− .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
323− { discard: true },
324− ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
325−
326− return {
327− slug,
328− toolCount: compiled.definitions.length,
329− addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
330− removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
331− };
332− });
333−
334− return {
335− addBundle,
336− updateBundle,
337− removeBundle: (slug: string) =>
338− ctx.transaction(
339− Effect.gen(function* () {
340− yield* ctx.storage.removeOperations(slug);
341− yield* ctx.core.integrations
342− .remove(IntegrationSlug.make(slug))
343− .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
344− }),
345− ),
346− getIntegration: (slug: string) =>
347− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
348− Effect.map((record) =>
349− record
350− ? ({
351− slug: record.slug,
352− description: record.description,
353− kind: record.kind,
354− canRemove: record.canRemove,
355− canRefresh: record.canRefresh,
356− } as Integration)
357− : null,
358− ),
359− ),
360− getConfig: (slug: string) =>
361− ctx.core.integrations
362− .get(IntegrationSlug.make(slug))
363− .pipe(
364− Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)),
365− ),
366− configure: (slug: string, input: GoogleConfigureInput) =>
367− ctx.transaction(
368− Effect.gen(function* () {
369− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
370− if (!record) return [] as readonly Authentication[];
371− const current = decodeGoogleIntegrationConfig(record.config);
372− if (!current) return [] as readonly Authentication[];
373−
374− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
375− const merged =
376− input.mode === "replace"
377− ? incoming
378− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
379−
380− const next: GoogleIntegrationConfig = {
381− ...current,
382− authenticationTemplate: merged,
383− };
384−
385− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
386− config: next satisfies GoogleIntegrationConfig as IntegrationConfig,
387− });
388−
389− return merged;
390− }),
391− ),
392− };
393−};
394−
395−export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
396−
397−export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
398− id: "google" as const,
399− packageName: "@executor-js/plugin-google",
400− integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],
401− storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
402−
403− extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx),
404−
405− describeAuthMethods: describeGoogleAuthMethods,
406− describeIntegrationDisplay: describeGoogleIntegrationDisplay,
407−
408− resolveTools: ({ integration, config, storage }) =>
409− resolveOpenApiBackedTools({ integration, config, storage }),
410−
411− invokeTool: ({ ctx, toolRow, credential, args }) => {
412− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
413− return invokeOpenApiBackedTool({
414− ctx,
415− toolRow,
416− credential,
417− args,
418− httpClientLayer,
419− });
420− },
421−
422− resolveAnnotations: ({ ctx, integration, toolRows }) =>
423− resolveOpenApiBackedAnnotations({
424− ctx,
425− integration: String(integration),
426− toolRows,
427− }),
428−
429− // Health checks reuse the OpenAPI backing (same store). The People API
430− // identity call is auto-defaulted at addBundle when present; core owns the
431− // stored spec, the user adjusts it via the editor.
432− listHealthCheckCandidates: (input) =>
433− listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
434− checkHealth: (input) =>
435− checkHealthOpenApi({
436− ctx: input.ctx,
437− integration: input.integration,
438− credential: input.credential,
439− spec: input.spec,
440− httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
441− }),
442−
443− removeConnection: () => Effect.void,
444−
445− detect: ({ ctx, url }) =>
446− Effect.gen(function* () {
447− const trimmed = url.trim();
448− const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed);
449− if (!trimmed || !discoveryUrl) return null;
450− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
451− const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe(
452− Effect.provide(httpClientLayer),
453− Effect.flatMap((documentText) =>
454− convertGoogleDiscoveryBundleToOpenApi({
455− documents: [{ discoveryUrl, documentText }],
456− }),
457− ),
458− Effect.catch(() => Effect.succeed(null)),
459− );
460− if (!conversion) return null;
461− return IntegrationDetectionResult.make({
462− kind: "google",
463− confidence: "high",
464− endpoint: discoveryUrl,
465− name: conversion.title,
466− slug: DEFAULT_GOOGLE_SLUG,
467− });
468− }),
469−}));
packages/plugins/google/src/sdk/presets.test.ts+0−29
@@ -1,29 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
4−
5−it("keeps Select all limited to Google services that can use normal user OAuth", () => {
6− const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id));
7−
8− expect(standardIds).toContain("google-calendar");
9− expect(standardIds).toContain("google-gmail");
10− expect(standardIds).toContain("google-tasks");
11− expect(standardIds).toContain("google-people");
12− expect(standardIds).toContain("google-search-console");
13−
14− expect(standardIds).not.toContain("google-youtube-data");
15− expect(standardIds).not.toContain("google-cloud-resource-manager");
16− expect(standardIds).not.toContain("google-chat");
17− expect(standardIds).not.toContain("google-keep");
18− expect(standardIds).not.toContain("google-admin-directory");
19− expect(standardIds).not.toContain("google-admin-reports");
20−});
21−
22−it("classifies every Google service for bundle OAuth UX", () => {
23− expect(
24− googleOpenApiPresets.map((preset) => ({
25− id: preset.id,
26− oauthAudience: preset.oauthAudience,
27− })),
28− ).toMatchSnapshot();
29−});
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−
11−import { expect, it } from "@effect/vitest";
12−
13−import {
14− googleAudienceWarningsForUrls,
15− googleOAuthConsentScopes,
16− googleOAuthConsentScopesForPreset,
17− googleOpenApiPresets,
18− googlePresetForDiscoveryUrl,
19− type GoogleOpenApiPreset,
20−} from "./presets";
21−import { googleOAuthConsentBatches } from "./oauth-batches";
22−import { compactGoogleOAuthScopes } from "./oauth-scopes";
23−
24−const batchInputsFor = (presetIds: readonly string[]) =>
25− googleOpenApiPresets
26− .filter((preset: GoogleOpenApiPreset) => presetIds.includes(preset.id))
27− .map((preset: GoogleOpenApiPreset) => ({
28− id: preset.id,
29− name: preset.name,
30− oauthAudience: preset.oauthAudience,
31− scopes: googleOAuthConsentScopesForPreset(preset.id),
32− }));
33−
34−it("declares representative consent scopes for every Google preset", () => {
35− for (const preset of googleOpenApiPresets) {
36− expect(googleOAuthConsentScopesForPreset(preset.id).length).toBeGreaterThan(0);
37− }
38− // No stray keys for presets that no longer exist.
39− const presetIds = new Set(googleOpenApiPresets.map((preset) => preset.id));
40− for (const key of Object.keys(googleOAuthConsentScopes)) {
41− expect(presetIds.has(key)).toBe(true);
42− }
43−});
44−
45−it("previews the featured selection as a single core-services consent batch", () => {
46− const featuredIds = googleOpenApiPresets
47− .filter((preset: GoogleOpenApiPreset) => preset.featured)
48− .map((preset: GoogleOpenApiPreset) => preset.id);
49−
50− const batches = googleOAuthConsentBatches(batchInputsFor(featuredIds));
51−
52− // The featured presets are all standard-user, so they collapse into one batch.
53− expect(batches.map((batch) => batch.id)).toEqual(["google-core"]);
54− expect(batches[0]?.apiScopes).toEqual([
55− "https://www.googleapis.com/auth/calendar",
56− "https://mail.google.com/",
57− "https://www.googleapis.com/auth/spreadsheets",
58− "https://www.googleapis.com/auth/drive",
59− "https://www.googleapis.com/auth/documents",
60− ]);
61−});
62−
63−it("maps stored Discovery URLs back to presets and flags caution-tier audiences", () => {
64− // A normalized stored URL (trailing slash, sorted query) still resolves.
65− expect(
66− googlePresetForDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest/")
67− ?.id,
68− ).toBe("google-calendar");
69−
70− // A bundle of only standard/advanced APIs raises no audience warning.
71− expect(
72− googleAudienceWarningsForUrls([
73− "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
74− "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest",
75− ]),
76− ).toEqual([]);
77−
78− // Admin-only (Chat) and unsupported-consent (Keep) APIs both flag.
79− expect(
80− [
81− ...googleAudienceWarningsForUrls([
82− "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
83− "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
84− "https://keep.googleapis.com/$discovery/rest?version=v1",
85− ]),
86− ].sort(),
87− ).toEqual(["unsupported-user", "workspace-admin"]);
88−});
89−
90−it("previews a consent set that matches what the bundle persists (compaction parity)", () => {
91− // The bundle converter runs the unioned Discovery scopes through
92− // `compactGoogleOAuthScopes` before persisting the auth template. The picker
93− // preview must agree: feeding every preview batch's scopes back through the
94− // same compaction is idempotent (already collapsed/filtered), so the previewed
95− // grant is exactly the persisted/requested grant.
96− const allPresetIds = googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => preset.id);
97− const batches = googleOAuthConsentBatches(batchInputsFor(allPresetIds));
98− for (const batch of batches) {
99− expect(compactGoogleOAuthScopes(batch.apiScopes)).toEqual([...batch.apiScopes]);
100− }
101−});
102−
103−it("splits a mixed selection into core + per-service consent batches", () => {
104− const batches = googleOAuthConsentBatches(
105− batchInputsFor(["google-calendar", "google-youtube-data", "google-bigquery"]),
106− );
107−
108− expect(batches.map((batch) => ({ id: batch.id, apiScopes: batch.apiScopes }))).toEqual([
109− {
110− id: "google-core",
111− apiScopes: ["https://www.googleapis.com/auth/calendar"],
112− },
113− {
114− id: "google-youtube-data",
115− apiScopes: ["https://www.googleapis.com/auth/youtube"],
116− },
117− {
118− id: "google-cloud",
119− apiScopes: ["https://www.googleapis.com/auth/bigquery"],
120− },
121− ]);
122−});
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 @@
1−import { defineConfig } from "tsup";
2−
3−export default defineConfig({
4− entry: {
5− index: "src/promise.ts",
6− core: "src/sdk/index.ts",
7− client: "src/react/plugin-client.tsx",
8− },
9− format: ["esm"],
10− dts: false,
11− sourcemap: true,
12− clean: true,
13− external: [/^@executor-js\//, /^effect/, /^@effect\//],
14−});
packages/plugins/google/vitest.config.ts+0−12
@@ -1,12 +0,0 @@
1−import { defineConfig } from "vitest/config";
2−
3−export default defineConfig({
4− test: {
5− // `.test.ts` only. React component tests are `.test.tsx` and need a DOM
6− // env, so they're excluded; the plugin-side converters are pure `.ts`
7− // logic and run here from `src/react/`.
8− include: ["src/**/*.test.ts"],
9− exclude: ["src/api/**", "src/react/**/*.test.tsx"],
10− testTimeout: 15_000,
11− },
12−});
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
2323 useSlugAlreadyExists,
2424 } from "@executor-js/react/lib/integration-add";
2525
26−import { addGraphqlIntegrationOptimistic } from "./atoms";
26+import { createGraphqlIntegrationOptimistic } from "./atoms";
2727 import { GraphqlSourceFields } from "./GraphqlSourceFields";
2828 import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
2929 import type { GraphqlAuthMethodInput } from "../sdk/types";
@@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
5252 const [adding, setAdding] = useState(false);
5353 const [addError, setAddError] = useState<string | null>(null);
5454
55− const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, {
55+ const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, {
5656 mode: "promiseExit",
5757 });
5858
packages/plugins/graphql/src/react/atoms.ts+3−3
@@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines
3333 // Mutation atoms
3434 // ---------------------------------------------------------------------------
3535
36−export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
36+export const createGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
3737
3838 // Merge-append custom auth methods onto an integration's `authenticationTemplate`.
3939 export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");
@@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line
4141 // Optimistically slot a pending row into the shared integration catalog so the
4242 // integrations list reflects the new GraphQL integration before the server
4343 // round-trips. The reducer mirrors the catalog `Integration` shape.
44−export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
44+export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
4545 Atom.optimisticFn({
4646 reducer: (
4747 current,
@@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines
6969 a.name.localeCompare(b.name),
7070 );
7171 }),
72− fn: addGraphqlIntegration,
72+ fn: createGraphqlIntegration,
7373 }),
7474 );
packages/plugins/graphql/src/react/index.ts+1−1
@@ -1,3 +1,3 @@
11 export { graphqlIntegrationPlugin } from "./source-plugin";
22 export { GraphqlClient } from "./client";
3−export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";
3+export { 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 @@
1−import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { Schema } from "effect";
3−import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4−import {
5− InternalError,
6− IntegrationAlreadyExistsError,
7− IntegrationNotFoundError,
8− IntegrationSlug,
9−} from "@executor-js/sdk/shared";
10−import {
11− OpenApiExtractionError,
12− OpenApiOAuthError,
13− OpenApiParseError,
14−} from "@executor-js/plugin-openapi";
15−
16−const DomainErrors = [
17− InternalError,
18− OpenApiParseError,
19− OpenApiExtractionError,
20− OpenApiOAuthError,
21− IntegrationAlreadyExistsError,
22−] as const;
23−
24−const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25−
26−const UpdateErrors = [
27− InternalError,
28− OpenApiParseError,
29− OpenApiExtractionError,
30− OpenApiOAuthError,
31− IntegrationNotFound,
32−] as const;
33−
34−const SlugParams = {
35− slug: Schema.String,
36−};
37−
38−const OAuthTemplatePayload = Schema.Struct({
39− slug: Schema.String,
40− kind: Schema.Literal("oauth2"),
41− authorizationUrl: Schema.String,
42− tokenUrl: Schema.String,
43− scopes: Schema.Array(Schema.String),
44−});
45−
46−const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47−const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48−
49−const AddGraphPayload = Schema.Struct({
50− presetIds: Schema.Array(Schema.String),
51− customScopes: Schema.optional(Schema.Array(Schema.String)),
52− slug: Schema.optional(Schema.String),
53− name: Schema.optional(Schema.String),
54− description: Schema.optional(Schema.String),
55− baseUrl: Schema.optional(Schema.String),
56− specUrl: Schema.optional(Schema.String),
57− authorizationUrl: Schema.optional(Schema.String),
58− tokenUrl: Schema.optional(Schema.String),
59− clientCredentialsTokenUrl: Schema.optional(Schema.String),
60−});
61−
62−const AddGraphResponse = Schema.Struct({
63− slug: IntegrationSlug,
64− toolCount: Schema.Number,
65−});
66−
67−const UpdateGraphPayload = Schema.Struct({
68− presetIds: Schema.optional(Schema.Array(Schema.String)),
69− customScopes: Schema.optional(Schema.Array(Schema.String)),
70− baseUrl: Schema.optional(Schema.String),
71− specUrl: Schema.optional(Schema.String),
72− authorizationUrl: Schema.optional(Schema.String),
73− tokenUrl: Schema.optional(Schema.String),
74− clientCredentialsTokenUrl: Schema.optional(Schema.String),
75−});
76−
77−const UpdateGraphResponse = Schema.Struct({
78− slug: IntegrationSlug,
79− toolCount: Schema.Number,
80− addedTools: Schema.Array(Schema.String),
81− removedTools: Schema.Array(Schema.String),
82−});
83−
84−const IntegrationView = Schema.Struct({
85− slug: IntegrationSlug,
86− description: Schema.String,
87− kind: Schema.String,
88− canRemove: Schema.Boolean,
89− canRefresh: Schema.Boolean,
90−});
91−
92−const MicrosoftConfigView = Schema.Struct({
93− sourceUrl: Schema.optional(Schema.String),
94− microsoftGraphPresetIds: Schema.optional(Schema.Array(Schema.String)),
95− microsoftGraphCustomScopes: Schema.optional(Schema.Array(Schema.String)),
96− microsoftGraphScopes: Schema.optional(Schema.Array(Schema.String)),
97− microsoftGraphExactPaths: Schema.optional(Schema.Array(Schema.String)),
98− microsoftGraphPathPrefixes: Schema.optional(Schema.Array(Schema.String)),
99− microsoftGraphTagPrefixes: Schema.optional(Schema.Array(Schema.String)),
100− microsoftGraphCoversFullGraph: Schema.optional(Schema.Boolean),
101− microsoftGraphAuthorizationUrl: Schema.optional(Schema.String),
102− microsoftGraphTokenUrl: Schema.optional(Schema.String),
103− microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String),
104− baseUrl: Schema.optional(Schema.String),
105− headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
106− queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
107− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
108−});
109−
110−const ConfigurePayload = Schema.Struct({
111− authenticationTemplate: Schema.Array(AuthenticationPayload),
112− mode: Schema.optional(Schema.Literals(["merge", "replace"])),
113−});
114−
115−const ConfigureResponse = Schema.Struct({
116− authenticationTemplate: Schema.Array(AuthenticationResponse),
117−});
118−
119−export const MicrosoftGroup = HttpApiGroup.make("microsoft")
120− .add(
121− HttpApiEndpoint.post("addGraph", "/microsoft/graph", {
122− payload: AddGraphPayload,
123− success: AddGraphResponse,
124− error: DomainErrors,
125− }),
126− )
127− .add(
128− HttpApiEndpoint.get("getIntegration", "/microsoft/integrations/:slug", {
129− params: SlugParams,
130− success: Schema.NullOr(IntegrationView),
131− error: DomainErrors,
132− }),
133− )
134− .add(
135− HttpApiEndpoint.get("getConfig", "/microsoft/integrations/:slug/config", {
136− params: SlugParams,
137− success: Schema.NullOr(MicrosoftConfigView),
138− error: DomainErrors,
139− }),
140− )
141− .add(
142− HttpApiEndpoint.post("configure", "/microsoft/integrations/:slug/config", {
143− params: SlugParams,
144− payload: ConfigurePayload,
145− success: ConfigureResponse,
146− error: DomainErrors,
147− }),
148− )
149− .add(
150− HttpApiEndpoint.post("updateGraph", "/microsoft/integrations/:slug/graph", {
151− params: SlugParams,
152− payload: UpdateGraphPayload,
153− success: UpdateGraphResponse,
154− error: UpdateErrors,
155− }),
156− )
157− .add(
158− HttpApiEndpoint.delete("removeGraph", "/microsoft/integrations/:slug", {
159− params: SlugParams,
160− success: Schema.Void,
161− error: DomainErrors,
162− }),
163− );
packages/plugins/microsoft/src/api/handlers.ts+0−144
@@ -1,144 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { Context, Effect } from "effect";
3−
4−import { addGroup, capture } from "@executor-js/api";
5−import type { MicrosoftPluginExtension } from "../sdk/plugin";
6−import { MicrosoftGroup } from "./group";
7−
8−export class MicrosoftExtensionService extends Context.Service<
9− MicrosoftExtensionService,
10− MicrosoftPluginExtension
11−>()("MicrosoftExtensionService") {}
12−
13−const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup);
14−
15−export const MicrosoftHandlers = HttpApiBuilder.group(
16− ExecutorApiWithMicrosoft,
17− "microsoft",
18− (handlers) =>
19− handlers
20− .handle("addGraph", ({ payload }) =>
21− capture(
22− Effect.gen(function* () {
23− const ext = yield* MicrosoftExtensionService;
24− return yield* ext.addGraph({
25− presetIds: payload.presetIds,
26− customScopes: payload.customScopes,
27− slug: payload.slug,
28− name: payload.name,
29− description: payload.description,
30− baseUrl: payload.baseUrl,
31− specUrl: payload.specUrl,
32− authorizationUrl: payload.authorizationUrl,
33− tokenUrl: payload.tokenUrl,
34− clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl,
35− });
36− }),
37− ),
38− )
39− .handle("getIntegration", ({ params }) =>
40− capture(
41− Effect.gen(function* () {
42− const ext = yield* MicrosoftExtensionService;
43− const integration = yield* ext.getIntegration(params.slug);
44− return integration
45− ? {
46− slug: integration.slug,
47− description: integration.description,
48− kind: integration.kind,
49− canRemove: integration.canRemove,
50− canRefresh: integration.canRefresh,
51− }
52− : null;
53− }),
54− ),
55− )
56− .handle("getConfig", ({ params }) =>
57− capture(
58− Effect.gen(function* () {
59− const ext = yield* MicrosoftExtensionService;
60− const config = yield* ext.getConfig(params.slug);
61− return config
62− ? {
63− sourceUrl: config.sourceUrl,
64− microsoftGraphPresetIds: config.microsoftGraphPresetIds
65− ? [...config.microsoftGraphPresetIds]
66− : undefined,
67− microsoftGraphCustomScopes: config.microsoftGraphCustomScopes
68− ? [...config.microsoftGraphCustomScopes]
69− : undefined,
70− microsoftGraphScopes: config.microsoftGraphScopes
71− ? [...config.microsoftGraphScopes]
72− : undefined,
73− microsoftGraphExactPaths: config.microsoftGraphExactPaths
74− ? [...config.microsoftGraphExactPaths]
75− : undefined,
76− microsoftGraphPathPrefixes: config.microsoftGraphPathPrefixes
77− ? [...config.microsoftGraphPathPrefixes]
78− : undefined,
79− microsoftGraphTagPrefixes: config.microsoftGraphTagPrefixes
80− ? [...config.microsoftGraphTagPrefixes]
81− : undefined,
82− microsoftGraphCoversFullGraph: config.microsoftGraphCoversFullGraph,
83− microsoftGraphAuthorizationUrl: config.microsoftGraphAuthorizationUrl,
84− microsoftGraphTokenUrl: config.microsoftGraphTokenUrl,
85− microsoftGraphClientCredentialsTokenUrl:
86− config.microsoftGraphClientCredentialsTokenUrl,
87− baseUrl: config.baseUrl,
88− headers: config.headers ? { ...config.headers } : undefined,
89− queryParams: config.queryParams ? { ...config.queryParams } : undefined,
90− authenticationTemplate: config.authenticationTemplate
91− ? [...config.authenticationTemplate]
92− : undefined,
93− }
94− : null;
95− }),
96− ),
97− )
98− .handle("configure", ({ params, payload }) =>
99− capture(
100− Effect.gen(function* () {
101− const ext = yield* MicrosoftExtensionService;
102− const authenticationTemplate = yield* ext.configure(params.slug, {
103− authenticationTemplate: payload.authenticationTemplate,
104− mode: payload.mode ?? "merge",
105− });
106− return { authenticationTemplate: [...authenticationTemplate] };
107− }),
108− ),
109− )
110− .handle("updateGraph", ({ params, payload }) =>
111− capture(
112− Effect.gen(function* () {
113− const ext = yield* MicrosoftExtensionService;
114− const result = yield* ext.updateGraph(params.slug, {
115− ...(payload.presetIds !== undefined ? { presetIds: payload.presetIds } : {}),
116− ...(payload.customScopes !== undefined ? { customScopes: payload.customScopes } : {}),
117− ...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}),
118− ...(payload.specUrl !== undefined ? { specUrl: payload.specUrl } : {}),
119− ...(payload.authorizationUrl !== undefined
120− ? { authorizationUrl: payload.authorizationUrl }
121− : {}),
122− ...(payload.tokenUrl !== undefined ? { tokenUrl: payload.tokenUrl } : {}),
123− ...(payload.clientCredentialsTokenUrl !== undefined
124− ? { clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl }
125− : {}),
126− });
127− return {
128− slug: result.slug,
129− toolCount: result.toolCount,
130− addedTools: [...result.addedTools],
131− removedTools: [...result.removedTools],
132− };
133− }),
134− ),
135− )
136− .handle("removeGraph", ({ params }) =>
137− capture(
138− Effect.gen(function* () {
139− const ext = yield* MicrosoftExtensionService;
140− yield* ext.removeGraph(params.slug);
141− }),
142− ),
143− ),
144−);
packages/plugins/microsoft/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1−import { definePlugin } from "@executor-js/sdk/core";
2−
3−import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin";
4−import { MicrosoftGroup } from "./group";
5−import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
6−
7−export { MicrosoftGroup } from "./group";
8−export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
9−
10−export const microsoftHttpPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({
11− ...microsoftPlugin(options),
12− routes: () => MicrosoftGroup,
13− handlers: () => MicrosoftHandlers,
14− extensionService: MicrosoftExtensionService,
15−}));
packages/plugins/microsoft/src/promise.ts+0−1
@@ -1 +0,0 @@
1−export { microsoftPlugin } from "./sdk/plugin";
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+0−155
@@ -1,155 +0,0 @@
1−import { useCallback, useMemo, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−
5−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6−import {
7− slugifyNamespace,
8− useIntegrationIdentity,
9−} from "@executor-js/react/plugins/integration-identity";
10−import { Button } from "@executor-js/react/components/button";
11−import { FloatActions } from "@executor-js/react/components/float-actions";
12−import {
13− addIntegrationErrorMessage,
14− FormErrorAlert,
15− SlugCollisionAlert,
16− useSlugAlreadyExists,
17−} from "@executor-js/react/lib/integration-add";
18−import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19−
20−import { addMicrosoftGraph } from "./atoms";
21−import { MicrosoftScopePicker } from "./MicrosoftScopePicker";
22−import {
23− MICROSOFT_GRAPH_BASE_URL,
24− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
25− microsoftGraphScopesForPresetIds,
26−} from "../sdk/presets";
27−
28−const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico";
29−
30−const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
31−
32−export default function AddMicrosoftSource(props: {
33− onComplete: (slug?: string) => void;
34− onCancel: () => void;
35− initialNamespace?: string;
36−}) {
37− const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(defaultPresetIds);
38− const [customScopes, setCustomScopes] = useState<readonly string[]>([]);
39− const [baseUrl, setBaseUrl] = useState("");
40− const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
41− const [adding, setAdding] = useState(false);
42− const [addError, setAddError] = useState<string | null>(null);
43−
44− const identity = useIntegrationIdentity({
45− fallbackName: "Microsoft Graph",
46− fallbackNamespace: props.initialNamespace ?? "microsoft_graph",
47− });
48−
49− const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]);
50− const selectedScopes = useMemo(
51− () => microsoftGraphScopesForPresetIds(selectedIds, customScopes),
52− [selectedIds, customScopes],
53− );
54−
55− const togglePreset = useCallback((presetId: string, checked: boolean) => {
56− setSelectedPresetIds((current: ReadonlySet<string>) => {
57− const next = new Set(current);
58− if (checked) next.add(presetId);
59− else next.delete(presetId);
60− return next;
61− });
62− }, []);
63−
64− const addCustomScope = useCallback((scope: string) => {
65− setCustomScopes((current: readonly string[]) =>
66− current.includes(scope) ? current : [...current, scope],
67− );
68− }, []);
69−
70− const removeCustomScope = useCallback((scope: string) => {
71− setCustomScopes((current: readonly string[]) =>
72− current.filter((entry: string) => entry !== scope),
73− );
74− }, []);
75−
76− const doAdd = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" });
77−
78− const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph";
79− const resolvedDisplayName = identity.name.trim() || "Microsoft Graph";
80− const resolvedDescription = descriptionDraft ?? "Selected Microsoft Graph workloads.";
81− const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
82− const canAdd = selectedIds.length > 0 && !slugAlreadyExists;
83−
84− const handleAdd = async () => {
85− setAdding(true);
86− setAddError(null);
87− const exit = await doAdd({
88− payload: {
89− presetIds: selectedIds,
90− customScopes: [...customScopes],
91− slug: resolvedSourceId,
92− name: resolvedDisplayName,
93− ...(resolvedDescription.trim().length > 0
94− ? { description: resolvedDescription.trim() }
95− : {}),
96− ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}),
97− },
98− reactivityKeys: integrationWriteKeys,
99− });
100− if (Exit.isFailure(exit)) {
101− setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft"));
102− setAdding(false);
103− return;
104− }
105− props.onComplete(String(exit.value.slug));
106− };
107−
108− return (
109− <div className="flex flex-1 flex-col gap-6">
110− <div>
111− <h1 className="text-xl font-semibold text-foreground">Add Microsoft integration</h1>
112− <p className="mt-1 text-[13px] text-muted-foreground">
113− Pick Microsoft 365 workloads and connect them through one delegated OAuth consent.
114− </p>
115− </div>
116−
117− <MicrosoftScopePicker
118− selectedPresetIds={selectedPresetIds}
119− onToggle={togglePreset}
120− customScopes={customScopes}
121− onAddCustomScope={addCustomScope}
122− onRemoveCustomScope={removeCustomScope}
123− />
124−
125− <OpenApiSourceDetailsFields
126− title="Microsoft Graph"
127− subtitle={`${selectedIds.length} operation group${
128− selectedIds.length !== 1 ? "s" : ""
129− } with ${selectedScopes.length} preview scope${selectedScopes.length !== 1 ? "s" : ""}`}
130− identity={identity}
131− description={resolvedDescription}
132− onDescriptionChange={setDescriptionDraft}
133− baseUrl={baseUrl}
134− onBaseUrlChange={setBaseUrl}
135− baseUrlLabel="Base URL override (optional)"
136− baseUrlPlaceholder={MICROSOFT_GRAPH_BASE_URL}
137− faviconIcon={MICROSOFT_FAVICON}
138− faviconUrl={baseUrl || MICROSOFT_GRAPH_BASE_URL}
139− />
140−
141− {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
142−
143− {addError && <FormErrorAlert message={addError} />}
144−
145− <FloatActions>
146− <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
147− Cancel
148− </Button>
149− <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>
150− Connect Microsoft
151− </Button>
152− </FloatActions>
153− </div>
154− );
155−}
packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx+0−118
@@ -1,118 +0,0 @@
1−import { useCallback, useMemo } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6−import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7−
8−import { AccountsSection } from "@executor-js/react/components/accounts-section";
9−import {
10− HealthCheckEditor,
11− type HealthCheckLivePreview,
12−} from "@executor-js/react/components/health-check-editor";
13−import { useOrganizationId } from "@executor-js/react/api/organization-context";
14−import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
15−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
16−import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
17−import {
18− useCustomMethodActions,
19− type AuthMethodsCodec,
20− type ConfigureAuthMethods,
21−} from "@executor-js/react/lib/custom-auth-methods";
22−import {
23− authMethodsFromConfig,
24− openApiWireAuthInput,
25− templateFromPlacements,
26−} from "@executor-js/plugin-openapi/react";
27−import type { Authentication } from "@executor-js/plugin-openapi";
28−
29−import { microsoftConfigAtom, microsoftConfigure } from "./atoms";
30−
31−const NO_AUTH_METHOD: AuthMethod = {
32− id: "none",
33− label: "No authentication",
34− kind: "none",
35− source: "spec",
36− template: AuthTemplateSlug.make("none"),
37− placements: [],
38−};
39−
40−export default function MicrosoftAccountsPanel(props: {
41− readonly sourceId: string;
42− readonly integrationName: string;
43− readonly accountHandoff?: IntegrationAccountHandoff | null;
44−}) {
45− const { sourceId, integrationName, accountHandoff } = props;
46− const slug = IntegrationSlug.make(sourceId);
47− const configResult = useAtomValue(microsoftConfigAtom(slug));
48− const doConfigure = useAtomSet(microsoftConfigure, { mode: "promiseExit" });
49−
50− const existingTemplate = useMemo<readonly Authentication[]>(() => {
51− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
52− return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[];
53− }, [configResult]);
54−
55− const methods = useMemo<readonly AuthMethod[]>(() => {
56− const declared = authMethodsFromConfig(existingTemplate);
57− return declared.length > 0 ? declared : [NO_AUTH_METHOD];
58− }, [existingTemplate]);
59−
60− const configure = useCallback<ConfigureAuthMethods<Authentication>>(
61− async (input) => {
62− const exit = await doConfigure({
63− params: { slug },
64− payload: {
65− authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
66− ...(input.mode ? { mode: input.mode } : {}),
67− },
68− reactivityKeys: integrationWriteKeys,
69− });
70− return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
71− },
72− [doConfigure, slug],
73− );
74−
75− const codec = useMemo<AuthMethodsCodec<Authentication>>(
76− () => ({
77− toAuthMethods: authMethodsFromConfig,
78− templatesFromPlacements: (placements: readonly Placement[]) => [
79− templateFromPlacements(placements),
80− ],
81− slugOf: (template: Authentication) => String(template.slug),
82− }),
83− [],
84− );
85−
86− const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
87− existing: existingTemplate,
88− codec,
89− configure,
90− });
91−
92− // Live-preview context for the health-check edit sheet (apiKey methods only;
93− // OAuth methods can't be previewed with a pasted key).
94− const organizationId = useOrganizationId();
95− const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => {
96− const templates = methods
97− .filter((m) => m.kind === "apikey")
98− .map((m) => ({ template: m.template, label: m.label }));
99− if (templates.length === 0) return undefined;
100− return { owner: defaultConnectionOwnerForHost(organizationId), templates };
101− }, [methods, organizationId]);
102−
103− return (
104− <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
105− <AccountsSection
106− integration={slug}
107− integrationName={integrationName}
108− methods={methods}
109− accountHandoff={accountHandoff}
110− createCustomMethod={createCustomMethod}
111− removeCustomMethod={removeCustomMethod}
112− />
113− {/* Graph auto-configures the /me identity probe at add time; this is
114− where an admin sees, adjusts, or clears it. */}
115− <HealthCheckEditor integration={slug} livePreview={livePreview} />
116− </div>
117− );
118−}
packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx+0−286
@@ -1,286 +0,0 @@
1−import { useMemo, useState } from "react";
2−import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react";
3−
4−import { cn } from "@executor-js/react/lib/utils";
5−import { Badge } from "@executor-js/react/components/badge";
6−import { Button } from "@executor-js/react/components/button";
7−import { Checkbox } from "@executor-js/react/components/checkbox";
8−import {
9− Collapsible,
10− CollapsibleContent,
11− CollapsibleTrigger,
12−} from "@executor-js/react/components/collapsible";
13−import { FieldLabel } from "@executor-js/react/components/field";
14−import { Input } from "@executor-js/react/components/input";
15−import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16−
17−import {
18− microsoftGraphScopePresets,
19− microsoftGraphScopesForPresetIds,
20− type MicrosoftGraphScopeAudience,
21− type MicrosoftGraphScopePreset,
22−} from "../sdk/presets";
23−
24−type MicrosoftScopePickerProps = {
25− readonly selectedPresetIds: ReadonlySet<string>;
26− readonly onToggle: (presetId: string, checked: boolean) => void;
27− readonly customScopes: readonly string[];
28− readonly onAddCustomScope: (scope: string) => void;
29− readonly onRemoveCustomScope: (scope: string) => void;
30−};
31−
32−const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [
33− "productivity",
34− "files-content",
35− "collaboration",
36− "directory-identity",
37− "admin-security",
38− "platform-business",
39−];
40−
41−const AUDIENCE_LABEL: Readonly<Record<MicrosoftGraphScopeAudience, string>> = {
42− productivity: "Productivity",
43− "files-content": "Files and content",
44− collaboration: "Collaboration",
45− "directory-identity": "Directory and identity",
46− "admin-security": "Admin and security",
47− "platform-business": "Platform and business",
48−};
49−
50−const AUDIENCE_DESCRIPTION: Readonly<Record<MicrosoftGraphScopeAudience, string>> = {
51− productivity: "User-facing Microsoft 365 operations for the connected account.",
52− "files-content": "Drive, SharePoint, OneNote, workbook, and content APIs.",
53− collaboration: "Teams, chats, online meetings, calls, and teamwork APIs.",
54− "directory-identity": "Tenant directory, app, identity, policy, and governance APIs.",
55− "admin-security": "Admin, audit, security, compliance, device, and education APIs.",
56− "platform-business": "Search, connectors, extensions, print, places, and business services.",
57−};
58−
59−const ScopeRow = ({
60− preset,
61− checked,
62− onToggle,
63−}: {
64− readonly preset: MicrosoftGraphScopePreset;
65− readonly checked: boolean;
66− readonly onToggle: (checked: boolean) => void;
67−}) => (
68− <FieldLabel
69− className={cn(
70− "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
71− checked ? "bg-primary/5" : "hover:bg-muted/40",
72− )}
73− >
74− <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} />
75− <div className="shrink-0">
76− <IntegrationFavicon icon={preset.icon} size={16} />
77− </div>
78− <div className="min-w-0 flex-1">
79− <div className="flex min-w-0 items-center gap-2">
80− <span className="truncate text-sm font-medium text-foreground">{preset.name}</span>
81− </div>
82− <p className="truncate text-[11px] text-muted-foreground">{preset.summary}</p>
83− </div>
84− </FieldLabel>
85−);
86−
87−const CustomScopeInput = ({
88− customScopes,
89− onAddCustomScope,
90− onRemoveCustomScope,
91−}: {
92− readonly customScopes: readonly string[];
93− readonly onAddCustomScope: (scope: string) => void;
94− readonly onRemoveCustomScope: (scope: string) => void;
95−}) => {
96− const [draft, setDraft] = useState("");
97− const trimmed = draft.trim();
98− const isValid = trimmed.length > 0 && !/\s/.test(trimmed);
99− const isDuplicate = customScopes.includes(trimmed);
100−
101− const commit = () => {
102− if (!isValid || isDuplicate) return;
103− onAddCustomScope(trimmed);
104− setDraft("");
105− };
106−
107− return (
108− <div className="space-y-2">
109− <FieldLabel className="text-[11px] font-medium text-muted-foreground">
110− Add custom Graph scope
111− </FieldLabel>
112− <div className="flex items-center gap-2">
113− <Input
114− value={draft}
115− onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)}
116− onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
117− if (event.key === "Enter") {
118− event.preventDefault();
119− commit();
120− }
121− }}
122− placeholder="Sites.Read.All"
123− className="font-mono text-[11px]"
124− />
125− <Button
126− type="button"
127− variant="outline"
128− size="sm"
129− disabled={!isValid || isDuplicate}
130− onClick={commit}
131− >
132− <PlusIcon className="size-3.5" />
133− Add
134− </Button>
135− </div>
136− {trimmed.length > 0 && !isValid ? (
137− <p className="text-[11px] text-destructive">Scopes cannot contain whitespace.</p>
138− ) : null}
139− {customScopes.length > 0 ? (
140− <ul className="space-y-1">
141− {customScopes.map((scope: string) => (
142− <li
143− key={scope}
144− className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5"
145− >
146− <span className="truncate font-mono text-[11px] text-foreground">{scope}</span>
147− <Button
148− type="button"
149− variant="ghost"
150− size="icon"
151− className="size-6 shrink-0"
152− onClick={() => onRemoveCustomScope(scope)}
153− aria-label={`Remove ${scope}`}
154− >
155− <XIcon className="size-3.5" />
156− </Button>
157− </li>
158− ))}
159− </ul>
160− ) : null}
161− </div>
162− );
163−};
164−
165−export function MicrosoftScopePicker({
166− selectedPresetIds,
167− onToggle,
168− customScopes,
169− onAddCustomScope,
170− onRemoveCustomScope,
171−}: MicrosoftScopePickerProps) {
172− const [scopesOpen, setScopesOpen] = useState(false);
173−
174− const groups = useMemo(
175− () =>
176− AUDIENCE_ORDER.flatMap((audience: MicrosoftGraphScopeAudience) => {
177− const presets = microsoftGraphScopePresets.filter(
178− (preset: MicrosoftGraphScopePreset) => preset.audience === audience,
179− );
180− return presets.length > 0 ? [{ audience, presets }] : [];
181− }),
182− [],
183− );
184−
185− const scopes = useMemo(
186− () => microsoftGraphScopesForPresetIds([...selectedPresetIds], customScopes),
187− [selectedPresetIds, customScopes],
188− );
189−
190− return (
191− <section className="space-y-4">
192− <div className="space-y-1">
193− <FieldLabel>Customize Microsoft Graph</FieldLabel>
194− <p className="text-[11px] text-muted-foreground">
195− Pick the operation groups to expose as tools. They share one Microsoft OAuth consent and
196− one account connection.
197− </p>
198− </div>
199−
200− {groups.map(
201− ({
202− audience,
203− presets,
204− }: {
205− readonly audience: MicrosoftGraphScopeAudience;
206− readonly presets: readonly MicrosoftGraphScopePreset[];
207− }) => (
208− <div key={audience} className="space-y-2">
209− <div className="flex items-center gap-2">
210− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
211− {AUDIENCE_LABEL[audience]}
212− </span>
213− <Button
214− type="button"
215− variant="ghost"
216− size="sm"
217− className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
218− onClick={() => {
219− const allSelected = presets.every((preset: MicrosoftGraphScopePreset) =>
220− selectedPresetIds.has(preset.id),
221− );
222− presets.forEach((preset: MicrosoftGraphScopePreset) =>
223− onToggle(preset.id, !allSelected),
224− );
225− }}
226− >
227− {presets.every((preset: MicrosoftGraphScopePreset) =>
228− selectedPresetIds.has(preset.id),
229− )
230− ? "Clear"
231− : "Select all"}
232− </Button>
233− </div>
234− <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p>
235− <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2">
236− {presets.map((preset: MicrosoftGraphScopePreset) => (
237− <ScopeRow
238− key={preset.id}
239− preset={preset}
240− checked={selectedPresetIds.has(preset.id)}
241− onToggle={(checked: boolean) => onToggle(preset.id, checked)}
242− />
243− ))}
244− </div>
245− </div>
246− ),
247− )}
248−
249− <CustomScopeInput
250− customScopes={customScopes}
251− onAddCustomScope={onAddCustomScope}
252− onRemoveCustomScope={onRemoveCustomScope}
253− />
254−
255− <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}>
256− <CollapsibleTrigger asChild>
257− <Button type="button" variant="outline" size="sm" disabled={scopes.length === 0}>
258− <ChevronDownIcon
259− className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")}
260− />
261− View scopes
262− {scopes.length > 0 ? (
263− <Badge variant="secondary" className="ml-1">
264− {scopes.length}
265− </Badge>
266− ) : null}
267− </Button>
268− </CollapsibleTrigger>
269− <CollapsibleContent className="pt-3">
270− <ul className="space-y-1">
271− {scopes.map((scope: string) => (
272− <li
273− key={scope}
274− className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
275− >
276− {scope}
277− </li>
278− ))}
279− </ul>
280− </CollapsibleContent>
281− </Collapsible>
282− </section>
283− );
284−}
285−
286−export default MicrosoftScopePicker;
packages/plugins/microsoft/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1−import type { IntegrationSlug } from "@executor-js/sdk/shared";
2−import * as Atom from "effect/unstable/reactivity/Atom";
3−import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4−import { MicrosoftClient } from "./client";
5−
6−export const microsoftIntegrationAtom = (slug: IntegrationSlug) =>
7− MicrosoftClient.query("microsoft", "getIntegration", {
8− params: { slug },
9− timeToLive: "15 seconds",
10− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11− });
12−
13−export const microsoftConfigAtom = (slug: IntegrationSlug) =>
14− MicrosoftClient.query("microsoft", "getConfig", {
15− params: { slug },
16− timeToLive: "15 seconds",
17− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18− });
19−
20−export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph");
21−
22−export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph");
23−
24−export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph");
25−
26−export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure");
27−
28−export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom);
29−
30−export const microsoftConfigFamily = Atom.family(microsoftConfigAtom);
packages/plugins/microsoft/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1−import { createPluginAtomClient } from "@executor-js/sdk/client";
2−import {
3− getExecutorOrganizationHeaders,
4− getExecutorApiBaseUrl,
5− getExecutorServerAuthorizationHeader,
6−} from "@executor-js/react/api/server-connection";
7−import { MicrosoftGroup } from "../api/group";
8−
9−export const MicrosoftClient = createPluginAtomClient(MicrosoftGroup, {
10− baseUrl: getExecutorApiBaseUrl,
11− authorizationHeader: getExecutorServerAuthorizationHeader,
12− headers: getExecutorOrganizationHeaders,
13−});
packages/plugins/microsoft/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1−export { microsoftIntegrationPlugin } from "./source-plugin";
2−export { MicrosoftClient } from "./client";
3−export {
4− addMicrosoftGraph,
5− microsoftConfigAtom,
6− microsoftConfigFamily,
7− microsoftConfigure,
8− microsoftIntegrationAtom,
9− microsoftIntegrationFamily,
10− removeMicrosoftGraph,
11− updateMicrosoftGraph,
12−} from "./atoms";
packages/plugins/microsoft/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1−import { defineClientPlugin } from "@executor-js/sdk/client";
2−
3−import { microsoftIntegrationPlugin } from "./source-plugin";
4−
5−export default defineClientPlugin({
6− id: "microsoft" as const,
7− integrationPlugin: microsoftIntegrationPlugin,
8−});
packages/plugins/microsoft/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
3−import { microsoftGraphPreset } from "../sdk/presets";
4−
5−const importAdd = () => import("./AddMicrosoftSource");
6−const importAccounts = () => import("./MicrosoftAccountsPanel");
7−
8−export const microsoftIntegrationPlugin: IntegrationPlugin = {
9− key: "microsoft",
10− label: "Microsoft",
11− add: lazy(importAdd),
12− accounts: lazy(importAccounts),
13− presets: [microsoftGraphPreset],
14− preload: () => {
15− void importAdd();
16− void importAccounts();
17− },
18−};
packages/plugins/microsoft/src/sdk/plugin.test.ts+0−692
@@ -1,692 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Exit, Layer } from "effect";
3−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4−
5−import {
6− AuthTemplateSlug,
7− ConnectionName,
8− IntegrationSlug,
9− createExecutor,
10−} from "@executor-js/sdk";
11−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
12−
13−import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
14−import {
15− MICROSOFT_AUTH_TEMPLATE_SLUG,
16− MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
17− MICROSOFT_GRAPH_ALL_PRESET_IDS,
18− MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES,
19− MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
20− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
21− MICROSOFT_GRAPH_OPENAPI_URL,
22− MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
23−} from "./presets";
24−
25−const graphFixture = `
26−openapi: 3.0.4
27−info:
28− title: Microsoft Graph Fixture
29− version: v1.0
30−servers:
31− - url: https://graph.microsoft.com/v1.0
32−paths:
33− /me:
34− get:
35− operationId: me.GetUser
36− summary: Get the signed-in user profile
37− security:
38− - azureAdDelegated:
39− - User.Read
40− responses:
41− "200":
42− description: OK
43− /me/messages:
44− get:
45− operationId: me.messages.ListMessages
46− security:
47− - azureAdDelegated:
48− - Mail.ReadWrite
49− responses:
50− "200":
51− description: OK
52− /me/events:
53− get:
54− operationId: me.events.ListEvents
55− security:
56− - azureAdDelegated:
57− - Calendars.ReadWrite
58− responses:
59− "200":
60− description: OK
61− /me/onenote/pages:
62− get:
63− operationId: me.onenote.pages.ListPages
64− security:
65− - azureAdDelegated:
66− - Notes.ReadWrite
67− responses:
68− "200":
69− description: OK
70− /sites:
71− get:
72− operationId: sites.ListSites
73− responses:
74− "200":
75− description: OK
76−components:
77− schemas:
78− user:
79− type: object
80−`;
81−
82−const permissionsReferenceFixture = `
83−### User.Read
84−
85−| Category | Application | Delegated |
86−|--|--|--|
87−| Identifier | - | e1fe6dd8-ba31-4d61-89e7-88639da4683d |
88−
89−---
90−
91−### Mail.ReadWrite
92−
93−| Category | Application | Delegated |
94−|--|--|--|
95−| Identifier | e2a3a72e-5f79-4c64-b1b1-878b674786c9 | 024d486e-b451-40bb-833d-3e66d98c5c73 |
96−
97−---
98−
99−### Calendars.ReadWrite
100−
101−| Category | Application | Delegated |
102−|--|--|--|
103−| Identifier | ef54d2bf-783f-4e0f-bca1-3210c0444d99 | 1ec239c2-d7c9-4623-a91a-a9775856bb36 |
104−
105−---
106−
107−### Notes.ReadWrite
108−
109−| Category | Application | Delegated |
110−|--|--|--|
111−| Identifier | 085ca537-6565-41c2-aca7-db852babc212 | 615e82d5-1f7f-4f99-a456-0a0484a820d5 |
112−
113−---
114−
115−### AppCatalog.Read.All
116−
117−| Category | Application | Delegated |
118−|--|--|--|
119−| Identifier | e12dae10-5a57-4817-b79d-dfbec5348930 | - |
120−`;
121−
122−const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi";
123−const EMULATOR_BASE_URL = "https://microsoft.emulators.dev";
124−const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi";
125−const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123";
126−const emulatorGraphFixture = `
127−openapi: 3.0.3
128−info:
129− title: Microsoft Graph Emulator
130− version: 1.0.0
131−servers:
132− - url: ${EMULATOR_BASE_URL}
133−paths:
134− /v1.0/users:
135− get:
136− operationId: graphUser_List
137− responses:
138− "200":
139− description: OK
140− /v1.0/me:
141− get:
142− operationId: graphUser_GetMyProfile
143− responses:
144− "200":
145− description: OK
146−components:
147− securitySchemes:
148− azureAdDelegated:
149− type: oauth2
150− flows:
151− authorizationCode:
152− authorizationUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/authorize
153− tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token
154− scopes:
155− User.Read: User.Read
156− User.Read.All: User.Read.All
157− clientCredentials:
158− tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token
159− scopes:
160− https://graph.microsoft.com/.default: https://graph.microsoft.com/.default
161−`;
162−
163−const localEmulatorGraphFixture = `
164−openapi: 3.0.3
165−info:
166− title: Microsoft Graph Local Emulator
167− version: 1.0.0
168−servers:
169− - url: ${LOCAL_EMULATOR_BASE_URL}
170−paths:
171− /v1.0/users:
172− get:
173− operationId: graphUser_List
174− responses:
175− "200":
176− description: OK
177−components:
178− securitySchemes:
179− azureAdDelegated:
180− type: oauth2
181− flows:
182− clientCredentials:
183− tokenUrl: ${LOCAL_EMULATOR_BASE_URL}/oauth2/v2.0/token
184− scopes:
185− https://graph.microsoft.com/.default: https://graph.microsoft.com/.default
186−`;
187−
188−const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
189− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
190− Effect.succeed(
191− HttpClientResponse.fromWeb(
192− request,
193− new Response(
194− request.url === MICROSOFT_GRAPH_OPENAPI_URL
195− ? graphFixture
196− : request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
197− ? permissionsReferenceFixture
198− : request.url === EMULATOR_SPEC_URL
199− ? emulatorGraphFixture
200− : request.url === LOCAL_EMULATOR_SPEC_URL
201− ? localEmulatorGraphFixture
202− : "not found",
203− {
204− status:
205− request.url === MICROSOFT_GRAPH_OPENAPI_URL ||
206− request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL ||
207− request.url === EMULATOR_SPEC_URL ||
208− request.url === LOCAL_EMULATOR_SPEC_URL
209− ? 200
210− : 404,
211− headers: {
212− "content-type":
213− request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
214− ? "text/markdown"
215− : "application/yaml",
216− },
217− },
218− ),
219− ),
220− ),
221− ),
222−);
223−
224−const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
225− [
226− microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
227− memoryCredentialsPlugin(),
228− ] as const;
229−
230−describe("Microsoft Graph provider", () => {
231− it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () =>
232− Effect.scoped(
233− Effect.gen(function* () {
234− let requests = 0;
235− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
236− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
237− Effect.sync(() => {
238− requests += 1;
239− return HttpClientResponse.fromWeb(
240− request,
241− new Response("unexpected request", { status: 500 }),
242− );
243− }),
244− ),
245− );
246− const executor = yield* createExecutor(
247− makeTestConfig({
248− plugins: [
249− microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }),
250− memoryCredentialsPlugin(),
251− ],
252− }),
253− );
254−
255− const exit = yield* executor.microsoft
256− .addGraph({
257− slug: "bad_graph",
258− baseUrl: "https://attacker.example/v1.0",
259− specUrl: "https://attacker.example/openapi.yaml",
260− authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize",
261− tokenUrl: "https://attacker.example/oauth2/v2.0/token",
262− clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token",
263− })
264− .pipe(Effect.exit);
265−
266− expect(Exit.isFailure(exit)).toBe(true);
267− expect(requests).toBe(0);
268− }),
269− ),
270− );
271−
272− it.effect("adds a selected Graph workload source with one OAuth template", () =>
273− Effect.scoped(
274− Effect.gen(function* () {
275− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
276−
277− const result = yield* executor.microsoft.addGraph({
278− presetIds: ["profile", "mail"],
279− slug: "microsoft_graph",
280− description: "Microsoft Graph",
281− });
282−
283− expect(String(result.slug)).toBe("microsoft_graph");
284−
285− const config = yield* executor.microsoft.getConfig("microsoft_graph");
286− expect(config?.microsoftGraphPresetIds).toEqual(["profile", "mail"]);
287− expect(config?.microsoftGraphCoversFullGraph).toBe(false);
288− expect(config?.microsoftGraphScopes).toEqual([
289− "offline_access",
290− "User.Read",
291− "Mail.ReadWrite",
292− "Mail.Send",
293− "MailboxSettings.ReadWrite",
294− ]);
295−
296− const oauthTemplates = config?.authenticationTemplate?.filter(
297− (entry) => entry.kind === "oauth2",
298− );
299− const delegated = oauthTemplates?.find(
300− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
301− );
302− const clientCredentials = oauthTemplates?.find(
303− (entry) => String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
304− );
305− expect(delegated?.kind === "oauth2" ? delegated.slug : undefined).toBe(
306− AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
307− );
308− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([
309− "offline_access",
310− "User.Read",
311− "Mail.ReadWrite",
312− "Mail.Send",
313− "MailboxSettings.ReadWrite",
314− ]);
315− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.slug : undefined).toBe(
316− AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
317− );
318− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual(
319− [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
320− );
321−
322− yield* executor.connections.create({
323− owner: "org",
324− name: ConnectionName.make("main"),
325− integration: IntegrationSlug.make("microsoft_graph"),
326− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
327− value: "token-xyz",
328− });
329−
330− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
331− expect(toolNames).toContain("me.getUser");
332− expect(toolNames).toContain("me.messagesListMessages");
333− expect(toolNames).not.toContain("sites.listSites");
334− }),
335− ),
336− );
337−
338− it.effect("adds common Microsoft Graph workloads by default", () =>
339− Effect.scoped(
340− Effect.gen(function* () {
341− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
342−
343− yield* executor.microsoft.addGraph({
344− slug: "microsoft_graph_all",
345− description: "Microsoft Graph",
346− });
347−
348− const config = yield* executor.microsoft.getConfig("microsoft_graph_all");
349− expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
350− expect(config?.microsoftGraphCoversFullGraph).toBe(false);
351− expect(config?.microsoftGraphScopes).toEqual([
352− "offline_access",
353− "User.Read",
354− "Mail.ReadWrite",
355− "Mail.Send",
356− "MailboxSettings.ReadWrite",
357− "Calendars.ReadWrite",
358− "Contacts.ReadWrite",
359− "People.Read.All",
360− "Tasks.ReadWrite",
361− "Files.ReadWrite.All",
362− "Sites.ReadWrite.All",
363− "Notes.ReadWrite",
364− "Chat.ReadWrite",
365− "Team.ReadBasic.All",
366− "Channel.ReadBasic.All",
367− "ChannelMessage.Read.All",
368− "ChannelMessage.Send",
369− "OnlineMeetings.ReadWrite",
370− ]);
371−
372− const delegated = config?.authenticationTemplate?.find(
373− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
374− );
375− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual(
376− config?.microsoftGraphScopes,
377− );
378−
379− yield* executor.connections.create({
380− owner: "org",
381− name: ConnectionName.make("all"),
382− integration: IntegrationSlug.make("microsoft_graph_all"),
383− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
384− value: "token-xyz",
385− });
386−
387− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
388− expect(toolNames).toContain("me.getUser");
389− expect(toolNames).toContain("me.messagesListMessages");
390− expect(toolNames).toContain("me.eventsListEvents");
391− expect(toolNames).toContain("me.onenotePagesListPages");
392− expect(toolNames).toContain("sites.listSites");
393− }),
394− ),
395− );
396−
397− it.effect("uses the app registration default scope when every Graph workload is selected", () =>
398− Effect.scoped(
399− Effect.gen(function* () {
400− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
401−
402− yield* executor.microsoft.addGraph({
403− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
404− slug: "microsoft_graph_full",
405− description: "Microsoft Graph",
406− });
407−
408− const config = yield* executor.microsoft.getConfig("microsoft_graph_full");
409− expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_ALL_PRESET_IDS);
410− expect(config?.microsoftGraphCoversFullGraph).toBe(true);
411− expect(config?.microsoftGraphScopes).toEqual(MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES);
412−
413− const delegated = config?.authenticationTemplate?.find(
414− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
415− );
416− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([
417− ...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
418− ]);
419−
420− // Full-graph add routes through the streaming compile (the path the
421− // real 37MB spec takes): the source text is structurally split and each
422− // op's binding plus a `description` is persisted, alongside the
423− // content-addressed defs blob, never materializing the whole-document
424− // tree. Read the operations back through the live serve path to prove
425− // they landed in storage AND that the serve fast path rebuilds tools
426− // from the persisted bindings (no spec parse).
427− yield* executor.connections.create({
428− owner: "org",
429− name: ConnectionName.make("full"),
430− integration: IntegrationSlug.make("microsoft_graph_full"),
431− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
432− value: "token-xyz",
433− });
434−
435− const tools = yield* executor.tools.list();
436− const toolNames = tools.map((tool) => String(tool.name));
437− expect(toolNames).toContain("me.getUser");
438− expect(toolNames).toContain("me.messagesListMessages");
439− expect(toolNames).toContain("sites.listSites");
440−
441− // The serve fast path must rebuild every tool's description from the
442− // persisted operation, not drop it. Each graph tool carries a non-empty
443− // description.
444− for (const tool of tools) {
445− expect(tool.description.length).toBeGreaterThan(0);
446− }
447−
448− // `me.getUser`'s spec summary survives the add -> persist -> serve
449− // round-trip. The bare `${METHOD} ${path}` fallback inside the serve
450− // path would be "GET /me", so matching the summary proves the persisted
451− // `description` field is what's served.
452− const getUser = tools.find((tool) => String(tool.name) === "me.getUser");
453− expect(getUser?.description).toBe("Get the signed-in user profile");
454−
455− // An op without a spec summary falls back to `${METHOD} ${path}`, also
456− // sourced from the persisted binding on the serve fast path.
457− const listSites = tools.find((tool) => String(tool.name) === "sites.listSites");
458− expect(listSites?.description).toBe("GET /sites");
459− }),
460− ),
461− );
462−
463− it.effect("uses explicit full Graph scopes when custom dynamic scopes are added", () =>
464− Effect.scoped(
465− Effect.gen(function* () {
466− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
467−
468− yield* executor.microsoft.addGraph({
469− slug: "microsoft_graph_all_custom",
470− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
471− customScopes: ["Custom.Scope"],
472− });
473−
474− const config = yield* executor.microsoft.getConfig("microsoft_graph_all_custom");
475− expect(config?.microsoftGraphCoversFullGraph).toBe(true);
476− expect(config?.microsoftGraphScopes).toEqual([
477− "offline_access",
478− "User.Read",
479− "Mail.ReadWrite",
480− "Calendars.ReadWrite",
481− "Notes.ReadWrite",
482− "Custom.Scope",
483− ]);
484− }),
485− ),
486− );
487−
488− it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () =>
489− Effect.scoped(
490− Effect.gen(function* () {
491− const executor = yield* createExecutor(
492− makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
493− );
494−
495− yield* executor.microsoft.addGraph({
496− presetIds: ["users"],
497− slug: "microsoft_graph_emulated",
498− baseUrl: EMULATOR_BASE_URL,
499− specUrl: EMULATOR_SPEC_URL,
500− });
501−
502− const config = yield* executor.microsoft.getConfig("microsoft_graph_emulated");
503− expect(config?.sourceUrl).toBe(EMULATOR_SPEC_URL);
504− expect(config?.baseUrl).toBe(EMULATOR_BASE_URL);
505− expect(config?.microsoftGraphAuthorizationUrl).toBe(
506− `${EMULATOR_BASE_URL}/oauth2/v2.0/authorize`,
507− );
508− expect(config?.microsoftGraphTokenUrl).toBe(`${EMULATOR_BASE_URL}/oauth2/v2.0/token`);
509− expect(config?.microsoftGraphClientCredentialsTokenUrl).toBe(
510− `${EMULATOR_BASE_URL}/oauth2/v2.0/token`,
511− );
512−
513− const delegated = config?.authenticationTemplate?.find(
514− (entry) => entry.kind === "oauth2" && String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
515− );
516− const clientCredentials = config?.authenticationTemplate?.find(
517− (entry) =>
518− entry.kind === "oauth2" &&
519− String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
520− );
521− expect(delegated?.kind === "oauth2" ? delegated.tokenUrl : undefined).toBe(
522− `${EMULATOR_BASE_URL}/oauth2/v2.0/token`,
523− );
524− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual(
525− [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
526− );
527−
528− yield* executor.connections.create({
529− owner: "org",
530− name: ConnectionName.make("machine"),
531− integration: IntegrationSlug.make("microsoft_graph_emulated"),
532− template: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
533− value: "token-xyz",
534− });
535−
536− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
537− expect(toolNames).toContain("users.graphUserList");
538− }),
539− ),
540− );
541−
542− it.effect("accepts a loopback http emulator spec only when the override is enabled", () =>
543− Effect.scoped(
544− Effect.gen(function* () {
545− const executor = yield* createExecutor(
546− makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
547− );
548−
549− yield* executor.microsoft.addGraph({
550− presetIds: ["users"],
551− slug: "microsoft_graph_local_emulated",
552− baseUrl: LOCAL_EMULATOR_BASE_URL,
553− specUrl: LOCAL_EMULATOR_SPEC_URL,
554− });
555−
556− const config = yield* executor.microsoft.getConfig("microsoft_graph_local_emulated");
557− expect(config?.sourceUrl).toBe(LOCAL_EMULATOR_SPEC_URL);
558− expect(config?.baseUrl).toBe(LOCAL_EMULATOR_BASE_URL);
559− }),
560− ),
561− );
562−
563− it.effect("rejects a loopback http spec URL when the override is disabled", () =>
564− Effect.scoped(
565− Effect.gen(function* () {
566− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
567−
568− const exit = yield* executor.microsoft
569− .addGraph({
570− slug: "microsoft_graph_local_disabled",
571− baseUrl: LOCAL_EMULATOR_BASE_URL,
572− specUrl: LOCAL_EMULATOR_SPEC_URL,
573− })
574− .pipe(Effect.exit);
575−
576− expect(Exit.isFailure(exit)).toBe(true);
577− }),
578− ),
579− );
580−
581− it.effect("rejects a non-loopback http override even with allowUnsafeUrlOverrides", () =>
582− Effect.scoped(
583− Effect.gen(function* () {
584− let requests = 0;
585− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
586− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
587− Effect.sync(() => {
588− requests += 1;
589− return HttpClientResponse.fromWeb(
590− request,
591− new Response("unexpected request", { status: 500 }),
592− );
593− }),
594− ),
595− );
596− const executor = yield* createExecutor(
597− makeTestConfig({
598− plugins: [
599− microsoftPlugin({
600− httpClientLayer: blockedHttpClientLayer,
601− allowUnsafeUrlOverrides: true,
602− }),
603− memoryCredentialsPlugin(),
604− ],
605− }),
606− );
607−
608− const exit = yield* executor.microsoft
609− .addGraph({
610− slug: "microsoft_graph_http_example",
611− baseUrl: "http://example.com/v1.0",
612− specUrl: "http://example.com/openapi.yaml",
613− })
614− .pipe(Effect.exit);
615−
616− expect(Exit.isFailure(exit)).toBe(true);
617− expect(requests).toBe(0);
618− }),
619− ),
620− );
621−});
622−
623−describe("Microsoft Graph health-check default", () => {
624− it.effect("addGraph without profile still auto-configures the /me identity check", () =>
625− Effect.scoped(
626− Effect.gen(function* () {
627− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
628−
629− yield* executor.microsoft.addGraph({
630− presetIds: ["mail"],
631− slug: "microsoft_graph_mail_hc",
632− description: "Microsoft Graph",
633− });
634−
635− const config = yield* executor.microsoft.getConfig("microsoft_graph_mail_hc");
636− expect(config?.microsoftGraphExactPaths).toEqual([]);
637− expect(config?.microsoftGraphScopes).toEqual([
638− "offline_access",
639− "User.Read",
640− "Mail.ReadWrite",
641− "Mail.Send",
642− "MailboxSettings.ReadWrite",
643− ]);
644−
645− const stored = yield* executor.integrations.healthCheck.get(
646− IntegrationSlug.make("microsoft_graph_mail_hc"),
647− );
648− expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser");
649− expect(stored?.identityField, "the default reads the principal name").toBe(
650− "userPrincipalName",
651− );
652−
653− yield* executor.connections.create({
654− owner: "org",
655− name: ConnectionName.make("mail"),
656− integration: IntegrationSlug.make("microsoft_graph_mail_hc"),
657− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
658− value: "token-xyz",
659− });
660−
661− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
662− expect(toolNames).toContain("me.getUser");
663− expect(toolNames).toContain("me.messagesListMessages");
664− expect(toolNames).not.toContain("me.eventsListEvents");
665− }),
666− ),
667− );
668−
669− it.effect("addGraph with a /me workload auto-configures the identity health check", () =>
670− Effect.scoped(
671− Effect.gen(function* () {
672− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
673−
674− yield* executor.microsoft.addGraph({
675− presetIds: ["profile", "mail"],
676− slug: "microsoft_graph_hc",
677− description: "Microsoft Graph",
678− });
679−
680− // GET /me is the canonical Graph identity endpoint: the default probe
681− // answers alive/expired + who-am-I with zero configuration.
682− const stored = yield* executor.integrations.healthCheck.get(
683− IntegrationSlug.make("microsoft_graph_hc"),
684− );
685− expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser");
686− expect(stored?.identityField, "the default reads the principal name").toBe(
687− "userPrincipalName",
688− );
689− }),
690− ),
691− );
692−});
packages/plugins/microsoft/src/sdk/plugin.ts+0−436
@@ -1,436 +0,0 @@
1−import { Effect } from "effect";
2−import type { Layer } from "effect";
3−import { HttpClient } from "effect/unstable/http";
4−
5−import {
6− IntegrationAlreadyExistsError,
7− IntegrationDetectionResult,
8− IntegrationNotFoundError,
9− IntegrationSlug,
10− definePlugin,
11− mergeAuthTemplates,
12− sha256Hex,
13− type AuthMethodDescriptor,
14− type Integration,
15− type IntegrationConfig,
16− type IntegrationRecord,
17− type PluginCtx,
18− type StorageFailure,
19−} from "@executor-js/sdk/core";
20−import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21−import {
22− checkHealthOpenApi,
23− compileAndPersistOpenApiSpecStreaming,
24− listHealthCheckCandidatesOpenApi,
25− decodeOpenApiIntegrationConfig,
26− invokeOpenApiBackedTool,
27− makeDefaultOpenapiStore,
28− normalizeOpenApiAuthInputs,
29− OpenApiExtractionError,
30− resolveOpenApiBackedAnnotations,
31− resolveOpenApiBackedTools,
32− type Authentication,
33− type AuthenticationInput,
34− type OpenApiPersistResult,
35− type OpenapiStore,
36−} from "@executor-js/plugin-openapi";
37−
38−import {
39− buildMicrosoftGraphOpenApiSpec,
40− decodeMicrosoftGraphIntegrationConfig,
41− microsoftGraphKeepPathItem,
42− type MicrosoftGraphUrlPolicy,
43− type MicrosoftGraphIntegrationConfig,
44− type MicrosoftGraphSpecBuild,
45−} from "./graph";
46−import {
47− MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
48− MICROSOFT_GRAPH_BASE_URL,
49− microsoftGraphPreset,
50−} from "./presets";
51−
52−export interface MicrosoftGraphConfig {
53− readonly presetIds?: readonly string[];
54− readonly customScopes?: readonly string[];
55− readonly slug?: string;
56− readonly name?: string;
57− readonly description?: string;
58− readonly baseUrl?: string;
59− readonly specUrl?: string;
60− readonly authorizationUrl?: string;
61− readonly tokenUrl?: string;
62− readonly clientCredentialsTokenUrl?: string;
63−}
64−
65−export interface MicrosoftConfigureInput {
66− readonly authenticationTemplate: readonly AuthenticationInput[];
67− readonly mode?: "merge" | "replace";
68−}
69−
70−export interface MicrosoftUpdateInput {
71− readonly presetIds?: readonly string[];
72− readonly customScopes?: readonly string[];
73− readonly baseUrl?: string;
74− readonly specUrl?: string;
75− readonly authorizationUrl?: string;
76− readonly tokenUrl?: string;
77− readonly clientCredentialsTokenUrl?: string;
78−}
79−
80−export interface MicrosoftUpdateResult {
81− readonly slug: IntegrationSlug;
82− readonly toolCount: number;
83− readonly addedTools: readonly string[];
84− readonly removedTools: readonly string[];
85−}
86−
87−export interface MicrosoftPluginOptions {
88− readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
89− /**
90− * Allows `addGraph` to point spec/base/OAuth URLs at a trusted https host
91− * other than the pinned Microsoft Graph endpoints, or at plain http on
92− * loopback (local Graph emulators). Off by default; hosts wire this to
93− * their own local-network dev posture (e.g. `allowLocalNetwork`), never on
94− * in production.
95− */
96− readonly allowUnsafeUrlOverrides?: boolean;
97−}
98−
99−const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
100−
101−const describeMicrosoftAuthMethods = (
102− record: IntegrationRecord,
103−): readonly AuthMethodDescriptor[] => {
104− const config = decodeOpenApiIntegrationConfig(record.config);
105− if (!config) return [];
106− return (config.authenticationTemplate ?? []).map(
107− (template: Authentication): AuthMethodDescriptor => {
108− if (template.kind === "oauth2") {
109− const machineFlow =
110− String(template.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG;
111− return {
112− id: String(template.slug),
113− label: machineFlow ? "Microsoft OAuth (client credentials)" : "Microsoft OAuth",
114− kind: "oauth",
115− template: String(template.slug),
116− oauth: {
117− authorizationUrl: template.authorizationUrl,
118− tokenUrl: template.tokenUrl,
119− scopes: template.scopes,
120− },
121− };
122− }
123− return describeApiKeyAuthMethod(template);
124− },
125− );
126−};
127−
128−const describeMicrosoftIntegrationDisplay = (
129− record: IntegrationRecord,
130−): { readonly url?: string } => {
131− const config = decodeMicrosoftGraphIntegrationConfig(record.config);
132− return { url: config?.baseUrl ?? MICROSOFT_GRAPH_BASE_URL };
133−};
134−
135−const makeMicrosoftPluginExtension = (
136− ctx: PluginCtx<OpenapiStore>,
137− httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
138− urlPolicy?: MicrosoftGraphUrlPolicy,
139−) => {
140− const persistGraphOperations = (
141− graph: MicrosoftGraphSpecBuild,
142− integration: string,
143− specHash: string,
144− ): Effect.Effect<OpenApiPersistResult, OpenApiExtractionError | StorageFailure> =>
145− // Stream the (full 37MB) source straight to persisted bindings + a
146− // content-addressed defs blob, never materializing the whole-document tree
147− // that OOMs the 128MB Workers isolate. `keepPathItem` applies the Microsoft
148− // Graph scope selection per path-item during the stream; a full-graph
149− // selection returns `undefined` (keep everything).
150− compileAndPersistOpenApiSpecStreaming({
151− specText: graph.specText,
152− integration,
153− storage: ctx.storage,
154− specHash,
155− keepPathItem: microsoftGraphKeepPathItem(graph),
156− });
157−
158− const addGraph = (config: MicrosoftGraphConfig) =>
159− Effect.gen(function* () {
160− const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy);
161− const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG);
162−
163− const existing = yield* ctx.core.integrations.get(slug);
164− if (existing) {
165− return yield* new IntegrationAlreadyExistsError({ slug });
166− }
167−
168− const specHash = yield* sha256Hex(graph.specText);
169− const integrationConfig: MicrosoftGraphIntegrationConfig = {
170− specHash,
171− sourceUrl: graph.specUrl,
172− microsoftGraphPresetIds: graph.presetIds,
173− microsoftGraphCustomScopes: graph.customScopes,
174− microsoftGraphScopes: graph.scopes,
175− microsoftGraphExactPaths: graph.exactPaths,
176− microsoftGraphPathPrefixes: graph.pathPrefixes,
177− microsoftGraphTagPrefixes: graph.tagPrefixes,
178− microsoftGraphCoversFullGraph: graph.coversFullGraph,
179− microsoftGraphAuthorizationUrl: graph.authorizationUrl,
180− microsoftGraphTokenUrl: graph.tokenUrl,
181− microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl,
182− authenticationTemplate: graph.authenticationTemplate,
183− ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
184− };
185−
186− yield* ctx.storage.putSpec(specHash, graph.specText);
187−
188− const persisted = yield* ctx.transaction(
189− Effect.gen(function* () {
190− yield* ctx.core.integrations.register({
191− slug,
192− name: config.name?.trim() || "Microsoft Graph",
193− description: config.description ?? "Selected Microsoft Graph workloads.",
194− config:
195− integrationConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
196− canRemove: true,
197− canRefresh: true,
198− });
199− return yield* persistGraphOperations(graph, String(slug), specHash);
200− }),
201− );
202−
203− // Default the health check to `GET /me` (the canonical Graph identity
204− // endpoint) when the selected workloads include it, so connections report
205− // alive/expired + who-am-I out of the box. The operations were streamed
206− // to storage above, so find the binding there. Best-effort: a Graph
207− // selection without /me just leaves the check unconfigured (the editor
208− // remains available).
209− const meOperation = (yield* ctx.storage.listOperations(String(slug))).find(
210− (op) => op.binding.method.toLowerCase() === "get" && op.binding.pathTemplate === "/me",
211− );
212− if (meOperation) {
213− yield* ctx.core.integrations.setHealthCheck(slug, {
214− operation: meOperation.toolName,
215− identityField: "userPrincipalName",
216− });
217− }
218−
219− return { slug, toolCount: persisted.toolCount };
220− });
221−
222− const updateGraph = (rawSlug: string, input?: MicrosoftUpdateInput) =>
223− Effect.gen(function* () {
224− const slug = IntegrationSlug.make(rawSlug);
225− const record = yield* ctx.core.integrations.get(slug);
226− const current = record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null;
227− if (!record || !current) {
228− return yield* new IntegrationNotFoundError({ slug });
229− }
230−
231− const graph = yield* buildMicrosoftGraphOpenApiSpec(
232− {
233− presetIds: input?.presetIds ?? current.microsoftGraphPresetIds,
234− customScopes: input?.customScopes ?? current.microsoftGraphCustomScopes,
235− baseUrl: input?.baseUrl ?? current.baseUrl,
236− specUrl: input?.specUrl ?? current.sourceUrl,
237− authorizationUrl: input?.authorizationUrl ?? current.microsoftGraphAuthorizationUrl,
238− tokenUrl: input?.tokenUrl ?? current.microsoftGraphTokenUrl,
239− clientCredentialsTokenUrl:
240− input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl,
241− },
242− httpClientLayer,
243− urlPolicy,
244− );
245− const previousOperations = yield* ctx.storage.listOperations(rawSlug);
246− const previousNames = new Set(previousOperations.map((op) => op.toolName));
247−
248− const specHash = yield* sha256Hex(graph.specText);
249− yield* ctx.storage.putSpec(specHash, graph.specText);
250−
251− const nextConfig: MicrosoftGraphIntegrationConfig = {
252− ...current,
253− specHash,
254− sourceUrl: graph.specUrl,
255− microsoftGraphPresetIds: graph.presetIds,
256− microsoftGraphCustomScopes: graph.customScopes,
257− microsoftGraphScopes: graph.scopes,
258− microsoftGraphExactPaths: graph.exactPaths,
259− microsoftGraphPathPrefixes: graph.pathPrefixes,
260− microsoftGraphTagPrefixes: graph.tagPrefixes,
261− microsoftGraphCoversFullGraph: graph.coversFullGraph,
262− microsoftGraphAuthorizationUrl: graph.authorizationUrl,
263− microsoftGraphTokenUrl: graph.tokenUrl,
264− microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl,
265− authenticationTemplate: graph.authenticationTemplate,
266− ...(input?.baseUrl ? { baseUrl: input.baseUrl } : {}),
267− };
268−
269− const persisted = yield* ctx.transaction(
270− Effect.gen(function* () {
271− yield* ctx.core.integrations.update(slug, {
272− config: nextConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
273− });
274− return yield* persistGraphOperations(graph, rawSlug, specHash);
275− }),
276− );
277− const nextNames = new Set(persisted.toolNames);
278−
279− const connections = yield* ctx.connections.list({ integration: slug });
280− yield* Effect.forEach(
281− connections,
282− (connection) =>
283− ctx.connections
284− .refresh({
285− owner: connection.owner,
286− integration: connection.integration,
287− name: connection.name,
288− })
289− .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
290− { discard: true },
291− ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
292−
293− return {
294− slug,
295− toolCount: persisted.toolCount,
296− addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
297− removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
298− };
299− });
300−
301− return {
302− addGraph,
303− updateGraph,
304− removeGraph: (slug: string) =>
305− ctx.transaction(
306− Effect.gen(function* () {
307− yield* ctx.storage.removeOperations(slug);
308− yield* ctx.core.integrations
309− .remove(IntegrationSlug.make(slug))
310− .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
311− }),
312− ),
313− getIntegration: (slug: string) =>
314− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
315− Effect.map((record) =>
316− record
317− ? ({
318− slug: record.slug,
319− description: record.description,
320− kind: record.kind,
321− canRemove: record.canRemove,
322− canRefresh: record.canRefresh,
323− } as Integration)
324− : null,
325− ),
326− ),
327− getConfig: (slug: string) =>
328− ctx.core.integrations
329− .get(IntegrationSlug.make(slug))
330− .pipe(
331− Effect.map((record) =>
332− record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null,
333− ),
334− ),
335− configure: (slug: string, input: MicrosoftConfigureInput) =>
336− ctx.transaction(
337− Effect.gen(function* () {
338− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
339− if (!record) return [] as readonly Authentication[];
340− const current = decodeMicrosoftGraphIntegrationConfig(record.config);
341− if (!current) return [] as readonly Authentication[];
342−
343− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
344− const merged =
345− input.mode === "replace"
346− ? incoming
347− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
348−
349− const next: MicrosoftGraphIntegrationConfig = {
350− ...current,
351− authenticationTemplate: merged,
352− };
353−
354− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
355− config: next satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
356− });
357−
358− return merged;
359− }),
360− ),
361− };
362−};
363−
364−export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>;
365−
366−export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({
367− id: "microsoft" as const,
368− packageName: "@executor-js/plugin-microsoft",
369− integrationPresets: [microsoftGraphPreset],
370− storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
371−
372− extension: (ctx) =>
373− makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, {
374− allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true,
375− }),
376−
377− describeAuthMethods: describeMicrosoftAuthMethods,
378− describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,
379−
380− resolveTools: ({ integration, config, storage }) =>
381− resolveOpenApiBackedTools({ integration, config, storage }),
382−
383− invokeTool: ({ ctx, toolRow, credential, args }) => {
384− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
385− return invokeOpenApiBackedTool({
386− ctx,
387− toolRow,
388− credential,
389− args,
390− httpClientLayer,
391− });
392− },
393−
394− resolveAnnotations: ({ ctx, integration, toolRows }) =>
395− resolveOpenApiBackedAnnotations({
396− ctx,
397− integration: String(integration),
398− toolRows,
399− }),
400−
401− // Health checks reuse the OpenAPI backing (same store). GET /me is
402− // auto-defaulted at addGraph when present; core owns the stored spec.
403− listHealthCheckCandidates: (input) =>
404− listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
405− checkHealth: (input) =>
406− checkHealthOpenApi({
407− ctx: input.ctx,
408− integration: input.integration,
409− credential: input.credential,
410− spec: input.spec,
411− httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
412− }),
413−
414− removeConnection: () => Effect.void,
415−
416− detect: ({ url }) =>
417− Effect.sync(() => {
418− const trimmed = url.trim();
419− if (!URL.canParse(trimmed)) return null;
420− const parsed = new URL(trimmed);
421− const host = parsed.hostname.toLowerCase();
422− const isGraph =
423− host === "graph.microsoft.com" ||
424− (host === "learn.microsoft.com" && parsed.pathname.startsWith("/graph/")) ||
425− (host === "raw.githubusercontent.com" &&
426− parsed.pathname.includes("/microsoftgraph/msgraph-metadata/"));
427− if (!isGraph) return null;
428− return IntegrationDetectionResult.make({
429− kind: "microsoft",
430− confidence: "high",
431− endpoint: trimmed,
432− name: "Microsoft Graph",
433− slug: DEFAULT_MICROSOFT_SLUG,
434− });
435− }),
436−}));
packages/plugins/microsoft/src/sdk/presets.test.ts+0−169
@@ -1,169 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−
3−import {
4− MICROSOFT_GRAPH_ALL_PRESET_IDS,
5− MICROSOFT_GRAPH_BASE_SCOPES,
6− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
7− microsoftGraphExactPathsForPresetIds,
8− microsoftGraphPathPrefixesForPresetIds,
9− microsoftGraphPresetIdsCoverFullGraph,
10− microsoftGraphScopePresets,
11− microsoftGraphScopesForPresetIds,
12− microsoftGraphTagPrefixesForPresetIds,
13−} from "./presets";
14−
15−describe("Microsoft Graph scope presets", () => {
16− it("keeps default workload ids backed by categorized presets", () => {
17− const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
18− expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.every((id) => ids.has(id))).toBe(true);
19− expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS).toEqual([
20− "profile",
21− "mail",
22− "calendar",
23− "contacts",
24− "tasks",
25− "files",
26− "excel",
27− "sites",
28− "onenote",
29− "teams-chat",
30− "teams-channels",
31− "meetings-calls",
32− ]);
33− expect(ids.has("all")).toBe(false);
34− });
35−
36− it("keeps the full workload ids backed by every categorized preset", () => {
37− const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
38− expect(MICROSOFT_GRAPH_ALL_PRESET_IDS.every((id) => ids.has(id))).toBe(true);
39− expect(MICROSOFT_GRAPH_ALL_PRESET_IDS).toEqual(
40− microsoftGraphScopePresets.map((preset) => preset.id),
41− );
42− });
43−
44− it("detects when the categorized catalog covers full Graph", () => {
45− expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_ALL_PRESET_IDS)).toBe(true);
46− expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS)).toBe(false);
47− expect(microsoftGraphPresetIdsCoverFullGraph(["profile", "mail"])).toBe(false);
48− });
49−
50− it("unions selected preset scopes with base and custom scopes", () => {
51− expect(microsoftGraphScopesForPresetIds(["profile", "mail"], ["Sites.Read.All"])).toEqual([
52− ...MICROSOFT_GRAPH_BASE_SCOPES,
53− "User.Read",
54− "Mail.ReadWrite",
55− "Mail.Send",
56− "MailboxSettings.ReadWrite",
57− "Sites.Read.All",
58− ]);
59− });
60−
61− it("includes User.Read for identity when profile is not selected", () => {
62− expect(microsoftGraphScopesForPresetIds(["mail"])).toEqual([
63− ...MICROSOFT_GRAPH_BASE_SCOPES,
64− "User.Read",
65− "Mail.ReadWrite",
66− "Mail.Send",
67− "MailboxSettings.ReadWrite",
68− ]);
69− });
70−
71− it("returns path filters for the selected workloads", () => {
72− expect(microsoftGraphExactPathsForPresetIds(["profile"])).toContain("/me");
73− expect(microsoftGraphPathPrefixesForPresetIds(["mail"])).toContain("/me/messages");
74− expect(microsoftGraphTagPrefixesForPresetIds(["mail"])).toEqual([]);
75− });
76−
77− it("covers Microsoft Graph root surfaces through category presets", () => {
78− const prefixes = new Set(
79− microsoftGraphPathPrefixesForPresetIds(MICROSOFT_GRAPH_ALL_PRESET_IDS),
80− );
81− for (const root of [
82− "/agreementAcceptances",
83− "/agreements",
84− "/admin",
85− "/appCatalogs",
86− "/applicationTemplates",
87− "/applications",
88− "/applications(appId='{appId}')",
89− "/applications(uniqueName='{uniqueName}')",
90− "/authenticationMethodConfigurations",
91− "/authenticationMethodsPolicy",
92− "/auditLogs",
93− "/certificateBasedAuthConfiguration",
94− "/chats",
95− "/communications",
96− "/compliance",
97− "/connections",
98− "/contacts",
99− "/contracts",
100− "/copilot",
101− "/dataPolicyOperations",
102− "/deviceAppManagement",
103− "/deviceManagement",
104− "/devices",
105− "/devices(deviceId='{deviceId}')",
106− "/directory",
107− "/directoryObjects",
108− "/directoryRoleTemplates",
109− "/directoryRoles",
110− "/directoryRoles(roleTemplateId='{roleTemplateId}')",
111− "/domainDnsRecords",
112− "/domains",
113− "/drives",
114− "/education",
115− "/employeeExperience",
116− "/external",
117− "/filterOperators",
118− "/functions",
119− "/groupLifecyclePolicies",
120− "/groupSettingTemplates",
121− "/groupSettings",
122− "/groups",
123− "/groups(uniqueName='{uniqueName}')",
124− "/identity",
125− "/identityGovernance",
126− "/identityProviders",
127− "/identityProtection",
128− "/informationProtection",
129− "/invitations",
130− "/me",
131− "/oauth2PermissionGrants",
132− "/organization",
133− "/permissionGrants",
134− "/places",
135− "/planner",
136− "/policies",
137− "/print",
138− "/privacy",
139− "/reports",
140− "/roleManagement",
141− "/schemaExtensions",
142− "/scopedRoleMemberships",
143− "/search",
144− "/security",
145− "/servicePrincipals",
146− "/servicePrincipals(appId='{appId}')",
147− "/shares",
148− "/sites",
149− "/solutions",
150− "/storage",
151− "/subscribedSkus",
152− "/subscriptions",
153− "/teamwork",
154− "/teams",
155− "/teamsTemplates",
156− "/tenantRelationships",
157− "/users",
158− "/users(userPrincipalName='{userPrincipalName}')",
159− ]) {
160− expect(prefixes.has(root)).toBe(true);
161− }
162− });
163−
164− it("declares product icons for each workload", () => {
165− for (const preset of microsoftGraphScopePresets) {
166− expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/);
167− }
168− });
169−});
packages/plugins/microsoft/vitest.config.ts+0−12
@@ -1,12 +0,0 @@
1−import { defineConfig } from "vitest/config";
2−
3−export default defineConfig({
4− test: {
5− // `.test.ts` only. React component tests are `.test.tsx` and need a DOM
6− // env, so they're excluded; the plugin-side converters are pure `.ts`
7− // logic and run here from `src/react/`.
8− include: ["src/**/*.test.ts"],
9− exclude: ["src/api/**", "src/react/**/*.test.tsx"],
10− testTimeout: 15_000,
11− },
12−});
packages/plugins/openapi/package.json+14−0
@@ -22,6 +22,8 @@ · 21 unmodified lines
2222 "./react": "./src/react/index.ts",
2323 "./client": "./src/react/plugin-client.tsx",
2424 "./presets": "./src/sdk/presets.ts",
25+ "./providers/google": "./src/providers/google/index.ts",
26+ "./providers/microsoft": "./src/providers/microsoft/index.ts",
2527 "./testing": "./src/testing/index.ts"
2628 },
2729 "publishConfig": {
@@ -45,6 +47,18 @@ · 17 unmodified lines
4547 "default": "./dist/client.js"
4648 }
4749 },
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+ },
4862 "./testing": {
4963 "import": {
5064 "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
22 import { Schema } from "effect";
33 import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
44 import {
5+ HealthCheckSpec,
56 InternalError,
67 IntegrationAlreadyExistsError,
78 IntegrationNotFoundError,
@@ -68,25 +69,31 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 60 unmodified lines
6869
6970 const AddSpecPayload = Schema.Struct({
7071 spec: OpenApiSpecInputPayload,
71− slug: Schema.String,
72+ slug: Schema.optional(Schema.String),
7273 name: Schema.optional(Schema.String),
7374 description: Schema.optional(Schema.String),
7475 baseUrl: Schema.optional(Schema.String),
7576 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
7677 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),
7781 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
7882 });
7983
8084 const PreviewSpecPayload = Schema.Struct({
8185 spec: Schema.String,
86+ specFormat: Schema.optional(Schema.String),
8287 });
8388
8489 // The `configure` payload — the new/updated auth methods to merge onto the
85−// integration's `authenticationTemplate`. Reuses the same `AuthenticationPayload`
86−// schema as `addSpec` so a custom apiKey method round-trips identically.
90+// integration's `authenticationTemplate`, plus request routing metadata.
91+// Reuses the same `AuthenticationPayload` schema as `addSpec` so a custom apiKey
92+// method round-trips identically.
8793 const ConfigurePayload = Schema.Struct({
88− authenticationTemplate: Schema.Array(AuthenticationPayload),
94+ authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
8995 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
96+ baseUrl: Schema.optional(Schema.String),
9097 });
9198
9299 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+8−1
@@ -44,7 +44,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
4444 capture(
4545 Effect.gen(function* () {
4646 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+ });
4851 return specPreviewSummary(preview);
4952 }),
5053 ),
@@ -61,6 +64,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines
6164 baseUrl: payload.baseUrl,
6265 headers: payload.headers ? { ...payload.headers } : undefined,
6366 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
67+ specFormat: payload.specFormat,
68+ family: payload.family,
69+ healthCheck: payload.healthCheck,
6470 authenticationTemplate: payload.authenticationTemplate,
6571 });
6672 }),
@@ -109,6 +115,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines
109115 const authenticationTemplate = yield* ext.configure(params.slug, {
110116 authenticationTemplate: payload.authenticationTemplate,
111117 mode: payload.mode ?? "merge",
118+ baseUrl: payload.baseUrl,
112119 });
113120 return { authenticationTemplate: [...authenticationTemplate] };
114121 }),
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 @@
11 import { Option, Schema } from "effect";
2−import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";
2+
3+import { AuthenticationSchema } from "../../sdk/config";
4+import type { Authentication } from "../../sdk/types";
35
46 export const GoogleIntegrationConfigSchema = Schema.Struct({
57 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
55 import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";
66 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
77
8−import { OpenApiParseError, type Authentication } from "@executor-js/plugin-openapi";
8+import { OpenApiParseError } from "../../sdk/errors";
9+import type { Authentication } from "../../sdk/types";
910 import { compactGoogleOAuthScopes } from "./oauth-scopes";
1011 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1112
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
77 } from "./discovery";
88 export {
99 googleOpenApiBundlePreset,
10+ googleCatalog,
1011 googleOpenApiPresets,
1112 googlePhotosOpenApiBundlePreset,
1213 googlePhotosOpenApiPresets,
@@ -16,6 +17,7 @@ export { · 3 unmodified lines
1617 googleStandardUserOAuthPresets,
1718 googleOAuthConsentScopes,
1819 googleOAuthConsentScopesForPreset,
20+ googleServiceSlug,
1921 googleAudienceWarningsForUrls,
2022 googlePresetForDiscoveryUrl,
2123 type GoogleOpenApiOAuthAudience,
@@ -27,20 +29,7 @@ export { · 5 unmodified lines
2729 filterGoogleUserConsentOAuthScopes,
2830 isGoogleUserConsentOAuthScope,
2931 } from "./oauth-scopes";
30−export {
31− googleOAuthConsentBatches,
32− type GoogleOAuthBatchInput,
33− type GoogleOAuthConsentBatch,
34−} from "./oauth-batches";
35−export {
36− googlePlugin,
37− type GoogleBundleConfig,
38− type GoogleConfigureInput,
39− type GooglePluginExtension,
40− type GooglePluginOptions,
41− type GoogleUpdateInput,
42− type GoogleUpdateResult,
43−} from "./plugin";
32+export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
4433 export {
4534 googleOpenApiOwnershipDataMigration,
4635 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 @@
1+import { expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { compileOpenApiSpec } from "@executor-js/plugin-openapi";
4+
5+import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery";
6+import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
7+
8+const googleHealthCheckDiscoveryFixtures = {
9+ "google-calendar": {
10+ url: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
11+ document: {
12+ name: "calendar",
13+ version: "v3",
14+ title: "Calendar API",
15+ rootUrl: "https://www.googleapis.com/",
16+ servicePath: "calendar/v3/",
17+ resources: {
18+ calendarList: {
19+ methods: {
20+ list: {
21+ id: "calendar.calendarList.list",
22+ path: "users/me/calendarList",
23+ httpMethod: "GET",
24+ response: { $ref: "CalendarList" },
25+ },
26+ },
27+ },
28+ },
29+ schemas: {
30+ CalendarList: {
31+ type: "object",
32+ properties: {
33+ items: {
34+ type: "array",
35+ items: { $ref: "CalendarListEntry" },
36+ },
37+ },
38+ },
39+ CalendarListEntry: {
40+ type: "object",
41+ properties: {
42+ id: { type: "string" },
43+ summary: { type: "string" },
44+ },
45+ },
46+ },
47+ },
48+ },
49+ "google-gmail": {
50+ url: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
51+ document: {
52+ name: "gmail",
53+ version: "v1",
54+ title: "Gmail API",
55+ rootUrl: "https://gmail.googleapis.com/",
56+ servicePath: "",
57+ resources: {
58+ users: {
59+ methods: {
60+ labelsList: {
61+ id: "gmail.users.labels.list",
62+ path: "gmail/v1/users/{userId}/labels",
63+ httpMethod: "GET",
64+ parameters: {
65+ userId: {
66+ type: "string",
67+ location: "path",
68+ required: true,
69+ },
70+ },
71+ response: { $ref: "ListLabelsResponse" },
72+ },
73+ },
74+ },
75+ },
76+ schemas: {
77+ ListLabelsResponse: {
78+ type: "object",
79+ properties: {
80+ labels: {
81+ type: "array",
82+ items: { $ref: "Label" },
83+ },
84+ },
85+ },
86+ Label: {
87+ type: "object",
88+ properties: {
89+ id: { type: "string" },
90+ name: { type: "string" },
91+ },
92+ },
93+ },
94+ },
95+ },
96+ "google-drive": {
97+ url: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
98+ document: {
99+ name: "drive",
100+ version: "v3",
101+ title: "Google Drive API",
102+ rootUrl: "https://www.googleapis.com/",
103+ servicePath: "drive/v3/",
104+ resources: {
105+ about: {
106+ methods: {
107+ get: {
108+ id: "drive.about.get",
109+ path: "about",
110+ httpMethod: "GET",
111+ response: { $ref: "About" },
112+ },
113+ },
114+ },
115+ },
116+ schemas: {
117+ About: {
118+ type: "object",
119+ properties: {
120+ user: { $ref: "User" },
121+ },
122+ },
123+ User: {
124+ type: "object",
125+ properties: {
126+ emailAddress: { type: "string" },
127+ displayName: { type: "string" },
128+ },
129+ },
130+ },
131+ },
132+ },
133+ "google-tasks": {
134+ url: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest",
135+ document: {
136+ name: "tasks",
137+ version: "v1",
138+ title: "Google Tasks API",
139+ rootUrl: "https://tasks.googleapis.com/",
140+ servicePath: "",
141+ resources: {
142+ tasklists: {
143+ methods: {
144+ list: {
145+ id: "tasks.tasklists.list",
146+ path: "tasks/v1/users/@me/lists",
147+ httpMethod: "GET",
148+ response: { $ref: "TaskLists" },
149+ },
150+ },
151+ },
152+ },
153+ schemas: {
154+ TaskLists: {
155+ type: "object",
156+ properties: {
157+ items: {
158+ type: "array",
159+ items: { $ref: "TaskList" },
160+ },
161+ },
162+ },
163+ TaskList: {
164+ type: "object",
165+ properties: {
166+ id: { type: "string" },
167+ title: { type: "string" },
168+ },
169+ },
170+ },
171+ },
172+ },
173+} as const;
174+
175+const FROZEN_GOOGLE_SLUGS = [
176+ "google_calendar",
177+ "google_gmail",
178+ "google_sheets",
179+ "google_drive",
180+ "google_docs",
181+ "google_slides",
182+ "google_forms",
183+ "google_tasks",
184+ "google_people",
185+ "google_photos_library",
186+ "google_photos_picker",
187+ "google_chat",
188+ "google_keep",
189+ "google_youtube_data",
190+ "google_search_console",
191+ "google_classroom",
192+ "google_admin_directory",
193+ "google_admin_reports",
194+ "google_apps_script",
195+ "google_bigquery",
196+ "google_cloud_resource_manager",
197+] as const;
198+
199+it("keeps Select all limited to Google services that can use normal user OAuth", () => {
200+ const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id));
201+
202+ expect(standardIds).toContain("google-calendar");
203+ expect(standardIds).toContain("google-gmail");
204+ expect(standardIds).toContain("google-tasks");
205+ expect(standardIds).toContain("google-people");
206+ expect(standardIds).toContain("google-search-console");
207+
208+ expect(standardIds).not.toContain("google-youtube-data");
209+ expect(standardIds).not.toContain("google-cloud-resource-manager");
210+ expect(standardIds).not.toContain("google-chat");
211+ expect(standardIds).not.toContain("google-keep");
212+ expect(standardIds).not.toContain("google-admin-directory");
213+ expect(standardIds).not.toContain("google-admin-reports");
214+});
215+
216+it("classifies every Google service for bundle OAuth UX", () => {
217+ expect(
218+ googleOpenApiPresets.map((preset) => ({
219+ id: preset.id,
220+ oauthAudience: preset.oauthAudience,
221+ })),
222+ ).toMatchSnapshot();
223+});
224+
225+it("exports one catalog row per Google service with stable slugs and per-service OAuth", () => {
226+ expect(googleCatalog.map((preset) => preset.defaultSlug)).toEqual([...FROZEN_GOOGLE_SLUGS]);
227+ for (const preset of googleCatalog) {
228+ expect(preset.family).toBe("google");
229+ expect(preset.specFormat).toBe("google-discovery");
230+ expect(preset.defaultSlug).toBeTruthy();
231+ expect(preset.authTemplate).toHaveLength(1);
232+ const oauthTemplates = (preset.authTemplate ?? []).filter(
233+ (template) => template.kind === "oauth2",
234+ );
235+ expect(oauthTemplates).toHaveLength(1);
236+ expect(oauthTemplates[0]?.scopes).toEqual(
237+ expect.arrayContaining(["openid", "email", "profile"]),
238+ );
239+ }
240+});
241+
242+it.effect("resolves fixture-backed Google catalog health checks in converted specs", () =>
243+ Effect.gen(function* () {
244+ for (const [presetId, fixture] of Object.entries(googleHealthCheckDiscoveryFixtures)) {
245+ const preset = googleCatalog.find((candidate) => candidate.id === presetId);
246+ expect(preset, `${presetId} catalog row exists`).toBeTruthy();
247+ expect(preset?.healthCheck, `${presetId} declares a health check`).toBeTruthy();
248+
249+ const converted = yield* convertGoogleDiscoveryBundleToOpenApi({
250+ documents: [{ discoveryUrl: fixture.url, documentText: JSON.stringify(fixture.document) }],
251+ });
252+ const compiled = yield* compileOpenApiSpec(converted.specText);
253+ expect(compiled.definitions.map((definition) => definition.toolPath)).toContain(
254+ preset!.healthCheck!.operation,
255+ );
256+ }
257+ }),
258+);
259+
260+it("keeps Google catalog health checks as liveness probes without identity fields", () => {
261+ for (const preset of googleCatalog) {
262+ expect(
263+ preset.healthCheck?.identityField,
264+ `${preset.id} should not declare health identity extraction`,
265+ ).toBeUndefined();
266+ }
267+});
268+
269+it("omits Google health checks when the service spec has no stable cheap read", () => {
270+ const omitted = [
271+ "google-sheets",
272+ "google-docs",
273+ "google-slides",
274+ "google-forms",
275+ "google-photos-picker",
276+ "google-admin-reports",
277+ ];
278+
279+ for (const presetId of omitted) {
280+ const preset = googleCatalog.find((candidate) => candidate.id === presetId);
281+ expect(preset?.healthCheck, `${presetId} should not declare a health check`).toBeUndefined();
282+ }
283+});
packages/plugins/openapi/src/providers/google/presets.tsrenamed from packages/plugins/google/src/sdk/presets.ts+69−1
@@ -1,4 +1,6 @@
11 import { normalizeGoogleDiscoveryUrl } from "./discovery";
2+import { compactGoogleOAuthScopes } from "./oauth-scopes";
3+import type { HealthCheckSpec, IntegrationPreset } from "@executor-js/sdk/core";
24
35 export interface GooglePreset {
46 readonly id: string;
@@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines
238240 // The picker shows the OAuth consent a user is about to grant BEFORE connecting
239241 // (the "View scopes" panel), but the authoritative scope list only exists in
240242 // each API's live Discovery document, which the add flow fetches lazily at
241−// `addBundle` time. To preview consent without N network round-trips, each preset
243+// provider add time. To preview consent without N network round-trips, each preset
242244 // declares the broad top-level scope(s) a full integration grants. These flow
243245 // through `googleOAuthConsentBatches` (which compacts sub-scopes under their
244246 // 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
276278 export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
277279 googleOAuthConsentScopes[presetId] ?? [];
278280
281+export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_");
282+
283+const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
284+const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
285+const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
286+const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"];
287+const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = {
288+ "google-calendar": { operation: "calendar.calendarList.list" },
289+ "google-gmail": {
290+ operation: "gmail.users.labels.list",
291+ args: { userId: "me" },
292+ },
293+ "google-drive": {
294+ operation: "drive.about.get",
295+ args: { fields: "user" },
296+ },
297+ "google-tasks": { operation: "tasks.tasklists.list" },
298+ "google-people": {
299+ operation: "people.people.get",
300+ args: { resourceName: "people/me", personFields: "emailAddresses" },
301+ },
302+ "google-photos-library": { operation: "photoslibrary.albums.list" },
303+ "google-chat": { operation: "chat.spaces.list" },
304+ "google-keep": { operation: "keep.notes.list" },
305+ "google-youtube-data": {
306+ operation: "youtube.channels.list",
307+ args: { part: "id", mine: true },
308+ },
309+ "google-search-console": { operation: "webmasters.sites.list" },
310+ "google-classroom": { operation: "classroom.courses.list" },
311+ "google-admin-directory": {
312+ operation: "directory.users.list",
313+ args: { customer: "my_customer", maxResults: 1 },
314+ },
315+ "google-apps-script": { operation: "script.processes.list" },
316+ "google-bigquery": { operation: "bigquery.projects.list" },
317+ "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" },
318+};
319+
320+const googleCatalogAuthTemplate = (presetId: string) => [
321+ {
322+ slug: GOOGLE_OAUTH_SECURITY_SCHEME,
323+ kind: "oauth2" as const,
324+ authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
325+ tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
326+ scopes: compactGoogleOAuthScopes([
327+ ...GOOGLE_IDENTITY_SCOPES,
328+ ...googleOAuthConsentScopesForPreset(presetId),
329+ ]),
330+ },
331+];
332+
333+export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({
334+ id: preset.id,
335+ name: preset.name,
336+ summary: preset.summary,
337+ ...(preset.url ? { url: preset.url } : {}),
338+ ...(preset.icon ? { icon: preset.icon } : {}),
339+ ...(preset.featured ? { featured: preset.featured } : {}),
340+ family: "google",
341+ specFormat: "google-discovery",
342+ defaultSlug: googleServiceSlug(preset.id),
343+ authTemplate: googleCatalogAuthTemplate(preset.id),
344+ ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
345+}));
346+
279347 // ---------------------------------------------------------------------------
280348 // Resolve a stored/normalized Discovery URL back to its preset, so a bundled
281349 // `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 @@
1+import { expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6+import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7+import type { AuthenticationInput } from "@executor-js/plugin-openapi";
8+
9+import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
10+import { googleCatalog } from "./presets";
11+
12+const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
13+
14+const tasksDiscoveryDoc = {
15+ name: "tasks",
16+ version: "v1",
17+ title: "Google Tasks API",
18+ description: "Manage your tasks and task lists.",
19+ rootUrl: "https://tasks.googleapis.com/",
20+ servicePath: "",
21+ auth: {
22+ oauth2: {
23+ scopes: {
24+ "https://www.googleapis.com/auth/tasks": {
25+ description: "Create, edit, organize, and delete all your tasks.",
26+ },
27+ },
28+ },
29+ },
30+ methods: {
31+ tasklistsList: {
32+ id: "tasks.tasklists.list",
33+ httpMethod: "GET",
34+ path: "tasks/v1/users/@me/lists",
35+ scopes: ["https://www.googleapis.com/auth/tasks"],
36+ response: { $ref: "TaskLists" },
37+ },
38+ },
39+ schemas: {
40+ TaskLists: {
41+ type: "object",
42+ properties: {
43+ items: {
44+ type: "array",
45+ items: { $ref: "TaskList" },
46+ },
47+ },
48+ },
49+ TaskList: {
50+ type: "object",
51+ properties: {
52+ id: { type: "string" },
53+ title: { type: "string" },
54+ },
55+ },
56+ },
57+};
58+
59+const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
60+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
61+ Effect.succeed(
62+ HttpClientResponse.fromWeb(
63+ request,
64+ new Response(JSON.stringify(tasksDiscoveryDoc), {
65+ status: request.url === TASKS_URL ? 200 : 404,
66+ headers: { "content-type": "application/json" },
67+ }),
68+ ),
69+ ),
70+ ),
71+);
72+
73+it.effect("fetches and converts a Google Discovery document", () =>
74+ Effect.gen(function* () {
75+ const converted = yield* googleDiscoveryAdapter.fetch({
76+ urls: [TASKS_URL],
77+ httpClientLayer: discoveryHttpClientLayer,
78+ });
79+ const parsed = yield* parse(converted.specText);
80+
81+ expect(parsed.info.title).toBe("Google");
82+ expect(Object.keys(parsed.paths ?? {})).toContain("/tasks/v1/users/@me/lists");
83+ expect(converted.authenticationTemplate?.[0]?.kind).toBe("oauth2");
84+ }),
85+);
86+
87+it("derives Google Discovery identity from the raw document", () => {
88+ expect(deriveGoogleDiscoveryIdentity(tasksDiscoveryDoc)).toEqual({
89+ slug: "google_tasks",
90+ name: "Google Tasks API",
91+ description: "Manage your tasks and task lists.",
92+ });
93+});
94+
95+it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived identity", () =>
96+ Effect.gen(function* () {
97+ const executor = yield* createExecutor(
98+ makeTestConfig({
99+ plugins: [
100+ openApiPlugin({
101+ httpClientLayer: discoveryHttpClientLayer,
102+ specFormats: [googleDiscoveryAdapter],
103+ }),
104+ memoryCredentialsPlugin(),
105+ ],
106+ }),
107+ );
108+
109+ const added = yield* executor.openapi.addSpec({
110+ spec: { kind: "url", url: TASKS_URL },
111+ specFormat: "google-discovery",
112+ });
113+ const integration = yield* executor.openapi.getIntegration("google_tasks");
114+
115+ expect(String(added.slug)).toBe("google_tasks");
116+ expect(integration?.slug).toEqual(IntegrationSlug.make("google_tasks"));
117+ expect(added.toolCount).toBe(1);
118+ }),
119+);
120+
121+it.effect(
122+ "adds a Google catalog preset through OpenAPI with family, format, and default slug",
123+ () =>
124+ Effect.gen(function* () {
125+ const tasksPreset = googleCatalog.find((preset) => preset.defaultSlug === "google_tasks")!;
126+ const authTemplate: readonly AuthenticationInput[] = (tasksPreset.authTemplate ?? []).flatMap(
127+ (template) => (template.kind === "oauth2" ? [template] : []),
128+ );
129+ const executor = yield* createExecutor(
130+ makeTestConfig({
131+ plugins: [
132+ openApiPlugin({
133+ httpClientLayer: discoveryHttpClientLayer,
134+ presets: [tasksPreset],
135+ specFormats: [googleDiscoveryAdapter],
136+ }),
137+ memoryCredentialsPlugin(),
138+ ],
139+ }),
140+ );
141+
142+ const added = yield* executor.openapi.addSpec({
143+ spec: { kind: "url", url: tasksPreset.url! },
144+ slug: tasksPreset.defaultSlug,
145+ specFormat: tasksPreset.specFormat,
146+ family: tasksPreset.family,
147+ authenticationTemplate: authTemplate,
148+ });
149+ const config = yield* executor.openapi.getConfig("google_tasks");
150+
151+ expect(String(added.slug)).toBe("google_tasks");
152+ expect(config?.family).toBe("google");
153+ expect(config?.specFormat).toBe("google-discovery");
154+ expect(config?.authenticationTemplate?.[0]?.kind).toBe("oauth2");
155+ const storedOAuthTemplates = (config?.authenticationTemplate ?? []).filter(
156+ (template) => template.kind === "oauth2",
157+ );
158+ expect(storedOAuthTemplates[0]?.scopes).toEqual(
159+ expect.arrayContaining([
160+ "openid",
161+ "email",
162+ "profile",
163+ "https://www.googleapis.com/auth/tasks",
164+ ]),
165+ );
166+ }),
167+);
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+85−0
@@ -0,0 +1,85 @@
1+import { Effect, Schema } from "effect";
2+
3+import { OpenApiParseError } from "../../sdk/errors";
4+import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-format";
5+
6+import {
7+ convertGoogleDiscoveryBundleToOpenApi,
8+ fetchGoogleDiscoveryDocument,
9+ normalizeGoogleDiscoveryUrl,
10+} from "./discovery";
11+
12+const isRecord = (value: unknown): value is Record<string, unknown> =>
13+ typeof value === "object" && value !== null && !Array.isArray(value);
14+
15+const parseJson = (text: string) =>
16+ Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe(
17+ Effect.mapError(
18+ () =>
19+ new OpenApiParseError({
20+ message: "Failed to parse Google Discovery document",
21+ }),
22+ ),
23+ );
24+
25+const googleDiscoverySlug = (service: string): string =>
26+ `google_${service
27+ .trim()
28+ .toLowerCase()
29+ .replace(/[^a-z0-9]+/g, "_")
30+ .replace(/^_+|_+$/g, "")}`;
31+
32+export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | null => {
33+ if (!isRecord(doc)) return null;
34+ const rawName = typeof doc.name === "string" ? doc.name.trim() : "";
35+ if (!rawName) return null;
36+ const title = typeof doc.title === "string" && doc.title.trim() ? doc.title.trim() : rawName;
37+ const description =
38+ typeof doc.description === "string" && doc.description.trim()
39+ ? doc.description.trim()
40+ : undefined;
41+ return {
42+ slug: googleDiscoverySlug(rawName),
43+ name: title,
44+ ...(description ? { description } : {}),
45+ };
46+};
47+
48+export const googleDiscoveryAdapter: SpecFormatAdapter = {
49+ id: "google-discovery",
50+ fetch: (input) =>
51+ Effect.gen(function* () {
52+ const documents = yield* Effect.forEach(
53+ input.urls,
54+ (url) =>
55+ fetchGoogleDiscoveryDocument(url, input.credentials).pipe(
56+ Effect.provide(input.httpClientLayer),
57+ Effect.map((documentText) => ({
58+ discoveryUrl: normalizeGoogleDiscoveryUrl(url) ?? url,
59+ documentText,
60+ })),
61+ ),
62+ { concurrency: 4 },
63+ );
64+ const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents });
65+ const document =
66+ documents.length === 1
67+ ? yield* parseJson(documents[0]!.documentText)
68+ : yield* Effect.forEach(documents, (item) =>
69+ parseJson(item.documentText).pipe(
70+ Effect.map((parsed) => ({
71+ discoveryUrl: item.discoveryUrl,
72+ document: parsed,
73+ })),
74+ ),
75+ );
76+ return {
77+ specText: conversion.specText,
78+ sourceUrl: documents[0]?.discoveryUrl,
79+ baseUrl: conversion.baseUrl,
80+ authenticationTemplate: conversion.authenticationTemplate,
81+ document,
82+ };
83+ }),
84+ deriveIdentity: deriveGoogleDiscoveryIdentity,
85+};
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
88 } from "@executor-js/plugin-openapi";
99
1010 import { microsoftGraphKeepPathItem } from "./graph";
11+import {
12+ microsoftGraphExactPathsForPresetIds,
13+ microsoftGraphPathPrefixesForPresetIds,
14+ microsoftGraphTagPrefixesForPresetIds,
15+} from "./presets";
1116
1217 // Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every
1318 // success response uses the OpenAPI wildcard status key `2XX` (the real spec
@@ -220,6 +225,29 @@ paths: · 206 unmodified lines
220225 components: {}
221226 `;
222227
228+const driveAndWorkbookFixture = `
229+openapi: 3.0.4
230+info:
231+ title: Microsoft Graph Fixture
232+ version: v1.0
233+servers:
234+ - url: https://graph.microsoft.com/v1.0
235+paths:
236+ /drives/{drive-id}/items/{driveItem-id}:
237+ get:
238+ operationId: drives.GetItems
239+ responses:
240+ "200":
241+ description: OK
242+ /drives/{drive-id}/items/{driveItem-id}/workbook/worksheets:
243+ get:
244+ operationId: drives.GetItemsWorkbookWorksheets
245+ responses:
246+ "200":
247+ description: OK
248+components: {}
249+`;
250+
223251 const fullGraphSelection = {
224252 coversFullGraph: true,
225253 presetIds: [],
@@ -268,6 +296,36 @@ const keptIdentityFixturePaths = ( · 42 unmodified lines
268296 return kept;
269297 };
270298
299+const presetSelection = (presetIds: readonly string[]): MicrosoftGraphKeepPathItemSelection => ({
300+ coversFullGraph: false,
301+ presetIds,
302+ customScopes: [],
303+ exactPaths: microsoftGraphExactPathsForPresetIds(presetIds),
304+ pathPrefixes: microsoftGraphPathPrefixesForPresetIds(presetIds),
305+ tagPrefixes: microsoftGraphTagPrefixesForPresetIds(presetIds),
306+});
307+
308+const keptOperationIds = (
309+ fixture: string,
310+ selection: MicrosoftGraphKeepPathItemSelection,
311+): readonly string[] => {
312+ const structure = structuralSplit(fixture);
313+ expect(structure).not.toBeNull();
314+ const keepPathItem = microsoftGraphKeepPathItem(selection);
315+
316+ return structure!.pathItems.flatMap((range) => {
317+ const entry = parseEntry(structure!.text, range, 2);
318+ expect(entry).not.toBeNull();
319+ const [path, rawPathItem] = entry!;
320+ const pathItem = keepPathItem(path, rawPathItem as Record<string, unknown>);
321+ if (!pathItem) return [];
322+ return Object.values(pathItem as Record<string, unknown>).flatMap((operation) => {
323+ const operationId = (operation as { readonly operationId?: unknown }).operationId;
324+ return typeof operationId === "string" ? [operationId] : [];
325+ });
326+ });
327+};
328+
271329 const keptPathItem = (fixture: string): Record<string, unknown> => {
272330 const structure = structuralSplit(fixture);
273331 expect(structure).not.toBeNull();
@@ -338,6 +396,16 @@ it("keeps all /me operations when profile selects the path", () => { · 64 unmodified lines
338396 expect(kept.get("/me")?.patch).toMatchObject({ operationId: "me.UpdateUser" });
339397 });
340398
399+it("matches fresh-add ownership for overlapping files and excel drive paths", () => {
400+ expect(keptOperationIds(driveAndWorkbookFixture, presetSelection(["files"]))).toEqual([
401+ "drives.GetItems",
402+ "drives.GetItemsWorkbookWorksheets",
403+ ]);
404+ expect(keptOperationIds(driveAndWorkbookFixture, presetSelection(["excel"]))).toEqual([
405+ "drives.GetItemsWorkbookWorksheets",
406+ ]);
407+});
408+
341409 it("keeps already-binary drive content responses untouched", () => {
342410 const pathItem = keptPathItem(driveContentFixture);
343411
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
33 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44
55 import { AuthTemplateSlug } from "@executor-js/sdk/core";
6+import { AuthenticationSchema, type OpenApiIntegrationConfig } from "../../sdk/config";
7+import { OpenApiParseError } from "../../sdk/errors";
68 import {
7− AuthenticationSchema,
8− OpenApiParseError,
99 parseEntry,
1010 parseHead,
1111 parseSmallComponents,
1212 structuralSplit,
13− type Authentication,
1413 type KeepPathItem,
15− type OpenApiIntegrationConfig,
1614 type SpecStructure,
17−} from "@executor-js/plugin-openapi";
15+} from "../../sdk/split";
16+import type { Authentication } from "../../sdk/types";
1817
1918 import {
2019 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
1313 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
1414 MICROSOFT_GRAPH_PRESET_ID,
1515 MICROSOFT_TOKEN_URL,
16+ microsoftCatalog,
1617 microsoftGraphExactPathsForPresetIds,
1718 microsoftGraphPathPrefixesForPresetIds,
1819 microsoftGraphPreset,
@@ -21,6 +22,7 @@ export { · 2 unmodified lines
2122 microsoftGraphScopePresets,
2223 microsoftGraphScopesForPresetIds,
2324 microsoftGraphTagPrefixesForPresetIds,
25+ microsoftServiceSlug,
2426 type MicrosoftGraphPreset,
2527 type MicrosoftGraphScopeAudience,
2628 type MicrosoftGraphScopePreset,
@@ -36,12 +38,4 @@ export { · 9 unmodified lines
3638 type MicrosoftGraphSelectionInput,
3739 type MicrosoftGraphSpecBuild,
3840 } from "./graph";
39−export {
40− microsoftPlugin,
41− type MicrosoftConfigureInput,
42− type MicrosoftGraphConfig,
43− type MicrosoftPluginExtension,
44− type MicrosoftPluginOptions,
45− type MicrosoftUpdateInput,
46− type MicrosoftUpdateResult,
47−} from "./plugin";
41+export { microsoftGraphAdapter } from "./spec-format-adapter";
packages/plugins/openapi/src/providers/microsoft/presets.test.ts+332−0
@@ -0,0 +1,332 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+import { parseEntry, structuralSplit, type KeepPathItem } from "@executor-js/plugin-openapi";
5+
6+import { microsoftGraphAdapter } from "./spec-format-adapter";
7+import {
8+ MICROSOFT_GRAPH_ALL_PRESET_IDS,
9+ MICROSOFT_GRAPH_BASE_SCOPES,
10+ MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
11+ MICROSOFT_GRAPH_OPENAPI_URL,
12+ microsoftCatalog,
13+ microsoftGraphExactPathsForPresetIds,
14+ microsoftGraphPathPrefixesForPresetIds,
15+ microsoftGraphPresetIdsCoverFullGraph,
16+ microsoftGraphScopePresets,
17+ microsoftGraphScopesForPresetIds,
18+ microsoftGraphTagPrefixesForPresetIds,
19+} from "./presets";
20+
21+const graphHealthCheckFixture = `
22+openapi: 3.0.4
23+info:
24+ title: Microsoft Graph Fixture
25+ version: v1.0
26+servers:
27+ - url: https://graph.microsoft.com/v1.0
28+paths:
29+ /me:
30+ get:
31+ operationId: me.GetUser
32+ security:
33+ - azureAdDelegated:
34+ - User.Read
35+ responses:
36+ "200":
37+ description: OK
38+ content:
39+ application/json:
40+ schema:
41+ $ref: '#/components/schemas/microsoft.graph.user'
42+ /me/messages:
43+ get:
44+ operationId: me.messages.ListMessages
45+ security:
46+ - azureAdDelegated:
47+ - Mail.ReadWrite
48+ responses:
49+ "200":
50+ description: OK
51+components:
52+ schemas:
53+ microsoft.graph.user:
54+ type: object
55+ properties:
56+ mail:
57+ type: string
58+`;
59+
60+const graphHealthCheckHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
61+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
62+ Effect.succeed(
63+ HttpClientResponse.fromWeb(
64+ request,
65+ new Response(
66+ request.url === MICROSOFT_GRAPH_OPENAPI_URL ? graphHealthCheckFixture : "not found",
67+ {
68+ status: request.url === MICROSOFT_GRAPH_OPENAPI_URL ? 200 : 404,
69+ headers: { "content-type": "application/yaml" },
70+ },
71+ ),
72+ ),
73+ ),
74+ ),
75+);
76+
77+const operationIdsFromStructure = (
78+ specText: string,
79+ keepPathItem: KeepPathItem,
80+): readonly string[] => {
81+ const structure = structuralSplit(specText);
82+ expect(structure, "fixture structurally splits").not.toBeNull();
83+ return structure!.pathItems.flatMap((range) => {
84+ const entry = parseEntry(structure!.text, range, 2);
85+ expect(entry, "fixture path item parses").not.toBeNull();
86+ const [path, rawPathItem] = entry!;
87+ const pathItem = keepPathItem(path, rawPathItem as Record<string, unknown>);
88+ if (!pathItem) return [];
89+ return Object.values(pathItem as Record<string, unknown>).flatMap((operation) => {
90+ const operationId = (operation as { readonly operationId?: unknown }).operationId;
91+ return typeof operationId === "string" ? [operationId] : [];
92+ });
93+ });
94+};
95+
96+const FROZEN_MICROSOFT_SLUGS = [
97+ "microsoft_profile",
98+ "microsoft_me_surface",
99+ "microsoft_mail",
100+ "microsoft_calendar",
101+ "microsoft_contacts",
102+ "microsoft_tasks",
103+ "microsoft_planner",
104+ "microsoft_files",
105+ "microsoft_excel",
106+ "microsoft_sites",
107+ "microsoft_onenote",
108+ "microsoft_teams_chat",
109+ "microsoft_teams_channels",
110+ "microsoft_meetings_calls",
111+ "microsoft_users",
112+ "microsoft_groups",
113+ "microsoft_directory",
114+ "microsoft_applications",
115+ "microsoft_identity",
116+ "microsoft_admin_reports",
117+ "microsoft_security_compliance",
118+ "microsoft_devices",
119+ "microsoft_education",
120+ "microsoft_search",
121+ "microsoft_external_connections",
122+ "microsoft_solutions",
123+ "microsoft_platform_services",
124+] as const;
125+
126+describe("Microsoft Graph scope presets", () => {
127+ it("keeps default workload ids backed by categorized presets", () => {
128+ const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
129+ expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.every((id) => ids.has(id))).toBe(true);
130+ expect(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS).toEqual([
131+ "profile",
132+ "mail",
133+ "calendar",
134+ "contacts",
135+ "tasks",
136+ "files",
137+ "excel",
138+ "sites",
139+ "onenote",
140+ "teams-chat",
141+ "teams-channels",
142+ "meetings-calls",
143+ ]);
144+ expect(ids.has("all")).toBe(false);
145+ });
146+
147+ it("keeps the full workload ids backed by every categorized preset", () => {
148+ const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
149+ expect(MICROSOFT_GRAPH_ALL_PRESET_IDS.every((id) => ids.has(id))).toBe(true);
150+ expect(MICROSOFT_GRAPH_ALL_PRESET_IDS).toEqual(
151+ microsoftGraphScopePresets.map((preset) => preset.id),
152+ );
153+ });
154+
155+ it("detects when the categorized catalog covers full Graph", () => {
156+ expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_ALL_PRESET_IDS)).toBe(true);
157+ expect(microsoftGraphPresetIdsCoverFullGraph(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS)).toBe(false);
158+ expect(microsoftGraphPresetIdsCoverFullGraph(["profile", "mail"])).toBe(false);
159+ });
160+
161+ it("unions selected preset scopes with base and custom scopes", () => {
162+ expect(microsoftGraphScopesForPresetIds(["profile", "mail"], ["Sites.Read.All"])).toEqual([
163+ ...MICROSOFT_GRAPH_BASE_SCOPES,
164+ "User.Read",
165+ "Mail.ReadWrite",
166+ "Mail.Send",
167+ "MailboxSettings.ReadWrite",
168+ "Sites.Read.All",
169+ ]);
170+ });
171+
172+ it("includes User.Read for identity when profile is not selected", () => {
173+ expect(microsoftGraphScopesForPresetIds(["mail"])).toEqual([
174+ ...MICROSOFT_GRAPH_BASE_SCOPES,
175+ "User.Read",
176+ "Mail.ReadWrite",
177+ "Mail.Send",
178+ "MailboxSettings.ReadWrite",
179+ ]);
180+ });
181+
182+ it("returns path filters for the selected workloads", () => {
183+ expect(microsoftGraphExactPathsForPresetIds(["profile"])).toContain("/me");
184+ expect(microsoftGraphPathPrefixesForPresetIds(["mail"])).toContain("/me/messages");
185+ expect(microsoftGraphTagPrefixesForPresetIds(["mail"])).toEqual([]);
186+ });
187+
188+ it("covers Microsoft Graph root surfaces through category presets", () => {
189+ const prefixes = new Set(
190+ microsoftGraphPathPrefixesForPresetIds(MICROSOFT_GRAPH_ALL_PRESET_IDS),
191+ );
192+ for (const root of [
193+ "/agreementAcceptances",
194+ "/agreements",
195+ "/admin",
196+ "/appCatalogs",
197+ "/applicationTemplates",
198+ "/applications",
199+ "/applications(appId='{appId}')",
200+ "/applications(uniqueName='{uniqueName}')",
201+ "/authenticationMethodConfigurations",
202+ "/authenticationMethodsPolicy",
203+ "/auditLogs",
204+ "/certificateBasedAuthConfiguration",
205+ "/chats",
206+ "/communications",
207+ "/compliance",
208+ "/connections",
209+ "/contacts",
210+ "/contracts",
211+ "/copilot",
212+ "/dataPolicyOperations",
213+ "/deviceAppManagement",
214+ "/deviceManagement",
215+ "/devices",
216+ "/devices(deviceId='{deviceId}')",
217+ "/directory",
218+ "/directoryObjects",
219+ "/directoryRoleTemplates",
220+ "/directoryRoles",
221+ "/directoryRoles(roleTemplateId='{roleTemplateId}')",
222+ "/domainDnsRecords",
223+ "/domains",
224+ "/drives",
225+ "/education",
226+ "/employeeExperience",
227+ "/external",
228+ "/filterOperators",
229+ "/functions",
230+ "/groupLifecyclePolicies",
231+ "/groupSettingTemplates",
232+ "/groupSettings",
233+ "/groups",
234+ "/groups(uniqueName='{uniqueName}')",
235+ "/identity",
236+ "/identityGovernance",
237+ "/identityProviders",
238+ "/identityProtection",
239+ "/informationProtection",
240+ "/invitations",
241+ "/me",
242+ "/oauth2PermissionGrants",
243+ "/organization",
244+ "/permissionGrants",
245+ "/places",
246+ "/planner",
247+ "/policies",
248+ "/print",
249+ "/privacy",
250+ "/reports",
251+ "/roleManagement",
252+ "/schemaExtensions",
253+ "/scopedRoleMemberships",
254+ "/search",
255+ "/security",
256+ "/servicePrincipals",
257+ "/servicePrincipals(appId='{appId}')",
258+ "/shares",
259+ "/sites",
260+ "/solutions",
261+ "/storage",
262+ "/subscribedSkus",
263+ "/subscriptions",
264+ "/teamwork",
265+ "/teams",
266+ "/teamsTemplates",
267+ "/tenantRelationships",
268+ "/users",
269+ "/users(userPrincipalName='{userPrincipalName}')",
270+ ]) {
271+ expect(prefixes.has(root)).toBe(true);
272+ }
273+ });
274+
275+ it("declares product icons for each workload", () => {
276+ for (const preset of microsoftGraphScopePresets) {
277+ expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/);
278+ }
279+ });
280+
281+ it("exports one catalog row per workload with stable slugs and Graph auth templates", () => {
282+ expect(microsoftCatalog.map((preset) => preset.defaultSlug)).toEqual([
283+ ...FROZEN_MICROSOFT_SLUGS,
284+ ]);
285+ for (const preset of microsoftCatalog) {
286+ expect(preset.family).toBe("microsoft");
287+ expect(preset.specFormat).toBe("microsoft-graph");
288+ expect(preset.defaultSlug).toBeTruthy();
289+ expect(preset.authTemplate).toHaveLength(2);
290+ expect(preset.authTemplate?.map((template) => template.kind)).toEqual(["oauth2", "oauth2"]);
291+ }
292+ });
293+
294+ it.effect("resolves fixture-backed Microsoft catalog health checks in workload splits", () =>
295+ Effect.gen(function* () {
296+ const fixtureCases = [
297+ { presetId: "profile", expectedOperation: "me.GetUser" },
298+ { presetId: "mail", expectedOperation: undefined },
299+ ] as const;
300+
301+ for (const { presetId, expectedOperation } of fixtureCases) {
302+ const preset = microsoftCatalog.find(
303+ (candidate) => candidate.id === `microsoft-${presetId}`,
304+ );
305+ expect(preset, `${presetId} catalog row exists`).toBeTruthy();
306+ expect(preset?.healthCheck?.operation, `${presetId} catalog health check`).toBe(
307+ expectedOperation,
308+ );
309+
310+ const converted = yield* microsoftGraphAdapter.fetch({
311+ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${presetId}`],
312+ httpClientLayer: graphHealthCheckHttpClientLayer,
313+ });
314+ expect(converted.keepPathItem, `${presetId} keeps a workload split`).toBeTruthy();
315+ const operationIds = operationIdsFromStructure(converted.specText, converted.keepPathItem!);
316+ expect(
317+ expectedOperation === undefined || operationIds.includes(expectedOperation),
318+ `${presetId} fixture resolves declared health check`,
319+ ).toBe(true);
320+ }
321+ }),
322+ );
323+
324+ it("keeps Microsoft catalog health checks as liveness probes without identity fields", () => {
325+ for (const preset of microsoftCatalog) {
326+ expect(
327+ preset.healthCheck?.identityField,
328+ `${preset.id} should not declare health identity extraction`,
329+ ).toBeUndefined();
330+ }
331+ });
332+});
packages/plugins/openapi/src/providers/microsoft/presets.tsrenamed from packages/plugins/microsoft/src/sdk/presets.ts+41−0
@@ -1,3 +1,5 @@
1+import type { IntegrationPreset } from "@executor-js/sdk/core";
2+
13 export interface MicrosoftGraphPreset {
24 readonly id: string;
35 readonly name: string;
@@ -522,3 +524,42 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines
522524 orderedUnique(
523525 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),
524526 );
527+
528+export const microsoftServiceSlug = (presetId: string): string =>
529+ `microsoft_${presetId.replaceAll("-", "_")}`;
530+
531+const microsoftGraphCatalogUrl = (presetId: string): string =>
532+ `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`;
533+
534+const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => [
535+ {
536+ slug: MICROSOFT_AUTH_TEMPLATE_SLUG,
537+ kind: "oauth2" as const,
538+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
539+ tokenUrl: MICROSOFT_TOKEN_URL,
540+ scopes: microsoftGraphScopesForPresetIds([preset.id]),
541+ },
542+ {
543+ slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
544+ kind: "oauth2" as const,
545+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
546+ tokenUrl: MICROSOFT_TOKEN_URL,
547+ scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
548+ },
549+];
550+
551+export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScopePresets.map(
552+ (preset) => ({
553+ id: `microsoft-${preset.id}`,
554+ name: preset.name,
555+ summary: preset.summary,
556+ url: microsoftGraphCatalogUrl(preset.id),
557+ ...(preset.icon ? { icon: preset.icon } : {}),
558+ ...(preset.featured ? { featured: preset.featured } : {}),
559+ family: "microsoft",
560+ specFormat: "microsoft-graph",
561+ defaultSlug: microsoftServiceSlug(preset.id),
562+ authTemplate: microsoftGraphCatalogAuthTemplate(preset),
563+ ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),
564+ }),
565+);
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+92−0
@@ -0,0 +1,92 @@
1+import { expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+import { structuralSplit } from "@executor-js/plugin-openapi";
5+
6+import { microsoftGraphAdapter } from "./spec-format-adapter";
7+import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
8+
9+const graphFixture = `
10+openapi: 3.0.4
11+info:
12+ title: Microsoft Graph Fixture
13+ version: v1.0
14+servers:
15+ - url: https://graph.microsoft.com/v1.0
16+paths:
17+ /me:
18+ get:
19+ operationId: me.GetUser
20+ security:
21+ - azureAdDelegated:
22+ - User.Read
23+ responses:
24+ "200":
25+ description: OK
26+ /irrelevant:
27+ get:
28+ operationId: irrelevant.Get
29+ security:
30+ - azureAdDelegated:
31+ - Directory.Read.All
32+ responses:
33+ "200":
34+ description: OK
35+components:
36+ securitySchemes:
37+ azureAdDelegated:
38+ type: oauth2
39+ flows:
40+ authorizationCode:
41+ authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
42+ tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token
43+ scopes:
44+ User.Read: Read user profile
45+`;
46+
47+const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
48+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
49+ Effect.succeed(
50+ HttpClientResponse.fromWeb(
51+ request,
52+ new Response(request.url === MICROSOFT_GRAPH_OPENAPI_URL ? graphFixture : "not found", {
53+ status: request.url === MICROSOFT_GRAPH_OPENAPI_URL ? 200 : 404,
54+ headers: { "content-type": "application/yaml" },
55+ }),
56+ ),
57+ ),
58+ ),
59+);
60+
61+it.effect("wraps Microsoft Graph structural split with a streaming keep filter", () =>
62+ Effect.gen(function* () {
63+ const converted = yield* microsoftGraphAdapter.fetch({
64+ urls: [MICROSOFT_GRAPH_OPENAPI_URL],
65+ httpClientLayer: graphHttpClientLayer,
66+ });
67+ const structure = structuralSplit(converted.specText);
68+ expect(structure).not.toBeNull();
69+ const keepPathItem = converted.keepPathItem!;
70+
71+ expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({
72+ get: { operationId: "me.GetUser" },
73+ });
74+ expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull();
75+ expect(structure!.pathItems.length).toBe(2);
76+ }),
77+);
78+
79+it.effect("uses catalog URL fragments to select one Graph workload", () =>
80+ Effect.gen(function* () {
81+ const converted = yield* microsoftGraphAdapter.fetch({
82+ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`],
83+ httpClientLayer: graphHttpClientLayer,
84+ });
85+ const keepPathItem = converted.keepPathItem!;
86+
87+ expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({
88+ get: { operationId: "me.GetUser" },
89+ });
90+ expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull();
91+ }),
92+);
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+52−0
@@ -0,0 +1,52 @@
1+import { Effect } from "effect";
2+
3+import type { SpecFormatAdapter } from "../../sdk/spec-format";
4+
5+import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6+
7+const graphCatalogSelection = (
8+ rawUrl: string | undefined,
9+): { readonly specUrl?: string; readonly presetIds?: readonly string[] } => {
10+ if (!rawUrl || !URL.canParse(rawUrl)) return rawUrl ? { specUrl: rawUrl } : {};
11+ const parsed = new URL(rawUrl);
12+ const preset = parsed.hash.startsWith("#preset=")
13+ ? decodeURIComponent(parsed.hash.slice("#preset=".length))
14+ : "";
15+ parsed.hash = "";
16+ return {
17+ specUrl: parsed.toString(),
18+ ...(preset.length > 0 ? { presetIds: [preset] } : {}),
19+ };
20+};
21+
22+export const microsoftGraphAdapter: SpecFormatAdapter = {
23+ id: "microsoft-graph",
24+ fetch: (input) =>
25+ buildMicrosoftGraphOpenApiSpec(
26+ graphCatalogSelection(input.urls[0]),
27+ input.httpClientLayer,
28+ ).pipe(
29+ Effect.map((graphSpec) => ({
30+ specText: graphSpec.specText,
31+ sourceUrl: graphSpec.specUrl,
32+ baseUrl: graphSpec.baseUrl,
33+ authenticationTemplate: graphSpec.authenticationTemplate,
34+ // Stream the full Graph source straight to persisted bindings. This is
35+ // the measured Workers contention/OOM path from the Microsoft plugin:
36+ // structural split stays serial and avoids materializing the 37MB tree.
37+ keepPathItem: microsoftGraphKeepPathItem(graphSpec),
38+ config: {
39+ microsoftGraphPresetIds: graphSpec.presetIds,
40+ microsoftGraphCustomScopes: graphSpec.customScopes,
41+ microsoftGraphScopes: graphSpec.scopes,
42+ microsoftGraphExactPaths: graphSpec.exactPaths,
43+ microsoftGraphPathPrefixes: graphSpec.pathPrefixes,
44+ microsoftGraphTagPrefixes: graphSpec.tagPrefixes,
45+ microsoftGraphCoversFullGraph: graphSpec.coversFullGraph,
46+ microsoftGraphAuthorizationUrl: graphSpec.authorizationUrl,
47+ microsoftGraphTokenUrl: graphSpec.tokenUrl,
48+ microsoftGraphClientCredentialsTokenUrl: graphSpec.clientCredentialsTokenUrl,
49+ },
50+ })),
51+ ),
52+};
packages/plugins/openapi/src/react/AddOpenApiSource.test.ts+68−1
@@ -1,6 +1,20 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import * as React from "react";
23
3−import { baseUrlFromSpecInput, openApiPreviewFailureMessage } from "./AddOpenApiSource";
4+import {
5+ AddOpenApiHealthCheckSection,
6+ baseUrlFromSpecInput,
7+ openApiPreviewFailureMessage,
8+} from "./AddOpenApiSource";
9+
10+const visibleText = (node: React.ReactNode): string => {
11+ if (node === null || node === undefined || typeof node === "boolean") return "";
12+ if (typeof node === "string" || typeof node === "number") return String(node);
13+ if (Array.isArray(node)) return node.map(visibleText).join("");
14+ if (!React.isValidElement(node)) return "";
15+ const props = node.props as { readonly children?: React.ReactNode };
16+ return visibleText(props.children);
17+};
418
519 describe("baseUrlFromSpecInput", () => {
620 it("defaults URL-hosted specs to their origin", () => {
@@ -27,3 +41,56 @@ describe("openApiPreviewFailureMessage", () => { · 20 unmodified lines
2741 );
2842 });
2943 });
44+
45+describe("AddOpenApiHealthCheckSection", () => {
46+ const candidate = {
47+ operation: "getCurrentUser",
48+ method: "get",
49+ requiredArgCount: 0,
50+ destructive: false,
51+ summary: "Get current user",
52+ parameters: [],
53+ responseFields: [{ path: "email", type: "string" }],
54+ };
55+
56+ it("renders a read-only catalog line when the preset has a health check", () => {
57+ const text = visibleText(
58+ AddOpenApiHealthCheckSection({
59+ presetHealthCheck: { operation: "getCurrentUser" },
60+ candidates: [candidate],
61+ selected: candidate,
62+ operation: "",
63+ onOperationChange: () => {},
64+ identityField: "",
65+ onIdentityFieldChange: () => {},
66+ args: {},
67+ onArgChange: () => {},
68+ disabled: false,
69+ }),
70+ );
71+
72+ expect(text).toContain("getCurrentUser");
73+ expect(text).toContain("From the catalog. Change it later from the integration page.");
74+ expect(text).not.toContain("Health check (optional)");
75+ });
76+
77+ it("renders the full optional editor when there is no preset health check", () => {
78+ const text = visibleText(
79+ AddOpenApiHealthCheckSection({
80+ candidates: [candidate],
81+ selected: candidate,
82+ operation: "getCurrentUser",
83+ onOperationChange: () => {},
84+ identityField: "",
85+ onIdentityFieldChange: () => {},
86+ args: {},
87+ onArgChange: () => {},
88+ disabled: false,
89+ }),
90+ );
91+
92+ expect(text).toContain("Health check (optional)");
93+ expect(text).toContain("OAuth connections, validity and identity come from the OAuth grant");
94+ expect(text).not.toContain("From the catalog.");
95+ });
96+});
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+105−37
@@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines
55 import * as Option from "effect/Option";
66
77 import {
8+ AuthTemplateSlug,
89 IntegrationSlug,
910 type HealthCheckCandidate,
1011 type HealthCheckSpec,
1112 } from "@executor-js/sdk/shared";
13+import { useIntegrationPlugins } from "@executor-js/sdk/client";
1214 import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";
1315 import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";
1416 import {
@@ -43,7 +45,6 @@ import { · 28 unmodified lines
4345 } from "./auth-method-config";
4446 import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
4547 import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
46−import { openApiPresets } from "../sdk/presets";
4748 import type { SpecPreviewSummary } from "../sdk/preview";
4849 import { type Authentication } from "../sdk/types";
4950 import { resolveServerUrl } from "../sdk/openapi-utils";
@@ -82,6 +83,58 @@ export const baseUrlFromSpecInput = (input: string): string => { · 32 unmodified lines
8283 export const openApiPreviewFailureMessage = (message: string | null | undefined): string =>
8384 `Couldn't load or parse this spec: ${message && message.trim().length > 0 ? message : "unknown error"}`;
8485
86+export function AddOpenApiHealthCheckSection(props: {
87+ readonly presetHealthCheck?: HealthCheckSpec;
88+ readonly candidates: readonly HealthCheckCandidate[];
89+ readonly selected: HealthCheckCandidate | null;
90+ readonly operation: string;
91+ readonly onOperationChange: (operation: string) => void;
92+ readonly identityField: string;
93+ readonly onIdentityFieldChange: (field: string) => void;
94+ readonly args: Record<string, string>;
95+ readonly onArgChange: (name: string, value: string) => void;
96+ readonly disabled: boolean;
97+}) {
98+ if (props.presetHealthCheck) {
99+ return (
100+ <section className="space-y-2">
101+ <h3 className="text-sm font-medium text-foreground">Health check</h3>
102+ <p className="text-[11px] text-muted-foreground">
103+ <span className="font-mono text-foreground">{props.presetHealthCheck.operation}</span>{" "}
104+ From the catalog. Change it later from the integration page.
105+ </p>
106+ </section>
107+ );
108+ }
109+
110+ if (props.candidates.length === 0) return null;
111+
112+ return (
113+ <section className="space-y-4">
114+ <div className="space-y-1">
115+ <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3>
116+ <p className="text-[11px] text-muted-foreground">
117+ An optional read-only probe adds live upstream depth. For OAuth connections, validity and
118+ identity come from the OAuth grant itself. For non-OAuth methods, the probe can validate
119+ the credential and identify the account.
120+ </p>
121+ </div>
122+ <HealthCheckConfigFields
123+ candidates={props.candidates}
124+ selected={props.selected}
125+ operation={props.operation}
126+ onOperationChange={props.onOperationChange}
127+ identityField={props.identityField}
128+ onIdentityFieldChange={props.onIdentityFieldChange}
129+ args={props.args}
130+ onArgChange={props.onArgChange}
131+ disabled={props.disabled}
132+ idPrefix="add-health-check"
133+ />
134+ </section>
135+ );
136+}
137+
85138 // ---------------------------------------------------------------------------
86139 // Component: single progressive form. Post-redesign: preview -> addSpec
87140 // (register the integration catalog entry with ALL detected auth methods) →
@@ -96,6 +149,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
96149 initialPreset?: string;
97150 initialNamespace?: string;
98151 }) {
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;
99156 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
100157 const [analyzing, setAnalyzing] = useState(false);
101158 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
@@ -106,7 +163,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
106163 // Agent-visible description: prefilled from the spec's `info.description`
107164 // until the user types (null = untouched, keep deriving from the preview).
108165 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, () => "") : "");
110168 const identity = useIntegrationIdentity({
111169 fallbackName: identityFallbackName,
112170 fallbackNamespace: props.initialNamespace,
@@ -159,12 +217,13 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines
159217 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
160218 : "";
161219 const previewPresetIcon =
162− openApiPresets.find(
220+ openApiPresets?.find(
163221 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
164222 )?.icon ?? null;
165223
166224 const resolvedBaseUrl = baseUrl.trim();
167225 const resolvedSourceId =
226+ initialPreset?.defaultSlug ||
168227 slugifyNamespace(identity.namespace) ||
169228 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
170229 const resolvedDisplayName =
@@ -176,15 +235,26 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
176235 // Register EVERY spec-detected auth method, not just a single selected one.
177236 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
178237 // freshly-allocated `?? []` fallback arrays.
179− const authenticationTemplate: readonly Authentication[] = useMemo(
180− () =>
181− detectedAuthenticationTemplates(
182− preview?.headerPresets ?? [],
183− preview?.oauth2Presets ?? [],
184− resolvedBaseUrl,
185− ),
186− [preview, resolvedBaseUrl],
187− );
238+ const authenticationTemplate: readonly Authentication[] = useMemo(() => {
239+ if (initialPreset?.authTemplate) {
240+ return initialPreset.authTemplate.flatMap((template): readonly Authentication[] =>
241+ template.kind === "oauth2"
242+ ? [
243+ {
244+ ...template,
245+ slug: AuthTemplateSlug.make(template.slug),
246+ resource: template.resource ?? undefined,
247+ },
248+ ]
249+ : [],
250+ );
251+ }
252+ return detectedAuthenticationTemplates(
253+ preview?.headerPresets ?? [],
254+ preview?.oauth2Presets ?? [],
255+ resolvedBaseUrl,
256+ );
257+ }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]);
188258
189259 // Editable auth methods, seeded from the spec-detected templates. The add flow
190260 // registers EVERY method (P6), so this is a LIST, preserving multi-method
@@ -271,7 +341,9 @@ export default function AddOpenApiSource(props: { · 80 unmodified lines
271341 setAnalyzing(true);
272342 setAnalyzeError(null);
273343 setAddError(null);
274− const exit = await doPreview({ payload: { spec: specUrl } });
344+ const exit = await doPreview({
345+ payload: { spec: specUrl, specFormat: initialPreset?.specFormat },
346+ });
275347 if (Exit.isFailure(exit)) {
276348 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));
277349 setAnalyzing(false);
@@ -298,6 +370,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
298370 ? { description: resolvedDescription.trim() }
299371 : {}),
300372 baseUrl: resolvedBaseUrl,
373+ specFormat: initialPreset?.specFormat,
374+ family: initialPreset?.family,
375+ healthCheck: initialPreset?.healthCheck,
301376 // Always send the edited method list (even empty) when the user has
302377 // inspected a preview: an explicit [] means "no auth methods", while
303378 // OMITTING the field tells the server to derive defaults from the
@@ -320,6 +395,9 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
320395 resolvedDescription,
321396 resolvedBaseUrl,
322397 editedAuthenticationTemplate,
398+ initialPreset?.family,
399+ initialPreset?.healthCheck,
400+ initialPreset?.specFormat,
323401 ]);
324402
325403 const handleAdd = async () => {
@@ -465,30 +543,20 @@ export default function AddOpenApiSource(props: { · 139 unmodified lines
465543 />
466544 )}
467545
468− {preview && healthCheckCandidates.length > 0 && (
469− <section className="space-y-4">
470− <div className="space-y-1">
471− <h3 className="text-sm font-medium text-foreground">Health check (optional)</h3>
472− <p className="text-[11px] text-muted-foreground">
473− One read-only call Executor runs to tell whether a connection's credential is still
474− alive and, optionally, whose account it is. You can change this later, and run a live
475− preview against a key, from the integration page.
476− </p>
477− </div>
478− <HealthCheckConfigFields
479− candidates={healthCheckCandidates}
480− selected={hcSelected}
481− operation={hcOperation}
482− onOperationChange={onHcOperationChange}
483− identityField={hcIdentityField}
484− onIdentityFieldChange={setHcIdentityField}
485− args={hcArgs}
486− onArgChange={onHcArgChange}
487− disabled={adding}
488− idPrefix="add-health-check"
489− />
490− </section>
491− )}
546+ {preview ? (
547+ <AddOpenApiHealthCheckSection
548+ presetHealthCheck={initialPreset?.healthCheck}
549+ candidates={healthCheckCandidates}
550+ selected={hcSelected}
551+ operation={hcOperation}
552+ onOperationChange={onHcOperationChange}
553+ identityField={hcIdentityField}
554+ onIdentityFieldChange={setHcIdentityField}
555+ args={hcArgs}
556+ onArgChange={onHcArgChange}
557+ disabled={adding}
558+ />
559+ ) : null}
492560
493561 {preview && slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
494562
packages/plugins/openapi/src/react/plugin-client.tsx+7−5
@@ -12,9 +12,11 @@ · 11 unmodified lines
1212
1313 import { defineClientPlugin } from "@executor-js/sdk/client";
1414
15−import { openApiIntegrationPlugin } from "./source-plugin";
15+import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin";
1616
17−export default defineClientPlugin({
18− id: "openapi" as const,
19− integrationPlugin: openApiIntegrationPlugin,
20−});
17+export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) {
18+ return defineClientPlugin({
19+ id: "openapi" as const,
20+ integrationPlugin: createOpenApiIntegrationPlugin(config),
21+ });
22+}
packages/plugins/openapi/src/react/source-plugin.ts+12−4
@@ -1,21 +1,29 @@
11 import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
2+import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";
33 import { openApiPresets } from "../sdk/presets";
44
55 const importAdd = () => import("./AddOpenApiSource");
66 const importEditSheet = () => import("./UpdateSpecSection");
77 const importAccounts = () => import("./OpenApiAccountsPanel");
88
9−export const openApiIntegrationPlugin: IntegrationPlugin = {
9+export interface OpenApiClientConfig {
10+ readonly presets?: readonly IntegrationPreset[];
11+}
12+
13+export const createOpenApiIntegrationPlugin = (
14+ config?: OpenApiClientConfig,
15+): IntegrationPlugin => ({
1016 key: "openapi",
1117 label: "OpenAPI",
1218 add: lazy(importAdd),
1319 editSheet: lazy(importEditSheet),
1420 accounts: lazy(importAccounts),
15− presets: openApiPresets,
21+ presets: [...openApiPresets, ...(config?.presets ?? [])],
1622 preload: () => {
1723 void importAdd();
1824 void importEditSheet();
1925 void importAccounts();
2026 },
21−};
27+});
28+
29+export 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
5151 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
5252 /** Static query params applied to every request (no secret material). */
5353 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),
5458 /** The auth methods a connection's value can be applied through. */
5559 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
5660 });
packages/plugins/openapi/src/sdk/index.ts+8−0
@@ -51,6 +51,14 @@ export { · 50 unmodified lines
5151 type OpenApiPluginExtension,
5252 type OpenApiPluginOptions,
5353 } from "./plugin";
54+export {
55+ resolveSpecFormatAdapter,
56+ type ConvertedSpec,
57+ type DerivedIdentity,
58+ type SpecFetchCredentials,
59+ type SpecFetchInput,
60+ type SpecFormatAdapter,
61+} from "./spec-format";
5462 export { type OpenapiStore, type StoredOperation, makeDefaultOpenapiStore } from "./store";
5563 export {
5664 decodeOpenApiIntegrationConfig,
packages/plugins/openapi/src/sdk/plugin.ts+197−60
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 IntegrationSlug,
1010 ToolResult,
1111 definePlugin,
12+ HealthCheckSpec,
1213 mergeAuthTemplates,
1314 sha256Hex,
1415 tool,
@@ -33,13 +34,19 @@ import { · 18 unmodified lines
3334 type SpecPreview,
3435 } from "./preview";
3536 import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
36−import { openApiPresets } from "./presets";
37+import { openApiPresets, type OpenApiPreset } from "./presets";
3738 import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
39+import {
40+ resolveSpecFormatAdapter,
41+ type ConvertedSpec,
42+ type SpecFormatAdapter,
43+} from "./spec-format";
3844 import type { Authentication } from "./types";
3945 import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
4046 import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
4147 import {
4248 checkHealthOpenApi,
49+ compileAndPersistOpenApiSpecStreaming,
4350 compileOpenApiSpec,
4451 invokeOpenApiBackedTool,
4552 listHealthCheckCandidatesOpenApi,
@@ -59,6 +66,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 13 unmodified lines
5966
6067 export interface OpenApiPreviewInput {
6168 readonly spec: string;
69+ readonly specFormat?: string;
6270 }
6371
6472 /** Add an OpenAPI integration to the catalog. The integration is the API
@@ -67,7 +75,7 @@ export interface OpenApiPreviewInput { · 2 unmodified lines
6775 export interface OpenApiSpecConfig {
6876 readonly spec: OpenApiSpecInput;
6977 /** The catalog slug for the new integration (the `<integration>` segment). */
70− readonly slug: string;
78+ readonly slug?: string;
7179 /** Display name (defaults to the spec title). */
7280 readonly name?: string;
7381 /** Agent-visible description (defaults to the spec's `info.description`,
@@ -78,6 +86,9 @@ export interface OpenApiSpecConfig { · 4 unmodified lines
7886 readonly headers?: Record<string, string>;
7987 /** Static query params applied to every request. */
8088 readonly queryParams?: Record<string, string>;
89+ readonly specFormat?: string;
90+ readonly family?: string;
91+ readonly healthCheck?: HealthCheckSpec;
8192 /** Auth methods a connection's value renders through - canonical
8293 * placements or the request-shaped authoring dialect. */
8394 readonly authenticationTemplate?: readonly AuthenticationInput[];
@@ -88,14 +99,16 @@ export interface OpenApiExtensionFailure { · 4 unmodified lines
8899 }
89100
90101 /** 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`. */
92104 export interface OpenApiConfigureInput {
93105 /** The auth methods to add. Each entry is appended to (or, when its `slug`
94106 * already exists, replaces) the integration's existing template array. A
95107 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
96108 * slug that is collision-checked against the existing template. */
97− readonly authenticationTemplate: readonly AuthenticationInput[];
109+ readonly authenticationTemplate?: readonly AuthenticationInput[];
98110 readonly mode?: "merge" | "replace";
111+ readonly baseUrl?: string;
99112 }
100113
101114 /** What changed in the tool catalog when a spec was updated in place. Tool
@@ -165,6 +178,7 @@ export interface OpenApiPluginExtension { · 63 unmodified lines
165178
166179 const PreviewSpecInputSchema = Schema.Struct({
167180 spec: Schema.String,
181+ specFormat: Schema.optional(Schema.String),
168182 });
169183
170184 const StaticPreviewServerVariableSchema = Schema.Struct({
@@ -261,11 +275,15 @@ const AuthenticationSchema = Schema.Union([ · 90 unmodified lines
261275
262276 const AddSourceInputSchema = Schema.Struct({
263277 spec: OpenApiSpecInputSchema,
264− slug: Schema.String,
278+ slug: Schema.optional(Schema.String),
279+ name: Schema.optional(Schema.String),
265280 description: Schema.optional(Schema.String),
266281 baseUrl: Schema.optional(Schema.String),
267282 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
268283 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),
269287 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
270288 });
271289
@@ -553,9 +571,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines
553571
554572 export const describeOpenApiIntegrationDisplay = (
555573 record: IntegrationRecord,
556−): { readonly url?: string } => {
574+): { readonly url?: string; readonly family?: string } => {
557575 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+ };
559580 };
560581
561582 // ---------------------------------------------------------------------------
@@ -565,36 +586,65 @@ export const describeOpenApiIntegrationDisplay = ( · 3 unmodified lines
565586 export interface OpenApiPluginOptions {
566587 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
567588 readonly invokeOptions?: InvokeOptions;
589+ readonly specFormats?: readonly SpecFormatAdapter[];
590+ readonly presets?: readonly OpenApiPreset[];
568591 }
569592
570−export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
593+export const openApiPlugin = definePlugin<
594+ "openapi",
595+ OpenApiPluginExtension,
596+ OpenapiStore,
597+ OpenApiPluginOptions
598+>((options?: OpenApiPluginOptions) => {
571599 const resolveSpecForInput = (
572− spec: OpenApiSpecInput,
600+ config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">,
573601 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
574− ): Effect.Effect<
575− {
576− readonly specText: string;
577− },
578− OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError
579− > =>
602+ ): Effect.Effect<ConvertedSpec, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError> =>
580603 Effect.gen(function* () {
581− if (spec.kind === "url") {
582− const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));
583− return { specText };
604+ const adapter = yield* resolveSpecFormatAdapter(
605+ options?.specFormats ?? [],
606+ config.specFormat,
607+ );
608+ if (adapter) {
609+ if (config.spec.kind !== "url") {
610+ return yield* new OpenApiParseError({
611+ message: "Spec format adapters require a URL spec input",
612+ });
613+ }
614+ return yield* adapter.fetch({
615+ urls: [config.spec.url],
616+ credentials: {
617+ ...(config.headers ? { headers: config.headers } : {}),
618+ ...(config.queryParams ? { queryParams: config.queryParams } : {}),
619+ },
620+ httpClientLayer,
621+ });
622+ }
623+ if (config.spec.kind === "url") {
624+ const specText = yield* resolveSpecText(config.spec.url).pipe(
625+ Effect.provide(httpClientLayer),
626+ );
627+ return { specText, sourceUrl: config.spec.url };
584628 }
585− return { specText: spec.value };
629+ return { specText: config.spec.value };
586630 });
587631
588632 return {
589633 id: "openapi" as const,
590634 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) => ({
592637 id: preset.id,
593638 name: preset.name,
594639 summary: preset.summary,
595640 ...(preset.url ? { url: preset.url } : {}),
596641 ...(preset.icon ? { icon: preset.icon } : {}),
597642 ...(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 } : {}),
598648 })),
599649 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
600650
@@ -650,8 +700,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 49 unmodified lines
650700 Effect.gen(function* () {
651701 // Resolve URL → text and parse BEFORE opening a transaction. Holding
652702 // `BEGIN` across a network fetch is the Hyperdrive deadlock path.
653− const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
654− const compiled = yield* compileOpenApiSpec(resolved.specText);
703+ const resolved = yield* resolveSpecForInput(config, httpClientLayer);
704+ const compiled = resolved.keepPathItem
705+ ? undefined
706+ : yield* compileOpenApiSpec(resolved.specText);
707+ const adapter = yield* resolveSpecFormatAdapter(
708+ options?.specFormats ?? [],
709+ config.specFormat,
710+ );
711+ const derivedIdentity =
712+ adapter?.deriveIdentity && resolved.document
713+ ? adapter.deriveIdentity(resolved.document)
714+ : null;
715+ const resolvedSlug = config.slug?.trim() || derivedIdentity?.slug;
716+ if (!resolvedSlug) {
717+ return yield* new OpenApiParseError({
718+ message: "OpenAPI integration slug is required",
719+ });
720+ }
655721
656722 // Defaults the add page derives from its preview, applied here so
657723 // headless callers (MCP, API) get the same integration the UI's
@@ -665,7 +731,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines
665731 // end with nowhere to paste a credential)
666732 // An explicit input always wins; for auth, an explicit EMPTY array
667733 // means "no auth methods" and suppresses the derivation.
668− const explicitBaseUrl = config.baseUrl;
734+ const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
669735 const needsDerivedBaseUrl = explicitBaseUrl == null;
670736 const needsDerivedAuth = config.authenticationTemplate == null;
671737 const preview =
@@ -675,8 +741,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines
675741 enrichPreviewWithDiscoveredOAuth({
676742 specText: resolved.specText,
677743 preview: rawPreview,
678− sourceUrl: specInputToSourceUrl(config.spec),
679− baseUrl: config.baseUrl,
744+ sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec),
745+ baseUrl: explicitBaseUrl,
680746 }),
681747 ),
682748 )
@@ -689,7 +755,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 6 unmodified lines
689755 ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)
690756 : undefined;
691757
692− const slug = IntegrationSlug.make(config.slug);
758+ const slug = IntegrationSlug.make(resolvedSlug);
693759
694760 // Block re-adding an existing slug. The core `integrations.register`
695761 // primitive upserts (so boot re-registration is idempotent), but an
@@ -704,9 +770,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 8 unmodified lines
704770 const specHash = yield* sha256Hex(resolved.specText);
705771
706772 const integrationConfig: OpenApiIntegrationConfig = {
773+ ...(resolved.config ?? {}),
707774 specHash,
708− ...(specInputToSourceUrl(config.spec) !== undefined
709− ? { sourceUrl: specInputToSourceUrl(config.spec) }
775+ ...((resolved.sourceUrl ?? specInputToSourceUrl(config.spec)) !== undefined
776+ ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(config.spec) }
710777 : {}),
711778 // baseUrl is an optional override only. The host is otherwise
712779 // resolved per call from the operation's `servers` (extracted from
@@ -714,15 +781,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
714781 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
715782 ...(config.headers ? { headers: config.headers } : {}),
716783 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
784+ ...(config.specFormat ? { specFormat: config.specFormat } : {}),
785+ ...(config.family ? { family: config.family } : {}),
717786 // Prefer the caller's explicit template; otherwise derive from the
718787 // spec's declared security schemes.
719788 ...(config.authenticationTemplate
720789 ? {
721790 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
722791 }
723− : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
724− ? { authenticationTemplate: derivedAuthenticationTemplate }
725− : {}),
792+ : resolved.authenticationTemplate && resolved.authenticationTemplate.length > 0
793+ ? { authenticationTemplate: resolved.authenticationTemplate }
794+ : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
795+ ? { authenticationTemplate: derivedAuthenticationTemplate }
796+ : {}),
726797 };
727798
728799 // The spec blob is written OUTSIDE the transaction: it's
@@ -733,27 +804,53 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines
733804 // The content-addressed defs blob lets the serve path resolve the
734805 // shared `definitions` without re-parsing the spec. Same idempotent,
735806 // 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+ }
737810
738811 yield* ctx.transaction(
739812 Effect.gen(function* () {
740813 yield* ctx.core.integrations.register({
741814 slug,
742− name: config.name?.trim() || compiled.title || config.slug,
815+ name:
816+ config.name?.trim() || derivedIdentity?.name || compiled?.title || resolvedSlug,
743817 description:
744− config.description ?? compiled.description ?? compiled.title ?? config.slug,
818+ config.description ??
819+ derivedIdentity?.description ??
820+ compiled?.description ??
821+ compiled?.title ??
822+ resolvedSlug,
745823 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
746824 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,
748843 });
749− yield* ctx.storage.putOperations(
750− config.slug,
751− openApiStoredOperationsFromCompiled(config.slug, compiled),
752− );
844+ return persisted.toolCount;
753845 }),
754846 );
755847
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 };
757854 });
758855
759856 // 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
784881
785882 // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock
786883 // rule as addSpec: never hold BEGIN across a network fetch).
787− const resolved = yield* resolveSpecForInput(specInput, httpClientLayer);
788− const compiled = yield* compileOpenApiSpec(resolved.specText);
884+ const resolved = yield* resolveSpecForInput(
885+ {
886+ spec: specInput,
887+ specFormat: current.specFormat,
888+ headers: current.headers,
889+ queryParams: current.queryParams,
890+ baseUrl: current.baseUrl,
891+ },
892+ httpClientLayer,
893+ );
894+ const compiled = resolved.keepPathItem
895+ ? undefined
896+ : yield* compileOpenApiSpec(resolved.specText);
789897
790898 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
791899 const previousNames = new Set(previousOperations.map((op) => op.toolName));
792− const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
793900
794901 // The resolved spec text lives in the plugin blob store keyed by its
795902 // content hash (`spec/<hash>`); the config carries only the hash. Put
@@ -797,13 +904,15 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
797904 // aborted config update just leaves an unreferenced blob.
798905 const specHash = yield* sha256Hex(resolved.specText);
799906 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+ }
801910
802911 const nextConfig: OpenApiIntegrationConfig = {
803912 ...current,
804913 specHash,
805− ...(specInputToSourceUrl(specInput) !== undefined
806− ? { sourceUrl: specInputToSourceUrl(specInput) }
914+ ...((resolved.sourceUrl ?? specInputToSourceUrl(specInput)) !== undefined
915+ ? { sourceUrl: resolved.sourceUrl ?? specInputToSourceUrl(specInput) }
807916 : {}),
808917 };
809918
@@ -812,13 +921,28 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines
812921 yield* ctx.core.integrations.update(slug, {
813922 config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
814923 });
815− yield* ctx.storage.putOperations(
816− rawSlug,
817− openApiStoredOperationsFromCompiled(rawSlug, compiled),
818− );
924+ if (compiled) {
925+ yield* ctx.storage.putOperations(
926+ rawSlug,
927+ openApiStoredOperationsFromCompiled(rawSlug, compiled),
928+ );
929+ } else {
930+ yield* compileAndPersistOpenApiSpecStreaming({
931+ specText: resolved.specText,
932+ integration: rawSlug,
933+ storage: ctx.storage,
934+ specHash,
935+ keepPathItem: resolved.keepPathItem,
936+ });
937+ }
819938 }),
820939 );
821940
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+
822946 // Rebuild each connection's tool rows from the new spec. Outside the
823947 // transaction: refresh opens its own, and a half-refreshed catalog
824948 // self-heals on the next refresh anyway.
@@ -843,7 +967,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 18 unmodified lines
843967
844968 return {
845969 slug,
846− toolCount: compiled.definitions.length,
970+ toolCount: nextNames.size,
847971 addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
848972 removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
849973 } satisfies UpdateSpecResult;
@@ -857,14 +981,18 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 7 unmodified lines
857981 previewSpec: (input: string | OpenApiPreviewInput) =>
858982 Effect.gen(function* () {
859983 const previewInput = typeof input === "string" ? { spec: input } : input;
860− const specText = yield* resolveSpecText(previewInput.spec).pipe(
861− Effect.provide(httpClientLayer),
984+ const spec = maybeUrl(previewInput.spec.trim())
985+ ? { kind: "url" as const, url: previewInput.spec.trim() }
986+ : { kind: "blob" as const, value: previewInput.spec };
987+ const resolved = yield* resolveSpecForInput(
988+ { spec, specFormat: previewInput.specFormat },
989+ httpClientLayer,
862990 );
863− const preview = yield* previewSpecText(specText);
991+ const preview = yield* previewSpecText(resolved.specText);
864992 return yield* enrichPreviewWithDiscoveredOAuth({
865− specText,
993+ specText: resolved.specText,
866994 preview,
867− sourceUrl: maybeUrl(previewInput.spec.trim()) ? previewInput.spec.trim() : undefined,
995+ sourceUrl: resolved.sourceUrl ?? (spec.kind === "url" ? spec.url : undefined),
868996 });
869997 }),
870998
@@ -917,14 +1045,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 46 unmodified lines
9171045 const current = decodeOpenApiIntegrationConfig(record.config);
9181046 if (!current) return [] as readonly Authentication[];
9191047
920− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
9211048 const merged =
922− input.mode === "replace"
923− ? incoming
924− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
1049+ input.authenticationTemplate === undefined
1050+ ? (current.authenticationTemplate ?? [])
1051+ : input.mode === "replace"
1052+ ? normalizeOpenApiAuthInputs(input.authenticationTemplate)
1053+ : mergeAuthTemplates(
1054+ current.authenticationTemplate ?? [],
1055+ normalizeOpenApiAuthInputs(input.authenticationTemplate),
1056+ );
9251057
9261058 const next: OpenApiIntegrationConfig = {
9271059 ...current,
1060+ ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
9281061 authenticationTemplate: merged,
9291062 };
9301063
@@ -978,10 +1111,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 47 unmodified lines
9781111 .addSpec({
9791112 spec: input.spec,
9801113 slug: input.slug,
1114+ name: input.name,
9811115 description: input.description,
9821116 baseUrl: input.baseUrl,
9831117 headers: input.headers,
9841118 queryParams: input.queryParams,
1119+ specFormat: input.specFormat,
1120+ family: input.family,
1121+ healthCheck: input.healthCheck,
9851122 authenticationTemplate: input.authenticationTemplate as
9861123 | readonly AuthenticationInput[]
9871124 | undefined,
packages/plugins/openapi/src/sdk/presets.ts+7−0
@@ -1,3 +1,5 @@
1+import type { HealthCheckSpec, IntegrationPresetAuthentication } from "@executor-js/sdk/core";
2+
13 export interface OpenApiPreset {
24 readonly id: string;
35 readonly name: string;
@@ -5,6 +7,11 @@ export interface OpenApiPreset { · 1 unmodified line
57 readonly url?: string;
68 readonly icon?: string;
79 readonly featured?: boolean;
10+ readonly family?: string;
11+ readonly specFormat?: string;
12+ readonly defaultSlug?: string;
13+ readonly authTemplate?: readonly IntegrationPresetAuthentication[];
14+ readonly healthCheck?: HealthCheckSpec;
815 }
916
1017 const openApiOnlyPresets: readonly OpenApiPreset[] = [
packages/plugins/openapi/src/sdk/spec-format.test.ts+32−0
@@ -0,0 +1,32 @@
1+import { expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import { OpenApiParseError } from "./errors";
5+import { resolveSpecFormatAdapter, type SpecFormatAdapter } from "./spec-format";
6+
7+const adapter: SpecFormatAdapter = {
8+ id: "known",
9+ fetch: () => Effect.succeed({ specText: "{}" }),
10+};
11+
12+it.effect("resolves a registered spec format adapter by id", () =>
13+ Effect.gen(function* () {
14+ const resolved = yield* resolveSpecFormatAdapter([adapter], "known");
15+ expect(resolved).toBe(adapter);
16+ }),
17+);
18+
19+it.effect("returns null when no spec format id is supplied", () =>
20+ Effect.gen(function* () {
21+ const resolved = yield* resolveSpecFormatAdapter([adapter], undefined);
22+ expect(resolved).toBeNull();
23+ }),
24+);
25+
26+it.effect("fails with a typed parse error for an unknown spec format id", () =>
27+ Effect.gen(function* () {
28+ const error = yield* resolveSpecFormatAdapter([adapter], "missing").pipe(Effect.flip);
29+ expect(error).toBeInstanceOf(OpenApiParseError);
30+ expect(error).toMatchObject({ message: "Unknown OpenAPI spec format: missing" });
31+ }),
32+);
packages/plugins/openapi/src/sdk/spec-format.ts+51−0
@@ -0,0 +1,51 @@
1+import type { Layer } from "effect";
2+import { Effect } from "effect";
3+import type { HttpClient } from "effect/unstable/http";
4+
5+import type { Authentication } from "./types";
6+import type { OpenApiIntegrationConfig } from "./config";
7+import type { KeepPathItem } from "./split";
8+import { OpenApiParseError } from "./errors";
9+
10+export interface SpecFetchCredentials {
11+ readonly headers?: Record<string, string>;
12+ readonly queryParams?: Record<string, string>;
13+}
14+
15+export interface SpecFetchInput {
16+ readonly urls: readonly string[];
17+ readonly credentials?: SpecFetchCredentials;
18+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
19+}
20+
21+export interface DerivedIdentity {
22+ readonly slug: string;
23+ readonly name: string;
24+ readonly description?: string;
25+}
26+
27+export interface ConvertedSpec {
28+ readonly specText: string;
29+ readonly sourceUrl?: string;
30+ readonly baseUrl?: string;
31+ readonly authenticationTemplate?: readonly Authentication[];
32+ readonly config?: Partial<OpenApiIntegrationConfig> & Readonly<Record<string, unknown>>;
33+ readonly keepPathItem?: KeepPathItem;
34+ readonly document?: unknown;
35+}
36+
37+export interface SpecFormatAdapter {
38+ readonly id: string;
39+ readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
40+ readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;
41+}
42+
43+export const resolveSpecFormatAdapter = (
44+ adapters: readonly SpecFormatAdapter[],
45+ id: string | undefined,
46+): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {
47+ if (!id) return Effect.succeed(null);
48+ const adapter = adapters.find((candidate) => candidate.id === id);
49+ if (adapter) return Effect.succeed(adapter);
50+ return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));
51+};
packages/plugins/openapi/tsup.config.ts+2−0
@@ -5,6 +5,8 @@ export default defineConfig({ · 4 unmodified lines
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
77 client: "src/react/plugin-client.tsx",
8+ "providers/google": "src/providers/google/index.ts",
9+ "providers/microsoft": "src/providers/microsoft/index.ts",
810 testing: "src/testing/index.ts",
911 },
1012 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 @@
1+export * from "./planner";
2+export * from "./sqlite";
packages/plugins/provider-service-split/src/planner.test.ts+1119−0
@@ -0,0 +1,1119 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { streamOperationBindingsFromStructure, structuralSplit } from "@executor-js/plugin-openapi";
4+
5+import {
6+ googlePresetIdForTool,
7+ microsoftPresetIdsForTool,
8+ microsoftToolFirstSegmentPresetIds,
9+ operationStorageKey,
10+ planMigration,
11+ verifyPolicyRewriteNeverWidens,
12+ type BlobRow,
13+ type ConnectionRow,
14+ type IntegrationRow,
15+ type MigrationInput,
16+ type PluginStorageRow,
17+ type ToolPolicyRow,
18+ type ToolRow,
19+} from "./planner";
20+import { googleCatalog } from "@executor-js/plugin-openapi/providers/google";
21+
22+const now = "2026-01-01T00:00:00.000Z";
23+
24+const integration = (overrides: Partial<IntegrationRow> = {}): IntegrationRow => ({
25+ tenant: "org_1",
26+ slug: "google",
27+ plugin_id: "google",
28+ name: "Google",
29+ description: "Google APIs",
30+ config: {
31+ googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
32+ specHash: "mono-hash",
33+ authenticationTemplate: [{ slug: "googleOAuth", kind: "oauth2" }],
34+ },
35+ health_check: null,
36+ config_revised_at: null,
37+ can_remove: true,
38+ can_refresh: true,
39+ created_at: now,
40+ updated_at: now,
41+ row_id: "int_1",
42+ ...overrides,
43+});
44+
45+const connection = (overrides: Partial<ConnectionRow> = {}): ConnectionRow => ({
46+ tenant: "org_1",
47+ owner: "org",
48+ subject: "",
49+ integration: "google",
50+ name: "main",
51+ template: "googleOAuth",
52+ provider: "vault",
53+ item_ids: { access: "access_item", refresh: "refresh_item" },
54+ identity_label: "person@example.test",
55+ description: null,
56+ last_health: null,
57+ tools_synced_at: 1,
58+ oauth_client: "google",
59+ oauth_client_owner: "org",
60+ refresh_item_id: "refresh_item",
61+ expires_at: 2,
62+ oauth_scope: "calendar gmail",
63+ oauth_token_url: "https://oauth2.googleapis.com/token",
64+ provider_state: { token: "metadata" },
65+ created_at: now,
66+ updated_at: now,
67+ row_id: "conn_1",
68+ ...overrides,
69+});
70+
71+const tool = (name: string, overrides: Partial<ToolRow> = {}): ToolRow => ({
72+ tenant: "org_1",
73+ owner: "org",
74+ subject: "",
75+ integration: "google",
76+ connection: "main",
77+ plugin_id: "google",
78+ name,
79+ description: "tool",
80+ input_schema: {},
81+ output_schema: {},
82+ annotations: {},
83+ created_at: now,
84+ updated_at: now,
85+ row_id: `tool_${name}`,
86+ ...overrides,
87+});
88+
89+const operation = (name: string, overrides: Partial<PluginStorageRow> = {}): PluginStorageRow => ({
90+ tenant: "org_1",
91+ owner: "org",
92+ subject: "",
93+ plugin_id: "google",
94+ collection: "operation",
95+ key: operationStorageKey("google", name),
96+ data: {
97+ integration: "google",
98+ toolName: name,
99+ binding: {
100+ method: "get",
101+ servers: [],
102+ pathTemplate: `/${name}`,
103+ parameters: [],
104+ },
105+ description: name,
106+ },
107+ created_at: now,
108+ updated_at: now,
109+ row_id: `op_${name}`,
110+ ...overrides,
111+});
112+
113+const blob = (key: string, overrides: Partial<BlobRow> = {}): BlobRow => ({
114+ id: `blob_${key}`,
115+ namespace: "o:org_1/google",
116+ key,
117+ ...overrides,
118+});
119+
120+const policy = (pattern: string, overrides: Partial<ToolPolicyRow> = {}): ToolPolicyRow => ({
121+ tenant: "org_1",
122+ owner: "org",
123+ subject: "",
124+ id: `pol_${pattern.replaceAll(".", "_").replaceAll("*", "star")}`,
125+ pattern,
126+ action: "block",
127+ position: "a0",
128+ created_at: now,
129+ updated_at: now,
130+ row_id: `row_${pattern}`,
131+ ...overrides,
132+});
133+
134+const googleDiscoveryConfig = (urls: readonly string[], specHash = "mono-hash") => ({
135+ googleDiscoveryUrls: urls,
136+ specHash,
137+});
138+
139+const youtubeDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest";
140+
141+const googleCatalogMethodPrefixFixtures: ReadonlyMap<string, readonly string[]> = new Map([
142+ ["google-calendar", ["calendar.events.list"]],
143+ ["google-gmail", ["gmail.users.messages.list"]],
144+ ["google-sheets", ["sheets.spreadsheets.get"]],
145+ ["google-drive", ["drive.files.list"]],
146+ ["google-docs", ["docs.documents.get"]],
147+ ["google-slides", ["slides.presentations.get"]],
148+ ["google-forms", ["forms.forms.get"]],
149+ ["google-tasks", ["tasks.tasks.list"]],
150+ ["google-people", ["people.people.get"]],
151+ ["google-photos-library", ["photoslibrary.albums.list"]],
152+ ["google-photos-picker", ["photospicker.sessions.create"]],
153+ ["google-chat", ["chat.spaces.list"]],
154+ ["google-keep", ["keep.notes.list"]],
155+ ["google-youtube-data", ["youtube.channels.list"]],
156+ ["google-search-console", ["searchconsole.sites.list", "webmasters.sites.list"]],
157+ ["google-classroom", ["classroom.courses.list"]],
158+ [
159+ "google-admin-directory",
160+ ["directory.users.list", "admin.customer.devices.chromeos.list", "admin.customers.get"],
161+ ],
162+ ["google-admin-reports", ["reports.activities.list", "admin.channels.stop"]],
163+ ["google-apps-script", ["script.projects.get"]],
164+ ["google-bigquery", ["bigquery.datasets.list"]],
165+ ["google-cloud-resource-manager", ["cloudresourcemanager.projects.list"]],
166+]);
167+
168+const microsoftDefaultPresetIds = [
169+ "profile",
170+ "mail",
171+ "calendar",
172+ "contacts",
173+ "tasks",
174+ "files",
175+ "excel",
176+ "sites",
177+ "onenote",
178+ "teams-chat",
179+ "teams-channels",
180+ "meetings-calls",
181+] as const;
182+
183+const microsoftFixtureTagForFirstSegment = (firstSegment: string): string =>
184+ firstSegment.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
185+
186+const microsoftToolFirstSegmentsFromSplitterFixture = () =>
187+ Effect.gen(function* () {
188+ const paths = [...microsoftToolFirstSegmentPresetIds.keys()]
189+ .map(
190+ (firstSegment) => ` /fixture/${firstSegment}:
191+ get:
192+ tags:
193+ - ${microsoftFixtureTagForFirstSegment(firstSegment)}
194+ operationId: ${firstSegment}.Get
195+ responses:
196+ "200":
197+ description: OK`,
198+ )
199+ .join("\n");
200+ const structure = structuralSplit(`openapi: 3.0.4
201+info:
202+ title: Microsoft Graph Tool Ownership Fixture
203+ version: v1.0
204+paths:
205+${paths}
206+components: {}
207+`);
208+ expect(structure).not.toBeNull();
209+ const toolNames: string[] = [];
210+ yield* streamOperationBindingsFromStructure(structure!, { chunkSize: 100 }, (chunk) =>
211+ Effect.sync(() => {
212+ toolNames.push(...chunk.map((row) => row.toolName));
213+ }),
214+ );
215+ return new Set(toolNames.map((toolName) => toolName.split(".")[0] ?? ""));
216+ });
217+
218+const input = (overrides: Partial<MigrationInput> = {}): MigrationInput => ({
219+ integrations: [integration()],
220+ connections: [connection()],
221+ tools: [tool("calendar.events.list")],
222+ pluginStorage: [operation("calendar.events.list")],
223+ blobs: [blob("spec/mono-hash"), blob("defs/mono-hash")],
224+ policies: [],
225+ ...overrides,
226+});
227+
228+describe("provider service split migration planner", () => {
229+ it("resolves Google reports, directory, and admin.channels tool prefixes", () => {
230+ expect(googlePresetIdForTool("reports.activities.list")).toBe("google-admin-reports");
231+ expect(googlePresetIdForTool("directory.users.list")).toBe("google-admin-directory");
232+ expect(googlePresetIdForTool("admin.channels.stop")).toBe("google-admin-reports");
233+ expect(googlePresetIdForTool("admin.channels.stopPost0019dmcn")).toBe("google-admin-reports");
234+ expect(googlePresetIdForTool("admin.customers.chrome.printers.list")).toBe(
235+ "google-admin-directory",
236+ );
237+ expect(googlePresetIdForTool("admin.customer.devices.chromeos.list")).toBe(
238+ "google-admin-directory",
239+ );
240+ });
241+
242+ it("keeps Google catalog method prefixes mapped to their owning presets", () => {
243+ expect([...googleCatalogMethodPrefixFixtures.keys()]).toEqual(
244+ googleCatalog.map((preset) => preset.id),
245+ );
246+ for (const [presetId, methods] of googleCatalogMethodPrefixFixtures) {
247+ for (const method of methods) {
248+ expect(googlePresetIdForTool(method), method).toBe(presetId);
249+ }
250+ }
251+ });
252+
253+ it.effect("keeps Microsoft planner ownership table aligned with splitter tool names", () =>
254+ Effect.gen(function* () {
255+ const splitterFirstSegments = yield* microsoftToolFirstSegmentsFromSplitterFixture();
256+
257+ expect([...splitterFirstSegments].sort()).toEqual(
258+ [...microsoftToolFirstSegmentPresetIds.keys()].sort(),
259+ );
260+ for (const [firstSegment, presetIds] of microsoftToolFirstSegmentPresetIds) {
261+ expect(microsoftPresetIdsForTool(`${firstSegment}.get`), firstSegment).toEqual(presetIds);
262+ }
263+ expect(microsoftPresetIdsForTool("drivesDriveItem.workbook.worksheets.list")).toEqual([
264+ "files",
265+ "excel",
266+ ]);
267+ }),
268+ );
269+
270+ it("plans a single-service monolith split", () => {
271+ const plan = planMigration(input());
272+
273+ expect(plan.summary.integrationsCreate).toBe(1);
274+ expect(plan.summary.connectionsClone).toBe(1);
275+ expect(plan.orgs[0]?.integrations.map((row) => row.target.pluginId)).toEqual(["openapi"]);
276+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual(["google_calendar"]);
277+ expect(plan.orgs[0]?.deleteMonoliths.map((row) => row.slug)).toEqual(["google"]);
278+ });
279+
280+ it("plans a multi-service monolith split from stored Discovery URLs", () => {
281+ const plan = planMigration(
282+ input({
283+ integrations: [
284+ integration({
285+ config: {
286+ googleDiscoveryUrls: [
287+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
288+ "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
289+ ],
290+ specHash: "mono-hash",
291+ },
292+ }),
293+ ],
294+ tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")],
295+ pluginStorage: [
296+ operation("calendar.events.list"),
297+ operation("gmail.users.messages.send"),
298+ operation("oauth2.userinfo.get"),
299+ ],
300+ }),
301+ );
302+
303+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([
304+ "google_calendar",
305+ "google_gmail",
306+ ]);
307+ expect(plan.summary.connectionsClone).toBe(2);
308+ });
309+
310+ it("models migrated integration serving storage under the new slug", () => {
311+ const plan = planMigration(input());
312+ const planned = plan.orgs[0]?.integrations[0];
313+
314+ expect(planned?.config).toEqual({
315+ specHash: "mono-hash",
316+ sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
317+ specFormat: "google-discovery",
318+ family: "google",
319+ authenticationTemplate: [
320+ {
321+ slug: "googleOAuth2",
322+ kind: "oauth2",
323+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
324+ tokenUrl: "https://oauth2.googleapis.com/token",
325+ scopes: expect.arrayContaining([
326+ "openid",
327+ "email",
328+ "profile",
329+ "https://www.googleapis.com/auth/calendar",
330+ ]),
331+ },
332+ ],
333+ });
334+ expect(planned?.healthCheck).toEqual({
335+ operation: "calendar.calendarList.list",
336+ });
337+ expect(planned?.servingState).toMatchObject({
338+ specHash: "mono-hash",
339+ specSource: "google/google",
340+ specBlobPresent: true,
341+ defsBlobPresent: true,
342+ operationsToBuild: 1,
343+ operationToolNames: ["calendar.events.list"],
344+ });
345+ expect(plan.orgs[0]?.blobCopies).toEqual([
346+ expect.objectContaining({
347+ sourceNamespace: "o:org_1/google",
348+ targetNamespace: "o:org_1/openapi",
349+ key: "spec/mono-hash",
350+ sourcePresent: true,
351+ }),
352+ expect.objectContaining({
353+ sourceNamespace: "o:org_1/google",
354+ targetNamespace: "o:org_1/openapi",
355+ key: "defs/mono-hash",
356+ sourcePresent: true,
357+ }),
358+ ]);
359+ expect(operationStorageKey("google_calendar", "calendar.events.list")).toMatch(/^op\./);
360+ });
361+
362+ it("keeps only the service discovery URL and drops the oauth2 document URL", () => {
363+ const plan = planMigration(
364+ input({
365+ integrations: [
366+ integration({
367+ config: {
368+ googleDiscoveryUrls: [
369+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
370+ "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest",
371+ ],
372+ specHash: "mono-hash",
373+ },
374+ }),
375+ ],
376+ pluginStorage: [operation("calendar.events.list"), operation("oauth2.userinfo.get")],
377+ }),
378+ );
379+
380+ const planned = plan.orgs[0]?.integrations[0];
381+
382+ expect(planned?.config).toMatchObject({
383+ sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
384+ specFormat: "google-discovery",
385+ family: "google",
386+ });
387+ expect(
388+ (planned?.config as { readonly sourceUrl?: string } | undefined)?.sourceUrl,
389+ ).not.toContain("oauth2");
390+ expect(planned?.servingState.operationToolNames).toEqual(["calendar.events.list"]);
391+ });
392+
393+ it("fans out wildcard policies by matched service without widening inventory matches", () => {
394+ const migrationInput = input({
395+ integrations: [
396+ integration({
397+ config: {
398+ googleDiscoveryUrls: [
399+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
400+ "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
401+ ],
402+ specHash: "mono-hash",
403+ },
404+ }),
405+ ],
406+ tools: [tool("calendar.events.delete"), tool("gmail.users.messages.delete")],
407+ pluginStorage: [
408+ operation("calendar.events.delete"),
409+ operation("gmail.users.messages.delete"),
410+ ],
411+ policies: [policy("google.*")],
412+ });
413+ const plan = planMigration(migrationInput);
414+
415+ expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([
416+ "google_calendar.*",
417+ "google_gmail.*",
418+ ]);
419+ expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({
420+ ok: true,
421+ checkedPolicies: 1,
422+ });
423+ });
424+
425+ it("fans restrictive wildcard policies out to every monolith service, not only currently matching tools", () => {
426+ const migrationInput = input({
427+ integrations: [
428+ integration({
429+ config: {
430+ googleDiscoveryUrls: [
431+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
432+ "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
433+ "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
434+ ],
435+ specHash: "mono-hash",
436+ },
437+ }),
438+ ],
439+ tools: [tool("calendar.events.update")],
440+ pluginStorage: [
441+ operation("calendar.events.update"),
442+ operation("gmail.users.messages.update"),
443+ ],
444+ policies: [policy("google.*.*.*.*.update")],
445+ });
446+ const plan = planMigration(migrationInput);
447+
448+ expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([
449+ "google_calendar.*.*.*.*.update",
450+ "google_gmail.*.*.*.*.update",
451+ "google_sheets.*.*.*.*.update",
452+ ]);
453+ expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({
454+ ok: true,
455+ checkedPolicies: 1,
456+ narrowed: [],
457+ });
458+ });
459+
460+ it("hard-errors when a policy would be dropped instead of silently skipped", () => {
461+ expect(() =>
462+ planMigration(
463+ input({
464+ integrations: [
465+ integration({
466+ config: {
467+ googleDiscoveryUrls: [
468+ "https://www.googleapis.com/discovery/v1/apis/searchconsole/v1/rest",
469+ ],
470+ specHash: "mono-hash",
471+ },
472+ }),
473+ ],
474+ tools: [tool("searchconsole.sites.list")],
475+ pluginStorage: [operation("searchconsole.sites.list")],
476+ policies: [policy("google.*.*.gmail.users.messages.send")],
477+ }),
478+ ),
479+ ).toThrow(/would be dropped/);
480+ });
481+
482+ it("hard-errors when config omits a tool-implied service", () => {
483+ expect(() =>
484+ planMigration(
485+ input({
486+ tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")],
487+ pluginStorage: [
488+ operation("calendar.events.list"),
489+ operation("gmail.users.messages.send"),
490+ ],
491+ }),
492+ ),
493+ ).toThrow(/config omits tool-implied service preset\(s\): google-gmail/);
494+ });
495+
496+ it("boot-rail mode creates a catalog-backed tool-implied service", () => {
497+ const plan = planMigration(
498+ input({
499+ tools: [tool("calendar.events.list"), tool("gmail.users.messages.send")],
500+ pluginStorage: [operation("calendar.events.list"), operation("gmail.users.messages.send")],
501+ bootRailCreateToolImpliedServices: true,
502+ }),
503+ );
504+
505+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([
506+ "google_calendar",
507+ "google_gmail",
508+ ]);
509+ });
510+
511+ it("records a hard error when a source blob is missing", () => {
512+ const plan = planMigration(
513+ input({ blobs: [blob("spec/mono-hash")], collectPolicyErrors: true }),
514+ );
515+
516+ expect(plan.orgs[0]?.hardErrors).toEqual([
517+ "Missing source blob o:org_1/google/defs/mono-hash for migrated OpenAPI service namespace o:org_1/openapi",
518+ ]);
519+ expect(plan.summary.hardErrorOrgs).toBe(1);
520+ });
521+
522+ it("retargets orphan block policies in boot-rail mode", () => {
523+ const plan = planMigration(
524+ input({
525+ integrations: [
526+ integration({
527+ config: {
528+ googleDiscoveryUrls: [
529+ "https://www.googleapis.com/discovery/v1/apis/searchconsole/v1/rest",
530+ ],
531+ specHash: "mono-hash",
532+ },
533+ }),
534+ ],
535+ tools: [tool("searchconsole.sites.list")],
536+ pluginStorage: [operation("searchconsole.sites.list")],
537+ policies: [policy("google.*.*.gmail.users.messages.send")],
538+ orphanPolicyMode: "retarget_all",
539+ }),
540+ );
541+
542+ expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([
543+ "google_search_console.*.*.gmail.users.messages.send",
544+ ]);
545+ });
546+
547+ it("skips openapi lookalike google slugs because plugin ownership is not google", () => {
548+ const plan = planMigration(
549+ input({
550+ integrations: [integration({ plugin_id: "openapi", slug: "google" })],
551+ tools: [tool("calendar.events.list", { plugin_id: "openapi" })],
552+ policies: [policy("google.*")],
553+ }),
554+ );
555+
556+ expect(plan.summary.orgs).toBe(0);
557+ expect(plan.summary.policiesRewrite).toBe(0);
558+ });
559+
560+ it("includes non-canonical provider slugs and fans out their policies", () => {
561+ const migrationInput = input({
562+ integrations: [
563+ integration({
564+ slug: "google_photos_youtube",
565+ config: googleDiscoveryConfig([youtubeDiscoveryUrl]),
566+ }),
567+ ],
568+ connections: [connection({ integration: "google_photos_youtube" })],
569+ tools: [tool("youtube.channels.list", { integration: "google_photos_youtube" })],
570+ pluginStorage: [
571+ operation("youtube.channels.list", {
572+ key: operationStorageKey("google_photos_youtube", "youtube.channels.list"),
573+ data: {
574+ integration: "google_photos_youtube",
575+ toolName: "youtube.channels.list",
576+ },
577+ }),
578+ ],
579+ policies: [policy("google_photos_youtube.*.*.youtube.*")],
580+ });
581+ const plan = planMigration(migrationInput);
582+
583+ expect(plan.summary.orgs).toBe(1);
584+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([
585+ "google_youtube_data",
586+ ]);
587+ expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual(["google_youtube_data.*.*.youtube.*"]);
588+ expect(verifyPolicyRewriteNeverWidens(plan, migrationInput).ok).toBe(true);
589+ });
590+
591+ it("merges same-tenant monoliths that derive the same target service", () => {
592+ const migrationInput = input({
593+ integrations: [
594+ integration({
595+ slug: "google",
596+ config: googleDiscoveryConfig([youtubeDiscoveryUrl], "google-hash"),
597+ }),
598+ integration({
599+ slug: "google_photos_youtube",
600+ name: "Google Photos YouTube",
601+ config: googleDiscoveryConfig([youtubeDiscoveryUrl], "photos-hash"),
602+ row_id: "int_google_photos_youtube",
603+ }),
604+ ],
605+ connections: [
606+ connection({ integration: "google", row_id: "conn_google" }),
607+ connection({
608+ integration: "google_photos_youtube",
609+ row_id: "conn_google_photos_youtube",
610+ }),
611+ ],
612+ tools: [
613+ tool("youtube.channels.list", { integration: "google" }),
614+ tool("youtube.videos.list", { integration: "google" }),
615+ tool("youtube.channels.list", {
616+ integration: "google_photos_youtube",
617+ row_id: "tool_photos_youtube_channels_list",
618+ }),
619+ ],
620+ pluginStorage: [
621+ operation("youtube.channels.list", {
622+ key: operationStorageKey("google", "youtube.channels.list"),
623+ data: { integration: "google", toolName: "youtube.channels.list" },
624+ }),
625+ operation("youtube.videos.list", {
626+ key: operationStorageKey("google", "youtube.videos.list"),
627+ data: { integration: "google", toolName: "youtube.videos.list" },
628+ }),
629+ operation("youtube.channels.list", {
630+ key: operationStorageKey("google_photos_youtube", "youtube.channels.list"),
631+ data: {
632+ integration: "google_photos_youtube",
633+ toolName: "youtube.channels.list",
634+ },
635+ row_id: "op_photos_youtube_channels_list",
636+ }),
637+ ],
638+ blobs: [
639+ blob("spec/google-hash"),
640+ blob("defs/google-hash"),
641+ blob("spec/photos-hash"),
642+ blob("defs/photos-hash"),
643+ ],
644+ policies: [policy("google.*"), policy("google_photos_youtube.*", { id: "pol_photos" })],
645+ });
646+ const plan = planMigration(migrationInput);
647+ const org = plan.orgs[0];
648+ const planned = org?.integrations[0];
649+
650+ expect(org?.integrations).toHaveLength(1);
651+ expect(planned?.target.slug).toBe("google_youtube_data");
652+ expect(planned?.servingState.specHash).toBe("google-hash");
653+ expect(planned?.sourceContributions.map((item) => item.source.slug)).toEqual([
654+ "google",
655+ "google_photos_youtube",
656+ ]);
657+ expect(planned?.sourceContributions.map((item) => item.operationsToBuild)).toEqual([2, 1]);
658+ expect(org?.connections.map((row) => [row.source.integration, row.action])).toEqual([
659+ ["google", "clone"],
660+ ["google_photos_youtube", "skip_existing"],
661+ ]);
662+ expect(org?.policies.map((row) => row.afterPatterns)).toEqual([
663+ ["google_youtube_data.*"],
664+ ["google_youtube_data.*"],
665+ ]);
666+ expect(org?.blobCopies.map((row) => row.key)).toEqual([
667+ "spec/google-hash",
668+ "defs/google-hash",
669+ "spec/photos-hash",
670+ "defs/photos-hash",
671+ ]);
672+ expect(plan.summary.integrationsCreate).toBe(1);
673+ expect(plan.summary.operationsToBuild).toBe(3);
674+ });
675+
676+ it("deletes config-only monoliths without creating specHash-less service rows", () => {
677+ const plan = planMigration(
678+ input({
679+ integrations: [
680+ integration({
681+ slug: "google_mark_life",
682+ config: {
683+ googleDiscoveryUrls: [
684+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
685+ "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
686+ ],
687+ },
688+ }),
689+ ],
690+ connections: [],
691+ tools: [],
692+ pluginStorage: [],
693+ blobs: [],
694+ }),
695+ );
696+
697+ expect(plan.orgs[0]?.integrations).toEqual([]);
698+ expect(plan.orgs[0]?.blobCopies).toEqual([]);
699+ expect(plan.orgs[0]?.deleteMonoliths.map((row) => row.slug)).toEqual(["google_mark_life"]);
700+ expect(plan.summary.monolithDeletes).toBe(1);
701+ expect(plan.summary.integrationsCreate).toBe(0);
702+ });
703+
704+ it("reads microsoft preset ids from the legacy scope preset config key", () => {
705+ const plan = planMigration(
706+ input({
707+ integrations: [
708+ integration({
709+ plugin_id: "microsoft",
710+ slug: "microsoft_graph_api",
711+ name: "Microsoft Graph API",
712+ config: {
713+ microsoftGraphScopePresetIds: ["mail"],
714+ specHash: "graph-hash",
715+ },
716+ }),
717+ ],
718+ connections: [
719+ connection({
720+ integration: "microsoft_graph_api",
721+ template: "microsoftOAuth",
722+ oauth_client: "microsoft",
723+ }),
724+ ],
725+ tools: [
726+ tool("meMessagesList", {
727+ integration: "microsoft_graph_api",
728+ plugin_id: "microsoft",
729+ }),
730+ ],
731+ pluginStorage: [
732+ operation("meMessagesList", {
733+ plugin_id: "microsoft",
734+ key: operationStorageKey("microsoft_graph_api", "meMessagesList"),
735+ data: {
736+ integration: "microsoft_graph_api",
737+ toolName: "meMessagesList",
738+ },
739+ }),
740+ ],
741+ blobs: [
742+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
743+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
744+ ],
745+ }),
746+ );
747+
748+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual(["microsoft_mail"]);
749+ });
750+
751+ it("plans Microsoft workbook operations into files and excel to match fresh adds", () => {
752+ const migrationInput = input({
753+ integrations: [
754+ integration({
755+ plugin_id: "microsoft",
756+ slug: "microsoft_graph_api",
757+ name: "Microsoft Graph API",
758+ config: {
759+ microsoftGraphPresetIds: ["files", "excel"],
760+ specHash: "graph-hash",
761+ },
762+ }),
763+ ],
764+ connections: [
765+ connection({
766+ integration: "microsoft_graph_api",
767+ template: "microsoftOAuth",
768+ oauth_client: "microsoft",
769+ }),
770+ ],
771+ tools: [
772+ tool("drivesDriveItem.get", {
773+ integration: "microsoft_graph_api",
774+ plugin_id: "microsoft",
775+ }),
776+ tool("drivesDriveItem.workbook.worksheets.list", {
777+ integration: "microsoft_graph_api",
778+ plugin_id: "microsoft",
779+ }),
780+ ],
781+ pluginStorage: [
782+ operation("drivesDriveItem.get", {
783+ plugin_id: "microsoft",
784+ key: operationStorageKey("microsoft_graph_api", "drivesDriveItem.get"),
785+ data: {
786+ integration: "microsoft_graph_api",
787+ toolName: "drivesDriveItem.get",
788+ },
789+ }),
790+ operation("drivesDriveItem.workbook.worksheets.list", {
791+ plugin_id: "microsoft",
792+ key: operationStorageKey(
793+ "microsoft_graph_api",
794+ "drivesDriveItem.workbook.worksheets.list",
795+ ),
796+ data: {
797+ integration: "microsoft_graph_api",
798+ toolName: "drivesDriveItem.workbook.worksheets.list",
799+ },
800+ }),
801+ ],
802+ blobs: [
803+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
804+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
805+ ],
806+ });
807+ const plan = planMigration(migrationInput);
808+ const bySlug = new Map(plan.orgs[0]?.integrations.map((row) => [row.target.slug, row]));
809+
810+ expect(bySlug.get("microsoft_files")?.servingState.operationToolNames).toEqual([
811+ "drivesDriveItem.get",
812+ "drivesDriveItem.workbook.worksheets.list",
813+ ]);
814+ expect(bySlug.get("microsoft_excel")?.servingState.operationToolNames).toEqual([
815+ "drivesDriveItem.workbook.worksheets.list",
816+ ]);
817+ expect(bySlug.get("microsoft_excel")?.servingState.operationsToBuild).toBe(1);
818+ expect(plan.summary.hardErrorOrgs).toBe(0);
819+ });
820+
821+ it("maps Microsoft me-surface tools to only configured specific presets", () => {
822+ const toolNames = [
823+ "meInferenceClassification",
824+ "meMailboxSettings",
825+ "meOutlookUser",
826+ "mePerson",
827+ "meSite",
828+ "meDrive",
829+ "meTeam",
830+ "meUser",
831+ "meUserActions",
832+ "meUserFunctions",
833+ "meChat",
834+ "meOnlineMeeting",
835+ ];
836+ const migrationInput = input({
837+ integrations: [
838+ integration({
839+ plugin_id: "microsoft",
840+ slug: "microsoft_graph_api",
841+ name: "Microsoft Graph API",
842+ config: {
843+ microsoftGraphPresetIds: microsoftDefaultPresetIds,
844+ specHash: "graph-hash",
845+ },
846+ }),
847+ ],
848+ connections: [
849+ connection({
850+ integration: "microsoft_graph_api",
851+ template: "microsoftOAuth",
852+ oauth_client: "microsoft",
853+ }),
854+ ],
855+ tools: toolNames.map((name) =>
856+ tool(name, {
857+ integration: "microsoft_graph_api",
858+ plugin_id: "microsoft",
859+ }),
860+ ),
861+ pluginStorage: toolNames.map((name) =>
862+ operation(name, {
863+ plugin_id: "microsoft",
864+ key: operationStorageKey("microsoft_graph_api", name),
865+ data: {
866+ integration: "microsoft_graph_api",
867+ toolName: name,
868+ },
869+ }),
870+ ),
871+ blobs: [
872+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
873+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
874+ ],
875+ });
876+ const plan = planMigration(migrationInput);
877+ const bySlug = new Map(plan.orgs[0]?.integrations.map((row) => [row.target.slug, row]));
878+
879+ expect(microsoftPresetIdsForTool("meInferenceClassification")).toEqual(["mail"]);
880+ expect(microsoftPresetIdsForTool("meMailboxSettings")).toEqual(["mail"]);
881+ expect(microsoftPresetIdsForTool("meOutlookUser")).toEqual(["mail"]);
882+ expect(microsoftPresetIdsForTool("mePerson")).toEqual(["contacts"]);
883+ expect(microsoftPresetIdsForTool("meSite")).toEqual(["files"]);
884+ expect(microsoftPresetIdsForTool("meDrive")).toEqual(["files"]);
885+ expect(microsoftPresetIdsForTool("meTeam")).toEqual(["teams-channels"]);
886+ expect(microsoftPresetIdsForTool("meUser")).toEqual(["profile"]);
887+ expect(microsoftPresetIdsForTool("meUserActions")).toEqual(["profile"]);
888+ expect(microsoftPresetIdsForTool("meUserFunctions")).toEqual(["profile"]);
889+ expect(microsoftPresetIdsForTool("meChat")).toEqual(["teams-chat"]);
890+ expect(microsoftPresetIdsForTool("meOnlineMeeting")).toEqual(["meetings-calls"]);
891+ expect(bySlug.get("microsoft_mail")?.servingState.operationToolNames).toEqual([
892+ "meInferenceClassification",
893+ "meMailboxSettings",
894+ "meOutlookUser",
895+ ]);
896+ expect(bySlug.get("microsoft_contacts")?.servingState.operationToolNames).toEqual(["mePerson"]);
897+ expect(bySlug.get("microsoft_files")?.servingState.operationToolNames).toEqual([
898+ "meSite",
899+ "meDrive",
900+ ]);
901+ expect(bySlug.get("microsoft_teams_channels")?.servingState.operationToolNames).toEqual([
902+ "meTeam",
903+ ]);
904+ expect(bySlug.get("microsoft_profile")?.servingState.operationToolNames).toEqual([
905+ "meUser",
906+ "meUserActions",
907+ "meUserFunctions",
908+ ]);
909+ expect(bySlug.get("microsoft_teams_chat")?.servingState.operationToolNames).toEqual(["meChat"]);
910+ expect(bySlug.get("microsoft_meetings_calls")?.servingState.operationToolNames).toEqual([
911+ "meOnlineMeeting",
912+ ]);
913+ expect(plan.summary.hardErrorOrgs).toBe(0);
914+ });
915+
916+ it("hard-errors when a Microsoft operation row maps to no target service", () => {
917+ expect(() =>
918+ planMigration(
919+ input({
920+ integrations: [
921+ integration({
922+ plugin_id: "microsoft",
923+ slug: "microsoft_graph_api",
924+ name: "Microsoft Graph API",
925+ config: {
926+ microsoftGraphPresetIds: ["mail"],
927+ specHash: "graph-hash",
928+ },
929+ }),
930+ ],
931+ connections: [
932+ connection({
933+ integration: "microsoft_graph_api",
934+ template: "microsoftOAuth",
935+ oauth_client: "microsoft",
936+ }),
937+ ],
938+ tools: [
939+ tool("meMessagesList", {
940+ integration: "microsoft_graph_api",
941+ plugin_id: "microsoft",
942+ }),
943+ ],
944+ pluginStorage: [
945+ operation("unknownGraphThing", {
946+ plugin_id: "microsoft",
947+ key: operationStorageKey("microsoft_graph_api", "unknownGraphThing"),
948+ data: {
949+ integration: "microsoft_graph_api",
950+ toolName: "unknownGraphThing",
951+ },
952+ }),
953+ ],
954+ blobs: [
955+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
956+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
957+ ],
958+ }),
959+ ),
960+ ).toThrow(/operation row\(s\) with no target service: unknownGraphThing/);
961+ });
962+
963+ it("hard-errors when a Microsoft tool row maps to no target service", () => {
964+ expect(() =>
965+ planMigration(
966+ input({
967+ integrations: [
968+ integration({
969+ plugin_id: "microsoft",
970+ slug: "microsoft_graph_api",
971+ name: "Microsoft Graph API",
972+ config: {
973+ microsoftGraphPresetIds: ["mail"],
974+ specHash: "graph-hash",
975+ },
976+ }),
977+ ],
978+ connections: [
979+ connection({
980+ integration: "microsoft_graph_api",
981+ template: "microsoftOAuth",
982+ oauth_client: "microsoft",
983+ }),
984+ ],
985+ tools: [
986+ tool("unknownGraphThing", {
987+ integration: "microsoft_graph_api",
988+ plugin_id: "microsoft",
989+ }),
990+ ],
991+ pluginStorage: [],
992+ blobs: [
993+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
994+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
995+ ],
996+ }),
997+ ),
998+ ).toThrow(/tool row\(s\) with no target service: unknownGraphThing/);
999+ });
1000+
1001+ it("hard-errors when Microsoft config lists an unmigratable umbrella preset", () => {
1002+ expect(() =>
1003+ planMigration(
1004+ input({
1005+ integrations: [
1006+ integration({
1007+ plugin_id: "microsoft",
1008+ slug: "microsoft_graph_api",
1009+ name: "Microsoft Graph API",
1010+ config: {
1011+ microsoftGraphPresetIds: ["me-surface"],
1012+ specHash: "graph-hash",
1013+ },
1014+ }),
1015+ ],
1016+ connections: [],
1017+ tools: [],
1018+ pluginStorage: [],
1019+ }),
1020+ ),
1021+ ).toThrow(/unmigratable-umbrella Microsoft Graph preset\(s\): me-surface/);
1022+ });
1023+
1024+ it("fans Microsoft workbook policies to files and excel without widening", () => {
1025+ const migrationInput = input({
1026+ integrations: [
1027+ integration({
1028+ plugin_id: "microsoft",
1029+ slug: "microsoft_graph_api",
1030+ name: "Microsoft Graph API",
1031+ config: {
1032+ microsoftGraphPresetIds: ["files", "excel"],
1033+ specHash: "graph-hash",
1034+ },
1035+ }),
1036+ ],
1037+ connections: [
1038+ connection({
1039+ integration: "microsoft_graph_api",
1040+ template: "microsoftOAuth",
1041+ oauth_client: "microsoft",
1042+ }),
1043+ ],
1044+ tools: [
1045+ tool("drivesDriveItem.workbook.worksheets.list", {
1046+ integration: "microsoft_graph_api",
1047+ plugin_id: "microsoft",
1048+ }),
1049+ ],
1050+ pluginStorage: [
1051+ operation("drivesDriveItem.workbook.worksheets.list", {
1052+ plugin_id: "microsoft",
1053+ key: operationStorageKey(
1054+ "microsoft_graph_api",
1055+ "drivesDriveItem.workbook.worksheets.list",
1056+ ),
1057+ data: {
1058+ integration: "microsoft_graph_api",
1059+ toolName: "drivesDriveItem.workbook.worksheets.list",
1060+ },
1061+ }),
1062+ ],
1063+ blobs: [
1064+ blob("spec/graph-hash", { namespace: "o:org_1/microsoft" }),
1065+ blob("defs/graph-hash", { namespace: "o:org_1/microsoft" }),
1066+ ],
1067+ policies: [policy("microsoft_graph_api.*.*.drivesDriveItem.*workbook*")],
1068+ });
1069+ const plan = planMigration(migrationInput);
1070+
1071+ expect(plan.orgs[0]?.policies[0]?.afterPatterns).toEqual([
1072+ "microsoft_files.*.*.drivesDriveItem.*workbook*",
1073+ "microsoft_excel.*.*.drivesDriveItem.*workbook*",
1074+ ]);
1075+ expect(verifyPolicyRewriteNeverWidens(plan, migrationInput)).toMatchObject({
1076+ ok: true,
1077+ checkedPolicies: 1,
1078+ widened: [],
1079+ narrowed: [],
1080+ });
1081+ });
1082+
1083+ it("is idempotent when target integration and connection already exist", () => {
1084+ const plan = planMigration(
1085+ input({
1086+ integrations: [
1087+ integration(),
1088+ integration({
1089+ slug: "google_calendar",
1090+ plugin_id: "openapi",
1091+ name: "Google Calendar",
1092+ row_id: "int_calendar",
1093+ }),
1094+ ],
1095+ connections: [
1096+ connection(),
1097+ connection({
1098+ integration: "google_calendar",
1099+ row_id: "conn_calendar",
1100+ }),
1101+ ],
1102+ }),
1103+ );
1104+
1105+ expect(plan.summary.integrationsCreate).toBe(0);
1106+ expect(plan.summary.integrationsSkipExisting).toBe(1);
1107+ expect(plan.summary.connectionsClone).toBe(0);
1108+ expect(plan.summary.connectionsSkipExisting).toBe(1);
1109+ });
1110+
1111+ it("skips completed orgs for resume-after-partial", () => {
1112+ const plan = planMigration(input({ completedTenants: ["org_1"] }));
1113+
1114+ expect(plan.orgs[0]?.completed).toBe(true);
1115+ expect(plan.summary.completedOrgs).toBe(1);
1116+ expect(plan.summary.integrationsCreate).toBe(0);
1117+ expect(plan.summary.monolithDeletes).toBe(0);
1118+ });
1119+});
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 */
2+import { createHash } from "node:crypto";
3+
4+import {
5+ googleCatalog,
6+ googlePresetForDiscoveryUrl,
7+} from "@executor-js/plugin-openapi/providers/google";
8+import {
9+ MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
10+ microsoftCatalog,
11+} from "@executor-js/plugin-openapi/providers/microsoft";
12+import type { OpenApiPreset } from "@executor-js/plugin-openapi/presets";
13+
14+type MonolithPluginId = "google" | "microsoft";
15+
16+const normalizeUrl = (url: string): string => {
17+ const trimmed = url.trim();
18+ if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
19+ const parsed = new URL(trimmed);
20+ parsed.hash = "";
21+ parsed.searchParams.sort();
22+ return parsed.toString().replace(/\/$/, "");
23+};
24+
25+const googleCatalogByDiscoveryUrl = new Map(
26+ googleCatalog.flatMap((preset) =>
27+ preset.url ? [[normalizeUrl(preset.url), preset] as const] : [],
28+ ),
29+);
30+
31+const matchPattern = (pattern: string, toolId: string): boolean => {
32+ if (pattern === "*") return true;
33+ const patternSegments = pattern.split(".");
34+ const toolSegments = toolId.split(".");
35+ for (let index = 0; index < patternSegments.length; index += 1) {
36+ const segment = patternSegments[index]!;
37+ if (segment === "*") {
38+ if (index === patternSegments.length - 1) return toolSegments.length >= index;
39+ if (index >= toolSegments.length) return false;
40+ continue;
41+ }
42+ if (index >= toolSegments.length || toolSegments[index] !== segment) return false;
43+ }
44+ return patternSegments.length === toolSegments.length;
45+};
46+
47+export type PluginId = "openapi";
48+
49+export interface IntegrationRow {
50+ readonly tenant: string;
51+ readonly slug: string;
52+ readonly plugin_id: string;
53+ readonly name: string | null;
54+ readonly description: string | null;
55+ readonly config: unknown;
56+ readonly health_check?: unknown;
57+ readonly config_revised_at?: string | number | bigint | null;
58+ readonly can_remove: boolean;
59+ readonly can_refresh: boolean;
60+ readonly created_at: string;
61+ readonly updated_at: string;
62+ readonly row_id: string;
63+}
64+
65+export interface ConnectionRow {
66+ readonly tenant: string;
67+ readonly owner: string;
68+ readonly subject: string;
69+ readonly integration: string;
70+ readonly name: string;
71+ readonly template: string;
72+ readonly provider: string;
73+ readonly item_ids: unknown;
74+ readonly identity_label: string | null;
75+ readonly description?: string | null;
76+ readonly last_health?: unknown;
77+ readonly tools_synced_at?: string | number | bigint | null;
78+ readonly oauth_client: string | null;
79+ readonly oauth_client_owner: string | null;
80+ readonly refresh_item_id: string | null;
81+ readonly expires_at: string | number | bigint | null;
82+ readonly oauth_scope: string | null;
83+ readonly oauth_token_url?: string | null;
84+ readonly provider_state: unknown;
85+ readonly created_at: string;
86+ readonly updated_at: string;
87+ readonly row_id: string;
88+}
89+
90+export interface ToolRow {
91+ readonly tenant: string;
92+ readonly owner: string;
93+ readonly subject: string;
94+ readonly integration: string;
95+ readonly connection: string;
96+ readonly plugin_id: string;
97+ readonly name: string;
98+ readonly description?: string;
99+ readonly input_schema?: unknown;
100+ readonly output_schema?: unknown;
101+ readonly annotations?: unknown;
102+ readonly created_at?: string;
103+ readonly updated_at?: string;
104+ readonly row_id: string;
105+}
106+
107+export interface PluginStorageRow {
108+ readonly tenant: string;
109+ readonly owner: string;
110+ readonly subject: string;
111+ readonly plugin_id: string;
112+ readonly collection: string;
113+ readonly key: string;
114+ readonly data: unknown;
115+ readonly created_at: string;
116+ readonly updated_at: string;
117+ readonly row_id: string;
118+}
119+
120+export interface BlobRow {
121+ readonly id: string;
122+ readonly namespace: string;
123+ readonly key: string;
124+}
125+
126+export interface ToolPolicyRow {
127+ readonly tenant: string;
128+ readonly owner: string;
129+ readonly subject: string;
130+ readonly id: string;
131+ readonly pattern: string;
132+ readonly action: string;
133+ readonly position: string;
134+ readonly created_at: string;
135+ readonly updated_at: string;
136+ readonly row_id: string;
137+}
138+
139+export interface MigrationInput {
140+ readonly integrations: readonly IntegrationRow[];
141+ readonly connections: readonly ConnectionRow[];
142+ readonly tools: readonly ToolRow[];
143+ readonly pluginStorage?: readonly PluginStorageRow[];
144+ readonly blobs?: readonly BlobRow[];
145+ readonly policies: readonly ToolPolicyRow[];
146+ readonly completedTenants?: readonly string[];
147+ readonly trafficLastTenant?: string;
148+ readonly collectPolicyErrors?: boolean;
149+ readonly orphanPolicyMode?: "hard_error" | "retarget_all";
150+ readonly blobBackend?: "database" | "external";
151+ readonly assumeExternalBlobSourcePresent?: boolean;
152+ readonly bootRailCreateToolImpliedServices?: boolean;
153+}
154+
155+export interface ServiceTarget {
156+ readonly family: MonolithPluginId;
157+ readonly pluginId: PluginId;
158+ readonly presetId: string;
159+ readonly slug: string;
160+ readonly name: string;
161+ readonly description: string;
162+ readonly sourceUrl?: string;
163+ readonly specFormat: "google-discovery" | "microsoft-graph";
164+ readonly authenticationTemplate?: readonly unknown[];
165+ readonly healthCheck?: unknown;
166+}
167+
168+export interface PlannedIntegration {
169+ readonly source: Pick<IntegrationRow, "tenant" | "slug" | "plugin_id" | "name">;
170+ readonly sourceContributions: readonly {
171+ readonly source: IntegrationRow;
172+ readonly specHash?: string;
173+ readonly operationsToBuild: number;
174+ readonly operationToolNames: readonly string[];
175+ readonly specBlobPresent: boolean;
176+ readonly defsBlobPresent: boolean;
177+ }[];
178+ readonly target: ServiceTarget;
179+ readonly action: "create" | "skip_existing";
180+ readonly config: unknown;
181+ readonly healthCheck?: unknown;
182+ readonly servingState: {
183+ readonly specHash?: string;
184+ readonly specSource: string;
185+ readonly blobBackend: "database" | "external";
186+ readonly specBlobPresent: boolean;
187+ readonly defsBlobPresent: boolean;
188+ readonly operationsToBuild: number;
189+ readonly operationToolNames: readonly string[];
190+ readonly expectedZeroOperations: boolean;
191+ };
192+}
193+
194+export interface PlannedBlobCopy {
195+ readonly source: Pick<IntegrationRow, "tenant" | "slug" | "plugin_id">;
196+ readonly specHash: string;
197+ readonly key: string;
198+ readonly sourceNamespace: string;
199+ readonly targetNamespace: string;
200+ readonly backend: "database" | "external";
201+ readonly sourcePresent: boolean;
202+ readonly targetPresent: boolean;
203+ readonly sourceObjectName: string;
204+ readonly targetObjectName: string;
205+}
206+
207+export interface PlannedConnection {
208+ readonly source: Pick<ConnectionRow, "tenant" | "owner" | "subject" | "integration" | "name">;
209+ readonly targetIntegration: string;
210+ readonly action: "clone" | "skip_existing";
211+ readonly tokenReuse: "copy_item_ids_and_oauth_columns";
212+}
213+
214+export interface PlannedPolicyRewrite {
215+ readonly policy: Pick<
216+ ToolPolicyRow,
217+ "tenant" | "owner" | "subject" | "id" | "pattern" | "action" | "position"
218+ >;
219+ readonly action: "rewrite";
220+ readonly afterPatterns: readonly string[];
221+ readonly matchedServices: readonly string[];
222+}
223+
224+export interface OrgPlan {
225+ readonly tenant: string;
226+ readonly tenantHash: string;
227+ readonly completed: boolean;
228+ readonly integrations: readonly PlannedIntegration[];
229+ readonly connections: readonly PlannedConnection[];
230+ readonly policies: readonly PlannedPolicyRewrite[];
231+ readonly blobCopies: readonly PlannedBlobCopy[];
232+ readonly deleteMonoliths: readonly Pick<
233+ IntegrationRow,
234+ "tenant" | "slug" | "plugin_id" | "name"
235+ >[];
236+ readonly clonedToolRows: number;
237+ readonly operationsToBuild: number;
238+ readonly hardErrors: readonly string[];
239+}
240+
241+export interface MigrationPlan {
242+ readonly orgs: readonly OrgPlan[];
243+ readonly summary: {
244+ readonly orgs: number;
245+ readonly completedOrgs: number;
246+ readonly integrationsCreate: number;
247+ readonly integrationsSkipExisting: number;
248+ readonly connectionsClone: number;
249+ readonly connectionsSkipExisting: number;
250+ readonly policiesRewrite: number;
251+ readonly policiesSkip: number;
252+ readonly policyRowsAfter: number;
253+ readonly monolithDeletes: number;
254+ readonly clonedToolRows: number;
255+ readonly operationsToBuild: number;
256+ readonly integrationsMissingSpecBlob: number;
257+ readonly integrationsMissingDefsBlob: number;
258+ readonly hardErrorOrgs: number;
259+ readonly policyHardErrors: number;
260+ };
261+}
262+
263+const GOOGLE_IDENTITY_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
264+
265+const GOOGLE_TOOL_PREFIX_TO_PRESET_ID: ReadonlyMap<string, string> = new Map([
266+ ["calendar", "google-calendar"],
267+ ["gmail", "google-gmail"],
268+ ["sheets", "google-sheets"],
269+ ["drive", "google-drive"],
270+ ["docs", "google-docs"],
271+ ["slides", "google-slides"],
272+ ["forms", "google-forms"],
273+ ["tasks", "google-tasks"],
274+ ["people", "google-people"],
275+ ["photoslibrary", "google-photos-library"],
276+ ["photospicker", "google-photos-picker"],
277+ ["chat", "google-chat"],
278+ ["keep", "google-keep"],
279+ ["youtube", "google-youtube-data"],
280+ ["searchconsole", "google-search-console"],
281+ ["webmasters", "google-search-console"],
282+ ["classroom", "google-classroom"],
283+ ["directory", "google-admin-directory"],
284+ ["reports", "google-admin-reports"],
285+ ["script", "google-apps-script"],
286+ ["bigquery", "google-bigquery"],
287+ ["cloudresourcemanager", "google-cloud-resource-manager"],
288+]);
289+
290+const unique = <T>(values: Iterable<T>): readonly T[] => [...new Set(values)];
291+
292+export const tenantHash = (tenant: string): string =>
293+ createHash("sha256").update(tenant).digest("hex").slice(0, 12);
294+
295+const isRecord = (value: unknown): value is Record<string, unknown> =>
296+ typeof value === "object" && value !== null && !Array.isArray(value);
297+
298+const recordFromJsonLike = (value: unknown): Record<string, unknown> => {
299+ if (isRecord(value)) return value;
300+ if (typeof value !== "string") return {};
301+ try {
302+ const parsed = JSON.parse(value) as unknown;
303+ return isRecord(parsed) ? parsed : {};
304+ } catch {
305+ return {};
306+ }
307+};
308+
309+const stringArray = (value: unknown): readonly string[] =>
310+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
311+
312+const configRecord = (integration: IntegrationRow): Record<string, unknown> =>
313+ recordFromJsonLike(integration.config);
314+
315+const toolAddress = (
316+ tool: Pick<ToolRow, "integration" | "owner" | "connection" | "name">,
317+): string => `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`;
318+
319+const withoutToolsPrefix = (pattern: string): string =>
320+ pattern.startsWith("tools.") ? pattern.slice("tools.".length) : pattern;
321+
322+const withOriginalToolsPrefix = (original: string, rewrittenTail: string): string =>
323+ original.startsWith("tools.") ? `tools.${rewrittenTail}` : rewrittenTail;
324+
325+const integrationPatternSegment = (
326+ pattern: string,
327+): { readonly prefix: boolean; readonly integration: string } => {
328+ const prefix = pattern.startsWith("tools.");
329+ const tail = prefix ? pattern.slice("tools.".length) : pattern;
330+ return { prefix, integration: tail.split(".")[0] ?? "" };
331+};
332+
333+const googleCatalogById: ReadonlyMap<string, OpenApiPreset> = new Map(
334+ googleCatalog.map((preset) => [preset.id, preset]),
335+);
336+
337+const microsoftCatalogByMonolithPresetId: ReadonlyMap<string, OpenApiPreset> = new Map(
338+ microsoftCatalog.map((preset) => [preset.id.replace(/^microsoft-/, ""), preset]),
339+);
340+
341+const MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS = new Set(["me-surface", "users", "groups"]);
342+const MICROSOFT_TOOL_IMPLIED_PRESET_IDS = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
343+const microsoftCatalogBackedPresetIds = new Set(
344+ microsoftCatalog
345+ .filter((preset) => preset.defaultSlug && preset.defaultSlug.length > 0)
346+ .map((preset) => preset.id.replace(/^microsoft-/, "")),
347+);
348+
349+export const microsoftToolFirstSegmentPresetIds: ReadonlyMap<string, readonly string[]> = new Map([
350+ ["chats", ["teams-chat"]],
351+ ["chatsChat", ["teams-chat"]],
352+ ["communications", ["meetings-calls"]],
353+ ["communicationsCall", ["meetings-calls"]],
354+ ["communicationsOnlineMeeting", ["meetings-calls"]],
355+ ["communicationsPresence", ["meetings-calls"]],
356+ ["drives", ["files"]],
357+ ["drivesDrive", ["files"]],
358+ ["drivesDriveItem", ["files"]],
359+ ["groupsDrive", ["files"]],
360+ ["groupsDrives", ["files"]],
361+ ["groupsOnenote", ["onenote"]],
362+ ["groupsTeam", ["teams-channels"]],
363+ ["meCalendar", ["calendar"]],
364+ ["meCalendarGroups", ["calendar"]],
365+ ["meCalendars", ["calendar"]],
366+ ["meCalendarView", ["calendar"]],
367+ ["meChat", ["teams-chat"]],
368+ ["meChats", ["teams-chat"]],
369+ ["meContact", ["contacts"]],
370+ ["meContactFolder", ["contacts"]],
371+ ["meContactFolders", ["contacts"]],
372+ ["meContacts", ["contacts"]],
373+ ["meDrive", ["files"]],
374+ ["meDrives", ["files"]],
375+ ["meEvent", ["calendar"]],
376+ ["meEvents", ["calendar"]],
377+ ["meFindMeetingTimes", ["calendar"]],
378+ ["meFollowedSites", ["files"]],
379+ ["meGetMailTips", ["mail"]],
380+ ["meInferenceClassification", ["mail"]],
381+ ["meJoinedTeams", ["teams-channels"]],
382+ ["meMailFolder", ["mail"]],
383+ ["meMailFolders", ["mail"]],
384+ ["meMailboxSettings", ["mail"]],
385+ ["meMessage", ["mail"]],
386+ ["meMessages", ["mail"]],
387+ ["meOnlineMeeting", ["meetings-calls"]],
388+ ["meOnlineMeetings", ["meetings-calls"]],
389+ ["meOnenote", ["onenote"]],
390+ ["meOutlook", ["mail"]],
391+ ["meOutlookUser", ["mail"]],
392+ ["mePeople", ["contacts"]],
393+ ["mePerson", ["contacts"]],
394+ ["mePhoto", ["profile"]],
395+ ["meProfilePhoto", ["profile"]],
396+ ["meReminderView", ["calendar"]],
397+ ["meSendMail", ["mail"]],
398+ ["meSite", ["files"]],
399+ ["meTeam", ["teams-channels"]],
400+ ["meTodo", ["tasks"]],
401+ ["meUser", ["profile"]],
402+ ["meUserActions", ["profile"]],
403+ ["meUserFunctions", ["profile"]],
404+ ["shares", ["files"]],
405+ ["sites", ["sites"]],
406+ ["sitesOnenote", ["onenote"]],
407+ ["sitesSite", ["sites"]],
408+ ["teams", ["teams-channels"]],
409+ ["teamsChannel", ["teams-channels"]],
410+ ["teamsTeam", ["teams-channels"]],
411+ ["teamsTemplates", ["teams-channels"]],
412+ ["teamwork", ["teams-channels"]],
413+ ["usersCalendar", ["calendar"]],
414+ ["usersCalendarGroups", ["calendar"]],
415+ ["usersCalendars", ["calendar"]],
416+ ["usersCalendarView", ["calendar"]],
417+ ["usersContact", ["contacts"]],
418+ ["usersContactFolder", ["contacts"]],
419+ ["usersContactFolders", ["contacts"]],
420+ ["usersContacts", ["contacts"]],
421+ ["usersDrive", ["files"]],
422+ ["usersDrives", ["files"]],
423+ ["usersEvent", ["calendar"]],
424+ ["usersEvents", ["calendar"]],
425+ ["usersFindMeetingTimes", ["calendar"]],
426+ ["usersMailFolder", ["mail"]],
427+ ["usersMailFolders", ["mail"]],
428+ ["usersMessage", ["mail"]],
429+ ["usersMessages", ["mail"]],
430+ ["usersOnlineMeeting", ["meetings-calls"]],
431+ ["usersOnlineMeetings", ["meetings-calls"]],
432+ ["usersOnenote", ["onenote"]],
433+ ["usersOutlook", ["mail"]],
434+ ["usersPeople", ["contacts"]],
435+ ["usersPerson", ["contacts"]],
436+ ["usersReminderView", ["calendar"]],
437+ ["usersSendMail", ["mail"]],
438+ ["usersTodo", ["tasks"]],
439+ ["usersUser", ["profile"]],
440+ ["usersUserActions", ["profile"]],
441+ ["usersUserFunctions", ["profile"]],
442+]);
443+
444+const MICROSOFT_WORKBOOK_FIRST_SEGMENTS = new Set([
445+ "drivesDriveItem",
446+ "groupsDrive",
447+ "meDrive",
448+ "usersDrive",
449+]);
450+
451+const microsoftFirstSegmentOwnershipKey = (firstSegment: string): string | undefined =>
452+ [...microsoftToolFirstSegmentPresetIds.keys()]
453+ .filter(
454+ (key) =>
455+ firstSegment === key ||
456+ (firstSegment.startsWith(key) &&
457+ firstSegment[key.length]?.toUpperCase() === firstSegment[key.length]),
458+ )
459+ .sort((left, right) => right.length - left.length)[0];
460+
461+const serviceSlugForPreset = (family: MonolithPluginId, presetId: string): string | undefined => {
462+ const preset =
463+ family === "google"
464+ ? googleCatalogById.get(presetId)
465+ : microsoftCatalogByMonolithPresetId.get(presetId);
466+ return preset?.defaultSlug;
467+};
468+
469+const serviceTargetForPreset = (
470+ family: MonolithPluginId,
471+ presetId: string,
472+): ServiceTarget | undefined => {
473+ if (family === "google") {
474+ const preset = googleCatalogById.get(presetId);
475+ if (!preset) return undefined;
476+ return {
477+ family,
478+ pluginId: "openapi",
479+ presetId,
480+ slug: preset.defaultSlug ?? preset.id,
481+ name: preset.name,
482+ description: preset.summary,
483+ ...(preset.url ? { sourceUrl: preset.url } : {}),
484+ specFormat: "google-discovery",
485+ ...(preset.authTemplate ? { authenticationTemplate: preset.authTemplate } : {}),
486+ ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}),
487+ };
488+ }
489+ const preset = microsoftCatalogByMonolithPresetId.get(presetId);
490+ if (!preset) return undefined;
491+ return {
492+ family,
493+ pluginId: "openapi",
494+ presetId,
495+ slug: preset.defaultSlug ?? preset.id,
496+ name: preset.name,
497+ description: preset.summary,
498+ ...(preset.url ? { sourceUrl: preset.url } : {}),
499+ specFormat: "microsoft-graph",
500+ ...(preset.authTemplate ? { authenticationTemplate: preset.authTemplate } : {}),
501+ ...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}),
502+ };
503+};
504+
505+const googlePresetIdsFromConfig = (integration: IntegrationRow): readonly string[] => {
506+ const urls = stringArray(configRecord(integration).googleDiscoveryUrls);
507+ return unique(
508+ urls.flatMap((url) => {
509+ if (url === GOOGLE_IDENTITY_DISCOVERY_URL || url.includes("/oauth2/")) return [];
510+ const preset =
511+ googleCatalogByDiscoveryUrl.get(normalizeUrl(url)) ?? googlePresetForDiscoveryUrl(url);
512+ return preset ? [preset.id] : [];
513+ }),
514+ );
515+};
516+
517+const microsoftPresetIdsFromConfig = (integration: IntegrationRow): readonly string[] => {
518+ const config = configRecord(integration);
519+ const configured = [
520+ ...stringArray(config.microsoftGraphPresetIds),
521+ ...stringArray(config.microsoftGraphScopePresetIds),
522+ ];
523+ if (configured.length > 0) return unique(configured);
524+ throw new Error(
525+ `Microsoft monolith ${tenantHash(integration.tenant)}/${integration.slug} has no stored microsoftGraphPresetIds; refusing to fabricate ${MICROSOFT_GRAPH_DEFAULT_PRESET_IDS.length} default workloads`,
526+ );
527+};
528+
529+const microsoftUnmigratablePresetIds = (presetIds: readonly string[]): readonly string[] =>
530+ presetIds.filter(
531+ (presetId) =>
532+ MICROSOFT_UNMIGRATABLE_UMBRELLA_PRESET_IDS.has(presetId) ||
533+ !microsoftCatalogBackedPresetIds.has(presetId),
534+ );
535+
536+export const googlePresetIdForTool = (toolName: string): string | undefined => {
537+ const [first, second] = toolName.split(".");
538+ if (first === "admin") {
539+ return second === "channels" ? "google-admin-reports" : "google-admin-directory";
540+ }
541+ return GOOGLE_TOOL_PREFIX_TO_PRESET_ID.get(first ?? "");
542+};
543+
544+export const microsoftPresetIdsForTool = (toolName: string): readonly string[] => {
545+ const segments = toolName.split(".");
546+ const first = segments[0] ?? "";
547+ const ownershipKey = microsoftFirstSegmentOwnershipKey(first);
548+ const fromFirstSegment = ownershipKey
549+ ? (microsoftToolFirstSegmentPresetIds.get(ownershipKey) ?? [])
550+ : [];
551+ const workbookOwned =
552+ ownershipKey !== undefined &&
553+ MICROSOFT_WORKBOOK_FIRST_SEGMENTS.has(ownershipKey) &&
554+ segments.some((segment) => segment.toLowerCase().includes("workbook"));
555+ return unique([...fromFirstSegment, ...(workbookOwned ? ["excel"] : [])]).filter((presetId) =>
556+ MICROSOFT_TOOL_IMPLIED_PRESET_IDS.has(presetId),
557+ );
558+};
559+
560+const presetIdsForTool = (pluginId: MonolithPluginId, toolName: string): readonly string[] => {
561+ const presetId = pluginId === "google" ? googlePresetIdForTool(toolName) : undefined;
562+ return pluginId === "google" ? (presetId ? [presetId] : []) : microsoftPresetIdsForTool(toolName);
563+};
564+
565+const deriveServices = (
566+ integration: IntegrationRow,
567+ tools: readonly ToolRow[],
568+ bootRailCreateToolImpliedServices: boolean,
569+): readonly ServiceTarget[] => {
570+ const pluginId = integration.plugin_id as MonolithPluginId;
571+ const fromConfig =
572+ pluginId === "google"
573+ ? googlePresetIdsFromConfig(integration)
574+ : microsoftPresetIdsFromConfig(integration);
575+ if (pluginId === "microsoft") {
576+ const unmigratablePresetIds = microsoftUnmigratablePresetIds(fromConfig);
577+ if (unmigratablePresetIds.length > 0) {
578+ throw new Error(
579+ `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} references unmigratable-umbrella Microsoft Graph preset(s): ${unmigratablePresetIds.join(", ")}; org needs manual handling`,
580+ );
581+ }
582+ }
583+ const fromTools = unique(
584+ tools.flatMap((tool) => {
585+ const presetIds = presetIdsForTool(pluginId, tool.name);
586+ if (
587+ pluginId === "microsoft" &&
588+ fromConfig.length > 0 &&
589+ presetIds.some((presetId) => fromConfig.includes(presetId))
590+ ) {
591+ return [];
592+ }
593+ return presetIds;
594+ }),
595+ );
596+ const missingToolPresetIds = fromTools.filter((presetId) => !fromConfig.includes(presetId));
597+ if (
598+ fromConfig.length > 0 &&
599+ missingToolPresetIds.length > 0 &&
600+ !bootRailCreateToolImpliedServices
601+ ) {
602+ throw new Error(
603+ `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} config omits tool-implied service preset(s): ${missingToolPresetIds.join(", ")}`,
604+ );
605+ }
606+ const presetIds =
607+ bootRailCreateToolImpliedServices && fromConfig.length > 0
608+ ? unique([...fromConfig, ...fromTools])
609+ : fromConfig.length > 0
610+ ? fromConfig
611+ : fromTools;
612+ const services = presetIds.flatMap(
613+ (presetId) => serviceTargetForPreset(pluginId, presetId) ?? [],
614+ );
615+ const missingCatalogPresetIds = presetIds.filter(
616+ (presetId) => !serviceTargetForPreset(pluginId, presetId),
617+ );
618+ if (missingCatalogPresetIds.length > 0) {
619+ throw new Error(
620+ `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} references unknown service preset(s): ${missingCatalogPresetIds.join(", ")}`,
621+ );
622+ }
623+ if (services.length === 0) {
624+ throw new Error(
625+ `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} has no derivable services`,
626+ );
627+ }
628+ return services;
629+};
630+
631+const configForService = (source: IntegrationRow, target: ServiceTarget): unknown => {
632+ const config = configRecord(source);
633+ const specHash =
634+ typeof config.specHash === "string" && config.specHash.length > 0 ? config.specHash : undefined;
635+ return {
636+ ...(specHash ? { specHash } : {}),
637+ ...(target.sourceUrl ? { sourceUrl: target.sourceUrl } : {}),
638+ specFormat: target.specFormat,
639+ family: target.family,
640+ ...(target.authenticationTemplate
641+ ? { authenticationTemplate: target.authenticationTemplate }
642+ : {}),
643+ };
644+};
645+
646+const rowKey = (...parts: readonly string[]): string => parts.join("\u0000");
647+
648+const stableKeyHash = (value: string): string => {
649+ let hash = 0xcbf29ce484222325n;
650+ const prime = 0x100000001b3n;
651+ const mask = 0xffffffffffffffffn;
652+ for (let index = 0; index < value.length; index += 1) {
653+ hash ^= BigInt(value.charCodeAt(index));
654+ hash = (hash * prime) & mask;
655+ }
656+ return hash.toString(36).padStart(13, "0");
657+};
658+
659+export const operationStorageKey = (integration: string, toolName: string): string =>
660+ `op.${stableKeyHash(integration)}.${stableKeyHash(toolName)}`;
661+
662+export const storageDataRecord = (row: Pick<PluginStorageRow, "data">): Record<string, unknown> =>
663+ recordFromJsonLike(row.data);
664+
665+const operationToolName = (row: PluginStorageRow): string | undefined => {
666+ const value = storageDataRecord(row).toolName;
667+ return typeof value === "string" && value.length > 0 ? value : undefined;
668+};
669+
670+const operationIntegration = (row: PluginStorageRow): string | undefined => {
671+ const value = storageDataRecord(row).integration;
672+ return typeof value === "string" && value.length > 0 ? value : undefined;
673+};
674+
675+const specHashFor = (integration: IntegrationRow): string => {
676+ const value = configRecord(integration).specHash;
677+ if (typeof value === "string" && value.length > 0) return value;
678+ throw new Error(
679+ `Monolith ${tenantHash(integration.tenant)}/${integration.plugin_id}/${integration.slug} has no specHash; serving state cannot be migrated`,
680+ );
681+};
682+
683+const pluginBlobNamespace = (tenant: string, pluginId: string): string => `o:${tenant}/${pluginId}`;
684+
685+const blobObjectName = (namespace: string, key: string): string => `${namespace}/${key}`;
686+
687+const operationRowsForService = (
688+ monolith: IntegrationRow,
689+ target: ServiceTarget,
690+ rows: readonly PluginStorageRow[],
691+): readonly PluginStorageRow[] =>
692+ rows.filter((row) => {
693+ if (row.tenant !== monolith.tenant) return false;
694+ if (row.plugin_id !== monolith.plugin_id) return false;
695+ if (row.collection !== "operation") return false;
696+ if (operationIntegration(row) !== monolith.slug) return false;
697+ const toolName = operationToolName(row);
698+ if (!toolName) return false;
699+ if (isIntentionallyDroppedTool(monolith.plugin_id as MonolithPluginId, toolName)) return false;
700+ return serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, toolName, [
701+ target,
702+ ]).includes(target.slug);
703+ });
704+
705+const operationRowsForMonolith = (
706+ monolith: IntegrationRow,
707+ rows: readonly PluginStorageRow[],
708+): readonly PluginStorageRow[] =>
709+ rows.filter((row) => {
710+ if (row.tenant !== monolith.tenant) return false;
711+ if (row.plugin_id !== monolith.plugin_id) return false;
712+ if (row.collection !== "operation") return false;
713+ return operationIntegration(row) === monolith.slug;
714+ });
715+
716+const isConfigOnlyMonolith = (input: {
717+ readonly tools: readonly ToolRow[];
718+ readonly connections: readonly ConnectionRow[];
719+ readonly operations: readonly PluginStorageRow[];
720+}): boolean =>
721+ input.tools.length === 0 && input.connections.length === 0 && input.operations.length === 0;
722+
723+const isIntentionallyDroppedTool = (pluginId: MonolithPluginId, toolName: string): boolean =>
724+ pluginId === "google" && toolName.startsWith("oauth2.");
725+
726+const serviceForMatchedTool = (
727+ pluginId: MonolithPluginId,
728+ toolName: string,
729+ services: readonly ServiceTarget[],
730+): readonly string[] => {
731+ const presetIds = presetIdsForTool(pluginId, toolName);
732+ return presetIds.flatMap((presetId) => {
733+ const slug = serviceSlugForPreset(pluginId, presetId);
734+ return slug && services.some((service) => service.slug === slug) ? [slug] : [];
735+ });
736+};
737+
738+const allServiceSlugs = (services: readonly ServiceTarget[]): readonly string[] =>
739+ services.map((service) => service.slug);
740+
741+const unassignedToolNames = (
742+ pluginId: MonolithPluginId,
743+ tools: readonly { readonly name: string }[],
744+ services: readonly ServiceTarget[],
745+): readonly string[] =>
746+ unique(
747+ tools.flatMap((tool) => {
748+ if (isIntentionallyDroppedTool(pluginId, tool.name)) return [];
749+ return serviceForMatchedTool(pluginId, tool.name, services).length === 0 ? [tool.name] : [];
750+ }),
751+ );
752+
753+const assertNoUnassignedRows = (
754+ monolith: IntegrationRow,
755+ tools: readonly ToolRow[],
756+ operations: readonly PluginStorageRow[],
757+ services: readonly ServiceTarget[],
758+): void => {
759+ const pluginId = monolith.plugin_id as MonolithPluginId;
760+ const unassignedTools = unassignedToolNames(pluginId, tools, services);
761+ if (unassignedTools.length > 0) {
762+ throw new Error(
763+ `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has tool row(s) with no target service: ${unassignedTools.slice(0, 20).join(", ")}`,
764+ );
765+ }
766+ const operationTools = operations.flatMap((row) => {
767+ const toolName = operationToolName(row);
768+ return toolName ? [{ name: toolName }] : [];
769+ });
770+ const unassignedOperations = unassignedToolNames(pluginId, operationTools, services);
771+ if (unassignedOperations.length > 0) {
772+ throw new Error(
773+ `Monolith ${tenantHash(monolith.tenant)}/${monolith.plugin_id}/${monolith.slug} has operation row(s) with no target service: ${unassignedOperations.slice(0, 20).join(", ")}`,
774+ );
775+ }
776+};
777+
778+const serviceSlugsForToolPattern = (
779+ pluginId: MonolithPluginId,
780+ toolSegments: readonly string[],
781+ services: readonly ServiceTarget[],
782+): readonly string[] => {
783+ const firstToolSegment = toolSegments[0];
784+ if (!firstToolSegment || firstToolSegment === "*") return allServiceSlugs(services);
785+ const presetIds = presetIdsForTool(pluginId, toolSegments.join("."));
786+ return presetIds.flatMap((presetId) => {
787+ const slug = serviceSlugForPreset(pluginId, presetId);
788+ return slug && services.some((service) => service.slug === slug) ? [slug] : [];
789+ });
790+};
791+
792+const serviceSlugsForPolicyPattern = (
793+ policy: ToolPolicyRow,
794+ monolith: IntegrationRow,
795+ services: readonly ServiceTarget[],
796+): readonly string[] => {
797+ const tail = withoutToolsPrefix(policy.pattern);
798+ const segments = tail.split(".");
799+ const rest = segments.slice(1);
800+ const pluginId = monolith.plugin_id as MonolithPluginId;
801+ if (rest.length === 0) return allServiceSlugs(services);
802+
803+ if (rest[0] === "org" || rest[0] === "user") {
804+ return serviceSlugsForToolPattern(pluginId, rest.slice(2), services);
805+ }
806+
807+ if (rest[0] === "*") {
808+ if (rest.length <= 2) return allServiceSlugs(services);
809+ return serviceSlugsForToolPattern(pluginId, rest.slice(2), services);
810+ }
811+
812+ return serviceSlugsForToolPattern(pluginId, rest, services);
813+};
814+
815+const rewritePatternIntegration = (pattern: string, targetSlug: string): string => {
816+ const hasTools = pattern.startsWith("tools.");
817+ const tail = hasTools ? pattern.slice("tools.".length) : pattern;
818+ const segments = tail.split(".");
819+ segments[0] = targetSlug;
820+ return withOriginalToolsPrefix(pattern, segments.join("."));
821+};
822+
823+const policyMatches = (pattern: string, tool: ToolRow): boolean => {
824+ const normalized = withoutToolsPrefix(pattern);
825+ return matchPattern(normalized, toolAddress(tool));
826+};
827+
828+const rewritePolicy = (
829+ policy: ToolPolicyRow,
830+ monolith: IntegrationRow,
831+ services: readonly ServiceTarget[],
832+ orphanPolicyMode: MigrationInput["orphanPolicyMode"],
833+): PlannedPolicyRewrite => {
834+ const patternIntegration = integrationPatternSegment(policy.pattern).integration;
835+ if (patternIntegration !== monolith.slug) {
836+ throw new Error(
837+ `Policy ${policy.id} for org ${tenantHash(policy.tenant)} does not target ${monolith.slug}`,
838+ );
839+ }
840+
841+ // Orphan policies (pattern targets a service the org never derived) retarget
842+ // to every derived service as dormant rows: block/require_approval so the
843+ // guardrail intent survives a later add of that service, and approve because
844+ // dropping it is the only alternative and a dormant row is equally inert.
845+ const serviceSlugs = unique(serviceSlugsForPolicyPattern(policy, monolith, services));
846+ const orphanPolicyRetarget = orphanPolicyMode === "retarget_all" ? allServiceSlugs(services) : [];
847+
848+ if (serviceSlugs.length === 0 && orphanPolicyRetarget.length === 0) {
849+ throw new Error(
850+ `Policy ${policy.id} (${policy.pattern}) for org ${tenantHash(policy.tenant)} would be dropped; no target service could be derived`,
851+ );
852+ }
853+ const matchedServices = serviceSlugs.length > 0 ? serviceSlugs : orphanPolicyRetarget;
854+
855+ return {
856+ policy,
857+ action: "rewrite",
858+ afterPatterns: matchedServices.map((slug) => rewritePatternIntegration(policy.pattern, slug)),
859+ matchedServices,
860+ };
861+};
862+
863+export interface NeverWidenResult {
864+ readonly ok: boolean;
865+ readonly checkedPolicies: number;
866+ readonly widened: readonly {
867+ readonly policyId: string;
868+ readonly beforePattern: string;
869+ readonly afterPatterns: readonly string[];
870+ readonly extraAddresses: readonly string[];
871+ }[];
872+ readonly narrowed: readonly {
873+ readonly policyId: string;
874+ readonly beforePattern: string;
875+ readonly missingServices: readonly string[];
876+ }[];
877+}
878+
879+export const verifyPolicyRewriteNeverWidens = (
880+ plan: MigrationPlan,
881+ input: Pick<MigrationInput, "tools">,
882+): NeverWidenResult => {
883+ const widened: {
884+ readonly policyId: string;
885+ readonly beforePattern: string;
886+ readonly afterPatterns: readonly string[];
887+ readonly extraAddresses: readonly string[];
888+ }[] = [];
889+ const narrowed: {
890+ readonly policyId: string;
891+ readonly beforePattern: string;
892+ readonly missingServices: readonly string[];
893+ }[] = [];
894+ let checkedPolicies = 0;
895+
896+ for (const org of plan.orgs) {
897+ const orgTools = input.tools.filter((tool) => tool.tenant === org.tenant);
898+ for (const policy of org.policies) {
899+ if (policy.action !== "rewrite") continue;
900+ checkedPolicies += 1;
901+ if (policy.policy.action === "block" || policy.policy.action === "require_approval") {
902+ const afterServices = new Set(
903+ policy.afterPatterns.map(
904+ (afterPattern) => integrationPatternSegment(afterPattern).integration,
905+ ),
906+ );
907+ const missingServices = policy.matchedServices.filter((service) => {
908+ if (!afterServices.has(service)) return true;
909+ const expectedPattern = rewritePatternIntegration(policy.policy.pattern, service);
910+ return !policy.afterPatterns.includes(expectedPattern);
911+ });
912+ if (missingServices.length > 0) {
913+ narrowed.push({
914+ policyId: policy.policy.id,
915+ beforePattern: policy.policy.pattern,
916+ missingServices,
917+ });
918+ }
919+ }
920+ const before = new Set(
921+ orgTools
922+ .filter((tool) => policyMatches(policy.policy.pattern, tool))
923+ .flatMap((tool) => {
924+ const monolith = org.deleteMonoliths.find((row) => row.slug === tool.integration);
925+ if (!monolith) return [];
926+ const services = policy.matchedServices.filter((slug) =>
927+ serviceForMatchedTool(
928+ monolith.plugin_id as MonolithPluginId,
929+ tool.name,
930+ org.integrations.map((i) => i.target),
931+ ).includes(slug),
932+ );
933+ return services.map((slug) => `${slug}.${tool.owner}.${tool.connection}.${tool.name}`);
934+ }),
935+ );
936+ const after = new Set(
937+ orgTools.flatMap((tool) => {
938+ const monolith = org.deleteMonoliths.find((row) => row.slug === tool.integration);
939+ if (!monolith) return [];
940+ const toolServices = serviceForMatchedTool(
941+ monolith.plugin_id as MonolithPluginId,
942+ tool.name,
943+ org.integrations.map((integration) => integration.target),
944+ );
945+ return policy.afterPatterns
946+ .filter((afterPattern) => {
947+ const targetSlug = integrationPatternSegment(afterPattern).integration;
948+ if (!toolServices.includes(targetSlug)) return false;
949+ const targetTool = { ...tool, integration: targetSlug };
950+ return policyMatches(afterPattern, targetTool);
951+ })
952+ .map((afterPattern) => {
953+ const targetSlug = integrationPatternSegment(afterPattern).integration;
954+ return `${targetSlug}.${tool.owner}.${tool.connection}.${tool.name}`;
955+ });
956+ }),
957+ );
958+ const extra = [...after].filter((address) => !before.has(address));
959+ if (extra.length > 0) {
960+ widened.push({
961+ policyId: policy.policy.id,
962+ beforePattern: policy.policy.pattern,
963+ afterPatterns: policy.afterPatterns,
964+ extraAddresses: extra.slice(0, 20),
965+ });
966+ }
967+ }
968+ }
969+
970+ return {
971+ ok: widened.length === 0 && narrowed.length === 0,
972+ checkedPolicies,
973+ widened,
974+ narrowed,
975+ };
976+};
977+
978+export const planMigration = (input: MigrationInput): MigrationPlan => {
979+ const completed = new Set(input.completedTenants ?? []);
980+ const monoliths = input.integrations.filter(
981+ (row) => row.plugin_id === "google" || row.plugin_id === "microsoft",
982+ );
983+ const tenants = [...unique(monoliths.map((row) => row.tenant))].sort();
984+ const trafficLastTenant = input.trafficLastTenant;
985+ const orderedTenants = trafficLastTenant
986+ ? [
987+ ...tenants.filter((tenant) => tenant !== trafficLastTenant),
988+ ...tenants.filter((tenant) => tenant === trafficLastTenant),
989+ ]
990+ : tenants;
991+
992+ const integrationExists = new Set(input.integrations.map((row) => rowKey(row.tenant, row.slug)));
993+ const blobBackend = input.blobBackend ?? "database";
994+ const connectionExists = new Set(
995+ input.connections.map((row) =>
996+ rowKey(row.tenant, row.owner, row.subject, row.integration, row.name),
997+ ),
998+ );
999+
1000+ const orgs = orderedTenants.map((tenant): OrgPlan => {
1001+ const orgMonoliths = monoliths.filter((row) => row.tenant === tenant);
1002+ const orgTools = input.tools.filter((row) => row.tenant === tenant);
1003+ const orgStorage = input.pluginStorage?.filter((row) => row.tenant === tenant) ?? [];
1004+ const orgBlobs = input.blobs?.filter((row) => row.namespace.startsWith(`o:${tenant}/`)) ?? [];
1005+ const orgConnections: PlannedConnection[] = [];
1006+ const orgPolicies: PlannedPolicyRewrite[] = [];
1007+ const orgBlobCopies: PlannedBlobCopy[] = [];
1008+ const plannedBlobCopyKeys = new Set<string>();
1009+ const hardErrors: string[] = [];
1010+ const plannedIntegrationsBySlug = new Map<string, PlannedIntegration>();
1011+ let clonedToolRows = 0;
1012+ let operationsToBuild = 0;
1013+
1014+ for (const monolith of orgMonoliths) {
1015+ const monolithTools = orgTools.filter((tool) => tool.integration === monolith.slug);
1016+ const monolithConnections = input.connections.filter(
1017+ (connection) => connection.tenant === tenant && connection.integration === monolith.slug,
1018+ );
1019+ const monolithOperations = operationRowsForMonolith(monolith, orgStorage);
1020+ let services: readonly ServiceTarget[];
1021+ try {
1022+ services = deriveServices(
1023+ monolith,
1024+ monolithTools,
1025+ input.bootRailCreateToolImpliedServices ?? false,
1026+ );
1027+ } catch (error) {
1028+ if (!input.collectPolicyErrors) throw error;
1029+ hardErrors.push(error instanceof Error ? error.message : String(error));
1030+ continue;
1031+ }
1032+ if (
1033+ isConfigOnlyMonolith({
1034+ tools: monolithTools,
1035+ connections: monolithConnections,
1036+ operations: monolithOperations,
1037+ })
1038+ ) {
1039+ continue;
1040+ }
1041+ try {
1042+ assertNoUnassignedRows(monolith, monolithTools, monolithOperations, services);
1043+ } catch (error) {
1044+ if (!input.collectPolicyErrors) throw error;
1045+ hardErrors.push(error instanceof Error ? error.message : String(error));
1046+ continue;
1047+ }
1048+ const specHash = specHashFor(monolith);
1049+ const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);
1050+ const targetNamespace = pluginBlobNamespace(tenant, "openapi");
1051+ const specBlobPresent =
1052+ (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) ||
1053+ orgBlobs.some((blob) => blob.namespace === namespace && blob.key === `spec/${specHash}`);
1054+ const defsBlobPresent =
1055+ (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) ||
1056+ orgBlobs.some((blob) => blob.namespace === namespace && blob.key === `defs/${specHash}`);
1057+ for (const key of [`spec/${specHash}`, `defs/${specHash}`]) {
1058+ const copyKey = rowKey(namespace, targetNamespace, key);
1059+ if (plannedBlobCopyKeys.has(copyKey)) continue;
1060+ plannedBlobCopyKeys.add(copyKey);
1061+ const sourcePresent =
1062+ (blobBackend === "external" && input.assumeExternalBlobSourcePresent === true) ||
1063+ orgBlobs.some((blob) => blob.namespace === namespace && blob.key === key);
1064+ const targetPresent =
1065+ blobBackend === "external"
1066+ ? false
1067+ : orgBlobs.some((blob) => blob.namespace === targetNamespace && blob.key === key);
1068+ if (!sourcePresent) {
1069+ hardErrors.push(
1070+ `Missing source blob ${namespace}/${key} for migrated OpenAPI service namespace ${targetNamespace}`,
1071+ );
1072+ }
1073+ orgBlobCopies.push({
1074+ source: monolith,
1075+ specHash,
1076+ key,
1077+ sourceNamespace: namespace,
1078+ targetNamespace,
1079+ backend: blobBackend,
1080+ sourcePresent,
1081+ targetPresent,
1082+ sourceObjectName: blobObjectName(namespace, key),
1083+ targetObjectName: blobObjectName(targetNamespace, key),
1084+ });
1085+ }
1086+ for (const target of services) {
1087+ const serviceOperations = operationRowsForService(monolith, target, orgStorage);
1088+ const operationToolNames = unique(
1089+ serviceOperations.flatMap((row) => operationToolName(row) ?? []),
1090+ );
1091+ operationsToBuild += operationToolNames.length;
1092+ const contribution = {
1093+ source: monolith,
1094+ specHash,
1095+ operationsToBuild: operationToolNames.length,
1096+ operationToolNames,
1097+ specBlobPresent,
1098+ defsBlobPresent,
1099+ };
1100+ const existing = plannedIntegrationsBySlug.get(target.slug);
1101+ if (existing) {
1102+ const sourceContributions = [...existing.sourceContributions, contribution];
1103+ const winner = sourceContributions.reduce((best, candidate) =>
1104+ candidate.operationsToBuild > best.operationsToBuild ? candidate : best,
1105+ );
1106+ const mergedOperationToolNames = unique(
1107+ sourceContributions.flatMap((item) => item.operationToolNames),
1108+ );
1109+ const next: PlannedIntegration = {
1110+ ...existing,
1111+ sourceContributions,
1112+ source: winner.source,
1113+ config: configForService(winner.source, target),
1114+ servingState: {
1115+ specHash: winner.specHash,
1116+ specSource: `${winner.source.plugin_id}/${winner.source.slug}`,
1117+ blobBackend,
1118+ specBlobPresent: winner.specBlobPresent,
1119+ defsBlobPresent: winner.defsBlobPresent,
1120+ operationsToBuild: mergedOperationToolNames.length,
1121+ operationToolNames: mergedOperationToolNames,
1122+ expectedZeroOperations: false,
1123+ },
1124+ };
1125+ plannedIntegrationsBySlug.set(target.slug, next);
1126+ } else {
1127+ const exists = integrationExists.has(rowKey(tenant, target.slug));
1128+ const planned: PlannedIntegration = {
1129+ source: monolith,
1130+ sourceContributions: [contribution],
1131+ target,
1132+ action: exists ? "skip_existing" : "create",
1133+ config: configForService(monolith, target),
1134+ ...(target.healthCheck ? { healthCheck: target.healthCheck } : {}),
1135+ servingState: {
1136+ specHash,
1137+ specSource: `${monolith.plugin_id}/${monolith.slug}`,
1138+ blobBackend,
1139+ specBlobPresent,
1140+ defsBlobPresent,
1141+ operationsToBuild: operationToolNames.length,
1142+ operationToolNames,
1143+ expectedZeroOperations: false,
1144+ },
1145+ };
1146+ plannedIntegrationsBySlug.set(target.slug, planned);
1147+ }
1148+ }
1149+
1150+ for (const connection of monolithConnections) {
1151+ for (const target of services) {
1152+ const key = rowKey(
1153+ tenant,
1154+ connection.owner,
1155+ connection.subject,
1156+ target.slug,
1157+ connection.name,
1158+ );
1159+ const exists = connectionExists.has(key);
1160+ orgConnections.push({
1161+ source: connection,
1162+ targetIntegration: target.slug,
1163+ action: exists ? "skip_existing" : "clone",
1164+ tokenReuse: "copy_item_ids_and_oauth_columns",
1165+ });
1166+ if (!exists) {
1167+ connectionExists.add(key);
1168+ }
1169+ clonedToolRows += monolithTools.filter((tool) =>
1170+ serviceForMatchedTool(monolith.plugin_id as MonolithPluginId, tool.name, [
1171+ target,
1172+ ]).includes(target.slug),
1173+ ).length;
1174+ }
1175+ }
1176+
1177+ const candidatePolicies = input.policies.filter((policy) => policy.tenant === tenant);
1178+ for (const policy of candidatePolicies) {
1179+ const patternIntegration = integrationPatternSegment(policy.pattern).integration;
1180+ if (patternIntegration !== monolith.slug) continue;
1181+ try {
1182+ orgPolicies.push(rewritePolicy(policy, monolith, services, input.orphanPolicyMode));
1183+ } catch (error) {
1184+ if (!input.collectPolicyErrors) throw error;
1185+ hardErrors.push(error instanceof Error ? error.message : String(error));
1186+ }
1187+ }
1188+ }
1189+
1190+ return {
1191+ tenant,
1192+ tenantHash: tenantHash(tenant),
1193+ completed: completed.has(tenant),
1194+ integrations: [...plannedIntegrationsBySlug.values()],
1195+ connections: orgConnections,
1196+ policies: orgPolicies,
1197+ blobCopies: orgBlobCopies,
1198+ deleteMonoliths: orgMonoliths,
1199+ clonedToolRows,
1200+ operationsToBuild,
1201+ hardErrors,
1202+ };
1203+ });
1204+
1205+ const activeOrgs = orgs.filter((org) => !org.completed && org.hardErrors.length === 0);
1206+ const summary = {
1207+ orgs: orgs.length,
1208+ completedOrgs: orgs.filter((org) => org.completed).length,
1209+ integrationsCreate: activeOrgs
1210+ .flatMap((org) => org.integrations)
1211+ .filter((row) => row.action === "create").length,
1212+ integrationsSkipExisting: activeOrgs
1213+ .flatMap((org) => org.integrations)
1214+ .filter((row) => row.action === "skip_existing").length,
1215+ connectionsClone: activeOrgs
1216+ .flatMap((org) => org.connections)
1217+ .filter((row) => row.action === "clone").length,
1218+ connectionsSkipExisting: activeOrgs
1219+ .flatMap((org) => org.connections)
1220+ .filter((row) => row.action === "skip_existing").length,
1221+ policiesRewrite: activeOrgs
1222+ .flatMap((org) => org.policies)
1223+ .filter((row) => row.action === "rewrite").length,
1224+ policiesSkip: 0,
1225+ policyRowsAfter: activeOrgs
1226+ .flatMap((org) => org.policies)
1227+ .reduce((sum, row) => sum + row.afterPatterns.length, 0),
1228+ monolithDeletes: activeOrgs.flatMap((org) => org.deleteMonoliths).length,
1229+ clonedToolRows: activeOrgs.reduce((sum, org) => sum + org.clonedToolRows, 0),
1230+ operationsToBuild: activeOrgs.reduce((sum, org) => sum + org.operationsToBuild, 0),
1231+ integrationsMissingSpecBlob: activeOrgs
1232+ .flatMap((org) => org.integrations)
1233+ .filter((row) => !row.servingState.specBlobPresent).length,
1234+ integrationsMissingDefsBlob: activeOrgs
1235+ .flatMap((org) => org.integrations)
1236+ .filter((row) => !row.servingState.defsBlobPresent).length,
1237+ hardErrorOrgs: orgs.filter((org) => !org.completed && org.hardErrors.length > 0).length,
1238+ policyHardErrors: orgs.reduce((sum, org) => sum + org.hardErrors.length, 0),
1239+ };
1240+ return { orgs, summary };
1241+};
1242+
1243+const printableJson = (value: unknown): string =>
1244+ JSON.stringify(value, (_key, inner) => (inner === undefined ? null : inner), 2);
1245+
1246+export const renderOrgDiff = (org: OrgPlan): string => {
1247+ const lines: string[] = [];
1248+ lines.push(`# Org ${org.tenantHash}`);
1249+ lines.push("");
1250+ if (org.completed) {
1251+ lines.push("Already completed, no changes planned.");
1252+ lines.push("");
1253+ return lines.join("\n");
1254+ }
1255+ if (org.hardErrors.length > 0) {
1256+ lines.push("## Hard Errors");
1257+ lines.push("Apply is blocked for this org until these errors are handled.");
1258+ for (const error of org.hardErrors) {
1259+ lines.push(`- ${error}`);
1260+ }
1261+ lines.push("");
1262+ }
1263+ lines.push("## Integrations");
1264+ for (const row of org.integrations) {
1265+ lines.push(
1266+ `- ${row.action}: ${row.source.plugin_id}/${row.source.slug} (${row.source.name ?? "unnamed"}) -> ${row.target.slug} (${row.target.name})`,
1267+ );
1268+ lines.push(
1269+ ` serving: operations to build: ${row.servingState.operationsToBuild} / spec source: ${row.servingState.specSource} / blob backend: ${row.servingState.blobBackend} / specHash: ${row.servingState.specHash} / spec blob: ${row.servingState.specBlobPresent ? "present" : "missing"} / defs blob: ${row.servingState.defsBlobPresent ? "present" : "missing"}`,
1270+ );
1271+ if (row.sourceContributions.length > 1) {
1272+ for (const contribution of row.sourceContributions) {
1273+ lines.push(
1274+ ` contribution: ${contribution.source.plugin_id}/${contribution.source.slug} / operations: ${contribution.operationsToBuild} / specHash: ${contribution.specHash ?? "none"}`,
1275+ );
1276+ }
1277+ }
1278+ lines.push(` config: ${printableJson(row.config).replaceAll("\n", "\n ")}`);
1279+ }
1280+ for (const row of org.deleteMonoliths) {
1281+ lines.push(
1282+ `- delete monolith in apply mode: ${row.plugin_id}/${row.slug} (${row.name ?? "unnamed"})`,
1283+ );
1284+ }
1285+ lines.push("");
1286+ lines.push("## Blob Copies");
1287+ for (const row of org.blobCopies) {
1288+ lines.push(
1289+ `- ${row.backend}: ${row.sourceObjectName} -> ${row.targetObjectName} / source: ${row.sourcePresent ? "present" : "missing"} / target: ${row.targetPresent ? "present" : "missing"}`,
1290+ );
1291+ }
1292+ lines.push("");
1293+ lines.push("## Connections");
1294+ for (const row of org.connections) {
1295+ lines.push(
1296+ `- ${row.action}: ${row.source.integration}.${row.source.owner}.${row.source.name} -> ${row.targetIntegration}.${row.source.owner}.${row.source.name} (${row.tokenReuse})`,
1297+ );
1298+ }
1299+ lines.push("");
1300+ lines.push("## Policies");
1301+ for (const row of org.policies) {
1302+ lines.push(`- rewrite ${row.policy.id}: ${row.policy.pattern}`);
1303+ for (const after of row.afterPatterns) {
1304+ lines.push(` -> ${after}`);
1305+ }
1306+ }
1307+ lines.push("");
1308+ lines.push("## Internal Rows");
1309+ lines.push(`- tool rows cloned in apply mode: ${org.clonedToolRows}`);
1310+ lines.push(`- operation rows copied in apply mode: ${org.operationsToBuild}`);
1311+ lines.push("");
1312+ return lines.join("\n");
1313+};
1314+
1315+export const renderSummary = (plan: MigrationPlan): string => {
1316+ const s = plan.summary;
1317+ return [
1318+ `orgs=${s.orgs}`,
1319+ `completed_orgs=${s.completedOrgs}`,
1320+ `integrations_create=${s.integrationsCreate}`,
1321+ `integrations_skip_existing=${s.integrationsSkipExisting}`,
1322+ `connections_clone=${s.connectionsClone}`,
1323+ `connections_skip_existing=${s.connectionsSkipExisting}`,
1324+ `policies_rewrite=${s.policiesRewrite}`,
1325+ `policy_rows_after=${s.policyRowsAfter}`,
1326+ `policies_skip=${s.policiesSkip}`,
1327+ `monolith_deletes=${s.monolithDeletes}`,
1328+ `tool_rows_clone=${s.clonedToolRows}`,
1329+ `operation_rows_build=${s.operationsToBuild}`,
1330+ `integrations_missing_spec_blob=${s.integrationsMissingSpecBlob}`,
1331+ `integrations_missing_defs_blob=${s.integrationsMissingDefsBlob}`,
1332+ `hard_error_orgs=${s.hardErrorOrgs}`,
1333+ `policy_hard_errors=${s.policyHardErrors}`,
1334+ ].join("\n");
1335+};
packages/plugins/provider-service-split/src/sqlite.test.ts+337−0
@@ -0,0 +1,337 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Schema } from "effect";
3+import {
4+ collectTables,
5+ DataMigrationError,
6+ runSqliteDataMigrations,
7+ type SqliteDataMigrationClient,
8+} from "@executor-js/sdk";
9+import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
10+
11+import { operationStorageKey } from "./planner";
12+import {
13+ providerServiceSplitDataMigration,
14+ runSqliteProviderServiceSplitMigration,
15+} from "./sqlite";
16+
17+const now = 1_780_000_000_000;
18+const parseJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
19+
20+const insertIntegration = (
21+ client: SqliteDataMigrationClient,
22+ row: {
23+ readonly rowId: string;
24+ readonly tenant: string;
25+ readonly slug: string;
26+ readonly pluginId: string;
27+ readonly config: unknown;
28+ readonly healthCheck?: unknown;
29+ },
30+) =>
31+ client.execute({
32+ sql: `INSERT INTO integration
33+ (row_id, tenant, slug, plugin_id, name, description, config, health_check,
34+ can_remove, can_refresh, created_at, updated_at)
35+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
36+ args: [
37+ row.rowId,
38+ row.tenant,
39+ row.slug,
40+ row.pluginId,
41+ row.slug,
42+ row.slug,
43+ JSON.stringify(row.config),
44+ row.healthCheck ? JSON.stringify(row.healthCheck) : null,
45+ now,
46+ now,
47+ ],
48+ });
49+
50+const insertConnection = (client: SqliteDataMigrationClient, tenant = "org_1") =>
51+ client.execute({
52+ sql: `INSERT INTO connection
53+ (integration, name, template, provider, item_ids, identity_label, description,
54+ last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id,
55+ expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at,
56+ row_id, tenant, owner, subject)
57+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
58+ args: [
59+ "google",
60+ "main",
61+ "googleOAuth",
62+ "vault",
63+ JSON.stringify({ token: "access_item", refresh: "refresh_item" }),
64+ "person@example.test",
65+ now,
66+ "google",
67+ "org",
68+ "refresh_item",
69+ now + 60_000,
70+ "calendar gmail",
71+ "https://oauth2.googleapis.com/token",
72+ JSON.stringify({ token: "metadata" }),
73+ now,
74+ now,
75+ `conn_google_${tenant}`,
76+ tenant,
77+ "org",
78+ "",
79+ ],
80+ });
81+
82+const insertTool = (client: SqliteDataMigrationClient, name: string, tenant = "org_1") =>
83+ client.execute({
84+ sql: `INSERT INTO tool
85+ (tenant, owner, subject, integration, connection, plugin_id, name, description,
86+ input_schema, output_schema, annotations, created_at, updated_at, row_id)
87+ VALUES (?, 'org', '', 'google', 'main', 'google', ?, 'tool', '{}', '{}', '{}', ?, ?, ?)`,
88+ args: [tenant, name, now, now, `tool_${tenant}_${name}`],
89+ });
90+
91+const insertOperation = (client: SqliteDataMigrationClient, name: string, tenant = "org_1") =>
92+ client.execute({
93+ sql: `INSERT INTO plugin_storage
94+ (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
95+ VALUES (?, 'org', '', 'google', 'operation', ?, ?, ?, ?, ?)`,
96+ args: [
97+ tenant,
98+ operationStorageKey("google", name),
99+ JSON.stringify({
100+ integration: "google",
101+ toolName: name,
102+ binding: { method: "get", pathTemplate: `/${name}`, parameters: [] },
103+ description: name,
104+ }),
105+ now,
106+ now,
107+ `op_${tenant}_${name}`,
108+ ],
109+ });
110+
111+const insertBlob = (client: SqliteDataMigrationClient, key: string, tenant = "org_1") =>
112+ client.execute({
113+ sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)",
114+ args: [
115+ `o:${tenant}/google`,
116+ key,
117+ "blob",
118+ `blob_${tenant}_${key}`,
119+ JSON.stringify([`o:${tenant}/google`, key]),
120+ ],
121+ });
122+
123+const insertPolicy = (client: SqliteDataMigrationClient) =>
124+ client.execute({
125+ sql: `INSERT INTO tool_policy
126+ (id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject)
127+ VALUES (?, ?, 'block', 'a0', ?, ?, ?, 'org_1', 'org', '')`,
128+ args: ["policy_orphan", "google.*.*.gmail.users.messages.send", now, now, "policy_orphan_row"],
129+ });
130+
131+const seedCalendarOrg = (
132+ client: SqliteDataMigrationClient,
133+ tenant = "org_1",
134+ options: { readonly includeBlobs?: boolean } = { includeBlobs: true },
135+) =>
136+ Effect.gen(function* () {
137+ yield* Effect.promise(() =>
138+ insertIntegration(client, {
139+ rowId: `google_row_${tenant}`,
140+ tenant,
141+ slug: "google",
142+ pluginId: "google",
143+ config: {
144+ googleDiscoveryUrls: [
145+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
146+ "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest",
147+ ],
148+ specHash: "mono-hash",
149+ },
150+ healthCheck: { operation: "oauth2.userinfo.get" },
151+ }),
152+ );
153+ yield* Effect.promise(() => insertConnection(client, tenant));
154+ yield* Effect.promise(() => insertTool(client, "calendar.events.list", tenant));
155+ yield* Effect.promise(() => insertOperation(client, "calendar.events.list", tenant));
156+ yield* Effect.promise(() => insertOperation(client, "oauth2.userinfo.get", tenant));
157+ if (options.includeBlobs !== false) {
158+ yield* Effect.promise(() => insertBlob(client, "spec/mono-hash", tenant));
159+ yield* Effect.promise(() => insertBlob(client, "defs/mono-hash", tenant));
160+ }
161+ });
162+
163+describe("providerServiceSplitDataMigration", () => {
164+ it.effect("splits libSQL monolith rows into openapi service rows and stamps once", () =>
165+ Effect.gen(function* () {
166+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
167+ const client = db.client;
168+
169+ yield* Effect.promise(() =>
170+ insertIntegration(client, {
171+ rowId: "google_row",
172+ tenant: "org_1",
173+ slug: "google",
174+ pluginId: "google",
175+ config: {
176+ googleDiscoveryUrls: [
177+ "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
178+ "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest",
179+ ],
180+ specHash: "mono-hash",
181+ },
182+ healthCheck: { operation: "oauth2.userinfo.get" },
183+ }),
184+ );
185+ yield* Effect.promise(() => insertConnection(client));
186+ yield* Effect.promise(() => insertTool(client, "calendar.events.list"));
187+ yield* Effect.promise(() => insertOperation(client, "calendar.events.list"));
188+ yield* Effect.promise(() => insertOperation(client, "oauth2.userinfo.get"));
189+ yield* Effect.promise(() => insertBlob(client, "spec/mono-hash"));
190+ yield* Effect.promise(() => insertBlob(client, "defs/mono-hash"));
191+ yield* Effect.promise(() => insertPolicy(client));
192+
193+ expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual([
194+ "2026-07-08-provider-service-split",
195+ ]);
196+ expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual(
197+ [],
198+ );
199+
200+ const integrations = yield* Effect.promise(() =>
201+ client.execute(
202+ "SELECT slug, plugin_id, config, health_check FROM integration ORDER BY slug",
203+ ),
204+ );
205+ expect(integrations.rows).toHaveLength(1);
206+ expect(integrations.rows[0]?.slug).toBe("google_calendar");
207+ expect(integrations.rows[0]?.plugin_id).toBe("openapi");
208+ expect(parseJson(String(integrations.rows[0]?.config))).toMatchObject({
209+ specHash: "mono-hash",
210+ sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
211+ specFormat: "google-discovery",
212+ family: "google",
213+ });
214+ expect(parseJson(String(integrations.rows[0]?.health_check))).toEqual({
215+ operation: "calendar.calendarList.list",
216+ });
217+
218+ const connections = yield* Effect.promise(() =>
219+ client.execute("SELECT integration, item_ids, oauth_client FROM connection"),
220+ );
221+ expect(connections.rows).toEqual([
222+ {
223+ integration: "google_calendar",
224+ item_ids: JSON.stringify({
225+ token: "access_item",
226+ refresh: "refresh_item",
227+ }),
228+ oauth_client: "google",
229+ },
230+ ]);
231+
232+ const storage = yield* Effect.promise(() =>
233+ client.execute(
234+ "SELECT plugin_id, key, data FROM plugin_storage WHERE collection = 'operation'",
235+ ),
236+ );
237+ expect(storage.rows).toHaveLength(1);
238+ expect(storage.rows[0]?.plugin_id).toBe("openapi");
239+ expect(storage.rows[0]?.key).toBe(
240+ operationStorageKey("google_calendar", "calendar.events.list"),
241+ );
242+ expect(parseJson(String(storage.rows[0]?.data))).toMatchObject({
243+ integration: "google_calendar",
244+ toolName: "calendar.events.list",
245+ });
246+
247+ const tools = yield* Effect.promise(() =>
248+ client.execute("SELECT integration, plugin_id, name FROM tool"),
249+ );
250+ expect(tools.rows).toEqual([
251+ {
252+ integration: "google_calendar",
253+ plugin_id: "openapi",
254+ name: "calendar.events.list",
255+ },
256+ ]);
257+
258+ const policies = yield* Effect.promise(() =>
259+ client.execute("SELECT id, pattern, action FROM tool_policy"),
260+ );
261+ expect(policies.rows).toEqual([
262+ {
263+ id: "policy_orphan",
264+ pattern: "google_calendar.*.*.gmail.users.messages.send",
265+ action: "block",
266+ },
267+ ]);
268+
269+ yield* Effect.promise(() => db.close());
270+ }),
271+ );
272+
273+ it.effect("skips an org intact when a source blob is missing", () =>
274+ Effect.gen(function* () {
275+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
276+ const client = db.client;
277+
278+ yield* seedCalendarOrg(client, "org_1", { includeBlobs: false });
279+
280+ expect(yield* runSqliteProviderServiceSplitMigration(client)).toBe(0);
281+
282+ const integrations = yield* Effect.promise(() =>
283+ client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
284+ );
285+ expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]);
286+
287+ yield* Effect.promise(() => db.close());
288+ }),
289+ );
290+
291+ it.effect("uses the per-org ledger to recover after a mid-run crash", () =>
292+ Effect.gen(function* () {
293+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
294+ const client = db.client;
295+
296+ yield* seedCalendarOrg(client, "org_1");
297+ yield* seedCalendarOrg(client, "org_2");
298+
299+ const failed = yield* Effect.flip(
300+ runSqliteProviderServiceSplitMigration(client, {
301+ beforeStampOrg: (org) =>
302+ org.tenant === "org_2"
303+ ? Effect.fail(
304+ new DataMigrationError({
305+ migration: "2026-07-08-provider-service-split",
306+ cause: "crash before org_2 stamp",
307+ }),
308+ )
309+ : Effect.void,
310+ }),
311+ );
312+ expect(failed).toBeInstanceOf(DataMigrationError);
313+
314+ const ledgerAfterCrash = yield* Effect.promise(() =>
315+ client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"),
316+ );
317+ expect(ledgerAfterCrash.rows).toEqual([{ tenant: "org_1" }]);
318+
319+ expect(yield* runSqliteProviderServiceSplitMigration(client)).toBe(1);
320+
321+ const ledgerAfterRerun = yield* Effect.promise(() =>
322+ client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"),
323+ );
324+ expect(ledgerAfterRerun.rows).toEqual([{ tenant: "org_1" }, { tenant: "org_2" }]);
325+
326+ const integrations = yield* Effect.promise(() =>
327+ client.execute("SELECT tenant, slug, plugin_id FROM integration ORDER BY tenant, slug"),
328+ );
329+ expect(integrations.rows).toEqual([
330+ { tenant: "org_1", slug: "google_calendar", plugin_id: "openapi" },
331+ { tenant: "org_2", slug: "google_calendar", plugin_id: "openapi" },
332+ ]);
333+
334+ yield* Effect.promise(() => db.close());
335+ }),
336+ );
337+});
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 */
2+import { Effect } from "effect";
3+import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk";
4+
5+import {
6+ operationStorageKey,
7+ planMigration,
8+ storageDataRecord,
9+ tenantHash,
10+ type BlobRow,
11+ type ConnectionRow,
12+ type IntegrationRow,
13+ type MigrationInput,
14+ type OrgPlan,
15+ type PluginStorageRow,
16+ type ToolPolicyRow,
17+ type ToolRow,
18+} from "./planner";
19+
20+const MIGRATION_NAME = "2026-07-08-provider-service-split";
21+const LEDGER_TABLE = "provider_service_split_org_migration";
22+
23+const operationKeyPrefix = (integration: string): string =>
24+ `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`;
25+
26+const execute = (
27+ client: SqliteDataMigrationClient,
28+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
29+) =>
30+ Effect.tryPromise({
31+ try: () => client.execute(stmt),
32+ catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
33+ });
34+
35+const parseJsonLike = (value: unknown): unknown => {
36+ if (typeof value !== "string") return value;
37+ try {
38+ return JSON.parse(value) as unknown;
39+ } catch {
40+ return value;
41+ }
42+};
43+
44+const scrubJson = (value: unknown): unknown => {
45+ if (Array.isArray(value)) return value.map(scrubJson);
46+ if (value && typeof value === "object") {
47+ return Object.fromEntries(
48+ Object.entries(value)
49+ .filter(([, inner]) => inner !== undefined)
50+ .map(([key, inner]) => [key, scrubJson(inner)]),
51+ );
52+ }
53+ return value;
54+};
55+
56+const stableId = (...parts: readonly string[]): string => {
57+ const text = parts.join("\u0000");
58+ let hash = 0;
59+ for (let index = 0; index < text.length; index += 1) {
60+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
61+ }
62+ return `service_split_${hash.toString(36)}`;
63+};
64+
65+const tableExists = (client: SqliteDataMigrationClient, table: string) =>
66+ execute(client, {
67+ sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
68+ args: [table],
69+ }).pipe(Effect.map((result) => result.rows.length > 0));
70+
71+const ensureLedgerTable = (client: SqliteDataMigrationClient) =>
72+ execute(
73+ client,
74+ `CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (
75+ tenant text PRIMARY KEY NOT NULL,
76+ time_completed integer NOT NULL
77+ )`,
78+ );
79+
80+const readCompletedTenants = (client: SqliteDataMigrationClient) =>
81+ execute(client, `SELECT tenant FROM ${LEDGER_TABLE} ORDER BY tenant`).pipe(
82+ Effect.map((result) => result.rows.map((row) => String(row.tenant))),
83+ );
84+
85+const stampCompletedTenant = (client: SqliteDataMigrationClient, tenant: string) =>
86+ execute(client, {
87+ sql: `INSERT INTO ${LEDGER_TABLE} (tenant, time_completed)
88+ VALUES (?, ?)
89+ ON CONFLICT(tenant) DO UPDATE SET time_completed = excluded.time_completed`,
90+ args: [tenant, Date.now()],
91+ });
92+
93+const readDatabaseInput = (
94+ client: SqliteDataMigrationClient,
95+ options: {
96+ readonly blobBackend?: MigrationInput["blobBackend"];
97+ readonly blobs?: readonly BlobRow[];
98+ } = {},
99+): Effect.Effect<MigrationInput, DataMigrationError> =>
100+ Effect.gen(function* () {
101+ yield* ensureLedgerTable(client);
102+ const completedTenants = yield* readCompletedTenants(client);
103+ const integrations = yield* execute(
104+ client,
105+ `SELECT tenant, slug, plugin_id, name, description, config, health_check,
106+ config_revised_at, can_remove, can_refresh, CAST(created_at AS TEXT) AS created_at,
107+ CAST(updated_at AS TEXT) AS updated_at, row_id
108+ FROM integration
109+ WHERE plugin_id IN ('google', 'microsoft')
110+ OR slug IN ('google', 'microsoft')
111+ OR tenant IN (
112+ SELECT tenant FROM integration
113+ WHERE plugin_id IN ('google', 'microsoft')
114+ )
115+ ORDER BY tenant, slug`,
116+ );
117+ const monolithTenants = new Set(
118+ integrations.rows
119+ .filter((row) => row.plugin_id === "google" || row.plugin_id === "microsoft")
120+ .map((row) => String(row.tenant)),
121+ );
122+ if (monolithTenants.size === 0) {
123+ return {
124+ integrations: [],
125+ connections: [],
126+ tools: [],
127+ pluginStorage: [],
128+ blobs: options.blobs ?? [],
129+ policies: [],
130+ completedTenants,
131+ };
132+ }
133+
134+ const connections = yield* execute(
135+ client,
136+ `SELECT tenant, owner, subject, integration, name, template, provider, item_ids,
137+ identity_label, description, last_health, tools_synced_at, oauth_client,
138+ oauth_client_owner, refresh_item_id, expires_at, oauth_scope, oauth_token_url,
139+ provider_state, CAST(created_at AS TEXT) AS created_at,
140+ CAST(updated_at AS TEXT) AS updated_at, row_id
141+ FROM connection
142+ ORDER BY tenant, integration, owner, subject, name`,
143+ );
144+ const tools = yield* execute(
145+ client,
146+ `SELECT tenant, owner, subject, integration, connection, plugin_id, name, description,
147+ input_schema, output_schema, annotations, CAST(created_at AS TEXT) AS created_at,
148+ CAST(updated_at AS TEXT) AS updated_at, row_id
149+ FROM tool
150+ ORDER BY tenant, integration, connection, name`,
151+ );
152+ const pluginStorage = yield* execute(
153+ client,
154+ `SELECT tenant, owner, subject, plugin_id, collection, key, data,
155+ CAST(created_at AS TEXT) AS created_at, CAST(updated_at AS TEXT) AS updated_at, row_id
156+ FROM plugin_storage
157+ WHERE plugin_id IN ('google', 'microsoft')
158+ AND collection = 'operation'
159+ ORDER BY tenant, plugin_id, collection, key`,
160+ );
161+ const blobs = yield* execute(
162+ client,
163+ `SELECT id, namespace, key
164+ FROM blob
165+ WHERE key LIKE 'spec/%' OR key LIKE 'defs/%'
166+ ORDER BY namespace, key`,
167+ );
168+ const policies = yield* execute(
169+ client,
170+ `SELECT tenant, owner, subject, id, pattern, action, position,
171+ CAST(created_at AS TEXT) AS created_at, CAST(updated_at AS TEXT) AS updated_at, row_id
172+ FROM tool_policy
173+ ORDER BY tenant, owner, subject, position, id`,
174+ );
175+
176+ const tenantFilter = (row: Record<string, unknown>) => monolithTenants.has(String(row.tenant));
177+ return {
178+ integrations: integrations.rows.filter(tenantFilter).map(
179+ (row): IntegrationRow => ({
180+ tenant: String(row.tenant),
181+ slug: String(row.slug),
182+ plugin_id: String(row.plugin_id),
183+ name: typeof row.name === "string" ? row.name : null,
184+ description: typeof row.description === "string" ? row.description : null,
185+ config: parseJsonLike(row.config),
186+ health_check: parseJsonLike(row.health_check),
187+ config_revised_at: row.config_revised_at as string | number | bigint | null,
188+ can_remove: Boolean(row.can_remove),
189+ can_refresh: Boolean(row.can_refresh),
190+ created_at: String(row.created_at),
191+ updated_at: String(row.updated_at),
192+ row_id: String(row.row_id),
193+ }),
194+ ),
195+ connections: connections.rows.filter(tenantFilter).map(
196+ (row): ConnectionRow => ({
197+ tenant: String(row.tenant),
198+ owner: String(row.owner),
199+ subject: String(row.subject),
200+ integration: String(row.integration),
201+ name: String(row.name),
202+ template: String(row.template),
203+ provider: String(row.provider),
204+ item_ids: parseJsonLike(row.item_ids),
205+ identity_label: typeof row.identity_label === "string" ? row.identity_label : null,
206+ description: typeof row.description === "string" ? row.description : null,
207+ last_health: parseJsonLike(row.last_health),
208+ tools_synced_at: row.tools_synced_at as string | number | bigint | null,
209+ oauth_client: typeof row.oauth_client === "string" ? row.oauth_client : null,
210+ oauth_client_owner:
211+ typeof row.oauth_client_owner === "string" ? row.oauth_client_owner : null,
212+ refresh_item_id: typeof row.refresh_item_id === "string" ? row.refresh_item_id : null,
213+ expires_at: row.expires_at as string | number | bigint | null,
214+ oauth_scope: typeof row.oauth_scope === "string" ? row.oauth_scope : null,
215+ oauth_token_url: typeof row.oauth_token_url === "string" ? row.oauth_token_url : null,
216+ provider_state: parseJsonLike(row.provider_state),
217+ created_at: String(row.created_at),
218+ updated_at: String(row.updated_at),
219+ row_id: String(row.row_id),
220+ }),
221+ ),
222+ tools: tools.rows.filter(tenantFilter).map(
223+ (row): ToolRow => ({
224+ tenant: String(row.tenant),
225+ owner: String(row.owner),
226+ subject: String(row.subject),
227+ integration: String(row.integration),
228+ connection: String(row.connection),
229+ plugin_id: String(row.plugin_id),
230+ name: String(row.name),
231+ description: typeof row.description === "string" ? row.description : undefined,
232+ input_schema: parseJsonLike(row.input_schema),
233+ output_schema: parseJsonLike(row.output_schema),
234+ annotations: parseJsonLike(row.annotations),
235+ created_at: String(row.created_at),
236+ updated_at: String(row.updated_at),
237+ row_id: String(row.row_id),
238+ }),
239+ ),
240+ pluginStorage: pluginStorage.rows.filter(tenantFilter).map(
241+ (row): PluginStorageRow => ({
242+ tenant: String(row.tenant),
243+ owner: String(row.owner),
244+ subject: String(row.subject),
245+ plugin_id: String(row.plugin_id),
246+ collection: String(row.collection),
247+ key: String(row.key),
248+ data: parseJsonLike(row.data),
249+ created_at: String(row.created_at),
250+ updated_at: String(row.updated_at),
251+ row_id: String(row.row_id),
252+ }),
253+ ),
254+ blobs: [
255+ ...blobs.rows.map(
256+ (row): BlobRow => ({
257+ id: String(row.id),
258+ namespace: String(row.namespace),
259+ key: String(row.key),
260+ }),
261+ ),
262+ ...(options.blobs ?? []),
263+ ],
264+ policies: policies.rows.filter(tenantFilter).map(
265+ (row): ToolPolicyRow => ({
266+ tenant: String(row.tenant),
267+ owner: String(row.owner),
268+ subject: String(row.subject),
269+ id: String(row.id),
270+ pattern: String(row.pattern),
271+ action: String(row.action),
272+ position: String(row.position),
273+ created_at: String(row.created_at),
274+ updated_at: String(row.updated_at),
275+ row_id: String(row.row_id),
276+ }),
277+ ),
278+ blobBackend: options.blobBackend ?? "database",
279+ orphanPolicyMode: "retarget_all",
280+ collectPolicyErrors: true,
281+ bootRailCreateToolImpliedServices: true,
282+ completedTenants,
283+ };
284+ });
285+
286+const applyOrg = (
287+ client: SqliteDataMigrationClient,
288+ org: OrgPlan,
289+): Effect.Effect<void, DataMigrationError> =>
290+ Effect.gen(function* () {
291+ if (org.completed) return;
292+ if (org.hardErrors.length > 0) return;
293+ const now = Date.now();
294+
295+ for (const copy of org.blobCopies.filter((item) => item.backend === "database")) {
296+ const source = yield* execute(client, {
297+ sql: "SELECT value FROM blob WHERE namespace = ? AND key = ? LIMIT 1",
298+ args: [copy.sourceNamespace, copy.key],
299+ });
300+ const sourceRow = source.rows[0];
301+ if (!sourceRow || typeof sourceRow.value !== "string") {
302+ return yield* new DataMigrationError({
303+ migration: MIGRATION_NAME,
304+ cause: `Missing source blob ${copy.sourceNamespace}/${copy.key}`,
305+ });
306+ }
307+ yield* execute(client, {
308+ sql: `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id)
309+ VALUES (?, ?, ?, ?, ?)`,
310+ args: [
311+ copy.targetNamespace,
312+ copy.key,
313+ sourceRow.value,
314+ stableId("blob", org.tenant, copy.targetNamespace, copy.key),
315+ JSON.stringify([copy.targetNamespace, copy.key]),
316+ ],
317+ });
318+ }
319+
320+ for (const row of org.integrations.filter((item) => item.action === "create")) {
321+ yield* execute(client, {
322+ sql: `INSERT OR IGNORE INTO integration
323+ (slug, plugin_id, name, description, config, health_check, config_revised_at,
324+ can_remove, can_refresh, created_at, updated_at, row_id, tenant)
325+ VALUES (?, ?, ?, ?, ?, ?, NULL, 1, 1, ?, ?, ?, ?)`,
326+ args: [
327+ row.target.slug,
328+ row.target.pluginId,
329+ row.target.name,
330+ row.target.description,
331+ JSON.stringify(scrubJson(row.config)),
332+ row.healthCheck ? JSON.stringify(scrubJson(row.healthCheck)) : null,
333+ now,
334+ now,
335+ stableId("integration", org.tenant, row.target.slug),
336+ org.tenant,
337+ ],
338+ });
339+ }
340+
341+ for (const row of org.connections.filter((item) => item.action === "clone")) {
342+ yield* execute(client, {
343+ sql: `INSERT OR IGNORE INTO connection
344+ (integration, name, template, provider, item_ids, identity_label, description,
345+ last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id,
346+ expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at,
347+ row_id, tenant, owner, subject)
348+ SELECT ?, name, template, provider, item_ids, identity_label, description,
349+ NULL, NULL, oauth_client, oauth_client_owner, refresh_item_id, expires_at,
350+ oauth_scope, oauth_token_url, provider_state, created_at, ?, ?, tenant, owner, subject
351+ FROM connection
352+ WHERE tenant = ? AND owner = ? AND subject = ? AND integration = ? AND name = ?`,
353+ args: [
354+ row.targetIntegration,
355+ now,
356+ stableId(
357+ "connection",
358+ org.tenant,
359+ row.source.owner,
360+ row.source.subject,
361+ row.targetIntegration,
362+ row.source.name,
363+ ),
364+ org.tenant,
365+ row.source.owner,
366+ row.source.subject,
367+ row.source.integration,
368+ row.source.name,
369+ ],
370+ });
371+ }
372+
373+ for (const integration of org.integrations) {
374+ for (const contribution of integration.sourceContributions) {
375+ for (const toolName of contribution.operationToolNames) {
376+ const operation = yield* execute(client, {
377+ sql: `SELECT data, created_at, updated_at
378+ FROM plugin_storage
379+ WHERE tenant = ?
380+ AND owner = 'org'
381+ AND subject = ''
382+ AND plugin_id = ?
383+ AND collection = 'operation'
384+ AND (
385+ key = ?
386+ OR (
387+ json_extract(data, '$.integration') = ?
388+ AND json_extract(data, '$.toolName') = ?
389+ )
390+ )
391+ LIMIT 1`,
392+ args: [
393+ org.tenant,
394+ contribution.source.plugin_id,
395+ operationStorageKey(contribution.source.slug, toolName),
396+ contribution.source.slug,
397+ toolName,
398+ ],
399+ });
400+ const source = operation.rows[0];
401+ if (!source) {
402+ return yield* new DataMigrationError({
403+ migration: MIGRATION_NAME,
404+ cause: new Error(
405+ `Missing operation row for ${tenantHash(org.tenant)}/${contribution.source.slug}/${toolName}`,
406+ ),
407+ });
408+ }
409+ yield* execute(client, {
410+ sql: `INSERT INTO plugin_storage
411+ (plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject)
412+ VALUES (?, 'operation', ?, ?, ?, ?, ?, ?, 'org', '')
413+ ON CONFLICT(tenant, owner, subject, plugin_id, collection, key)
414+ DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
415+ args: [
416+ integration.target.pluginId,
417+ operationStorageKey(integration.target.slug, toolName),
418+ JSON.stringify(
419+ scrubJson({
420+ ...storageDataRecord({ data: parseJsonLike(source.data) }),
421+ integration: integration.target.slug,
422+ toolName,
423+ }),
424+ ),
425+ source.created_at,
426+ now,
427+ stableId("operation", org.tenant, integration.target.slug, toolName),
428+ org.tenant,
429+ ],
430+ });
431+ yield* execute(client, {
432+ sql: `INSERT OR IGNORE INTO tool
433+ (integration, connection, plugin_id, name, description, input_schema, output_schema,
434+ annotations, created_at, updated_at, row_id, tenant, owner, subject)
435+ SELECT ?, connection, ?, name, description, input_schema, output_schema,
436+ annotations, created_at, ?, ? || '_' || row_id, tenant, owner, subject
437+ FROM tool
438+ WHERE tenant = ? AND integration = ? AND name = ?`,
439+ args: [
440+ integration.target.slug,
441+ integration.target.pluginId,
442+ now,
443+ stableId("tool", org.tenant, integration.target.slug),
444+ org.tenant,
445+ contribution.source.slug,
446+ toolName,
447+ ],
448+ });
449+ }
450+ }
451+ }
452+
453+ for (const policy of org.policies) {
454+ yield* execute(client, {
455+ sql: "DELETE FROM tool_policy WHERE tenant = ? AND owner = ? AND subject = ? AND id = ?",
456+ args: [org.tenant, policy.policy.owner, policy.policy.subject, policy.policy.id],
457+ });
458+ for (const [index, pattern] of policy.afterPatterns.entries()) {
459+ yield* execute(client, {
460+ sql: `INSERT OR IGNORE INTO tool_policy
461+ (id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject)
462+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
463+ args: [
464+ policy.afterPatterns.length === 1
465+ ? policy.policy.id
466+ : `${policy.policy.id}_${index + 1}`,
467+ pattern,
468+ policy.policy.action,
469+ policy.policy.position,
470+ now,
471+ now,
472+ stableId("policy", org.tenant, policy.policy.id, pattern),
473+ org.tenant,
474+ policy.policy.owner,
475+ policy.policy.subject,
476+ ],
477+ });
478+ }
479+ }
480+
481+ for (const monolith of org.deleteMonoliths) {
482+ yield* execute(client, {
483+ sql: "DELETE FROM tool WHERE tenant = ? AND integration = ?",
484+ args: [org.tenant, monolith.slug],
485+ });
486+ yield* execute(client, {
487+ sql: "DELETE FROM connection WHERE tenant = ? AND integration = ?",
488+ args: [org.tenant, monolith.slug],
489+ });
490+ yield* execute(client, {
491+ sql: `DELETE FROM plugin_storage
492+ WHERE tenant = ?
493+ AND owner = 'org'
494+ AND subject = ''
495+ AND plugin_id = ?
496+ AND collection = 'operation'
497+ AND (
498+ json_extract(data, '$.integration') = ?
499+ OR key LIKE ?
500+ )`,
501+ args: [org.tenant, monolith.plugin_id, monolith.slug, operationKeyPrefix(monolith.slug)],
502+ });
503+ yield* execute(client, {
504+ sql: "DELETE FROM integration WHERE tenant = ? AND slug = ? AND plugin_id = ?",
505+ args: [org.tenant, monolith.slug, monolith.plugin_id],
506+ });
507+ }
508+ });
509+
510+export const runSqliteProviderServiceSplitMigration = (
511+ client: SqliteDataMigrationClient,
512+ options: {
513+ readonly beforeStampOrg?: (org: OrgPlan) => Effect.Effect<void, DataMigrationError>;
514+ readonly blobBackend?: MigrationInput["blobBackend"];
515+ readonly blobs?: readonly BlobRow[];
516+ } = {},
517+): Effect.Effect<number, DataMigrationError> =>
518+ Effect.gen(function* () {
519+ if (!(yield* tableExists(client, "integration"))) return 0;
520+ const input = yield* readDatabaseInput(client, options);
521+ const plan = planMigration(input);
522+ const moved = plan.orgs.filter((org) => !org.completed && org.hardErrors.length === 0).length;
523+ if (moved === 0) return 0;
524+
525+ for (const org of plan.orgs) {
526+ if (org.completed) continue;
527+ if (org.hardErrors.length > 0) {
528+ console.warn(
529+ `provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`,
530+ );
531+ continue;
532+ }
533+ yield* execute(client, "BEGIN");
534+ const applyOne = Effect.gen(function* () {
535+ yield* applyOrg(client, org);
536+ if (options.beforeStampOrg) yield* options.beforeStampOrg(org);
537+ yield* stampCompletedTenant(client, org.tenant);
538+ yield* execute(client, "COMMIT");
539+ });
540+ yield* applyOne.pipe(Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)));
541+ }
542+ return moved;
543+ });
544+
545+export const providerServiceSplitDataMigration = {
546+ name: MIGRATION_NAME,
547+ run: (client: SqliteDataMigrationClient) =>
548+ runSqliteProviderServiceSplitMigration(client).pipe(Effect.asVoid),
549+};
packages/plugins/provider-service-split/tsconfig.jsonrenamed from packages/plugins/microsoft/tsconfig.json+1−2
@@ -19,6 +19,5 @@ · 18 unmodified lines
1919 }
2020 ]
2121 },
22− "include": ["src/**/*.ts", "src/**/*.tsx"],
23− "exclude": ["src/promise.ts"]
22+ "include": ["src/**/*.ts"]
2423 }
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
22
33 export default defineConfig({
44 entry: {
5− index: "src/promise.ts",
6− core: "src/sdk/index.ts",
7− client: "src/react/plugin-client.tsx",
5+ index: "src/index.ts",
86 },
97 format: ["esm"],
108 dts: false,
packages/plugins/provider-service-split/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ testTimeout: 15_000,
7+ },
8+});
packages/react/src/api/atoms.tsx+1−0
@@ -366,6 +366,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 365 unmodified lines
366366 oauthClient: null,
367367 oauthClientOwner: null,
368368 oauthScope: null,
369+ missingOAuthScopes: [],
369370 lastHealth: null,
370371 };
371372 return [optimistic, ...rows];
packages/react/src/components/accounts-section.tsx+10−4
@@ -85,16 +85,17 @@ function AccountRow(props: { · 84 unmodified lines
8585 const { probe, status, runCheck } = useConnectionHealth(connection);
8686 const indicator = HEALTH_INDICATOR_COLOR[status];
8787
88− // Prefer a probed identity (the live account), then the stored label, then the
89− // connection name. The probe is the whole point: it shows WHICH account this is.
88+ // Prefer the stored label from the connection row, then a probed identity,
89+ // then the connection name. OAuth labels come from the grant's OIDC claims,
90+ // while health identities remain useful for non-OAuth probes.
9091 const identity =
91− (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ??
9292 (connection.identityLabel && connection.identityLabel.length > 0
9393 ? connection.identityLabel
94− : null);
94+ : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null);
9595 const displayLabel = identity ?? String(connection.name);
9696
9797 const expired = status === "expired";
98+ const missingOAuthScopes = connection.missingOAuthScopes ?? [];
9899
99100 const handleCheck = async () => {
100101 if (checking) return;
@@ -150,6 +151,11 @@ function AccountRow(props: { · 49 unmodified lines
150151 This connection wasn't granted all the access this integration now needs.
151152 </CardStackEntryDescription>
152153 ) : null}
154+ {missingOAuthScopes.length > 0 ? (
155+ <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
156+ Missing scopes: {missingOAuthScopes.join(", ")}
157+ </CardStackEntryDescription>
158+ ) : null}
153159 </CardStackEntryContent>
154160 <CardStackEntryActions className="self-start pt-0.5">
155161 {props.showOwnerLabel ? (
packages/react/src/components/health-check-editor.tsx+4−4
@@ -477,8 +477,8 @@ function HealthCheckEditorSheet(props: { · 476 unmodified lines
477477 <SheetHeader>
478478 <SheetTitle>Health check</SheetTitle>
479479 <SheetDescription>
480− One read-only call Executor runs to tell whether a connection's credential is still
481− alive and, optionally, whose account it is. A 401 or 403 marks the connection expired.
480+ Optional read-only depth for live upstream checks. OAuth validity and identity come from
481+ the grant itself. For non-OAuth methods, a 401 or 403 marks the connection expired.
482482 </SheetDescription>
483483 </SheetHeader>
484484
@@ -553,8 +553,8 @@ export function HealthCheckEditor(props: { · 68 unmodified lines
553553 <div className="space-y-1">
554554 <h3 className="text-sm font-medium">Health check</h3>
555555 <p className="text-xs text-muted-foreground">
556− A read-only call Executor runs to tell whether a connection's credential is still alive
557− and, optionally, whose account it is.
556+ Optional read-only depth for live upstream checks. OAuth identity comes from the account
557+ grant, while probe identity fields are useful for non-OAuth methods.
558558 </p>
559559 </div>
560560 <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
224224 ).toBe("https://example.com/stripe.png");
225225 });
226226
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+
227249 it("does not split generic words into brand matches", () => {
228250 expect(
229251 integrationPresetIconUrl(
packages/react/src/components/integration-favicon.tsx+3−0
@@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines
157157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
158158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
159159 const presets = plugin?.presets ?? [];
160+ const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon;
161+ if (exactSlugIcon) return exactSlugIcon;
162+
160163 const sourceUrl = normalizeUrl(source.url);
161164 const sourceGoogleService = googleApiServiceFromUrl(source.url);
162165 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];
packages/react/src/lib/integration-grouping.test.ts+80−0
@@ -0,0 +1,80 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared";
3+
4+import {
5+ groupIntegrations,
6+ integrationFamily,
7+ type IntegrationFamilyGroup,
8+} from "./integration-grouping";
9+
10+const integration = (slug: string, kind: string, name = slug, family?: string): Integration => ({
11+ slug: IntegrationSlug.make(slug),
12+ name,
13+ description: name,
14+ kind,
15+ canRemove: true,
16+ canRefresh: true,
17+ authMethods: [],
18+ ...(family ? { family } : {}),
19+});
20+
21+describe("groupIntegrations", () => {
22+ it("collapses multi-member config families under one umbrella", () => {
23+ const items = groupIntegrations([
24+ integration("google_calendar", "openapi", "Google Calendar", "google"),
25+ integration("google_gmail", "openapi", "Gmail", "google"),
26+ integration("google_drive", "openapi", "Google Drive", "google"),
27+ ]);
28+
29+ expect(items).toHaveLength(1);
30+ const group = items[0] as IntegrationFamilyGroup;
31+ expect(group.type).toBe("group");
32+ expect(group.family).toBe("google");
33+ expect(group.label).toBe("Google");
34+ expect(group.members.map((m) => String(m.slug))).toEqual([
35+ "google_calendar",
36+ "google_gmail",
37+ "google_drive",
38+ ]);
39+ });
40+
41+ it("leaves integrations without config family ungrouped", () => {
42+ const items = groupIntegrations([
43+ integration("stripe", "openapi", "Stripe"),
44+ integration("sentry", "mcp", "Sentry"),
45+ ]);
46+
47+ expect(items).toHaveLength(2);
48+ expect(items.map((item) => item.type)).toEqual(["single", "single"]);
49+ });
50+
51+ it("renders a lone family member flat", () => {
52+ const items = groupIntegrations([
53+ integration("google_calendar", "openapi", "Google Calendar", "google"),
54+ ]);
55+
56+ expect(items).toHaveLength(1);
57+ expect(items[0]?.type).toBe("single");
58+ });
59+
60+ it("places each group where its first member appeared", () => {
61+ const items = groupIntegrations([
62+ integration("stripe", "openapi", "Stripe"),
63+ integration("google_calendar", "openapi", "Google Calendar", "google"),
64+ integration("microsoft_mail", "openapi", "Outlook Mail", "microsoft"),
65+ integration("google_gmail", "openapi", "Gmail", "google"),
66+ integration("microsoft_calendar", "openapi", "Outlook Calendar", "microsoft"),
67+ integration("sentry", "mcp", "Sentry"),
68+ ]);
69+
70+ expect(
71+ items.map((item) => (item.type === "group" ? `group:${item.family}` : "single")),
72+ ).toEqual(["single", "group:google", "group:microsoft", "single"]);
73+ });
74+
75+ it("does not group from integration kind alone", () => {
76+ expect(
77+ integrationFamily(integration("google_calendar", "google", "Google Calendar")),
78+ ).toBeNull();
79+ });
80+});
packages/react/src/lib/integration-grouping.ts+69−0
@@ -0,0 +1,69 @@
1+import type { Integration } from "@executor-js/sdk/shared";
2+
3+export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4+
5+const FAMILY_LABELS: Record<string, string> = {
6+ google: "Google",
7+ microsoft: "Microsoft",
8+};
9+
10+export const familyLabel = (family: string): string =>
11+ FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1);
12+
13+export const integrationFamily = (integration: Integration): string | null => {
14+ const family = integration.family?.trim();
15+ return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;
16+};
17+
18+export interface IntegrationFamilyGroup {
19+ readonly type: "group";
20+ readonly family: string;
21+ readonly label: string;
22+ readonly members: readonly Integration[];
23+}
24+
25+export interface IntegrationSingle {
26+ readonly type: "single";
27+ readonly integration: Integration;
28+}
29+
30+export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle;
31+
32+export function groupIntegrations(
33+ integrations: readonly Integration[],
34+): readonly IntegrationGridItem[] {
35+ const familyCounts = new Map<string, number>();
36+ for (const integration of integrations) {
37+ const family = integrationFamily(integration);
38+ if (family) familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
39+ }
40+
41+ const items: IntegrationGridItem[] = [];
42+ const groupIndexByFamily = new Map<string, number>();
43+
44+ for (const integration of integrations) {
45+ const family = integrationFamily(integration);
46+ const grouped = family !== null && (familyCounts.get(family) ?? 0) > 1;
47+
48+ if (!grouped || family === null) {
49+ items.push({ type: "single", integration });
50+ continue;
51+ }
52+
53+ const existing = groupIndexByFamily.get(family);
54+ if (existing === undefined) {
55+ groupIndexByFamily.set(family, items.length);
56+ items.push({
57+ type: "group",
58+ family,
59+ label: familyLabel(family),
60+ members: [integration],
61+ });
62+ } else {
63+ const group = items[existing] as IntegrationFamilyGroup;
64+ items[existing] = { ...group, members: [...group.members, integration] };
65+ }
66+ }
67+
68+ return items;
69+}
packages/react/src/pages/integrations.tsx+100−42
@@ -1,4 +1,4 @@
1−import { Suspense, useCallback, useMemo, useState } from "react";
1+import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -33,11 +33,14 @@ import { · 28 unmodified lines
3333 CardStackEntryDescription,
3434 CardStackEntryMedia,
3535 CardStackEntryTitle,
36+ CardStackHeader,
3637 } from "../components/card-stack";
3738 import {
39+ IntegrationFavicon,
3840 integrationInferredUrl,
3941 integrationPresetIconUrl,
4042 } from "../components/integration-favicon";
43+import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
4144 import { IntegrationHealthSummary } from "../components/integration-health-summary";
4245 import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
4346 import { Skeleton } from "../components/skeleton";
@@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines
349352 const q = (props.searchQuery ?? "").trim().toLowerCase();
350353 if (q.length === 0) return allPresets;
351354 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();
353357 return corpus.includes(q);
354358 });
355359 }, [allPresets, props.searchQuery]);
@@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines
434438 return out;
435439 }, [integrationPlugins]);
436440
441+ const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]);
442+
443+ const renderEntry = (integration: Integration) => {
444+ const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;
445+ const plugin = pluginByKind.get(pluginKey);
446+ const SummaryComponent = plugin?.summary;
447+ const slug = String(integration.slug);
448+ const name = integration.name || slug;
449+ return (
450+ <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
451+ <Link
452+ to="/{-$orgSlug}/integrations/$namespace"
453+ params={{ namespace: slug }}
454+ data-testid={`integration-entry-${slug}`}
455+ >
456+ <IntegrationIconWithAccount
457+ icon={integrationPresetIconUrl(
458+ { id: slug, kind: integration.kind, name, url: integration.displayUrl },
459+ integrationPlugins,
460+ )}
461+ sourceId={slug}
462+ url={integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined}
463+ />
464+ <CardStackEntryContent>
465+ <CardStackEntryTitle>{name}</CardStackEntryTitle>
466+ <CardStackEntryDescription>{slug}</CardStackEntryDescription>
467+ </CardStackEntryContent>
468+ <CardStackEntryActions>
469+ {SummaryComponent && (
470+ <Suspense fallback={null}>
471+ <SummaryComponent sourceId={slug} />
472+ </Suspense>
473+ )}
474+ <IntegrationHealthSummary integration={integration.slug} />
475+ </CardStackEntryActions>
476+ </Link>
477+ </CardStackEntry>
478+ );
479+ };
480+
481+ const rendered: ReactNode[] = [];
482+ let flatRun: Integration[] = [];
483+ const flushFlat = () => {
484+ if (flatRun.length === 0) return;
485+ const run = flatRun;
486+ flatRun = [];
487+ rendered.push(
488+ <CardStack key={`flat-${String(run[0]!.slug)}`} searchable>
489+ <CardStackContent>{run.map(renderEntry)}</CardStackContent>
490+ </CardStack>,
491+ );
492+ };
493+
494+ for (const item of items) {
495+ if (item.type === "single") {
496+ flatRun.push(item.integration);
497+ continue;
498+ }
499+ flushFlat();
500+ rendered.push(
501+ <IntegrationFamilyGroupCard
502+ key={`group-${item.family}`}
503+ group={item}
504+ plugin={pluginByKind.get("openapi")}
505+ renderEntry={renderEntry}
506+ />,
507+ );
508+ }
509+ flushFlat();
510+
511+ return <div className="space-y-3">{rendered}</div>;
512+}
513+
514+function IntegrationFamilyGroupCard(props: {
515+ group: IntegrationFamilyGroup;
516+ plugin: IntegrationPlugin | undefined;
517+ renderEntry: (integration: Integration) => ReactNode;
518+}) {
519+ const { group, plugin, renderEntry } = props;
520+ const headerIcon =
521+ plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null;
437522 return (
438− <CardStack searchable>
439− <CardStackContent>
440− {props.integrations.map((integration) => {
441− const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;
442− const plugin = pluginByKind.get(pluginKey);
443− const SummaryComponent = plugin?.summary;
444− const slug = String(integration.slug);
445− const name = integration.name || slug;
446− return (
447− <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
448− <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}>
449− <IntegrationIconWithAccount
450− icon={integrationPresetIconUrl(
451− { id: slug, kind: integration.kind, name, url: integration.displayUrl },
452− integrationPlugins,
453− )}
454− sourceId={slug}
455− url={
456− integration.displayUrl ??
457− integrationInferredUrl({ id: slug, name }) ??
458− undefined
459− }
460− />
461− <CardStackEntryContent>
462− <CardStackEntryTitle>{name}</CardStackEntryTitle>
463− <CardStackEntryDescription>{slug}</CardStackEntryDescription>
464− </CardStackEntryContent>
465− <CardStackEntryActions>
466− {SummaryComponent && (
467− <Suspense fallback={null}>
468− <SummaryComponent sourceId={slug} />
469− </Suspense>
470− )}
471− <IntegrationHealthSummary integration={integration.slug} />
472− </CardStackEntryActions>
473− </Link>
474− </CardStackEntry>
475− );
476− })}
477− </CardStackContent>
523+ <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}>
524+ <CardStackHeader>
525+ <span className="flex min-w-0 items-center gap-2">
526+ <span className="flex size-5 shrink-0 items-center justify-center">
527+ <IntegrationFavicon icon={headerIcon} size={16} />
528+ </span>
529+ <span className="truncate">{group.label}</span>
530+ <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">
531+ {group.members.length}
532+ </span>
533+ </span>
534+ </CardStackHeader>
535+ <CardStackContent>{group.members.map(renderEntry)}</CardStackContent>
478536 </CardStack>
479537 );
480538 }
plans/openapi-provider-catalog-design.md+139−0
@@ -0,0 +1,139 @@
1+# Providers as OpenAPI catalog entries
2+
3+Decision (Rhys, 2026-07-07): dissolve `plugin-google` / `plugin-microsoft` into
4+the openapi plugin. No bulk-select picker — per-service OAuth killed the shared
5+consent that justified it; every service is added and authed individually, like
6+any other openapi integration. The dedicated Google/Microsoft add pages go away.
7+
8+## Why this is small
9+
10+Serving is already 100% openapi: both provider plugins' `resolveTools` /
11+`invokeTool` / `storage` are pass-throughs to `@executor-js/plugin-openapi`.
12+What the provider plugins actually own is add-time (spec acquisition/conversion,
13+scope selection UI) and identity (presets, icons, OAuth templates). The add-time
14+UI is being deleted, so what remains is data plus two spec converters.
15+
16+## End-state model
17+
18+### 1. Catalog entries (presets grow a little)
19+
20+Each service/workload is one `IntegrationPreset` on the openapi plugin:
21+
22+```ts
23+// packages/core/sdk — IntegrationPreset gains:
24+readonly family?: string; // "google" | "microsoft" — grid grouping + umbrella label
25+readonly specFormat?: string; // "google-discovery" | "microsoft-graph" (default: openapi)
26+readonly defaultSlug?: string; // "google_gmail" — stable slug for policy/migration identity
27+readonly authTemplate?: readonly Authentication[]; // per-service OAuth: ONLY this service's scopes
28+readonly healthCheck?: HealthCheckSpec;
29+```
30+
31+Google Calendar, Gmail, Drive, Docs, Sheets, YouTube, Photos (picker scope is
32+just this entry's scopes), Search Console, … and Microsoft Mail, Calendar,
33+Teams, Files, … are rows in this catalog. `featured` controls grid presence;
34+search finds the rest. The "single Google page" is gone — Google appears in the
35+picker as N entries (grid groups them under one umbrella, see §4).
36+
37+### 2. Spec-format adapters (the only real new interface)
38+
39+The openapi plugin accepts registered converters:
40+
41+```ts
42+export interface SpecFormatAdapter {
43+ readonly id: string; // matches preset.specFormat
44+ /** Fetch + convert to the OpenAPI document shape the plugin already parses.
45+ * Receives the preset URL(s) and auth credentials for authed spec hosts. */
46+ readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
47+ /** Optional: derive identity (slug/name/description) from the fetched doc —
48+ * powers custom Discovery URLs (google_tasks from the tasks discovery doc). */
49+ readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;
50+}
51+```
52+
53+Two adapters exist, both already written:
54+
55+- `google-discovery`: `convertGoogleDiscoveryBundleToOpenApi` (discovery.ts) +
56+ the derived-identity rules from the parked stack (b14985d7)
57+- `microsoft-graph`: the 37MB Graph structural split (graph.ts). Fetch stays
58+ serial per process — the measured contention finding carries over as a note
59+ on the adapter, though without bulk add it rarely triggers.
60+
61+Custom Discovery URL = the normal "add openapi spec" flow with format
62+`google-discovery`; identity derived from the doc; `google_custom` remains only
63+as the fetch-failure fallback. `microsoft_graph_custom` keeps explicit identity
64+(scopes carry no service identity).
65+
66+### 3. Where the data lives
67+
68+`@executor-js/plugin-google` / `-microsoft` shrink to data-only packages (or
69+fold into plugin-openapi outright — decide by size after deletion; bias: fold
70+in, packages are cheap to resurrect). They export `googleCatalog:
71+IntegrationPreset[]`, `googleDiscoveryAdapter`, `microsoftCatalog`,
72+`microsoftGraphAdapter`. Apps compose:
73+
74+```ts
75+openapiPlugin({
76+ presets: [...googleCatalog, ...microsoftCatalog],
77+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
78+});
79+```
80+
81+Deleted entirely: AddGoogleSource.tsx, AddMicrosoftSource.tsx,
82+GoogleProductPicker, MicrosoftScopePicker, oauth-batches (scope batching served
83+the union-consent flow), the fan-out add APIs (addServices/addWorkloads,
84+added/skipped/failed result model, per-slug dedup locks, concurrency knobs),
85+both plugins' api/ groups and handlers, source-plugin.ts registrations.
86+
87+### 4. Grid
88+
89+Grouping (#1336) is harvested nearly as-is, with one change: key off
90+`preset.family` / integration config instead of `integration.kind` (everything
91+is kind "openapi" now). The family tag is stamped into the integration config
92+at add time so grouping works without a preset lookup. Umbrella card, entry
93+testids, slug-keyed icons all carry over.
94+
95+### 5. OAuth / accounts
96+
97+Each catalog entry's `authTemplate` carries exactly its service's scopes —
98+the consent screen asks for one service's access, which is the behavior that
99+motivated killing shared auth. The account-entity/login_hint work (#1311,
100+merged) is unaffected: connections still group by provider account, so adding
101+Gmail then Calendar with the same account is two consents but one visible
102+account. Connection cloning in the migration hands every derived service a
103+copy of the monolith token (union scopes) — grandfathered; converges as users
104+reconnect.
105+
106+## Migration retarget
107+
108+The planner from #1337 (policy fan-out with never-widen, connection cloning,
109+serving-state carry, ledger/resume, hard-error gating) survives with a new
110+target shape:
111+
112+- created rows: `plugin_id = 'openapi'`, slug = catalog `defaultSlug`
113+ (unchanged: google_gmail, …), config = openapi config with
114+ `specFormat: "google-discovery"`, discovery URLs, family, specHash carried
115+- plugin_storage operation rows re-keyed under the openapi plugin's collection
116+ (same content-addressing; R2 blobs untouched)
117+- policies: same fan-out, same slugs — policy semantics identical to v2 dry-run
118+- connections: cloned (approved), provider/account fields preserved
119+- refactor into pure planner + two runners (cloud Postgres CLI; libSQL boot
120+ migration for selfhost + local registries, name
121+ `2026-07-XX-provider-service-split`). Boot rail can't ask a human: orphan
122+ block/require_approval policies are retargeted to the derived service as
123+ dormant rows, never dropped, never boot-blocking. The cloud CLI keeps
124+ fail-fast + the org 8fcaa0e54239 decision.
125+- prod dry-run rerun against the new shape before any apply; same review gate.
126+
127+## Sequencing
128+
129+1. Core: preset fields + openapi `specFormats` option + adapter interface
130+2. Move discovery.ts / graph.ts behind adapters; catalog data; delete provider
131+ add UI/APIs/plugins; grid family-tag grouping
132+3. e2e: adapt parked-stack specs (add google_calendar via catalog; custom
133+ discovery URL derives google_tasks; grid umbrella)
134+4. Migration planner refactor + both runners + dry-run v3
135+5. Park→close stack PRs #1308/1309/1315/1334/1336/1337 once the branch is up
136+
137+Open items to settle during build, not before: exact `ConvertedSpec` shape
138+(reuse existing `OpenApiIntegrationConfig` plumbing), whether photos picker
139+needs any UI affordance beyond its scope set, fold-in vs data-only packages.
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+
3+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4+import { resolve } from "node:path";
5+import { pathToFileURL } from "node:url";
6+
7+import postgres from "../../apps/cloud/node_modules/postgres/src/index.js";
8+
9+import {
10+ operationStorageKey,
11+ planMigration,
12+ renderOrgDiff,
13+ renderSummary,
14+ storageDataRecord,
15+ tenantHash,
16+ verifyPolicyRewriteNeverWidens,
17+ type BlobRow,
18+ type ConnectionRow,
19+ type IntegrationRow,
20+ type MigrationInput,
21+ type OrgPlan,
22+ type PluginStorageRow,
23+ type ToolPolicyRow,
24+ type ToolRow,
25+} from "@executor-js/plugin-provider-service-split/planner";
26+
27+export interface SqlClient {
28+ readonly unsafe: <T extends readonly object[] = readonly Record<string, unknown>[]>(
29+ query: string,
30+ params?: readonly unknown[],
31+ ) => Promise<T>;
32+ readonly begin: <T>(fn: (sql: SqlClient) => Promise<T>) => Promise<T>;
33+ readonly end: (options?: { readonly timeout?: number }) => Promise<void>;
34+}
35+
36+const args = process.argv.slice(2);
37+const hasArg = (name: string): boolean => args.includes(name);
38+const argValue = (name: string): string | undefined => {
39+ const index = args.indexOf(name);
40+ return index >= 0 ? args[index + 1] : undefined;
41+};
42+
43+const apply = hasArg("--apply");
44+const inputJson = argValue("--input-json");
45+const outputDir = resolve(
46+ argValue("--output-dir") ?? "~/agent-workspace/scratch/service-split-migration-dryrun",
47+).replace(/^~(?=\/)/, process.env.HOME ?? "~");
48+const orgFilter = argValue("--org");
49+const trafficLastTenant = argValue("--traffic-last-tenant");
50+const databaseUrl = argValue("--database-url") ?? process.env.DATABASE_URL;
51+const r2CopiesDone = hasArg("--r2-copies-done");
52+export const LEDGER_TABLE = "provider_service_split_org_migration";
53+
54+const parseInputJson = (path: string): MigrationInput =>
55+ JSON.parse(readFileSync(path, "utf8")) as MigrationInput;
56+
57+const readRows = async <T extends readonly object[]>(sql: SqlClient, query: string): Promise<T> =>
58+ sql.unsafe<T>(query);
59+
60+const readCompletedTenants = async (
61+ sql: SqlClient,
62+ dryRun: boolean,
63+): Promise<readonly string[]> => {
64+ const [table] = await sql.unsafe<{ ledger_table: string | null }[]>(
65+ `SELECT to_regclass('public.${LEDGER_TABLE}')::text AS ledger_table`,
66+ );
67+ if (!table?.ledger_table) {
68+ if (dryRun) return [];
69+ await sql.unsafe(`
70+ CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE} (
71+ tenant text PRIMARY KEY,
72+ time_completed bigint NOT NULL
73+ )
74+ `);
75+ return [];
76+ }
77+ const rows = await sql.unsafe<{ tenant: string }[]>(
78+ `SELECT tenant FROM ${LEDGER_TABLE} ORDER BY tenant`,
79+ );
80+ return rows.map((row) => row.tenant);
81+};
82+
83+const filterInputByOrg = (input: MigrationInput, filter: string | undefined): MigrationInput => {
84+ if (!filter) return input;
85+ const tenantMatches = new Set(
86+ input.integrations
87+ .map((row) => row.tenant)
88+ .filter((tenant) => tenant === filter || tenantHash(tenant) === filter),
89+ );
90+ return {
91+ ...input,
92+ integrations: input.integrations.filter((row) => tenantMatches.has(row.tenant)),
93+ connections: input.connections.filter((row) => tenantMatches.has(row.tenant)),
94+ tools: input.tools.filter((row) => tenantMatches.has(row.tenant)),
95+ pluginStorage: input.pluginStorage?.filter((row) => tenantMatches.has(row.tenant)),
96+ blobs: input.blobs?.filter((row) => {
97+ for (const tenant of tenantMatches) {
98+ if (row.namespace.startsWith(`o:${tenant}/`)) return true;
99+ }
100+ return false;
101+ }),
102+ policies: input.policies.filter((row) => tenantMatches.has(row.tenant)),
103+ completedTenants: input.completedTenants?.filter((tenant) => tenantMatches.has(tenant)),
104+ };
105+};
106+
107+export const readDatabaseInput = async (
108+ sql: SqlClient,
109+ dryRun: boolean,
110+ blobBackend: MigrationInput["blobBackend"],
111+): Promise<MigrationInput> => {
112+ const completedTenants = await readCompletedTenants(sql, dryRun);
113+ const integrations = await readRows<IntegrationRow[]>(
114+ sql,
115+ `
116+ SELECT tenant, slug, plugin_id, name, description, config, health_check, config_revised_at,
117+ can_remove, can_refresh, created_at::text, updated_at::text, row_id
118+ FROM integration
119+ WHERE plugin_id IN ('google', 'microsoft')
120+ OR slug IN ('google', 'microsoft')
121+ OR tenant IN (
122+ SELECT tenant FROM integration
123+ WHERE plugin_id IN ('google', 'microsoft')
124+ )
125+ ORDER BY tenant, slug
126+ `,
127+ );
128+ const connections = await readRows<ConnectionRow[]>(
129+ sql,
130+ `
131+ SELECT tenant, owner, subject, integration, name, template, provider, item_ids,
132+ identity_label, description, last_health, tools_synced_at, oauth_client,
133+ oauth_client_owner, refresh_item_id, expires_at, oauth_scope, oauth_token_url,
134+ provider_state, created_at::text, updated_at::text, row_id
135+ FROM connection
136+ WHERE tenant IN (
137+ SELECT tenant FROM integration
138+ WHERE plugin_id IN ('google', 'microsoft')
139+ )
140+ ORDER BY tenant, integration, owner, subject, name
141+ `,
142+ );
143+ const tools = await readRows<ToolRow[]>(
144+ sql,
145+ `
146+ SELECT tenant, owner, subject, integration, connection, plugin_id, name, description,
147+ input_schema, output_schema, annotations, created_at::text, updated_at::text, row_id
148+ FROM tool
149+ WHERE tenant IN (
150+ SELECT tenant FROM integration
151+ WHERE plugin_id IN ('google', 'microsoft')
152+ )
153+ ORDER BY tenant, integration, connection, name
154+ `,
155+ );
156+ const pluginStorage = await sql.unsafe<PluginStorageRow[]>(
157+ `
158+ SELECT tenant, owner, subject, plugin_id, collection, key, data,
159+ created_at::text, updated_at::text, row_id
160+ FROM plugin_storage
161+ WHERE tenant IN (
162+ SELECT tenant FROM integration
163+ WHERE plugin_id IN ('google', 'microsoft')
164+ )
165+ AND plugin_id IN ('google', 'microsoft')
166+ AND collection = 'operation'
167+ ORDER BY tenant, plugin_id, collection, key
168+ `,
169+ );
170+ const blobs = await readRows<BlobRow[]>(
171+ sql,
172+ `
173+ SELECT id, namespace, key
174+ FROM blob
175+ WHERE namespace IN (
176+ SELECT 'o:' || tenant || '/' || plugin_id
177+ FROM integration
178+ WHERE plugin_id IN ('google', 'microsoft')
179+ )
180+ AND (key LIKE 'spec/%' OR key LIKE 'defs/%')
181+ ORDER BY namespace, key
182+ `,
183+ );
184+ const policies = await readRows<ToolPolicyRow[]>(
185+ sql,
186+ `
187+ SELECT tenant, owner, subject, id, pattern, action, position, created_at::text,
188+ updated_at::text, row_id
189+ FROM tool_policy
190+ WHERE tenant IN (
191+ SELECT tenant FROM integration
192+ WHERE plugin_id IN ('google', 'microsoft')
193+ )
194+ ORDER BY tenant, owner, subject, position, id
195+ `,
196+ );
197+ return {
198+ integrations,
199+ connections,
200+ tools,
201+ pluginStorage,
202+ blobs,
203+ policies,
204+ completedTenants,
205+ trafficLastTenant,
206+ blobBackend,
207+ assumeExternalBlobSourcePresent: blobBackend === "external",
208+ };
209+};
210+
211+const scrubJson = (value: unknown): unknown => {
212+ if (Array.isArray(value)) return value.map(scrubJson);
213+ if (value && typeof value === "object") {
214+ return Object.fromEntries(
215+ Object.entries(value)
216+ .filter(([, inner]) => inner !== undefined)
217+ .map(([key, inner]) => [key, scrubJson(inner)]),
218+ );
219+ }
220+ return value;
221+};
222+
223+const stableId = (...parts: readonly string[]): string => {
224+ const text = parts.join("\u0000");
225+ let hash = 0;
226+ for (let index = 0; index < text.length; index += 1) {
227+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
228+ }
229+ return `service_split_${hash.toString(36)}`;
230+};
231+
232+const operationKeyPrefix = (integration: string): string =>
233+ `${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`;
234+
235+export const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
236+ if (org.completed) return;
237+ if (org.hardErrors.length > 0) {
238+ throw new Error(`org ${org.tenantHash} has unresolved hard errors; apply is blocked`);
239+ }
240+ await sql.begin(async (tx) => {
241+ const now = new Date();
242+ for (const copy of org.blobCopies.filter((item) => item.backend === "database")) {
243+ const [source] = await tx.unsafe<{ readonly value: string }[]>(
244+ "SELECT value FROM blob WHERE namespace = $1 AND key = $2 LIMIT 1",
245+ [copy.sourceNamespace, copy.key],
246+ );
247+ if (!source) {
248+ throw new Error(`Missing source blob ${copy.sourceNamespace}/${copy.key}`);
249+ }
250+ await tx.unsafe(
251+ `
252+ INSERT INTO blob (namespace, key, value, row_id, id)
253+ VALUES ($1, $2, $3, $4, $5)
254+ ON CONFLICT (id) DO NOTHING
255+ `,
256+ [
257+ copy.targetNamespace,
258+ copy.key,
259+ source.value,
260+ stableId("blob", org.tenant, copy.targetNamespace, copy.key),
261+ JSON.stringify([copy.targetNamespace, copy.key]),
262+ ],
263+ );
264+ }
265+
266+ for (const row of org.integrations.filter((item) => item.action === "create")) {
267+ await tx.unsafe(
268+ `
269+ INSERT INTO integration (
270+ slug, plugin_id, name, description, config, health_check, config_revised_at,
271+ can_remove, can_refresh, created_at, updated_at, row_id, tenant
272+ )
273+ VALUES ($1, $2, $3, $4, $5::json, $6::json, NULL, true, true, $7, $7, $8, $9)
274+ ON CONFLICT (tenant, slug) DO NOTHING
275+ `,
276+ [
277+ row.target.slug,
278+ row.target.pluginId,
279+ row.target.name,
280+ row.target.description,
281+ JSON.stringify(scrubJson(row.config)),
282+ row.healthCheck ? JSON.stringify(scrubJson(row.healthCheck)) : null,
283+ now,
284+ stableId("integration", org.tenant, row.target.slug),
285+ org.tenant,
286+ ],
287+ );
288+ }
289+
290+ for (const row of org.connections.filter((item) => item.action === "clone")) {
291+ await tx.unsafe(
292+ `
293+ INSERT INTO connection (
294+ integration, name, template, provider, item_ids, identity_label, description,
295+ last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id,
296+ expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at,
297+ row_id, tenant, owner, subject
298+ )
299+ SELECT $1, name, template, provider, item_ids, identity_label, description,
300+ NULL, NULL, oauth_client, oauth_client_owner, refresh_item_id, expires_at,
301+ oauth_scope, oauth_token_url, provider_state, created_at, $2, $3, tenant, owner, subject
302+ FROM connection
303+ WHERE tenant = $4 AND owner = $5 AND subject = $6 AND integration = $7 AND name = $8
304+ ON CONFLICT (tenant, owner, subject, integration, name) DO NOTHING
305+ `,
306+ [
307+ row.targetIntegration,
308+ now,
309+ stableId(
310+ "connection",
311+ org.tenant,
312+ row.source.owner,
313+ row.source.subject,
314+ row.targetIntegration,
315+ row.source.name,
316+ ),
317+ org.tenant,
318+ row.source.owner,
319+ row.source.subject,
320+ row.source.integration,
321+ row.source.name,
322+ ],
323+ );
324+ }
325+
326+ for (const integration of org.integrations) {
327+ for (const contribution of integration.sourceContributions) {
328+ for (const toolName of contribution.operationToolNames) {
329+ const [operation] = await tx.unsafe<
330+ {
331+ readonly data: unknown;
332+ readonly created_at: string;
333+ readonly updated_at: string;
334+ }[]
335+ >(
336+ `
337+ SELECT data, created_at::text, updated_at::text
338+ FROM plugin_storage
339+ WHERE tenant = $1
340+ AND owner = 'org'
341+ AND subject = ''
342+ AND plugin_id = $2
343+ AND collection = 'operation'
344+ AND (
345+ key = $3
346+ OR (
347+ data::jsonb ->> 'integration' = $4
348+ AND data::jsonb ->> 'toolName' = $5
349+ )
350+ )
351+ LIMIT 1
352+ `,
353+ [
354+ org.tenant,
355+ contribution.source.plugin_id,
356+ operationStorageKey(contribution.source.slug, toolName),
357+ contribution.source.slug,
358+ toolName,
359+ ],
360+ );
361+ if (!operation) {
362+ throw new Error(
363+ `Missing operation row for ${tenantHash(org.tenant)}/${contribution.source.slug}/${toolName}`,
364+ );
365+ }
366+ await tx.unsafe(
367+ `
368+ INSERT INTO plugin_storage (
369+ plugin_id, collection, key, data, created_at, updated_at, row_id,
370+ tenant, owner, subject
371+ )
372+ VALUES ($1, 'operation', $2, $3::json, $4, $5, $6, $7, 'org', '')
373+ ON CONFLICT (tenant, owner, subject, plugin_id, collection, key)
374+ DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at
375+ `,
376+ [
377+ integration.target.pluginId,
378+ operationStorageKey(integration.target.slug, toolName),
379+ JSON.stringify(
380+ scrubJson({
381+ ...storageDataRecord(operation),
382+ integration: integration.target.slug,
383+ toolName,
384+ }),
385+ ),
386+ operation.created_at,
387+ now,
388+ stableId("operation", org.tenant, integration.target.slug, toolName),
389+ org.tenant,
390+ ],
391+ );
392+ await tx.unsafe(
393+ `
394+ INSERT INTO tool (
395+ integration, connection, plugin_id, name, description, input_schema, output_schema,
396+ annotations, created_at, updated_at, row_id, tenant, owner, subject
397+ )
398+ SELECT $1, connection, $2, name, description, input_schema, output_schema,
399+ annotations, created_at, $3, concat($4::text, '_', row_id), tenant, owner, subject
400+ FROM tool
401+ WHERE tenant = $5 AND integration = $6 AND name = $7
402+ ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
403+ `,
404+ [
405+ integration.target.slug,
406+ integration.target.pluginId,
407+ now,
408+ stableId("tool", org.tenant, integration.target.slug),
409+ org.tenant,
410+ contribution.source.slug,
411+ toolName,
412+ ],
413+ );
414+ }
415+ }
416+ }
417+
418+ for (const policy of org.policies.filter((item) => item.action === "rewrite")) {
419+ await tx.unsafe(
420+ "DELETE FROM tool_policy WHERE tenant = $1 AND owner = $2 AND subject = $3 AND id = $4",
421+ [org.tenant, policy.policy.owner, policy.policy.subject, policy.policy.id],
422+ );
423+ for (const [index, pattern] of policy.afterPatterns.entries()) {
424+ await tx.unsafe(
425+ `
426+ INSERT INTO tool_policy (
427+ id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject
428+ )
429+ VALUES ($1, $2, $3, $4, $5, $5, $6, $7, $8, $9)
430+ ON CONFLICT (tenant, owner, subject, id) DO NOTHING
431+ `,
432+ [
433+ policy.afterPatterns.length === 1
434+ ? policy.policy.id
435+ : `${policy.policy.id}_${index + 1}`,
436+ pattern,
437+ policy.policy.action,
438+ policy.policy.position,
439+ now,
440+ stableId("policy", org.tenant, policy.policy.id, pattern),
441+ org.tenant,
442+ policy.policy.owner,
443+ policy.policy.subject,
444+ ],
445+ );
446+ }
447+ }
448+
449+ for (const monolith of org.deleteMonoliths) {
450+ await tx.unsafe("DELETE FROM tool WHERE tenant = $1 AND integration = $2", [
451+ org.tenant,
452+ monolith.slug,
453+ ]);
454+ await tx.unsafe("DELETE FROM connection WHERE tenant = $1 AND integration = $2", [
455+ org.tenant,
456+ monolith.slug,
457+ ]);
458+ await tx.unsafe(
459+ "DELETE FROM integration WHERE tenant = $1 AND slug = $2 AND plugin_id = $3",
460+ [org.tenant, monolith.slug, monolith.plugin_id],
461+ );
462+ await tx.unsafe(
463+ `
464+ DELETE FROM plugin_storage
465+ WHERE tenant = $1
466+ AND owner = 'org'
467+ AND subject = ''
468+ AND plugin_id = $2
469+ AND collection = 'operation'
470+ AND (
471+ data::jsonb ->> 'integration' = $3
472+ OR key LIKE $4
473+ )
474+ `,
475+ [org.tenant, monolith.plugin_id, monolith.slug, operationKeyPrefix(monolith.slug)],
476+ );
477+ }
478+
479+ await tx.unsafe(
480+ `
481+ INSERT INTO ${LEDGER_TABLE} (tenant, time_completed)
482+ VALUES ($1, $2)
483+ ON CONFLICT (tenant) DO UPDATE SET time_completed = EXCLUDED.time_completed
484+ `,
485+ [org.tenant, Date.now()],
486+ );
487+ });
488+};
489+
490+const writeDryRun = (planInput: MigrationInput): void => {
491+ const plan = planMigration({
492+ ...planInput,
493+ collectPolicyErrors: true,
494+ orphanPolicyMode: "retarget_all",
495+ });
496+ const neverWiden = verifyPolicyRewriteNeverWidens(plan, planInput);
497+ const r2Copies = plan.orgs
498+ .filter((org) => !org.completed && org.hardErrors.length === 0)
499+ .flatMap((org) =>
500+ org.blobCopies
501+ .filter((copy) => copy.backend === "external")
502+ .map((copy) => ({
503+ tenantHash: org.tenantHash,
504+ tenant: org.tenant,
505+ sourceKey: copy.sourceObjectName,
506+ destKey: copy.targetObjectName,
507+ })),
508+ );
509+ mkdirSync(outputDir, { recursive: true });
510+ for (const org of plan.orgs) {
511+ writeFileSync(`${outputDir}/org-${org.tenantHash}.md`, renderOrgDiff(org));
512+ }
513+ writeFileSync(`${outputDir}/r2-copy-manifest.json`, `${JSON.stringify(r2Copies, null, 2)}\n`);
514+ writeFileSync(
515+ `${outputDir}/summary.md`,
516+ `${renderSummary(plan)}\npolicy_never_widen=${neverWiden.ok}\npolicy_never_widen_checked=${neverWiden.checkedPolicies}\nr2_copy_manifest=${outputDir}/r2-copy-manifest.json\nr2_copy_count=${r2Copies.length}\nr2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe\n`,
517+ );
518+ console.log(renderSummary(plan));
519+ console.log(`policy_never_widen=${neverWiden.ok}`);
520+ console.log(`policy_never_widen_checked=${neverWiden.checkedPolicies}`);
521+ console.log(`r2_copy_manifest=${outputDir}/r2-copy-manifest.json`);
522+ console.log(`r2_copy_count=${r2Copies.length}`);
523+ console.log(
524+ "r2_copy_command=wrangler r2 object get <bucket>/<sourceKey> | wrangler r2 object put <bucket>/<destKey> --pipe",
525+ );
526+ console.log(`diff_dir=${outputDir}`);
527+ if (!neverWiden.ok) {
528+ throw new Error(
529+ `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`,
530+ );
531+ }
532+ if (
533+ plan.summary.integrationsMissingSpecBlob > 0 ||
534+ plan.summary.integrationsMissingDefsBlob > 0
535+ ) {
536+ throw new Error(
537+ `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`,
538+ );
539+ }
540+ const zeroOperationIntegrations = plan.orgs
541+ .filter((org) => !org.completed && org.hardErrors.length === 0)
542+ .flatMap((org) => org.integrations)
543+ .filter(
544+ (integration) =>
545+ integration.servingState.operationsToBuild === 0 &&
546+ !integration.servingState.expectedZeroOperations,
547+ );
548+ if (zeroOperationIntegrations.length > 0) {
549+ throw new Error(
550+ `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`,
551+ );
552+ }
553+};
554+
555+const main = async (): Promise<void> => {
556+ if (inputJson) {
557+ const input = parseInputJson(inputJson);
558+ writeDryRun(
559+ filterInputByOrg(
560+ {
561+ ...input,
562+ trafficLastTenant: trafficLastTenant ?? input.trafficLastTenant,
563+ },
564+ orgFilter,
565+ ),
566+ );
567+ return;
568+ }
569+ if (!databaseUrl) {
570+ throw new Error("Set DATABASE_URL or pass --input-json");
571+ }
572+ const usesLocalDatabase = databaseUrl.includes("127.0.0.1") || databaseUrl.includes("localhost");
573+ const sql = postgres(databaseUrl, {
574+ max: 1,
575+ prepare: false,
576+ ...(usesLocalDatabase ? {} : { ssl: "require" as const }),
577+ }) as unknown as SqlClient;
578+ try {
579+ const input = filterInputByOrg(
580+ await readDatabaseInput(sql, !apply, usesLocalDatabase ? "database" : "external"),
581+ orgFilter,
582+ );
583+ if (!apply) {
584+ writeDryRun(input);
585+ return;
586+ }
587+ // Orphan block/require_approval policies (a guardrail targeting a service
588+ // the org never derived, e.g. a gmail send-block on a Search-Console-only
589+ // org) retarget to every derived service as dormant rows rather than
590+ // blocking apply. Approved by Rhys 2026-07-08; matches the boot rail.
591+ const plan = planMigration({ ...input, orphanPolicyMode: "retarget_all" });
592+ const neverWiden = verifyPolicyRewriteNeverWidens(plan, input);
593+ if (!neverWiden.ok) {
594+ throw new Error(
595+ `Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`,
596+ );
597+ }
598+ if (
599+ plan.summary.integrationsMissingSpecBlob > 0 ||
600+ plan.summary.integrationsMissingDefsBlob > 0
601+ ) {
602+ throw new Error(
603+ `Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`,
604+ );
605+ }
606+ const zeroOperationIntegrations = plan.orgs
607+ .filter((org) => !org.completed && org.hardErrors.length === 0)
608+ .flatMap((org) => org.integrations)
609+ .filter(
610+ (integration) =>
611+ integration.servingState.operationsToBuild === 0 &&
612+ !integration.servingState.expectedZeroOperations,
613+ );
614+ if (zeroOperationIntegrations.length > 0) {
615+ throw new Error(
616+ `Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`,
617+ );
618+ }
619+ const requiredR2Copies = plan.orgs
620+ .filter((org) => !org.completed && org.hardErrors.length === 0)
621+ .flatMap((org) => org.blobCopies.filter((copy) => copy.backend === "external"));
622+ if (requiredR2Copies.length > 0 && !r2CopiesDone) {
623+ throw new Error(
624+ `External R2 blob copies are required before apply (${requiredR2Copies.length}); run dry-run, execute r2-copy-manifest.json, then pass --r2-copies-done`,
625+ );
626+ }
627+ for (const org of plan.orgs) {
628+ await applyOrg(sql, org);
629+ console.log(`applied_org=${tenantHash(org.tenant)}`);
630+ }
631+ console.log(renderSummary(plan));
632+ } finally {
633+ await sql.end({ timeout: 0 });
634+ }
635+};
636+
637+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
638+ await main();
639+}
tests/migration/service-split-postgres-cli.test.ts+468−0
@@ -0,0 +1,468 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Schema } from "effect";
3+
4+import { PGlite } from "../../apps/cloud/node_modules/@electric-sql/pglite";
5+import { PGLiteSocketServer } from "../../apps/cloud/node_modules/@electric-sql/pglite-socket";
6+import postgres from "../../apps/cloud/node_modules/postgres/src/index.js";
7+import {
8+ applyOrg,
9+ LEDGER_TABLE,
10+ readDatabaseInput,
11+ type SqlClient,
12+} from "../../scripts/migration/service-split-postgres-cli";
13+import {
14+ operationStorageKey,
15+ planMigration,
16+} from "@executor-js/plugin-provider-service-split/planner";
17+
18+const now = "2026-01-01T00:00:00.000Z";
19+const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
20+
21+const parseJson = (value: unknown): unknown =>
22+ typeof value === "string" ? decodeJson(value) : value;
23+
24+const createSchema = async (sql: SqlClient): Promise<void> => {
25+ await sql.unsafe(`
26+ CREATE TABLE integration (
27+ slug varchar(255) NOT NULL,
28+ plugin_id varchar(255) NOT NULL,
29+ name text,
30+ description text,
31+ config json,
32+ health_check json,
33+ config_revised_at bigint,
34+ can_remove boolean NOT NULL,
35+ can_refresh boolean NOT NULL,
36+ created_at timestamp NOT NULL,
37+ updated_at timestamp NOT NULL,
38+ row_id text NOT NULL,
39+ tenant text NOT NULL
40+ );
41+ CREATE UNIQUE INDEX integration_uidx ON integration (tenant, slug);
42+
43+ CREATE TABLE connection (
44+ integration varchar(255) NOT NULL,
45+ name varchar(255) NOT NULL,
46+ template varchar(255) NOT NULL,
47+ provider text NOT NULL,
48+ item_ids json,
49+ identity_label text,
50+ description text,
51+ last_health json,
52+ tools_synced_at bigint,
53+ oauth_client text,
54+ oauth_client_owner text,
55+ refresh_item_id text,
56+ expires_at bigint,
57+ oauth_scope text,
58+ oauth_token_url text,
59+ provider_state json,
60+ created_at timestamp NOT NULL,
61+ updated_at timestamp NOT NULL,
62+ row_id text NOT NULL,
63+ tenant text NOT NULL,
64+ owner text NOT NULL,
65+ subject text NOT NULL
66+ );
67+ CREATE UNIQUE INDEX connection_uidx ON connection
68+ (tenant, owner, subject, integration, name);
69+
70+ CREATE TABLE tool (
71+ integration varchar(255) NOT NULL,
72+ connection varchar(255) NOT NULL,
73+ plugin_id varchar(255) NOT NULL,
74+ name text NOT NULL,
75+ description text,
76+ input_schema json,
77+ output_schema json,
78+ annotations json,
79+ created_at timestamp NOT NULL,
80+ updated_at timestamp NOT NULL,
81+ row_id text NOT NULL,
82+ tenant text NOT NULL,
83+ owner text NOT NULL,
84+ subject text NOT NULL
85+ );
86+ CREATE UNIQUE INDEX tool_uidx ON tool
87+ (tenant, owner, subject, integration, connection, name);
88+
89+ CREATE TABLE plugin_storage (
90+ plugin_id varchar(255) NOT NULL,
91+ collection varchar(255) NOT NULL,
92+ key text NOT NULL,
93+ data jsonb,
94+ created_at timestamp NOT NULL,
95+ updated_at timestamp NOT NULL,
96+ row_id text NOT NULL,
97+ tenant text NOT NULL,
98+ owner text NOT NULL,
99+ subject text NOT NULL
100+ );
101+ CREATE UNIQUE INDEX plugin_storage_uidx ON plugin_storage
102+ (tenant, owner, subject, plugin_id, collection, key);
103+
104+ CREATE TABLE blob (
105+ id text NOT NULL,
106+ namespace text NOT NULL,
107+ key text NOT NULL,
108+ value text NOT NULL,
109+ row_id text NOT NULL
110+ );
111+ CREATE UNIQUE INDEX blob_id_uidx ON blob (id);
112+
113+ CREATE TABLE tool_policy (
114+ id text NOT NULL,
115+ pattern text NOT NULL,
116+ action text NOT NULL,
117+ position text NOT NULL,
118+ created_at timestamp NOT NULL,
119+ updated_at timestamp NOT NULL,
120+ row_id text NOT NULL,
121+ tenant text NOT NULL,
122+ owner text NOT NULL,
123+ subject text NOT NULL
124+ );
125+ CREATE UNIQUE INDEX tool_policy_uidx ON tool_policy (tenant, owner, subject, id);
126+ `);
127+};
128+
129+const seedMonolith = async (sql: SqlClient): Promise<void> => {
130+ await sql.unsafe(
131+ `
132+ INSERT INTO integration (
133+ slug, plugin_id, name, description, config, health_check, config_revised_at,
134+ can_remove, can_refresh, created_at, updated_at, row_id, tenant
135+ )
136+ VALUES ('google', 'google', 'Google', 'Google APIs', $1::json, $2::json, NULL,
137+ true, true, $3, $3, 'google_row', 'org_1')
138+ `,
139+ [
140+ JSON.stringify({
141+ googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
142+ specHash: "mono-hash",
143+ }),
144+ JSON.stringify({ operation: "oauth2.userinfo.get" }),
145+ now,
146+ ],
147+ );
148+ await sql.unsafe(
149+ `
150+ INSERT INTO connection (
151+ integration, name, template, provider, item_ids, identity_label, description,
152+ last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id,
153+ expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at,
154+ row_id, tenant, owner, subject
155+ )
156+ VALUES ('google', 'main', 'googleOAuth', 'vault', $1::json, 'person@example.test',
157+ NULL, NULL, 1, 'google', 'org', 'refresh_item', 2, 'calendar',
158+ 'https://oauth2.googleapis.com/token', $2::json, $3, $3, 'conn_google',
159+ 'org_1', 'org', '')
160+ `,
161+ [JSON.stringify({ access: "access_item", refresh: "refresh_item" }), "{}", now],
162+ );
163+ await sql.unsafe(
164+ `
165+ INSERT INTO tool (
166+ integration, connection, plugin_id, name, description, input_schema, output_schema,
167+ annotations, created_at, updated_at, row_id, tenant, owner, subject
168+ )
169+ VALUES ('google', 'main', 'google', 'calendar.events.list', 'tool', '{}'::json,
170+ '{}'::json, '{}'::json, $1, $1, 'tool_calendar_events_list', 'org_1', 'org', '')
171+ `,
172+ [now],
173+ );
174+ await sql.unsafe(
175+ `
176+ INSERT INTO plugin_storage (
177+ plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject
178+ )
179+ VALUES ('google', 'operation', $1, $2::jsonb, $3, $3, 'op_calendar_events_list',
180+ 'org_1', 'org', '')
181+ `,
182+ [
183+ operationStorageKey("google", "calendar.events.list"),
184+ JSON.stringify({
185+ integration: "google",
186+ toolName: "calendar.events.list",
187+ binding: {
188+ method: "get",
189+ pathTemplate: "/calendar.events.list",
190+ parameters: [],
191+ },
192+ description: "calendar.events.list",
193+ }),
194+ now,
195+ ],
196+ );
197+ await sql.unsafe(
198+ `INSERT INTO blob (id, namespace, key, value, row_id)
199+ VALUES
200+ ($1, 'o:org_1/google', 'spec/mono-hash', 'spec text', 'spec_row'),
201+ ($2, 'o:org_1/google', 'defs/mono-hash', 'defs text', 'defs_row')`,
202+ [
203+ JSON.stringify(["o:org_1/google", "spec/mono-hash"]),
204+ JSON.stringify(["o:org_1/google", "defs/mono-hash"]),
205+ ],
206+ );
207+ await sql.unsafe(
208+ `
209+ INSERT INTO tool_policy (
210+ id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject
211+ )
212+ VALUES ('policy_google_all', 'google.*', 'block', 'a0', $1, $1, 'policy_row',
213+ 'org_1', 'org', '')
214+ `,
215+ [now],
216+ );
217+};
218+
219+const seedNonCanonicalGoogleMonolith = async (sql: SqlClient): Promise<void> => {
220+ await sql.unsafe(
221+ `
222+ INSERT INTO integration (
223+ slug, plugin_id, name, description, config, health_check, config_revised_at,
224+ can_remove, can_refresh, created_at, updated_at, row_id, tenant
225+ )
226+ VALUES ('google_photos_youtube', 'google', 'Google Photos YouTube', 'Google APIs',
227+ $1::json, NULL, NULL, true, true, $2, $2, 'google_photos_youtube_row', 'org_1')
228+ `,
229+ [
230+ JSON.stringify({
231+ googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest"],
232+ specHash: "youtube-hash",
233+ }),
234+ now,
235+ ],
236+ );
237+ await sql.unsafe(
238+ `
239+ INSERT INTO connection (
240+ integration, name, template, provider, item_ids, identity_label, description,
241+ last_health, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id,
242+ expires_at, oauth_scope, oauth_token_url, provider_state, created_at, updated_at,
243+ row_id, tenant, owner, subject
244+ )
245+ VALUES ('google_photos_youtube', 'main', 'googleOAuth', 'vault', '{}'::json,
246+ 'person@example.test', NULL, NULL, 1, 'google', 'org', 'refresh_item', 2,
247+ 'youtube', 'https://oauth2.googleapis.com/token', '{}'::json, $1, $1,
248+ 'conn_google_photos_youtube', 'org_1', 'org', '')
249+ `,
250+ [now],
251+ );
252+ await sql.unsafe(
253+ `
254+ INSERT INTO tool (
255+ integration, connection, plugin_id, name, description, input_schema, output_schema,
256+ annotations, created_at, updated_at, row_id, tenant, owner, subject
257+ )
258+ VALUES ('google_photos_youtube', 'main', 'google', 'youtube.channels.list', 'tool',
259+ '{}'::json, '{}'::json, '{}'::json, $1, $1, 'tool_youtube_channels_list',
260+ 'org_1', 'org', '')
261+ `,
262+ [now],
263+ );
264+ await sql.unsafe(
265+ `
266+ INSERT INTO plugin_storage (
267+ plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject
268+ )
269+ VALUES ('google', 'operation', $1, $2::jsonb, $3, $3, 'op_youtube_channels_list',
270+ 'org_1', 'org', '')
271+ `,
272+ [
273+ operationStorageKey("google_photos_youtube", "youtube.channels.list"),
274+ JSON.stringify({
275+ integration: "google_photos_youtube",
276+ toolName: "youtube.channels.list",
277+ }),
278+ now,
279+ ],
280+ );
281+ await sql.unsafe(
282+ `INSERT INTO blob (id, namespace, key, value, row_id)
283+ VALUES
284+ ($1, 'o:org_1/google', 'spec/youtube-hash', 'spec text', 'spec_youtube_row'),
285+ ($2, 'o:org_1/google', 'defs/youtube-hash', 'defs text', 'defs_youtube_row')`,
286+ [
287+ JSON.stringify(["o:org_1/google", "spec/youtube-hash"]),
288+ JSON.stringify(["o:org_1/google", "defs/youtube-hash"]),
289+ ],
290+ );
291+};
292+
293+describe("service-split-postgres-cli", () => {
294+ it("reads non-canonical provider monolith slugs from Postgres input", async () => {
295+ const pglite = await PGlite.create("memory://");
296+ const server = new PGLiteSocketServer({
297+ db: pglite,
298+ port: 0,
299+ host: "127.0.0.1",
300+ });
301+ await server.start();
302+ const port = Number(server.getServerConn().split(":").at(1));
303+ const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, {
304+ max: 1,
305+ prepare: false,
306+ }) as SqlClient;
307+
308+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test cleans up pglite/postgres resources after assertions
309+ try {
310+ await createSchema(sql);
311+ await seedNonCanonicalGoogleMonolith(sql);
312+
313+ const input = await readDatabaseInput(sql, false, "database");
314+ const plan = planMigration(input);
315+
316+ expect(input.integrations.map((row) => row.slug)).toContain("google_photos_youtube");
317+ expect(input.tools.map((row) => row.integration)).toEqual(["google_photos_youtube"]);
318+ expect(plan.orgs[0]?.integrations.map((row) => row.target.slug)).toEqual([
319+ "google_youtube_data",
320+ ]);
321+ } finally {
322+ await sql.end({ timeout: 0 });
323+ await server.stop();
324+ await pglite.close();
325+ }
326+ });
327+
328+ it("applies a synthetic monolith split through the Postgres runner and stamps the ledger", async () => {
329+ const pglite = await PGlite.create("memory://");
330+ const server = new PGLiteSocketServer({
331+ db: pglite,
332+ port: 0,
333+ host: "127.0.0.1",
334+ });
335+ await server.start();
336+ const port = Number(server.getServerConn().split(":").at(1));
337+ const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, {
338+ max: 1,
339+ prepare: false,
340+ }) as SqlClient;
341+
342+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test cleans up pglite/postgres resources after assertions
343+ try {
344+ await createSchema(sql);
345+ await seedMonolith(sql);
346+
347+ const input = await readDatabaseInput(sql, false, "database");
348+ const plan = planMigration(input);
349+ expect(plan.summary).toMatchObject({
350+ integrationsCreate: 1,
351+ connectionsClone: 1,
352+ policiesRewrite: 1,
353+ monolithDeletes: 1,
354+ });
355+
356+ const org = plan.orgs[0];
357+ expect(org).toBeDefined();
358+ await applyOrg(sql, org!);
359+
360+ const integrations = await sql.unsafe<
361+ {
362+ readonly slug: string;
363+ readonly plugin_id: string;
364+ readonly config: unknown;
365+ }[]
366+ >("SELECT slug, plugin_id, config::jsonb AS config FROM integration ORDER BY slug");
367+ expect(integrations).toHaveLength(1);
368+ expect(integrations[0]).toMatchObject({
369+ slug: "google_calendar",
370+ plugin_id: "openapi",
371+ });
372+ expect(parseJson(integrations[0]?.config)).toMatchObject({
373+ specHash: "mono-hash",
374+ sourceUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
375+ specFormat: "google-discovery",
376+ family: "google",
377+ });
378+
379+ const connections = await sql.unsafe<
380+ {
381+ readonly integration: string;
382+ readonly provider: string;
383+ readonly item_ids: unknown;
384+ readonly oauth_client: string;
385+ }[]
386+ >("SELECT integration, provider, item_ids, oauth_client FROM connection");
387+ expect(connections).toHaveLength(1);
388+ expect(connections[0]).toMatchObject({
389+ integration: "google_calendar",
390+ provider: "vault",
391+ oauth_client: "google",
392+ });
393+ expect(parseJson(connections[0]?.item_ids)).toEqual({
394+ access: "access_item",
395+ refresh: "refresh_item",
396+ });
397+
398+ const operations = await sql.unsafe<
399+ {
400+ readonly plugin_id: string;
401+ readonly key: string;
402+ readonly data: unknown;
403+ }[]
404+ >("SELECT plugin_id, key, data FROM plugin_storage WHERE collection = 'operation'");
405+ expect(operations).toHaveLength(1);
406+ expect(operations[0]).toMatchObject({
407+ plugin_id: "openapi",
408+ key: operationStorageKey("google_calendar", "calendar.events.list"),
409+ });
410+ expect(parseJson(operations[0]?.data)).toMatchObject({
411+ integration: "google_calendar",
412+ toolName: "calendar.events.list",
413+ });
414+
415+ const tools = await sql.unsafe<
416+ {
417+ readonly integration: string;
418+ readonly plugin_id: string;
419+ readonly name: string;
420+ }[]
421+ >("SELECT integration, plugin_id, name FROM tool");
422+ expect(tools).toEqual([
423+ {
424+ integration: "google_calendar",
425+ plugin_id: "openapi",
426+ name: "calendar.events.list",
427+ },
428+ ]);
429+
430+ const policies = await sql.unsafe<{ readonly pattern: string; readonly action: string }[]>(
431+ "SELECT pattern, action FROM tool_policy",
432+ );
433+ expect(policies).toEqual([{ pattern: "google_calendar.*", action: "block" }]);
434+
435+ const blobs = await sql.unsafe<
436+ {
437+ readonly namespace: string;
438+ readonly key: string;
439+ readonly value: string;
440+ }[]
441+ >("SELECT namespace, key, value FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key");
442+ expect(blobs).toEqual([
443+ {
444+ namespace: "o:org_1/openapi",
445+ key: "defs/mono-hash",
446+ value: "defs text",
447+ },
448+ {
449+ namespace: "o:org_1/openapi",
450+ key: "spec/mono-hash",
451+ value: "spec text",
452+ },
453+ ]);
454+
455+ const ledger = await sql.unsafe<{ readonly tenant: string }[]>(
456+ `SELECT tenant FROM ${LEDGER_TABLE}`,
457+ );
458+ expect(ledger).toEqual([{ tenant: "org_1" }]);
459+
460+ const rerunInput = await readDatabaseInput(sql, false, "database");
461+ expect(planMigration(rerunInput).summary.orgs).toBe(0);
462+ } finally {
463+ await sql.end({ timeout: 0 });
464+ await server.stop();
465+ await pglite.close();
466+ }
467+ });
468+});
tests/presets-reachable.test.ts+1−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 convertGoogleDiscoveryToOpenApi,
1111 fetchGoogleDiscoveryDocument,
1212 isGoogleDiscoveryUrl,
13−} from "../packages/plugins/google/src/sdk/discovery";
13+} from "../packages/plugins/openapi/src/providers/google/discovery";
1414 import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin";
1515 import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin";
1616 import { introspect } from "../packages/plugins/graphql/src/sdk/introspect";