History

Move Google discovery ownership out of OpenAPI

Rhys Sullivan committed 4 months ago

Commit ba657e31 parent ecf7b3d43 files · +2209 −2876Browse files

Unified · Split

43 files
  1. apps/cloud/drizzle/0006_google_openapi_ownership.sql +86 −0
  2. apps/cloud/drizzle/meta/0006_snapshot.json +1266 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/host-cloudflare/src/db/d1.ts +2 −0
  5. apps/host-cloudflare/src/db/data-migrations.test.ts +153 −0
  6. apps/host-cloudflare/src/db/data-migrations.ts +90 −0
  7. apps/host-cloudflare/src/plugins.ts +2 −0
  8. apps/host-selfhost/src/db/data-migrations.ts +2 −0
  9. apps/local/src/db/data-migrations.ts +2 −0
  10. bun.lock +1 −0
  11. examples/all-plugins/package.json +1 −0
  12. examples/all-plugins/src/main.ts +3 −1
  13. knip.config.ts +1 −1
  14. packages/plugins/google/CHANGELOG.md +1 −0
  15. packages/plugins/google/src/sdk/config.ts +24 −0
  16. packages/plugins/google/src/sdk/index.ts +4 −0
  17. packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +219 −0
  18. packages/plugins/google/src/sdk/openapi-ownership-migration.ts +148 −0
  19. packages/plugins/google/src/sdk/plugin.ts +175 −205
  20. packages/plugins/openapi/src/api/group.ts +0 −9
  21. packages/plugins/openapi/src/api/handlers.ts +0 −3
  22. packages/plugins/openapi/src/react/UpdateSpecSection.tsx +1 −7
  23. packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap +0 −82
  24. packages/plugins/openapi/src/sdk/config.ts +0 −2
  25. packages/plugins/openapi/src/sdk/configure.test.ts +0 −1
  26. packages/plugins/openapi/src/sdk/google-bundle.test.ts +0 −270
  27. packages/plugins/openapi/src/sdk/google-discovery.test.ts +0 −604
  28. packages/plugins/openapi/src/sdk/google-discovery.ts +0 −854
  29. packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts +0 −98
  30. packages/plugins/openapi/src/sdk/google-oauth-batches.ts +0 −69
  31. packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts +0 −37
  32. packages/plugins/openapi/src/sdk/google-oauth-scopes.ts +0 −66
  33. packages/plugins/openapi/src/sdk/google-presets.test.ts +0 −29
  34. packages/plugins/openapi/src/sdk/google-presets.ts +0 −267
  35. packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts +0 −122
  36. packages/plugins/openapi/src/sdk/index.ts +0 −7
  37. packages/plugins/openapi/src/sdk/plugin.test.ts +0 −1
  38. packages/plugins/openapi/src/sdk/plugin.ts +14 −133
  39. packages/plugins/openapi/src/sdk/spec-blob.test.ts +0 −1
  40. packages/react/src/components/integration-favicon.test.tsx +4 −4
  41. packages/react/src/components/integration-favicon.tsx +1 −1
  42. packages/react/src/pages/integrations.tsx +1 −1
  43. tests/presets-reachable.test.ts +1 −1
apps/cloud/drizzle/0006_google_openapi_ownership.sql+86−0
@@ -0,0 +1,86 @@
1+CREATE TEMP TABLE "__google_openapi_integrations" AS
2+SELECT
3+ "tenant",
4+ "slug",
5+ "config"::jsonb ->> 'specHash' AS "spec_hash"
6+FROM "integration"
7+WHERE "plugin_id" = 'openapi'
8+ AND "config" IS NOT NULL
9+ AND jsonb_typeof("config"::jsonb -> 'googleDiscoveryUrls') = 'array';--> statement-breakpoint
10+INSERT INTO "blob" ("namespace", "key", "value", "row_id", "id")
11+SELECT
12+ 'o:' || g."tenant" || '/google' AS "namespace",
13+ b."key",
14+ b."value",
15+ 'mig_google_blob_' || md5('o:' || g."tenant" || '/google' || ':' || b."key") AS "row_id",
16+ '["' || 'o:' || g."tenant" || '/google' || '","' || b."key" || '"]' AS "id"
17+FROM "__google_openapi_integrations" g
18+JOIN "blob" b
19+ ON b."namespace" = 'o:' || g."tenant" || '/openapi'
20+ AND b."key" = 'spec/' || g."spec_hash"
21+WHERE g."spec_hash" IS NOT NULL
22+ AND g."spec_hash" <> ''
23+ON CONFLICT ("id") DO NOTHING;--> statement-breakpoint
24+INSERT INTO "plugin_storage"
25+ ("tenant", "owner", "subject", "plugin_id", "collection", "key", "data", "created_at", "updated_at", "row_id")
26+SELECT
27+ ps."tenant",
28+ ps."owner",
29+ ps."subject",
30+ 'google',
31+ ps."collection",
32+ ps."key",
33+ ps."data",
34+ ps."created_at",
35+ ps."updated_at",
36+ 'mig_google_storage_' || md5(ps."tenant" || ':' || ps."owner" || ':' || ps."subject" || ':' || ps."collection" || ':' || ps."key")
37+FROM "plugin_storage" ps
38+JOIN "__google_openapi_integrations" g
39+ ON g."tenant" = ps."tenant"
40+WHERE ps."plugin_id" = 'openapi'
41+ AND ps."collection" = 'operation'
42+ AND (
43+ ps."data"::jsonb ->> 'integration' = g."slug"
44+ OR ps."key" LIKE g."slug" || '.%'
45+ )
46+ON CONFLICT ("tenant", "owner", "subject", "plugin_id", "collection", "key")
47+DO UPDATE SET
48+ "data" = EXCLUDED."data",
49+ "updated_at" = EXCLUDED."updated_at";--> statement-breakpoint
50+DELETE FROM "plugin_storage" ps
51+USING "__google_openapi_integrations" g
52+WHERE ps."plugin_id" = 'openapi'
53+ AND ps."collection" = 'operation'
54+ AND g."tenant" = ps."tenant"
55+ AND (
56+ ps."data"::jsonb ->> 'integration' = g."slug"
57+ OR ps."key" LIKE g."slug" || '.%'
58+ );--> statement-breakpoint
59+UPDATE "tool"
60+SET "plugin_id" = 'google'
61+WHERE "plugin_id" = 'openapi'
62+ AND EXISTS (
63+ SELECT 1
64+ FROM "__google_openapi_integrations" g
65+ WHERE g."tenant" = "tool"."tenant"
66+ AND g."slug" = "tool"."integration"
67+ );--> statement-breakpoint
68+UPDATE "definition"
69+SET "plugin_id" = 'google'
70+WHERE "plugin_id" = 'openapi'
71+ AND EXISTS (
72+ SELECT 1
73+ FROM "__google_openapi_integrations" g
74+ WHERE g."tenant" = "definition"."tenant"
75+ AND g."slug" = "definition"."integration"
76+ );--> statement-breakpoint
77+UPDATE "integration"
78+SET "plugin_id" = 'google'
79+WHERE "plugin_id" = 'openapi'
80+ AND EXISTS (
81+ SELECT 1
82+ FROM "__google_openapi_integrations" g
83+ WHERE g."tenant" = "integration"."tenant"
84+ AND g."slug" = "integration"."slug"
85+ );--> statement-breakpoint
86+DROP TABLE "__google_openapi_integrations";
apps/cloud/drizzle/meta/0006_snapshot.json+1266−0
@@ -0,0 +1,1266 @@
1+{
2+ "id": "3beb97b9-a590-403f-88f5-8a67b499dcf4",
3+ "prevId": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": ["account_id"],
64+ "columnsTo": ["id"],
65+ "onDelete": "cascade",
66+ "onUpdate": "no action"
67+ },
68+ "memberships_organization_id_organizations_id_fk": {
69+ "name": "memberships_organization_id_organizations_id_fk",
70+ "tableFrom": "memberships",
71+ "tableTo": "organizations",
72+ "columnsFrom": ["organization_id"],
73+ "columnsTo": ["id"],
74+ "onDelete": "cascade",
75+ "onUpdate": "no action"
76+ }
77+ },
78+ "compositePrimaryKeys": {
79+ "memberships_account_id_organization_id_pk": {
80+ "name": "memberships_account_id_organization_id_pk",
81+ "columns": ["account_id", "organization_id"]
82+ }
83+ },
84+ "uniqueConstraints": {},
85+ "policies": {},
86+ "checkConstraints": {},
87+ "isRLSEnabled": false
88+ },
89+ "public.organizations": {
90+ "name": "organizations",
91+ "schema": "",
92+ "columns": {
93+ "id": {
94+ "name": "id",
95+ "type": "text",
96+ "primaryKey": true,
97+ "notNull": true
98+ },
99+ "name": {
100+ "name": "name",
101+ "type": "text",
102+ "primaryKey": false,
103+ "notNull": true
104+ },
105+ "slug": {
106+ "name": "slug",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": true
110+ },
111+ "created_at": {
112+ "name": "created_at",
113+ "type": "timestamp with time zone",
114+ "primaryKey": false,
115+ "notNull": true,
116+ "default": "now()"
117+ }
118+ },
119+ "indexes": {
120+ "organizations_slug_unique": {
121+ "name": "organizations_slug_unique",
122+ "columns": [
123+ {
124+ "expression": "slug",
125+ "isExpression": false,
126+ "asc": true,
127+ "nulls": "last"
128+ }
129+ ],
130+ "isUnique": true,
131+ "concurrently": false,
132+ "method": "btree",
133+ "with": {}
134+ }
135+ },
136+ "foreignKeys": {},
137+ "compositePrimaryKeys": {},
138+ "uniqueConstraints": {},
139+ "policies": {},
140+ "checkConstraints": {},
141+ "isRLSEnabled": false
142+ },
143+ "public.blob": {
144+ "name": "blob",
145+ "schema": "",
146+ "columns": {
147+ "namespace": {
148+ "name": "namespace",
149+ "type": "varchar(255)",
150+ "primaryKey": false,
151+ "notNull": true
152+ },
153+ "key": {
154+ "name": "key",
155+ "type": "varchar(255)",
156+ "primaryKey": false,
157+ "notNull": true
158+ },
159+ "value": {
160+ "name": "value",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": true
164+ },
165+ "row_id": {
166+ "name": "row_id",
167+ "type": "varchar(255)",
168+ "primaryKey": true,
169+ "notNull": true
170+ },
171+ "id": {
172+ "name": "id",
173+ "type": "varchar(255)",
174+ "primaryKey": false,
175+ "notNull": true
176+ }
177+ },
178+ "indexes": {
179+ "blob_id_uidx": {
180+ "name": "blob_id_uidx",
181+ "columns": [
182+ {
183+ "expression": "id",
184+ "isExpression": false,
185+ "asc": true,
186+ "nulls": "last"
187+ }
188+ ],
189+ "isUnique": true,
190+ "concurrently": false,
191+ "method": "btree",
192+ "with": {}
193+ }
194+ },
195+ "foreignKeys": {},
196+ "compositePrimaryKeys": {},
197+ "uniqueConstraints": {},
198+ "policies": {},
199+ "checkConstraints": {},
200+ "isRLSEnabled": false
201+ },
202+ "public.connection": {
203+ "name": "connection",
204+ "schema": "",
205+ "columns": {
206+ "integration": {
207+ "name": "integration",
208+ "type": "varchar(255)",
209+ "primaryKey": false,
210+ "notNull": true
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "varchar(255)",
215+ "primaryKey": false,
216+ "notNull": true
217+ },
218+ "template": {
219+ "name": "template",
220+ "type": "text",
221+ "primaryKey": false,
222+ "notNull": true
223+ },
224+ "provider": {
225+ "name": "provider",
226+ "type": "text",
227+ "primaryKey": false,
228+ "notNull": true
229+ },
230+ "item_ids": {
231+ "name": "item_ids",
232+ "type": "json",
233+ "primaryKey": false,
234+ "notNull": true
235+ },
236+ "identity_label": {
237+ "name": "identity_label",
238+ "type": "text",
239+ "primaryKey": false,
240+ "notNull": false
241+ },
242+ "description": {
243+ "name": "description",
244+ "type": "text",
245+ "primaryKey": false,
246+ "notNull": false
247+ },
248+ "tools_synced_at": {
249+ "name": "tools_synced_at",
250+ "type": "bigint",
251+ "primaryKey": false,
252+ "notNull": false
253+ },
254+ "oauth_client": {
255+ "name": "oauth_client",
256+ "type": "text",
257+ "primaryKey": false,
258+ "notNull": false
259+ },
260+ "oauth_client_owner": {
261+ "name": "oauth_client_owner",
262+ "type": "text",
263+ "primaryKey": false,
264+ "notNull": false
265+ },
266+ "refresh_item_id": {
267+ "name": "refresh_item_id",
268+ "type": "text",
269+ "primaryKey": false,
270+ "notNull": false
271+ },
272+ "expires_at": {
273+ "name": "expires_at",
274+ "type": "bigint",
275+ "primaryKey": false,
276+ "notNull": false
277+ },
278+ "oauth_scope": {
279+ "name": "oauth_scope",
280+ "type": "text",
281+ "primaryKey": false,
282+ "notNull": false
283+ },
284+ "provider_state": {
285+ "name": "provider_state",
286+ "type": "json",
287+ "primaryKey": false,
288+ "notNull": false
289+ },
290+ "created_at": {
291+ "name": "created_at",
292+ "type": "timestamp",
293+ "primaryKey": false,
294+ "notNull": true
295+ },
296+ "updated_at": {
297+ "name": "updated_at",
298+ "type": "timestamp",
299+ "primaryKey": false,
300+ "notNull": true
301+ },
302+ "row_id": {
303+ "name": "row_id",
304+ "type": "varchar(255)",
305+ "primaryKey": true,
306+ "notNull": true
307+ },
308+ "tenant": {
309+ "name": "tenant",
310+ "type": "varchar(255)",
311+ "primaryKey": false,
312+ "notNull": true
313+ },
314+ "owner": {
315+ "name": "owner",
316+ "type": "varchar(255)",
317+ "primaryKey": false,
318+ "notNull": true
319+ },
320+ "subject": {
321+ "name": "subject",
322+ "type": "varchar(255)",
323+ "primaryKey": false,
324+ "notNull": true
325+ }
326+ },
327+ "indexes": {
328+ "connection_uidx": {
329+ "name": "connection_uidx",
330+ "columns": [
331+ {
332+ "expression": "tenant",
333+ "isExpression": false,
334+ "asc": true,
335+ "nulls": "last"
336+ },
337+ {
338+ "expression": "owner",
339+ "isExpression": false,
340+ "asc": true,
341+ "nulls": "last"
342+ },
343+ {
344+ "expression": "subject",
345+ "isExpression": false,
346+ "asc": true,
347+ "nulls": "last"
348+ },
349+ {
350+ "expression": "integration",
351+ "isExpression": false,
352+ "asc": true,
353+ "nulls": "last"
354+ },
355+ {
356+ "expression": "name",
357+ "isExpression": false,
358+ "asc": true,
359+ "nulls": "last"
360+ }
361+ ],
362+ "isUnique": true,
363+ "concurrently": false,
364+ "method": "btree",
365+ "with": {}
366+ }
367+ },
368+ "foreignKeys": {},
369+ "compositePrimaryKeys": {},
370+ "uniqueConstraints": {},
371+ "policies": {},
372+ "checkConstraints": {},
373+ "isRLSEnabled": false
374+ },
375+ "public.definition": {
376+ "name": "definition",
377+ "schema": "",
378+ "columns": {
379+ "integration": {
380+ "name": "integration",
381+ "type": "varchar(255)",
382+ "primaryKey": false,
383+ "notNull": true
384+ },
385+ "connection": {
386+ "name": "connection",
387+ "type": "varchar(255)",
388+ "primaryKey": false,
389+ "notNull": true
390+ },
391+ "plugin_id": {
392+ "name": "plugin_id",
393+ "type": "text",
394+ "primaryKey": false,
395+ "notNull": true
396+ },
397+ "name": {
398+ "name": "name",
399+ "type": "text",
400+ "primaryKey": false,
401+ "notNull": true
402+ },
403+ "schema": {
404+ "name": "schema",
405+ "type": "json",
406+ "primaryKey": false,
407+ "notNull": true
408+ },
409+ "created_at": {
410+ "name": "created_at",
411+ "type": "timestamp",
412+ "primaryKey": false,
413+ "notNull": true
414+ },
415+ "row_id": {
416+ "name": "row_id",
417+ "type": "varchar(255)",
418+ "primaryKey": true,
419+ "notNull": true
420+ },
421+ "tenant": {
422+ "name": "tenant",
423+ "type": "varchar(255)",
424+ "primaryKey": false,
425+ "notNull": true
426+ },
427+ "owner": {
428+ "name": "owner",
429+ "type": "varchar(255)",
430+ "primaryKey": false,
431+ "notNull": true
432+ },
433+ "subject": {
434+ "name": "subject",
435+ "type": "varchar(255)",
436+ "primaryKey": false,
437+ "notNull": true
438+ }
439+ },
440+ "indexes": {
441+ "definition_uidx": {
442+ "name": "definition_uidx",
443+ "columns": [
444+ {
445+ "expression": "tenant",
446+ "isExpression": false,
447+ "asc": true,
448+ "nulls": "last"
449+ },
450+ {
451+ "expression": "owner",
452+ "isExpression": false,
453+ "asc": true,
454+ "nulls": "last"
455+ },
456+ {
457+ "expression": "subject",
458+ "isExpression": false,
459+ "asc": true,
460+ "nulls": "last"
461+ },
462+ {
463+ "expression": "integration",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ },
468+ {
469+ "expression": "connection",
470+ "isExpression": false,
471+ "asc": true,
472+ "nulls": "last"
473+ },
474+ {
475+ "expression": "name",
476+ "isExpression": false,
477+ "asc": true,
478+ "nulls": "last"
479+ }
480+ ],
481+ "isUnique": true,
482+ "concurrently": false,
483+ "method": "btree",
484+ "with": {}
485+ }
486+ },
487+ "foreignKeys": {},
488+ "compositePrimaryKeys": {},
489+ "uniqueConstraints": {},
490+ "policies": {},
491+ "checkConstraints": {},
492+ "isRLSEnabled": false
493+ },
494+ "public.integration": {
495+ "name": "integration",
496+ "schema": "",
497+ "columns": {
498+ "slug": {
499+ "name": "slug",
500+ "type": "varchar(255)",
501+ "primaryKey": false,
502+ "notNull": true
503+ },
504+ "plugin_id": {
505+ "name": "plugin_id",
506+ "type": "text",
507+ "primaryKey": false,
508+ "notNull": true
509+ },
510+ "name": {
511+ "name": "name",
512+ "type": "text",
513+ "primaryKey": false,
514+ "notNull": false
515+ },
516+ "description": {
517+ "name": "description",
518+ "type": "text",
519+ "primaryKey": false,
520+ "notNull": false
521+ },
522+ "config_revised_at": {
523+ "name": "config_revised_at",
524+ "type": "bigint",
525+ "primaryKey": false,
526+ "notNull": false
527+ },
528+ "config": {
529+ "name": "config",
530+ "type": "json",
531+ "primaryKey": false,
532+ "notNull": false
533+ },
534+ "can_remove": {
535+ "name": "can_remove",
536+ "type": "boolean",
537+ "primaryKey": false,
538+ "notNull": true,
539+ "default": true
540+ },
541+ "can_refresh": {
542+ "name": "can_refresh",
543+ "type": "boolean",
544+ "primaryKey": false,
545+ "notNull": true,
546+ "default": false
547+ },
548+ "created_at": {
549+ "name": "created_at",
550+ "type": "timestamp",
551+ "primaryKey": false,
552+ "notNull": true
553+ },
554+ "updated_at": {
555+ "name": "updated_at",
556+ "type": "timestamp",
557+ "primaryKey": false,
558+ "notNull": true
559+ },
560+ "row_id": {
561+ "name": "row_id",
562+ "type": "varchar(255)",
563+ "primaryKey": true,
564+ "notNull": true
565+ },
566+ "tenant": {
567+ "name": "tenant",
568+ "type": "varchar(255)",
569+ "primaryKey": false,
570+ "notNull": true
571+ }
572+ },
573+ "indexes": {
574+ "integration_uidx": {
575+ "name": "integration_uidx",
576+ "columns": [
577+ {
578+ "expression": "tenant",
579+ "isExpression": false,
580+ "asc": true,
581+ "nulls": "last"
582+ },
583+ {
584+ "expression": "slug",
585+ "isExpression": false,
586+ "asc": true,
587+ "nulls": "last"
588+ }
589+ ],
590+ "isUnique": true,
591+ "concurrently": false,
592+ "method": "btree",
593+ "with": {}
594+ }
595+ },
596+ "foreignKeys": {},
597+ "compositePrimaryKeys": {},
598+ "uniqueConstraints": {},
599+ "policies": {},
600+ "checkConstraints": {},
601+ "isRLSEnabled": false
602+ },
603+ "public.oauth_client": {
604+ "name": "oauth_client",
605+ "schema": "",
606+ "columns": {
607+ "slug": {
608+ "name": "slug",
609+ "type": "varchar(255)",
610+ "primaryKey": false,
611+ "notNull": true
612+ },
613+ "authorization_url": {
614+ "name": "authorization_url",
615+ "type": "text",
616+ "primaryKey": false,
617+ "notNull": true
618+ },
619+ "token_url": {
620+ "name": "token_url",
621+ "type": "text",
622+ "primaryKey": false,
623+ "notNull": true
624+ },
625+ "grant": {
626+ "name": "grant",
627+ "type": "text",
628+ "primaryKey": false,
629+ "notNull": true
630+ },
631+ "client_id": {
632+ "name": "client_id",
633+ "type": "text",
634+ "primaryKey": false,
635+ "notNull": true
636+ },
637+ "client_secret_item_id": {
638+ "name": "client_secret_item_id",
639+ "type": "text",
640+ "primaryKey": false,
641+ "notNull": false
642+ },
643+ "resource": {
644+ "name": "resource",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": false
648+ },
649+ "origin_kind": {
650+ "name": "origin_kind",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": false
654+ },
655+ "origin_integration": {
656+ "name": "origin_integration",
657+ "type": "text",
658+ "primaryKey": false,
659+ "notNull": false
660+ },
661+ "created_at": {
662+ "name": "created_at",
663+ "type": "timestamp",
664+ "primaryKey": false,
665+ "notNull": true
666+ },
667+ "row_id": {
668+ "name": "row_id",
669+ "type": "varchar(255)",
670+ "primaryKey": true,
671+ "notNull": true
672+ },
673+ "tenant": {
674+ "name": "tenant",
675+ "type": "varchar(255)",
676+ "primaryKey": false,
677+ "notNull": true
678+ },
679+ "owner": {
680+ "name": "owner",
681+ "type": "varchar(255)",
682+ "primaryKey": false,
683+ "notNull": true
684+ },
685+ "subject": {
686+ "name": "subject",
687+ "type": "varchar(255)",
688+ "primaryKey": false,
689+ "notNull": true
690+ }
691+ },
692+ "indexes": {
693+ "oauth_client_uidx": {
694+ "name": "oauth_client_uidx",
695+ "columns": [
696+ {
697+ "expression": "tenant",
698+ "isExpression": false,
699+ "asc": true,
700+ "nulls": "last"
701+ },
702+ {
703+ "expression": "owner",
704+ "isExpression": false,
705+ "asc": true,
706+ "nulls": "last"
707+ },
708+ {
709+ "expression": "subject",
710+ "isExpression": false,
711+ "asc": true,
712+ "nulls": "last"
713+ },
714+ {
715+ "expression": "slug",
716+ "isExpression": false,
717+ "asc": true,
718+ "nulls": "last"
719+ }
720+ ],
721+ "isUnique": true,
722+ "concurrently": false,
723+ "method": "btree",
724+ "with": {}
725+ }
726+ },
727+ "foreignKeys": {},
728+ "compositePrimaryKeys": {},
729+ "uniqueConstraints": {},
730+ "policies": {},
731+ "checkConstraints": {},
732+ "isRLSEnabled": false
733+ },
734+ "public.oauth_session": {
735+ "name": "oauth_session",
736+ "schema": "",
737+ "columns": {
738+ "state": {
739+ "name": "state",
740+ "type": "varchar(255)",
741+ "primaryKey": false,
742+ "notNull": true
743+ },
744+ "client_slug": {
745+ "name": "client_slug",
746+ "type": "text",
747+ "primaryKey": false,
748+ "notNull": true
749+ },
750+ "integration": {
751+ "name": "integration",
752+ "type": "text",
753+ "primaryKey": false,
754+ "notNull": true
755+ },
756+ "name": {
757+ "name": "name",
758+ "type": "text",
759+ "primaryKey": false,
760+ "notNull": true
761+ },
762+ "template": {
763+ "name": "template",
764+ "type": "text",
765+ "primaryKey": false,
766+ "notNull": true
767+ },
768+ "redirect_url": {
769+ "name": "redirect_url",
770+ "type": "text",
771+ "primaryKey": false,
772+ "notNull": true
773+ },
774+ "pkce_verifier": {
775+ "name": "pkce_verifier",
776+ "type": "text",
777+ "primaryKey": false,
778+ "notNull": false
779+ },
780+ "identity_label": {
781+ "name": "identity_label",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": false
785+ },
786+ "payload": {
787+ "name": "payload",
788+ "type": "json",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "expires_at": {
793+ "name": "expires_at",
794+ "type": "bigint",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "created_at": {
799+ "name": "created_at",
800+ "type": "timestamp",
801+ "primaryKey": false,
802+ "notNull": true
803+ },
804+ "row_id": {
805+ "name": "row_id",
806+ "type": "varchar(255)",
807+ "primaryKey": true,
808+ "notNull": true
809+ },
810+ "tenant": {
811+ "name": "tenant",
812+ "type": "varchar(255)",
813+ "primaryKey": false,
814+ "notNull": true
815+ },
816+ "owner": {
817+ "name": "owner",
818+ "type": "varchar(255)",
819+ "primaryKey": false,
820+ "notNull": true
821+ },
822+ "subject": {
823+ "name": "subject",
824+ "type": "varchar(255)",
825+ "primaryKey": false,
826+ "notNull": true
827+ }
828+ },
829+ "indexes": {
830+ "oauth_session_uidx": {
831+ "name": "oauth_session_uidx",
832+ "columns": [
833+ {
834+ "expression": "tenant",
835+ "isExpression": false,
836+ "asc": true,
837+ "nulls": "last"
838+ },
839+ {
840+ "expression": "state",
841+ "isExpression": false,
842+ "asc": true,
843+ "nulls": "last"
844+ }
845+ ],
846+ "isUnique": true,
847+ "concurrently": false,
848+ "method": "btree",
849+ "with": {}
850+ }
851+ },
852+ "foreignKeys": {},
853+ "compositePrimaryKeys": {},
854+ "uniqueConstraints": {},
855+ "policies": {},
856+ "checkConstraints": {},
857+ "isRLSEnabled": false
858+ },
859+ "public.plugin_storage": {
860+ "name": "plugin_storage",
861+ "schema": "",
862+ "columns": {
863+ "plugin_id": {
864+ "name": "plugin_id",
865+ "type": "varchar(255)",
866+ "primaryKey": false,
867+ "notNull": true
868+ },
869+ "collection": {
870+ "name": "collection",
871+ "type": "varchar(255)",
872+ "primaryKey": false,
873+ "notNull": true
874+ },
875+ "key": {
876+ "name": "key",
877+ "type": "varchar(255)",
878+ "primaryKey": false,
879+ "notNull": true
880+ },
881+ "data": {
882+ "name": "data",
883+ "type": "json",
884+ "primaryKey": false,
885+ "notNull": true
886+ },
887+ "created_at": {
888+ "name": "created_at",
889+ "type": "timestamp",
890+ "primaryKey": false,
891+ "notNull": true
892+ },
893+ "updated_at": {
894+ "name": "updated_at",
895+ "type": "timestamp",
896+ "primaryKey": false,
897+ "notNull": true
898+ },
899+ "row_id": {
900+ "name": "row_id",
901+ "type": "varchar(255)",
902+ "primaryKey": true,
903+ "notNull": true
904+ },
905+ "tenant": {
906+ "name": "tenant",
907+ "type": "varchar(255)",
908+ "primaryKey": false,
909+ "notNull": true
910+ },
911+ "owner": {
912+ "name": "owner",
913+ "type": "varchar(255)",
914+ "primaryKey": false,
915+ "notNull": true
916+ },
917+ "subject": {
918+ "name": "subject",
919+ "type": "varchar(255)",
920+ "primaryKey": false,
921+ "notNull": true
922+ }
923+ },
924+ "indexes": {
925+ "plugin_storage_uidx": {
926+ "name": "plugin_storage_uidx",
927+ "columns": [
928+ {
929+ "expression": "tenant",
930+ "isExpression": false,
931+ "asc": true,
932+ "nulls": "last"
933+ },
934+ {
935+ "expression": "owner",
936+ "isExpression": false,
937+ "asc": true,
938+ "nulls": "last"
939+ },
940+ {
941+ "expression": "subject",
942+ "isExpression": false,
943+ "asc": true,
944+ "nulls": "last"
945+ },
946+ {
947+ "expression": "plugin_id",
948+ "isExpression": false,
949+ "asc": true,
950+ "nulls": "last"
951+ },
952+ {
953+ "expression": "collection",
954+ "isExpression": false,
955+ "asc": true,
956+ "nulls": "last"
957+ },
958+ {
959+ "expression": "key",
960+ "isExpression": false,
961+ "asc": true,
962+ "nulls": "last"
963+ }
964+ ],
965+ "isUnique": true,
966+ "concurrently": false,
967+ "method": "btree",
968+ "with": {}
969+ }
970+ },
971+ "foreignKeys": {},
972+ "compositePrimaryKeys": {},
973+ "uniqueConstraints": {},
974+ "policies": {},
975+ "checkConstraints": {},
976+ "isRLSEnabled": false
977+ },
978+ "public.private_executor_cloud_settings": {
979+ "name": "private_executor_cloud_settings",
980+ "schema": "",
981+ "columns": {
982+ "id": {
983+ "name": "id",
984+ "type": "varchar(255)",
985+ "primaryKey": true,
986+ "notNull": true
987+ },
988+ "version": {
989+ "name": "version",
990+ "type": "varchar(255)",
991+ "primaryKey": false,
992+ "notNull": true,
993+ "default": "'1.0.0'"
994+ }
995+ },
996+ "indexes": {},
997+ "foreignKeys": {},
998+ "compositePrimaryKeys": {},
999+ "uniqueConstraints": {},
1000+ "policies": {},
1001+ "checkConstraints": {},
1002+ "isRLSEnabled": false
1003+ },
1004+ "public.tool": {
1005+ "name": "tool",
1006+ "schema": "",
1007+ "columns": {
1008+ "integration": {
1009+ "name": "integration",
1010+ "type": "varchar(255)",
1011+ "primaryKey": false,
1012+ "notNull": true
1013+ },
1014+ "connection": {
1015+ "name": "connection",
1016+ "type": "varchar(255)",
1017+ "primaryKey": false,
1018+ "notNull": true
1019+ },
1020+ "plugin_id": {
1021+ "name": "plugin_id",
1022+ "type": "text",
1023+ "primaryKey": false,
1024+ "notNull": true
1025+ },
1026+ "name": {
1027+ "name": "name",
1028+ "type": "varchar(255)",
1029+ "primaryKey": false,
1030+ "notNull": true
1031+ },
1032+ "description": {
1033+ "name": "description",
1034+ "type": "text",
1035+ "primaryKey": false,
1036+ "notNull": true
1037+ },
1038+ "input_schema": {
1039+ "name": "input_schema",
1040+ "type": "json",
1041+ "primaryKey": false,
1042+ "notNull": false
1043+ },
1044+ "output_schema": {
1045+ "name": "output_schema",
1046+ "type": "json",
1047+ "primaryKey": false,
1048+ "notNull": false
1049+ },
1050+ "annotations": {
1051+ "name": "annotations",
1052+ "type": "json",
1053+ "primaryKey": false,
1054+ "notNull": false
1055+ },
1056+ "created_at": {
1057+ "name": "created_at",
1058+ "type": "timestamp",
1059+ "primaryKey": false,
1060+ "notNull": true
1061+ },
1062+ "updated_at": {
1063+ "name": "updated_at",
1064+ "type": "timestamp",
1065+ "primaryKey": false,
1066+ "notNull": true
1067+ },
1068+ "row_id": {
1069+ "name": "row_id",
1070+ "type": "varchar(255)",
1071+ "primaryKey": true,
1072+ "notNull": true
1073+ },
1074+ "tenant": {
1075+ "name": "tenant",
1076+ "type": "varchar(255)",
1077+ "primaryKey": false,
1078+ "notNull": true
1079+ },
1080+ "owner": {
1081+ "name": "owner",
1082+ "type": "varchar(255)",
1083+ "primaryKey": false,
1084+ "notNull": true
1085+ },
1086+ "subject": {
1087+ "name": "subject",
1088+ "type": "varchar(255)",
1089+ "primaryKey": false,
1090+ "notNull": true
1091+ }
1092+ },
1093+ "indexes": {
1094+ "tool_uidx": {
1095+ "name": "tool_uidx",
1096+ "columns": [
1097+ {
1098+ "expression": "tenant",
1099+ "isExpression": false,
1100+ "asc": true,
1101+ "nulls": "last"
1102+ },
1103+ {
1104+ "expression": "owner",
1105+ "isExpression": false,
1106+ "asc": true,
1107+ "nulls": "last"
1108+ },
1109+ {
1110+ "expression": "subject",
1111+ "isExpression": false,
1112+ "asc": true,
1113+ "nulls": "last"
1114+ },
1115+ {
1116+ "expression": "integration",
1117+ "isExpression": false,
1118+ "asc": true,
1119+ "nulls": "last"
1120+ },
1121+ {
1122+ "expression": "connection",
1123+ "isExpression": false,
1124+ "asc": true,
1125+ "nulls": "last"
1126+ },
1127+ {
1128+ "expression": "name",
1129+ "isExpression": false,
1130+ "asc": true,
1131+ "nulls": "last"
1132+ }
1133+ ],
1134+ "isUnique": true,
1135+ "concurrently": false,
1136+ "method": "btree",
1137+ "with": {}
1138+ }
1139+ },
1140+ "foreignKeys": {},
1141+ "compositePrimaryKeys": {},
1142+ "uniqueConstraints": {},
1143+ "policies": {},
1144+ "checkConstraints": {},
1145+ "isRLSEnabled": false
1146+ },
1147+ "public.tool_policy": {
1148+ "name": "tool_policy",
1149+ "schema": "",
1150+ "columns": {
1151+ "id": {
1152+ "name": "id",
1153+ "type": "varchar(255)",
1154+ "primaryKey": false,
1155+ "notNull": true
1156+ },
1157+ "pattern": {
1158+ "name": "pattern",
1159+ "type": "text",
1160+ "primaryKey": false,
1161+ "notNull": true
1162+ },
1163+ "action": {
1164+ "name": "action",
1165+ "type": "text",
1166+ "primaryKey": false,
1167+ "notNull": true
1168+ },
1169+ "position": {
1170+ "name": "position",
1171+ "type": "text",
1172+ "primaryKey": false,
1173+ "notNull": true
1174+ },
1175+ "created_at": {
1176+ "name": "created_at",
1177+ "type": "timestamp",
1178+ "primaryKey": false,
1179+ "notNull": true
1180+ },
1181+ "updated_at": {
1182+ "name": "updated_at",
1183+ "type": "timestamp",
1184+ "primaryKey": false,
1185+ "notNull": true
1186+ },
1187+ "row_id": {
1188+ "name": "row_id",
1189+ "type": "varchar(255)",
1190+ "primaryKey": true,
1191+ "notNull": true
1192+ },
1193+ "tenant": {
1194+ "name": "tenant",
1195+ "type": "varchar(255)",
1196+ "primaryKey": false,
1197+ "notNull": true
1198+ },
1199+ "owner": {
1200+ "name": "owner",
1201+ "type": "varchar(255)",
1202+ "primaryKey": false,
1203+ "notNull": true
1204+ },
1205+ "subject": {
1206+ "name": "subject",
1207+ "type": "varchar(255)",
1208+ "primaryKey": false,
1209+ "notNull": true
1210+ }
1211+ },
1212+ "indexes": {
1213+ "tool_policy_uidx": {
1214+ "name": "tool_policy_uidx",
1215+ "columns": [
1216+ {
1217+ "expression": "tenant",
1218+ "isExpression": false,
1219+ "asc": true,
1220+ "nulls": "last"
1221+ },
1222+ {
1223+ "expression": "owner",
1224+ "isExpression": false,
1225+ "asc": true,
1226+ "nulls": "last"
1227+ },
1228+ {
1229+ "expression": "subject",
1230+ "isExpression": false,
1231+ "asc": true,
1232+ "nulls": "last"
1233+ },
1234+ {
1235+ "expression": "id",
1236+ "isExpression": false,
1237+ "asc": true,
1238+ "nulls": "last"
1239+ }
1240+ ],
1241+ "isUnique": true,
1242+ "concurrently": false,
1243+ "method": "btree",
1244+ "with": {}
1245+ }
1246+ },
1247+ "foreignKeys": {},
1248+ "compositePrimaryKeys": {},
1249+ "uniqueConstraints": {},
1250+ "policies": {},
1251+ "checkConstraints": {},
1252+ "isRLSEnabled": false
1253+ }
1254+ },
1255+ "enums": {},
1256+ "schemas": {},
1257+ "sequences": {},
1258+ "roles": {},
1259+ "policies": {},
1260+ "views": {},
1261+ "_meta": {
1262+ "columns": {},
1263+ "schemas": {},
1264+ "tables": {}
1265+ }
1266+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -43,6 +43,13 @@ · 42 unmodified lines
4343 "when": 1781491100009,
4444 "tag": "0005_integration_descriptions",
4545 "breakpoints": true
46+ },
47+ {
48+ "idx": 6,
49+ "version": "7",
50+ "when": 1781994790376,
51+ "tag": "0006_google_openapi_ownership",
52+ "breakpoints": true
4653 }
4754 ]
4855 }
apps/host-cloudflare/src/db/d1.ts+2−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store";
1414
1515 import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config";
16+import { runCloudflareDataMigrations } from "./data-migrations";
1617
1718 // ---------------------------------------------------------------------------
1819 // D1 DbProvider handle — the CF-native swap for self-host's libSQL handle.
@@ -44,6 +45,7 @@ export const createD1ExecutorDb = async ( · 25 unmodified lines
4445 // exposes one. The bring-up is idempotent `CREATE TABLE IF NOT EXISTS`, so run
4546 // it WITHOUT a transaction by handing the ensure a run-only view of the handle.
4647 await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options);
48+ await runCloudflareDataMigrations(db, blobs);
4749
4850 // `interactiveTransactions: false` — D1 rejects interactive transactions, so
4951 // the fuma adapter runs transaction callbacks directly (auto-commit per
apps/host-cloudflare/src/db/data-migrations.test.ts+153−0
@@ -0,0 +1,153 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
4+
5+import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk";
6+import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
7+
8+import { runCloudflareDataMigrations } from "./data-migrations";
9+
10+const now = 1_780_000_000_000;
11+
12+const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => {
13+ const prepare = (sql: string) => {
14+ const statement = (args: readonly unknown[]): Record<string, unknown> => ({
15+ bind: (...values: readonly unknown[]) => statement([...args, ...values]),
16+ all: async () => {
17+ const result = await client.execute({ sql, args });
18+ return { success: true, meta: {}, results: result.rows };
19+ },
20+ run: async () => {
21+ await client.execute({ sql, args });
22+ return { success: true, meta: {}, results: [] };
23+ },
24+ });
25+ return statement([]);
26+ };
27+
28+ // oxlint-disable-next-line executor/no-double-cast -- test double: only the D1 methods used by the migration runner are implemented
29+ return {
30+ prepare,
31+ withSession: () => ({ prepare }),
32+ } as unknown as D1Database;
33+};
34+
35+const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {
36+ const objects = new Map<string, string>();
37+ // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
38+ const bucket = {
39+ get: async (key: string) => {
40+ const value = objects.get(key);
41+ return value === undefined ? null : { text: async () => value };
42+ },
43+ put: async (key: string, value: string) => {
44+ objects.set(key, value);
45+ },
46+ head: async (key: string) => (objects.has(key) ? {} : null),
47+ } as unknown as R2Bucket;
48+ return { bucket, objects };
49+};
50+
51+const insertIntegration = (
52+ client: SqliteDataMigrationClient,
53+ row: {
54+ readonly rowId: string;
55+ readonly tenant: string;
56+ readonly slug: string;
57+ readonly pluginId: string;
58+ readonly config: unknown;
59+ },
60+) =>
61+ client.execute({
62+ sql: `INSERT INTO integration
63+ (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
64+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
65+ args: [
66+ row.rowId,
67+ row.tenant,
68+ row.slug,
69+ row.pluginId,
70+ row.slug,
71+ row.slug,
72+ JSON.stringify(row.config),
73+ now,
74+ now,
75+ ],
76+ });
77+
78+const insertOperationStorage = (
79+ client: SqliteDataMigrationClient,
80+ row: {
81+ readonly tenant: string;
82+ readonly pluginId: string;
83+ readonly integration: string;
84+ },
85+) =>
86+ client.execute({
87+ sql: `INSERT INTO plugin_storage
88+ (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
89+ VALUES (?, 'org', '', ?, 'operation', ?, ?, ?, ?, ?)`,
90+ args: [
91+ row.tenant,
92+ row.pluginId,
93+ `${row.integration}.items.list`,
94+ JSON.stringify({
95+ integration: row.integration,
96+ toolName: "items.list",
97+ binding: { method: "get", pathTemplate: "/items" },
98+ }),
99+ now,
100+ now,
101+ `storage-${row.pluginId}-${row.integration}`,
102+ ],
103+ });
104+
105+describe("runCloudflareDataMigrations", () => {
106+ it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107+ Effect.gen(function* () {
108+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
109+ const { bucket, objects } = makeFakeR2();
110+
111+ yield* Effect.promise(() =>
112+ insertIntegration(db.client, {
113+ rowId: "google-row",
114+ tenant: "org_1",
115+ slug: "google",
116+ pluginId: "openapi",
117+ config: {
118+ specHash: "googlehash",
119+ googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
120+ },
121+ }),
122+ );
123+ yield* Effect.promise(() =>
124+ insertOperationStorage(db.client, {
125+ tenant: "org_1",
126+ pluginId: "openapi",
127+ integration: "google",
128+ }),
129+ );
130+ objects.set("o:org_1/openapi/spec/googlehash", "google spec");
131+
132+ const d1 = makeFakeD1(db.client);
133+ expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
134+ "2026-06-20-google-openapi-ownership",
135+ ]);
136+ expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
137+
138+ expect(objects.get("o:org_1/google/spec/googlehash")).toBe("google spec");
139+
140+ const integrations = yield* Effect.promise(() =>
141+ db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
142+ );
143+ expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]);
144+
145+ const storage = yield* Effect.promise(() =>
146+ db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"),
147+ );
148+ expect(storage.rows).toEqual([{ plugin_id: "google", key: "google.items.list" }]);
149+
150+ yield* Effect.promise(() => db.close());
151+ }),
152+ );
153+});
apps/host-cloudflare/src/db/data-migrations.ts+90−0
@@ -0,0 +1,90 @@
1+import { Effect } from "effect";
2+import type { D1Database, D1DatabaseSession, R2Bucket } from "@cloudflare/workers-types";
3+
4+import {
5+ DataMigrationError,
6+ runSqliteDataMigrations,
7+ type SqliteDataMigration,
8+ type SqliteDataMigrationClient,
9+} from "@executor-js/sdk";
10+import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
11+
12+const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]);
13+
14+const firstWord = (sql: string): string => sql.trimStart().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
15+
16+const queryRows = <T extends Record<string, unknown>>(
17+ session: D1DatabaseSession,
18+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
19+): Promise<readonly T[]> => {
20+ const sql = typeof stmt === "string" ? stmt : stmt.sql;
21+ const args = typeof stmt === "string" ? [] : stmt.args;
22+ const prepared = session.prepare(sql).bind(...args);
23+ if (firstWord(sql) === "SELECT") {
24+ return prepared.all<T>().then((result) => result.results);
25+ }
26+ return prepared.run<T>().then(() => []);
27+};
28+
29+export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient => {
30+ const session = db.withSession("first-primary");
31+ return {
32+ execute: (stmt) => {
33+ const sql =
34+ typeof stmt === "string" ? stmt.trim().toUpperCase() : stmt.sql.trim().toUpperCase();
35+ if (TX_CONTROL.has(sql)) return Promise.resolve({ rows: [] });
36+ return queryRows(session, stmt).then((rows) => ({ rows }));
37+ },
38+ };
39+};
40+
41+const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42+ `o:${tenant}/${pluginId}/${key}`;
43+
44+const copyGoogleOpenApiSpecBlobsToR2 = (
45+ client: SqliteDataMigrationClient,
46+ bucket: R2Bucket,
47+): Effect.Effect<void, DataMigrationError> =>
48+ Effect.tryPromise({
49+ try: async () => {
50+ const result = await client.execute(
51+ `SELECT tenant, json_extract(config, '$.specHash') AS spec_hash
52+ FROM integration
53+ WHERE plugin_id = 'openapi'
54+ AND config IS NOT NULL
55+ AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56+ AND json_extract(config, '$.specHash') IS NOT NULL
57+ AND json_extract(config, '$.specHash') <> ''`,
58+ );
59+ for (const row of result.rows) {
60+ if (typeof row.tenant !== "string" || typeof row.spec_hash !== "string") continue;
61+ const key = `spec/${row.spec_hash}`;
62+ const target = r2ObjectName(row.tenant, "google", key);
63+ if ((await bucket.head(target)) != null) continue;
64+ const source = await bucket.get(r2ObjectName(row.tenant, "openapi", key));
65+ if (source == null) continue;
66+ await bucket.put(target, await source.text());
67+ }
68+ },
69+ catch: (cause) =>
70+ new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),
71+ });
72+
73+const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
74+ {
75+ name: googleOpenApiOwnershipDataMigration.name,
76+ run: (client) =>
77+ Effect.gen(function* () {
78+ if (bucket) yield* copyGoogleOpenApiSpecBlobsToR2(client, bucket);
79+ yield* googleOpenApiOwnershipDataMigration.run(client);
80+ }),
81+ },
82+];
83+
84+export const runCloudflareDataMigrations = (
85+ db: D1Database,
86+ bucket: R2Bucket | undefined,
87+): Promise<readonly string[]> =>
88+ Effect.runPromise(
89+ runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
90+ );
apps/host-cloudflare/src/plugins.ts+2−0
@@ -1,4 +1,5 @@
11 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2+import { googleHttpPlugin } from "@executor-js/plugin-google/api";
23 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
34 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
45 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -17,6 +18,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 12 unmodified lines
1718 export const makeCloudflarePlugins = (secretKey: string) =>
1819 [
1920 openApiHttpPlugin(),
21+ googleHttpPlugin(),
2022 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2123 graphqlHttpPlugin(),
2224 encryptedSecretsPlugin({ key: secretKey }),
apps/host-selfhost/src/db/data-migrations.ts+2−0
@@ -12,6 +12,7 @@ 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";
1516
1617 import { authConfigTransforms } from "./auth-config-migration";
1718
@@ -29,4 +30,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines
2930 // migrate-specs-to-blobs script.
3031 openApiSpecBlobDataMigration,
3132 graphqlIntrospectionBlobDataMigration,
33+ googleOpenApiOwnershipDataMigration,
3234 ];
apps/local/src/db/data-migrations.ts+2−0
@@ -12,6 +12,7 @@ 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";
1516
1617 import { authConfigTransforms } from "./auth-config-migration";
1718 import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";
@@ -36,4 +37,5 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 18 unmodified lines
3637 // migrate-specs-to-blobs script.
3738 openApiSpecBlobDataMigration,
3839 graphqlIntrospectionBlobDataMigration,
40+ googleOpenApiOwnershipDataMigration,
3941 ];
bun.lock+1−0
@@ -369,6 +369,7 @@ · 368 unmodified lines
369369 "version": "0.0.33",
370370 "dependencies": {
371371 "@executor-js/plugin-file-secrets": "workspace:*",
372+ "@executor-js/plugin-google": "workspace:*",
372373 "@executor-js/plugin-graphql": "workspace:*",
373374 "@executor-js/plugin-keychain": "workspace:*",
374375 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 },
1111 "dependencies": {
1212 "@executor-js/plugin-file-secrets": "workspace:*",
13+ "@executor-js/plugin-google": "workspace:*",
1314 "@executor-js/plugin-graphql": "workspace:*",
1415 "@executor-js/plugin-keychain": "workspace:*",
1516 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/src/main.ts+3−1
@@ -33,6 +33,7 @@ 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";
3637 import { graphqlPlugin } from "@executor-js/plugin-graphql";
3738 import { keychainPlugin } from "@executor-js/plugin-keychain";
3839 import { mcpPlugin } from "@executor-js/plugin-mcp";
@@ -77,6 +78,7 @@ const plugins = [ · 38 unmodified lines
7778 // Integration plugins — these declare their own schemas (tables), register
7879 // integrations via their extension methods (`addSpec` / `addServer` /
7980 // `addIntegration`), and produce tools per connection.
81+ googlePlugin(),
8082 graphqlPlugin(),
8183 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
8284 openApiPlugin(),
@@ -416,7 +418,7 @@ const program = Effect.gen(function* () { · 333 unmodified lines
416418 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
417419
418420 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });
419− // executor.openapi.addSpec({ spec: { kind: "googleDiscovery", url: "..." }, slug: "..." });
421+ // executor.google.addBundle({ urls: ["..."], slug: "google" });
420422 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
421423
422424 // -------------------------------------------------------------------------
knip.config.ts+1−1
@@ -41,7 +41,7 @@ const config: KnipConfig = { · 40 unmodified lines
4141 "packages/plugins/file-secrets": {
4242 includeEntryExports: true,
4343 },
44− "packages/plugins/google-discovery": {
44+ "packages/plugins/google": {
4545 includeEntryExports: true,
4646 },
4747 "packages/plugins/onepassword": {
packages/plugins/google/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1+# @executor-js/plugin-google
packages/plugins/google/src/sdk/config.ts+24−0
@@ -0,0 +1,24 @@
1+import { Option, Schema } from "effect";
2+import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";
3+
4+export const GoogleIntegrationConfigSchema = Schema.Struct({
5+ specHash: Schema.optional(Schema.String),
6+ sourceUrl: Schema.optional(Schema.String),
7+ googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
8+ baseUrl: Schema.optional(Schema.String),
9+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
10+ queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
11+ authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
12+});
13+
14+export type GoogleIntegrationConfig = Omit<
15+ typeof GoogleIntegrationConfigSchema.Type,
16+ "authenticationTemplate"
17+> & {
18+ readonly authenticationTemplate?: readonly Authentication[];
19+};
20+
21+const decodeConfig = Schema.decodeUnknownOption(GoogleIntegrationConfigSchema);
22+
23+export const decodeGoogleIntegrationConfig = (value: unknown): GoogleIntegrationConfig | null =>
24+ Option.getOrNull(decodeConfig(value)) as GoogleIntegrationConfig | null;
packages/plugins/google/src/sdk/index.ts+4−0
@@ -36,3 +36,7 @@ export { · 35 unmodified lines
3636 type GoogleUpdateInput,
3737 type GoogleUpdateResult,
3838 } from "./plugin";
39+export {
40+ googleOpenApiOwnershipDataMigration,
41+ runSqliteGoogleOpenApiOwnershipMigration,
42+} from "./openapi-ownership-migration";
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+219−0
@@ -0,0 +1,219 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { collectTables } from "@executor-js/sdk";
4+import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
5+import type { SqliteDataMigrationClient } from "@executor-js/sdk/core";
6+
7+import { runSqliteGoogleOpenApiOwnershipMigration } from "./openapi-ownership-migration";
8+
9+const now = 1_780_000_000_000;
10+
11+const insertIntegration = (
12+ client: SqliteDataMigrationClient,
13+ row: {
14+ readonly rowId: string;
15+ readonly tenant: string;
16+ readonly slug: string;
17+ readonly pluginId: string;
18+ readonly config: unknown;
19+ },
20+) =>
21+ client.execute({
22+ sql: `INSERT INTO integration
23+ (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
24+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
25+ args: [
26+ row.rowId,
27+ row.tenant,
28+ row.slug,
29+ row.pluginId,
30+ row.slug,
31+ row.slug,
32+ JSON.stringify(row.config),
33+ now,
34+ now,
35+ ],
36+ });
37+
38+const insertBlob = (
39+ client: SqliteDataMigrationClient,
40+ row: {
41+ readonly namespace: string;
42+ readonly key: string;
43+ readonly value: string;
44+ },
45+) =>
46+ client.execute({
47+ sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)",
48+ args: [
49+ row.namespace,
50+ row.key,
51+ row.value,
52+ `blob-${row.namespace}-${row.key}`,
53+ JSON.stringify([row.namespace, row.key]),
54+ ],
55+ });
56+
57+const insertOperationStorage = (
58+ client: SqliteDataMigrationClient,
59+ row: {
60+ readonly tenant: string;
61+ readonly pluginId: string;
62+ readonly integration: string;
63+ readonly toolName: string;
64+ },
65+) =>
66+ client.execute({
67+ sql: `INSERT INTO plugin_storage
68+ (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
69+ VALUES (?, 'org', '', ?, 'operation', ?, ?, ?, ?, ?)`,
70+ args: [
71+ row.tenant,
72+ row.pluginId,
73+ `${row.integration}.${row.toolName}`,
74+ JSON.stringify({
75+ integration: row.integration,
76+ toolName: row.toolName,
77+ binding: { method: "get", pathTemplate: "/items" },
78+ }),
79+ now,
80+ now,
81+ `storage-${row.pluginId}-${row.integration}-${row.toolName}`,
82+ ],
83+ });
84+
85+const insertTool = (
86+ client: SqliteDataMigrationClient,
87+ row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
88+) =>
89+ client.execute({
90+ sql: `INSERT INTO tool
91+ (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)
92+ VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,
93+ args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`],
94+ });
95+
96+const insertDefinition = (
97+ client: SqliteDataMigrationClient,
98+ row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
99+) =>
100+ client.execute({
101+ sql: `INSERT INTO definition
102+ (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)
103+ VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,
104+ args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`],
105+ });
106+
107+describe("runSqliteGoogleOpenApiOwnershipMigration", () => {
108+ it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109+ Effect.gen(function* () {
110+ const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
111+ const client = db.client;
112+
113+ yield* Effect.promise(() =>
114+ insertIntegration(client, {
115+ rowId: "google-row",
116+ tenant: "org_1",
117+ slug: "google",
118+ pluginId: "openapi",
119+ config: {
120+ specHash: "googlehash",
121+ googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
122+ },
123+ }),
124+ );
125+ yield* Effect.promise(() =>
126+ insertIntegration(client, {
127+ rowId: "stripe-row",
128+ tenant: "org_1",
129+ slug: "stripe",
130+ pluginId: "openapi",
131+ config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" },
132+ }),
133+ );
134+ yield* Effect.promise(() =>
135+ insertBlob(client, {
136+ namespace: "o:org_1/openapi",
137+ key: "spec/googlehash",
138+ value: "google spec",
139+ }),
140+ );
141+ yield* Effect.promise(() =>
142+ insertBlob(client, {
143+ namespace: "o:org_1/openapi",
144+ key: "spec/stripehash",
145+ value: "stripe spec",
146+ }),
147+ );
148+
149+ for (const integration of ["google", "stripe"] as const) {
150+ yield* Effect.promise(() =>
151+ insertOperationStorage(client, {
152+ tenant: "org_1",
153+ pluginId: "openapi",
154+ integration,
155+ toolName: "items.list",
156+ }),
157+ );
158+ yield* Effect.promise(() =>
159+ insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }),
160+ );
161+ yield* Effect.promise(() =>
162+ insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }),
163+ );
164+ }
165+
166+ expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(1);
167+ expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(0);
168+
169+ const integrations = yield* Effect.promise(() =>
170+ client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
171+ );
172+ expect(integrations.rows).toEqual([
173+ { slug: "google", plugin_id: "google" },
174+ { slug: "stripe", plugin_id: "openapi" },
175+ ]);
176+
177+ const googleBlob = yield* Effect.promise(() =>
178+ client.execute({
179+ sql: "SELECT value FROM blob WHERE id = ?",
180+ args: [JSON.stringify(["o:org_1/google", "spec/googlehash"])],
181+ }),
182+ );
183+ expect(googleBlob.rows).toEqual([{ value: "google spec" }]);
184+
185+ const openApiBlobs = yield* Effect.promise(() =>
186+ client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"),
187+ );
188+ expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]);
189+
190+ const storage = yield* Effect.promise(() =>
191+ client.execute(
192+ "SELECT plugin_id, key FROM plugin_storage WHERE collection = 'operation' ORDER BY plugin_id, key",
193+ ),
194+ );
195+ expect(storage.rows).toEqual([
196+ { plugin_id: "google", key: "google.items.list" },
197+ { plugin_id: "openapi", key: "stripe.items.list" },
198+ ]);
199+
200+ const tools = yield* Effect.promise(() =>
201+ client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"),
202+ );
203+ expect(tools.rows).toEqual([
204+ { integration: "google", plugin_id: "google" },
205+ { integration: "stripe", plugin_id: "openapi" },
206+ ]);
207+
208+ const definitions = yield* Effect.promise(() =>
209+ client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"),
210+ );
211+ expect(definitions.rows).toEqual([
212+ { integration: "google", plugin_id: "google" },
213+ { integration: "stripe", plugin_id: "openapi" },
214+ ]);
215+
216+ yield* Effect.promise(() => db.close());
217+ }),
218+ );
219+});
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+148−0
@@ -0,0 +1,148 @@
1+import { Effect } from "effect";
2+import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
3+
4+const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
5+const googleOpenApiCandidate = (alias?: string): string => {
6+ const column = (name: string) => (alias ? `${alias}.${name}` : name);
7+ return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;
8+};
9+
10+const execute = (
11+ client: SqliteDataMigrationClient,
12+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
13+) =>
14+ Effect.tryPromise({
15+ try: () => client.execute(stmt),
16+ catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
17+ });
18+
19+export const runSqliteGoogleOpenApiOwnershipMigration = (
20+ client: SqliteDataMigrationClient,
21+): Effect.Effect<number, DataMigrationError> =>
22+ Effect.gen(function* () {
23+ const exists = yield* execute(
24+ client,
25+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'",
26+ );
27+ if (exists.rows.length === 0) return 0;
28+
29+ const count = yield* execute(
30+ client,
31+ `SELECT COUNT(*) AS count FROM integration WHERE ${googleOpenApiCandidate()}`,
32+ );
33+ const moved = Number(count.rows[0]?.count ?? 0);
34+ if (moved === 0) return 0;
35+
36+ const applyAll = Effect.gen(function* () {
37+ yield* execute(
38+ client,
39+ `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id)
40+ SELECT
41+ 'o:' || g.tenant || '/google',
42+ b.key,
43+ b.value,
44+ lower(hex(randomblob(16))),
45+ json_array('o:' || g.tenant || '/google', b.key)
46+ FROM integration g
47+ JOIN blob b
48+ ON b.namespace = 'o:' || g.tenant || '/openapi'
49+ AND b.key = 'spec/' || json_extract(g.config, '$.specHash')
50+ WHERE ${googleOpenApiCandidate("g")}
51+ AND json_extract(g.config, '$.specHash') IS NOT NULL
52+ AND json_extract(g.config, '$.specHash') <> ''`,
53+ );
54+
55+ yield* execute(
56+ client,
57+ `INSERT OR IGNORE INTO plugin_storage
58+ (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
59+ SELECT
60+ ps.tenant,
61+ ps.owner,
62+ ps.subject,
63+ 'google',
64+ ps.collection,
65+ ps.key,
66+ ps.data,
67+ ps.created_at,
68+ ps.updated_at,
69+ lower(hex(randomblob(16)))
70+ FROM plugin_storage ps
71+ JOIN integration g
72+ ON g.tenant = ps.tenant
73+ WHERE ps.plugin_id = 'openapi'
74+ AND ${googleOpenApiCandidate("g")}
75+ AND ps.collection = 'operation'
76+ AND (
77+ json_extract(ps.data, '$.integration') = g.slug
78+ OR ps.key LIKE g.slug || '.%'
79+ )`,
80+ );
81+
82+ yield* execute(
83+ client,
84+ `DELETE FROM plugin_storage
85+ WHERE plugin_id = 'openapi'
86+ AND collection = 'operation'
87+ AND EXISTS (
88+ SELECT 1
89+ FROM integration g
90+ WHERE g.tenant = plugin_storage.tenant
91+ AND ${googleOpenApiCandidate("g")}
92+ AND (
93+ json_extract(plugin_storage.data, '$.integration') = g.slug
94+ OR plugin_storage.key LIKE g.slug || '.%'
95+ )
96+ )`,
97+ );
98+
99+ yield* execute(
100+ client,
101+ `UPDATE tool
102+ SET plugin_id = 'google'
103+ WHERE plugin_id = 'openapi'
104+ AND EXISTS (
105+ SELECT 1
106+ FROM integration g
107+ WHERE g.tenant = tool.tenant
108+ AND ${googleOpenApiCandidate("g")}
109+ AND g.slug = tool.integration
110+ )`,
111+ );
112+
113+ yield* execute(
114+ client,
115+ `UPDATE definition
116+ SET plugin_id = 'google'
117+ WHERE plugin_id = 'openapi'
118+ AND EXISTS (
119+ SELECT 1
120+ FROM integration g
121+ WHERE g.tenant = definition.tenant
122+ AND ${googleOpenApiCandidate("g")}
123+ AND g.slug = definition.integration
124+ )`,
125+ );
126+
127+ yield* execute(
128+ client,
129+ `UPDATE integration
130+ SET plugin_id = 'google'
131+ WHERE ${googleOpenApiCandidate()}`,
132+ );
133+
134+ yield* execute(client, "COMMIT");
135+ return moved;
136+ });
137+
138+ yield* execute(client, "BEGIN");
139+ return yield* applyAll.pipe(
140+ Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
141+ );
142+ });
143+
144+export const googleOpenApiOwnershipDataMigration = {
145+ name: MIGRATION_NAME,
146+ run: (client: SqliteDataMigrationClient) =>
147+ runSqliteGoogleOpenApiOwnershipMigration(client).pipe(Effect.asVoid),
148+};
packages/plugins/google/src/sdk/plugin.ts+175−205
@@ -14,12 +14,11 @@ import { · 13 unmodified lines
1414 type Integration,
1515 type IntegrationConfig,
1616 type IntegrationRecord,
17− type StorageFailure,
17+ type PluginCtx,
1818 } from "@executor-js/sdk/core";
1919 import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
2020 import {
2121 compileOpenApiSpec,
22− decodeOpenApiIntegrationConfig,
2322 invokeOpenApiBackedTool,
2423 makeDefaultOpenapiStore,
2524 normalizeOpenApiAuthInputs,
@@ -28,11 +27,7 @@ import { · 2 unmodified lines
2827 resolveOpenApiBackedTools,
2928 type Authentication,
3029 type AuthenticationInput,
31− type OpenApiIntegrationConfig,
3230 type OpenapiStore,
33− type OpenApiExtractionError,
34− type OpenApiOAuthError,
35− type OpenApiParseError,
3631 } from "@executor-js/plugin-openapi";
3732
3833 import {
@@ -40,6 +35,7 @@ import { · 1 unmodified line
4035 fetchGoogleDiscoveryDocument,
4136 isGoogleDiscoveryUrl,
4237 } from "./discovery";
38+import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
4339 import { googleOpenApiBundlePreset } from "./presets";
4440
4541 export interface GoogleBundleConfig {
@@ -66,39 +62,6 @@ export interface GoogleUpdateResult { · 20 unmodified lines
6662 readonly removedTools: readonly string[];
6763 }
6864
69−export interface GooglePluginExtension {
70− readonly addBundle: (
71− config: GoogleBundleConfig,
72− ) => Effect.Effect<
73− { readonly slug: IntegrationSlug; readonly toolCount: number },
74− | OpenApiParseError
75− | OpenApiExtractionError
76− | OpenApiOAuthError
77− | IntegrationAlreadyExistsError
78− | StorageFailure
79− >;
80− readonly updateBundle: (
81− slug: string,
82− input?: GoogleUpdateInput,
83− ) => Effect.Effect<
84− GoogleUpdateResult,
85− | OpenApiParseError
86− | OpenApiExtractionError
87− | OpenApiOAuthError
88− | IntegrationNotFoundError
89− | StorageFailure
90− >;
91− readonly removeBundle: (slug: string) => Effect.Effect<void, StorageFailure>;
92− readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>;
93− readonly getConfig: (
94− slug: string,
95− ) => Effect.Effect<OpenApiIntegrationConfig | null, StorageFailure>;
96− readonly configure: (
97− slug: string,
98− input: GoogleConfigureInput,
99− ) => Effect.Effect<readonly Authentication[], StorageFailure>;
100−}
101−
10265 export interface GooglePluginOptions {
10366 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
10467 }
@@ -124,7 +87,7 @@ const uniqueUrls = (urls: readonly string[]): readonly string[] => [ · 19 unmodified lines
12487 ];
12588
12689 const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
127− const config = decodeOpenApiIntegrationConfig(record.config);
90+ const config = decodeGoogleIntegrationConfig(record.config);
12891 if (!config) return [];
12992 return (config.authenticationTemplate ?? []).map(
13093 (template: Authentication): AuthMethodDescriptor => {
@@ -147,183 +110,190 @@ const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMeth · 16 unmodified lines
147110 };
148111
149112 const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
150− const config = decodeOpenApiIntegrationConfig(record.config);
113+ const config = decodeGoogleIntegrationConfig(record.config);
151114 return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
152115 };
153116
154−export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
155− id: "google" as const,
156− packageName: "@executor-js/plugin-google",
157− integrationPresets: [googleOpenApiBundlePreset],
158− storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
159−
160− extension: (ctx): GooglePluginExtension => {
161− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
117+const makeGooglePluginExtension = (
118+ options: GooglePluginOptions | undefined,
119+ ctx: PluginCtx<OpenapiStore>,
120+) => {
121+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
162122
163− const addBundle = (config: GoogleBundleConfig) =>
164− Effect.gen(function* () {
165− const urls = uniqueUrls(config.urls);
166− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
167− const compiled = yield* compileOpenApiSpec(conversion.specText);
168− const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);
169−
170− const existing = yield* ctx.core.integrations.get(slug);
171− if (existing) {
172− return yield* new IntegrationAlreadyExistsError({ slug });
173− }
174−
175− const specHash = yield* sha256Hex(conversion.specText);
176− const integrationConfig: OpenApiIntegrationConfig = {
177− specHash,
178− googleDiscoveryUrls: urls,
179− ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
180− ...(conversion.authenticationTemplate
181− ? { authenticationTemplate: conversion.authenticationTemplate }
182− : {}),
183− };
123+ const addBundle = (config: GoogleBundleConfig) =>
124+ Effect.gen(function* () {
125+ const urls = uniqueUrls(config.urls);
126+ const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
127+ const compiled = yield* compileOpenApiSpec(conversion.specText);
128+ const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);
129+
130+ const existing = yield* ctx.core.integrations.get(slug);
131+ if (existing) {
132+ return yield* new IntegrationAlreadyExistsError({ slug });
133+ }
184134
185− yield* ctx.storage.putSpec(specHash, conversion.specText);
186−
187− yield* ctx.transaction(
188− Effect.gen(function* () {
189− yield* ctx.core.integrations.register({
190− slug,
191− name: config.name?.trim() || "Google",
192− description: config.description ?? "Google APIs",
193− config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
194− canRemove: true,
195− canRefresh: true,
196− });
197− yield* ctx.storage.putOperations(
198− String(slug),
199− openApiStoredOperationsFromCompiled(String(slug), compiled),
200− );
201− }),
202− );
135+ const specHash = yield* sha256Hex(conversion.specText);
136+ const integrationConfig: GoogleIntegrationConfig = {
137+ specHash,
138+ googleDiscoveryUrls: urls,
139+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
140+ ...(conversion.authenticationTemplate
141+ ? { authenticationTemplate: conversion.authenticationTemplate }
142+ : {}),
143+ };
144+
145+ yield* ctx.storage.putSpec(specHash, conversion.specText);
146+
147+ yield* ctx.transaction(
148+ Effect.gen(function* () {
149+ yield* ctx.core.integrations.register({
150+ slug,
151+ name: config.name?.trim() || "Google",
152+ description: config.description ?? "Google APIs",
153+ config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
154+ canRemove: true,
155+ canRefresh: true,
156+ });
157+ yield* ctx.storage.putOperations(
158+ String(slug),
159+ openApiStoredOperationsFromCompiled(String(slug), compiled),
160+ );
161+ }),
162+ );
203163
204− return { slug, toolCount: compiled.definitions.length };
205− });
164+ return { slug, toolCount: compiled.definitions.length };
165+ });
206166
207− const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>
208− Effect.gen(function* () {
209− const slug = IntegrationSlug.make(rawSlug);
210− const record = yield* ctx.core.integrations.get(slug);
211− const current = record ? decodeOpenApiIntegrationConfig(record.config) : null;
212− if (!record || !current) {
213− return yield* new IntegrationNotFoundError({ slug });
214− }
215−
216− const urls = uniqueUrls(input?.urls ?? current.googleDiscoveryUrls ?? []);
217− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
218− const compiled = yield* compileOpenApiSpec(conversion.specText);
219−
220− const previousOperations = yield* ctx.storage.listOperations(rawSlug);
221− const previousNames = new Set(previousOperations.map((op) => op.toolName));
222− const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
223−
224− const specHash = yield* sha256Hex(conversion.specText);
225− yield* ctx.storage.putSpec(specHash, conversion.specText);
226−
227− const nextConfig: OpenApiIntegrationConfig = {
228− ...current,
229− specHash,
230− googleDiscoveryUrls: urls,
231− };
167+ const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>
168+ Effect.gen(function* () {
169+ const slug = IntegrationSlug.make(rawSlug);
170+ const record = yield* ctx.core.integrations.get(slug);
171+ const current = record ? decodeGoogleIntegrationConfig(record.config) : null;
172+ if (!record || !current) {
173+ return yield* new IntegrationNotFoundError({ slug });
174+ }
232175
233− yield* ctx.transaction(
234− Effect.gen(function* () {
235− yield* ctx.core.integrations.update(slug, {
236− config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
237− });
238− yield* ctx.storage.putOperations(
239− rawSlug,
240− openApiStoredOperationsFromCompiled(rawSlug, compiled),
241− );
242− }),
243− );
244−
245− const connections = yield* ctx.connections.list({ integration: slug });
246− yield* Effect.forEach(
247− connections,
248− (connection) =>
249− ctx.connections
250− .refresh({
251− owner: connection.owner,
252− integration: connection.integration,
253− name: connection.name,
254− })
255− .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
256− { discard: true },
257− ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
176+ const urls = uniqueUrls(input?.urls ?? current.googleDiscoveryUrls ?? []);
177+ const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
178+ const compiled = yield* compileOpenApiSpec(conversion.specText);
179+
180+ const previousOperations = yield* ctx.storage.listOperations(rawSlug);
181+ const previousNames = new Set(previousOperations.map((op) => op.toolName));
182+ const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
183+
184+ const specHash = yield* sha256Hex(conversion.specText);
185+ yield* ctx.storage.putSpec(specHash, conversion.specText);
186+
187+ const nextConfig: GoogleIntegrationConfig = {
188+ ...current,
189+ specHash,
190+ googleDiscoveryUrls: urls,
191+ };
192+
193+ yield* ctx.transaction(
194+ Effect.gen(function* () {
195+ yield* ctx.core.integrations.update(slug, {
196+ config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
197+ });
198+ yield* ctx.storage.putOperations(
199+ rawSlug,
200+ openApiStoredOperationsFromCompiled(rawSlug, compiled),
201+ );
202+ }),
203+ );
258204
259− return {
260− slug,
261− toolCount: compiled.definitions.length,
262− addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
263− removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
264− };
265− });
205+ const connections = yield* ctx.connections.list({ integration: slug });
206+ yield* Effect.forEach(
207+ connections,
208+ (connection) =>
209+ ctx.connections
210+ .refresh({
211+ owner: connection.owner,
212+ integration: connection.integration,
213+ name: connection.name,
214+ })
215+ .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
216+ { discard: true },
217+ ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
218+
219+ return {
220+ slug,
221+ toolCount: compiled.definitions.length,
222+ addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
223+ removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
224+ };
225+ });
266226
267− return {
268− addBundle,
269− updateBundle,
270− removeBundle: (slug: string) =>
271− ctx.transaction(
272− Effect.gen(function* () {
273− yield* ctx.storage.removeOperations(slug);
274− yield* ctx.core.integrations
275− .remove(IntegrationSlug.make(slug))
276− .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
277− }),
278− ),
279− getIntegration: (slug: string) =>
280− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
281− Effect.map((record) =>
282− record
283− ? ({
284− slug: record.slug,
285− description: record.description,
286− kind: record.kind,
287− canRemove: record.canRemove,
288− canRefresh: record.canRefresh,
289− } as Integration)
290− : null,
291− ),
227+ return {
228+ addBundle,
229+ updateBundle,
230+ removeBundle: (slug: string) =>
231+ ctx.transaction(
232+ Effect.gen(function* () {
233+ yield* ctx.storage.removeOperations(slug);
234+ yield* ctx.core.integrations
235+ .remove(IntegrationSlug.make(slug))
236+ .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
237+ }),
238+ ),
239+ getIntegration: (slug: string) =>
240+ ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
241+ Effect.map((record) =>
242+ record
243+ ? ({
244+ slug: record.slug,
245+ description: record.description,
246+ kind: record.kind,
247+ canRemove: record.canRemove,
248+ canRefresh: record.canRefresh,
249+ } as Integration)
250+ : null,
292251 ),
293− getConfig: (slug: string) =>
294− ctx.core.integrations
295− .get(IntegrationSlug.make(slug))
296− .pipe(
297− Effect.map((record) => (record ? decodeOpenApiIntegrationConfig(record.config) : null)),
298− ),
299− configure: (slug: string, input: GoogleConfigureInput) =>
300− ctx.transaction(
301− Effect.gen(function* () {
302− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
303− if (!record) return [] as readonly Authentication[];
304− const current = decodeOpenApiIntegrationConfig(record.config);
305− if (!current) return [] as readonly Authentication[];
306−
307− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
308− const merged =
309− input.mode === "replace"
310− ? incoming
311− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
312−
313− const next: OpenApiIntegrationConfig = {
314− ...current,
315− authenticationTemplate: merged,
316− };
317−
318− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
319− config: next satisfies OpenApiIntegrationConfig as IntegrationConfig,
320− });
321−
322− return merged;
323− }),
252+ ),
253+ getConfig: (slug: string) =>
254+ ctx.core.integrations
255+ .get(IntegrationSlug.make(slug))
256+ .pipe(
257+ Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)),
324258 ),
325− };
326− },
259+ configure: (slug: string, input: GoogleConfigureInput) =>
260+ ctx.transaction(
261+ Effect.gen(function* () {
262+ const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
263+ if (!record) return [] as readonly Authentication[];
264+ const current = decodeGoogleIntegrationConfig(record.config);
265+ if (!current) return [] as readonly Authentication[];
266+
267+ const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
268+ const merged =
269+ input.mode === "replace"
270+ ? incoming
271+ : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
272+
273+ const next: GoogleIntegrationConfig = {
274+ ...current,
275+ authenticationTemplate: merged,
276+ };
277+
278+ yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
279+ config: next satisfies GoogleIntegrationConfig as IntegrationConfig,
280+ });
281+
282+ return merged;
283+ }),
284+ ),
285+ };
286+};
287+
288+export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
289+
290+export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
291+ id: "google" as const,
292+ packageName: "@executor-js/plugin-google",
293+ integrationPresets: [googleOpenApiBundlePreset],
294+ storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
295+
296+ extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx),
327297
328298 describeAuthMethods: describeGoogleAuthMethods,
329299 describeIntegrationDisplay: describeGoogleIntegrationDisplay,
packages/plugins/openapi/src/api/group.ts+0−9
@@ -48,14 +48,6 @@ const SlugParams = { · 47 unmodified lines
4848 const OpenApiSpecInputPayload = Schema.Union([
4949 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
5050 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
51− Schema.Struct({
52− kind: Schema.Literal("googleDiscovery"),
53− url: Schema.String,
54− }),
55− Schema.Struct({
56− kind: Schema.Literal("googleDiscoveryBundle"),
57− urls: Schema.Array(Schema.String),
58− }),
5951 ]);
6052
6153 const OAuthTemplatePayload = Schema.Struct({
@@ -134,7 +126,6 @@ const IntegrationView = Schema.Struct({ · 72 unmodified lines
134126 // store, and no client reads it (the configure UI only touches the template).
135127 const OpenApiConfigView = Schema.Struct({
136128 sourceUrl: Schema.optional(Schema.String),
137− googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
138129 baseUrl: Schema.optional(Schema.String),
139130 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
140131 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
packages/plugins/openapi/src/api/handlers.ts+0−3
@@ -91,9 +91,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 90 unmodified lines
9191 return config
9292 ? {
9393 sourceUrl: config.sourceUrl,
94− googleDiscoveryUrls: config.googleDiscoveryUrls
95− ? [...config.googleDiscoveryUrls]
96− : undefined,
9794 baseUrl: config.baseUrl,
9895 headers: config.headers ? { ...config.headers } : undefined,
9996 queryParams: config.queryParams ? { ...config.queryParams } : undefined,
packages/plugins/openapi/src/react/UpdateSpecSection.tsx+1−7
@@ -52,13 +52,7 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) { · 51 unmodified lines
5252 const config =
5353 AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;
5454
55− const sourceUrl = config
56− ? config.googleDiscoveryUrls?.length
57− ? `${config.googleDiscoveryUrls.length} Google Discovery document${
58− config.googleDiscoveryUrls.length !== 1 ? "s" : ""
59− }`
60− : config.sourceUrl
61− : undefined;
55+ const sourceUrl = config?.sourceUrl;
6256
6357 // The staged apply, rebuilt whenever the staged inputs change. Reported to
6458 // the sheet through a ref-stable callback so Save can run it.
packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap+0−82
@@ -1,82 +0,0 @@
1−// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2−
3−exports[`classifies every Google service for bundle OAuth UX 1`] = `
4−[
5− {
6− "id": "google-calendar",
7− "oauthAudience": "standard-user",
8− },
9− {
10− "id": "google-gmail",
11− "oauthAudience": "standard-user",
12− },
13− {
14− "id": "google-sheets",
15− "oauthAudience": "standard-user",
16− },
17− {
18− "id": "google-drive",
19− "oauthAudience": "standard-user",
20− },
21− {
22− "id": "google-docs",
23− "oauthAudience": "standard-user",
24− },
25− {
26− "id": "google-slides",
27− "oauthAudience": "standard-user",
28− },
29− {
30− "id": "google-forms",
31− "oauthAudience": "standard-user",
32− },
33− {
34− "id": "google-tasks",
35− "oauthAudience": "standard-user",
36− },
37− {
38− "id": "google-people",
39− "oauthAudience": "standard-user",
40− },
41− {
42− "id": "google-chat",
43− "oauthAudience": "workspace-admin",
44− },
45− {
46− "id": "google-keep",
47− "oauthAudience": "unsupported-user",
48− },
49− {
50− "id": "google-youtube-data",
51− "oauthAudience": "advanced-user",
52− },
53− {
54− "id": "google-search-console",
55− "oauthAudience": "standard-user",
56− },
57− {
58− "id": "google-classroom",
59− "oauthAudience": "advanced-user",
60− },
61− {
62− "id": "google-admin-directory",
63− "oauthAudience": "workspace-admin",
64− },
65− {
66− "id": "google-admin-reports",
67− "oauthAudience": "workspace-admin",
68− },
69− {
70− "id": "google-apps-script",
71− "oauthAudience": "advanced-user",
72− },
73− {
74− "id": "google-bigquery",
75− "oauthAudience": "advanced-user",
76− },
77− {
78− "id": "google-cloud-resource-manager",
79− "oauthAudience": "advanced-user",
80− },
81−]
82−`;
packages/plugins/openapi/src/sdk/config.ts+0−2
@@ -43,8 +43,6 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 42 unmodified lines
4343 specHash: Schema.optional(Schema.String),
4444 /** Origin URL the spec was fetched from, when known. Enables refresh. */
4545 sourceUrl: Schema.optional(Schema.String),
46− /** Google Discovery bundle URLs, when the spec came from a Google bundle. */
47− googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
4846 /** Optional base URL override. */
4947 baseUrl: Schema.optional(Schema.String),
5048 /** Static headers applied to every request (no secret material). */
packages/plugins/openapi/src/sdk/configure.test.ts+0−1
@@ -68,7 +68,6 @@ const servePluginTestApi = () => · 67 unmodified lines
6868 const specText = () => {
6969 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
7070 if (spec.kind === "blob") return spec.value;
71− if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
7271 return spec.url;
7372 };
7473
packages/plugins/openapi/src/sdk/google-bundle.test.ts+0−270
@@ -1,270 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Google bundle add flow — Option A ("customize your Google connection").
3−//
4−// The product picker emits `{ kind: "googleDiscoveryBundle", urls }`; the
5−// server fetches each Discovery 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−// - the per-API single-preset path (`kind: "googleDiscovery"`) is unchanged.
13−// ---------------------------------------------------------------------------
14−
15−import { describe, expect, it } from "@effect/vitest";
16−import { Effect, Layer } from "effect";
17−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
18−
19−import {
20− ConnectionName,
21− IntegrationSlug,
22− createExecutor,
23− AuthTemplateSlug,
24−} from "@executor-js/sdk";
25−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
26−
27−import { openApiPlugin } from "./plugin";
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 DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
38−
39−const calendarDoc = {
40− name: "calendar",
41− version: "v3",
42− title: "Calendar API",
43− rootUrl: "https://www.googleapis.com/",
44− servicePath: "calendar/v3/",
45− auth: {
46− oauth2: {
47− scopes: {
48− "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" },
49− "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" },
50− },
51− },
52− },
53− resources: {
54− events: {
55− methods: {
56− list: {
57− id: "calendar.events.list",
58− httpMethod: "GET",
59− path: "calendars/{calendarId}/events",
60− scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
61− parameters: {
62− calendarId: { location: "path", required: true, type: "string" },
63− },
64− },
65− },
66− },
67− },
68− schemas: {
69− Event: { id: "Event", type: "object", properties: { id: { type: "string" } } },
70− },
71−};
72−
73−const gmailDoc = {
74− name: "gmail",
75− version: "v1",
76− title: "Gmail API",
77− rootUrl: "https://gmail.googleapis.com/",
78− servicePath: "",
79− auth: {
80− oauth2: {
81− scopes: {
82− "https://mail.google.com/": { description: "Full Gmail access" },
83− "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
84− },
85− },
86− },
87− resources: {
88− users: {
89− resources: {
90− messages: {
91− methods: {
92− // Same trailing `list` as calendar.events.list — would collide on a
93− // naive merge; service-prefixed method id keeps them distinct.
94− list: {
95− id: "gmail.users.messages.list",
96− httpMethod: "GET",
97− path: "gmail/v1/users/{userId}/messages",
98− scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
99− parameters: {
100− userId: { location: "path", required: true, type: "string" },
101− },
102− },
103− },
104− },
105− },
106− },
107− },
108− schemas: {
109− Message: { id: "Message", type: "object", properties: { id: { type: "string" } } },
110− },
111−};
112−
113−const driveDoc = {
114− name: "drive",
115− version: "v3",
116− title: "Drive API",
117− rootUrl: "https://www.googleapis.com/",
118− servicePath: "drive/v3/",
119− auth: {
120− oauth2: {
121− scopes: {
122− "https://www.googleapis.com/auth/drive": { description: "Manage Drive" },
123− },
124− },
125− },
126− resources: {
127− files: {
128− methods: {
129− // A third `list` — three generic method names across three APIs.
130− list: {
131− id: "drive.files.list",
132− httpMethod: "GET",
133− path: "files",
134− scopes: ["https://www.googleapis.com/auth/drive"],
135− parameters: {},
136− },
137− },
138− },
139− },
140− schemas: {
141− File: { id: "File", type: "object", properties: { id: { type: "string" } } },
142− },
143−};
144−
145−const toJson = (value: unknown): string => JSON.stringify(value);
146−
147−const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
148− [CALENDAR_URL]: toJson(calendarDoc),
149− [GMAIL_URL]: toJson(gmailDoc),
150− [DRIVE_URL]: toJson(driveDoc),
151−};
152−
153−// A stub HTTP client that serves the canned Discovery document for whichever
154−// URL the bundle converter fetches (query params are ignored when matching).
155−const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
156− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
157− const url = new URL(request.url);
158− const key = `${url.origin}${url.pathname}`;
159− const body = DISCOVERY_BODIES[key];
160− return Effect.succeed(
161− HttpClientResponse.fromWeb(
162− request,
163− body === undefined
164− ? new Response("not found", { status: 404 })
165− : new Response(body, {
166− status: 200,
167− headers: { "content-type": "application/json" },
168− }),
169− ),
170− );
171− }),
172−);
173−
174−const bundlePlugins = () =>
175− [
176− openApiPlugin({ httpClientLayer: discoveryHttpClientLayer }),
177− memoryCredentialsPlugin(),
178− ] as const;
179−
180−describe("Google bundle add flow", () => {
181− it.effect(
182− "addSpec(googleDiscoveryBundle) merges calendar+gmail+drive into one google integration with no tool-name collisions",
183− () =>
184− Effect.scoped(
185− Effect.gen(function* () {
186− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
187−
188− const result = yield* executor.openapi.addSpec({
189− spec: {
190− kind: "googleDiscoveryBundle",
191− urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL],
192− },
193− slug: "google",
194− description: "Google",
195− });
196− expect(String(result.slug)).toBe("google");
197−
198− // ONE integration, not three.
199− const integration = yield* executor.openapi.getIntegration("google");
200− expect(integration?.slug).toBe(IntegrationSlug.make("google"));
201−
202− // The stored oauth template carries the COMPACTED union of every API's
203− // scopes — the same set the picker previews and `oauth.start` requests.
204− // `calendar.readonly` collapses under `calendar`, and `gmail.readonly`
205− // collapses under `https://mail.google.com/`, so the requested consent
206− // is clean rather than the raw per-method union.
207− const config = yield* executor.openapi.getConfig("google");
208− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210− [
211− "https://mail.google.com/",
212− "https://www.googleapis.com/auth/calendar",
213− "https://www.googleapis.com/auth/drive",
214− ].sort(),
215− );
216−
217− // A connection stamps the merged tools; assert all three `list`s are
218− // present under distinct service-prefixed names (no collision).
219− yield* executor.connections.create({
220− owner: "org",
221− name: ConnectionName.make("main"),
222− integration: IntegrationSlug.make("google"),
223− template: AuthTemplateSlug.make("googleOAuth2"),
224− value: "token-xyz",
225− });
226−
227− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
228− expect(toolNames).toContain("calendar.events.list");
229− expect(toolNames).toContain("gmail.users.messages.list");
230− expect(toolNames).toContain("drive.files.list");
231−
232− // No duplicate tool names across the merged surface.
233− const googleTools = toolNames.filter((name) => name.endsWith(".list"));
234− expect(new Set(googleTools).size).toBe(googleTools.length);
235− expect(googleTools.length).toBe(3);
236− }),
237− ),
238− );
239−
240− it.effect(
241− "single-API googleDiscovery path is unchanged (one integration, service-trimmed tools)",
242− () =>
243− Effect.scoped(
244− Effect.gen(function* () {
245− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
246−
247− const result = yield* executor.openapi.addSpec({
248− spec: { kind: "googleDiscovery", url: CALENDAR_URL },
249− slug: "google-calendar",
250− description: "Google Calendar",
251− });
252− expect(String(result.slug)).toBe("google-calendar");
253−
254− yield* executor.connections.create({
255− owner: "org",
256− name: ConnectionName.make("main"),
257− integration: IntegrationSlug.make("google-calendar"),
258− template: AuthTemplateSlug.make("googleOAuth2"),
259− value: "token-xyz",
260− });
261−
262− // The single-API path trims the service prefix off the tool name
263− // (`events.list`, not `calendar.events.list`) — distinct from the bundle.
264− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
265− expect(toolNames).toContain("events.list");
266− expect(toolNames).not.toContain("calendar.events.list");
267− }),
268− ),
269− );
270−});
packages/plugins/openapi/src/sdk/google-discovery.test.ts+0−604
@@ -1,604 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−import { Effect, Option, Schema } from "effect";
3−import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
4−
5−import {
6− convertGoogleDiscoveryBundleToOpenApi,
7− convertGoogleDiscoveryToOpenApi,
8−} from "./google-discovery";
9−import { extract } from "./extract";
10−import { parse } from "./parse";
11−
12−const ConvertedOperation = Schema.Struct({
13− operationId: Schema.String,
14− "x-executor-toolPath": Schema.String,
15− parameters: Schema.Array(
16− Schema.Struct({
17− name: Schema.String,
18− in: Schema.String,
19− required: Schema.Boolean,
20− description: Schema.optional(Schema.String),
21− schema: Schema.Unknown,
22− style: Schema.optional(Schema.String),
23− explode: Schema.optional(Schema.Boolean),
24− allowReserved: Schema.optional(Schema.Boolean),
25− }),
26− ),
27− servers: Schema.optional(Schema.Array(Schema.Struct({ url: Schema.String }))),
28− security: Schema.optional(
29− Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),
30− ),
31− "x-executor-pathTemplate": Schema.optional(Schema.String),
32− requestBody: Schema.optional(Schema.Unknown),
33− responses: Schema.Unknown,
34− "x-google-scopes": Schema.Array(Schema.String),
35−});
36−
37−const ConvertedSpec = Schema.Struct({
38− openapi: Schema.String,
39− servers: Schema.Array(Schema.Struct({ url: Schema.String })),
40− paths: Schema.Record(Schema.String, Schema.Record(Schema.String, ConvertedOperation)),
41− components: Schema.Struct({
42− schemas: Schema.Record(Schema.String, Schema.Unknown),
43− }),
44−});
45−
46−const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));
47−
48−const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
49− if (node == null || typeof node !== "object") return node;
50− if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);
51− const obj = node as Record<string, unknown>;
52− if (typeof obj.$ref === "string") {
53− const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);
54− return match ? { ...obj, $ref: `#/$defs/${match[1]}` } : obj;
55− }
56− return Object.fromEntries(
57− Object.entries(obj).map(([key, value]) => [key, normalizeOpenApiRefsForPreview(value)]),
58− );
59−};
60−
61−it.effect("converts Google Discovery documents into Executor-preserving OpenAPI 3 specs", () =>
62− Effect.gen(function* () {
63− const result = yield* convertGoogleDiscoveryToOpenApi({
64− discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
65− // @effect-diagnostics-next-line preferSchemaOverJson:off
66− documentText: JSON.stringify({
67− name: "gmail",
68− version: "v1",
69− title: "Gmail API",
70− rootUrl: "https://gmail.googleapis.com/",
71− servicePath: "",
72− auth: {
73− oauth2: {
74− scopes: {
75− "https://www.googleapis.com/auth/gmail.metadata": {
76− description: "Read metadata",
77− },
78− },
79− },
80− },
81− resources: {
82− users: {
83− resources: {
84− messages: {
85− methods: {
86− list: {
87− id: "gmail.users.messages.list",
88− httpMethod: "GET",
89− path: "gmail/v1/users/{userId}/messages",
90− scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
91− parameters: {
92− userId: {
93− location: "path",
94− required: true,
95− type: "string",
96− description: "The user's email address. The special value me can be used.",
97− },
98− metadataHeaders: {
99− location: "query",
100− repeated: true,
101− type: "string",
102− },
103− },
104− },
105− },
106− },
107− drafts: {
108− methods: {
109− create: {
110− id: "gmail.users.drafts.create",
111− httpMethod: "POST",
112− path: "gmail/v1/users/{userId}/drafts",
113− request: { $ref: "Draft" },
114− response: { $ref: "Draft" },
115− scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
116− parameters: {
117− userId: {
118− location: "path",
119− required: true,
120− type: "string",
121− },
122− },
123− },
124− },
125− },
126− },
127− },
128− },
129− schemas: {
130− Draft: {
131− id: "Draft",
132− type: "object",
133− description: "A draft email.",
134− properties: {
135− id: {
136− type: "string",
137− description: "The immutable ID of the draft.",
138− },
139− message: {
140− $ref: "Message",
141− },
142− },
143− },
144− Message: {
145− id: "Message",
146− type: "object",
147− properties: {
148− id: {
149− type: "string",
150− },
151− labelIds: {
152− type: "array",
153− items: { type: "string" },
154− },
155− },
156− },
157− },
158− }),
159− });
160−
161− const spec = decodeConvertedSpec(result.specText);
162− const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get;
163− const createDraft = spec.paths["/gmail/v1/users/{userId}/drafts"]?.post;
164− expect(spec.openapi).toBe("3.1.0");
165− expect(spec.servers).toEqual([{ url: "https://gmail.googleapis.com/" }]);
166− expect(result.specText).not.toContain("_tag");
167− expect(operation).toMatchObject({
168− operationId: "users.messages.list",
169− "x-executor-toolPath": "users.messages.list",
170− "x-google-scopes": ["https://www.googleapis.com/auth/gmail.metadata"],
171− });
172− expect(operation?.security).toEqual([
173− { googleOAuth2: ["https://www.googleapis.com/auth/gmail.metadata"] },
174− ]);
175− expect(operation?.parameters).toContainEqual(
176− expect.objectContaining({
177− name: "metadataHeaders",
178− in: "query",
179− style: "form",
180− explode: true,
181− }),
182− );
183− expect(operation?.parameters).toContainEqual(
184− expect.objectContaining({
185− name: "userId",
186− description: "The user's email address. The special value me can be used.",
187− schema: expect.objectContaining({ type: "string" }),
188− }),
189− );
190− expect(createDraft).toMatchObject({
191− operationId: "users.drafts.create",
192− "x-executor-toolPath": "users.drafts.create",
193− });
194− expect(createDraft).toMatchObject({
195− requestBody: {
196− content: {
197− "application/json": {
198− schema: { $ref: "#/components/schemas/Draft" },
199− },
200− },
201− },
202− });
203− expect(createDraft?.parameters).toContainEqual(
204− expect.objectContaining({
205− name: "userId",
206− schema: expect.objectContaining({ type: "string" }),
207− }),
208− );
209−
210− const parsed = yield* parse(result.specText);
211− const extracted = yield* extract(parsed);
212− const extractedDraftCreate = extracted.operations.find(
213− (candidate) => candidate.operationId === "users.drafts.create",
214− );
215− expect(extractedDraftCreate?.operationId).toBe("users.drafts.create");
216− const preview = yield* Effect.promise(() =>
217− buildToolTypeScriptPreview({
218− inputSchema: normalizeOpenApiRefsForPreview(
219− extractedDraftCreate
220− ? Option.getOrUndefined(extractedDraftCreate.inputSchema)
221− : undefined,
222− ),
223− outputSchema: normalizeOpenApiRefsForPreview(
224− extractedDraftCreate
225− ? Option.getOrUndefined(extractedDraftCreate.outputSchema)
226− : undefined,
227− ),
228− defs: new Map(
229− Object.entries(spec.components.schemas).map(([name, schema]) => [
230− name,
231− normalizeOpenApiRefsForPreview(schema),
232− ]),
233− ),
234− }),
235− );
236− expect(preview.inputTypeScript).toBe("{ userId: string; body?: Draft; }");
237− expect(preview.outputTypeScript).toBe("Draft");
238− expect(preview.typeScriptDefinitions).toMatchObject({
239− Draft: "{ id?: string; message?: Message; }",
240− Message: "{ id?: string; labelIds?: string[]; }",
241− });
242− // v2: the conversion now exposes a v2 oauth `authenticationTemplate` rather
243− // than v1's `oauth2` source config.
244− // removed: identityScopes assertion — that field belonged to the v1
245− // OAuth2SourceConfig slot model, which no longer exists in v2.
246− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
247− expect(oauthTemplate).toBeDefined();
248− }),
249−);
250−
251−it.effect("bundles Google Discovery documents into one Google OpenAPI source", () =>
252− Effect.gen(function* () {
253− const result = yield* convertGoogleDiscoveryBundleToOpenApi({
254− documents: [
255− {
256− discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
257− // @effect-diagnostics-next-line preferSchemaOverJson:off
258− documentText: JSON.stringify({
259− name: "gmail",
260− version: "v1",
261− title: "Gmail API",
262− rootUrl: "https://gmail.googleapis.com/",
263− servicePath: "",
264− auth: {
265− oauth2: {
266− scopes: {
267− "https://www.googleapis.com/auth/gmail.metadata": {
268− description: "Read metadata",
269− },
270− },
271− },
272− },
273− resources: {
274− users: {
275− resources: {
276− messages: {
277− methods: {
278− list: {
279− id: "gmail.users.messages.list",
280− httpMethod: "GET",
281− path: "gmail/v1/users/{userId}/messages",
282− response: { $ref: "Message" },
283− scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
284− parameters: {
285− userId: {
286− location: "path",
287− required: true,
288− type: "string",
289− },
290− },
291− },
292− },
293− },
294− },
295− },
296− },
297− schemas: {
298− Message: {
299− type: "object",
300− properties: {
301− id: { type: "string" },
302− },
303− },
304− },
305− }),
306− },
307− {
308− discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
309− // @effect-diagnostics-next-line preferSchemaOverJson:off
310− documentText: JSON.stringify({
311− name: "chat",
312− version: "v1",
313− title: "Google Chat API",
314− rootUrl: "https://chat.googleapis.com/",
315− servicePath: "",
316− auth: {
317− oauth2: {
318− scopes: {
319− "https://www.googleapis.com/auth/chat.spaces.readonly": {
320− description: "Read spaces",
321− },
322− },
323− },
324− },
325− resources: {
326− spaces: {
327− methods: {
328− get: {
329− id: "chat.spaces.get",
330− httpMethod: "GET",
331− path: "v1/{+name}",
332− response: { $ref: "Space" },
333− scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"],
334− parameters: {
335− name: {
336− location: "path",
337− required: true,
338− type: "string",
339− },
340− },
341− },
342− },
343− resources: {
344− messages: {
345− methods: {
346− get: {
347− id: "chat.spaces.messages.get",
348− httpMethod: "GET",
349− path: "v1/{+name}",
350− response: { $ref: "Message" },
351− scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"],
352− parameters: {
353− name: {
354− location: "path",
355− required: true,
356− type: "string",
357− },
358− },
359− },
360− },
361− },
362− },
363− },
364− },
365− schemas: {
366− Space: {
367− type: "object",
368− properties: {
369− name: { type: "string" },
370− },
371− },
372− Message: {
373− type: "object",
374− properties: {
375− name: { type: "string" },
376− },
377− },
378− },
379− }),
380− },
381− ],
382− });
383−
384− const spec = decodeConvertedSpec(result.specText);
385− expect(result.title).toBe("Google");
386− expect(result.baseUrl).toBe("https://www.googleapis.com/");
387− expect(result.discoveryUrls).toEqual([
388− "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
389− "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
390− ]);
391− expect(spec.servers).toEqual([{ url: "https://www.googleapis.com/" }]);
392− expect(spec.components.schemas).toHaveProperty("gmail_v1_Message");
393− expect(spec.components.schemas).toHaveProperty("chat_v1_Message");
394−
395− const gmailList = spec.paths["/gmail/v1/users/{userId}/messages"]?.get;
396− expect(gmailList).toMatchObject({
397− operationId: "gmail.users.messages.list",
398− "x-executor-toolPath": "gmail.users.messages.list",
399− servers: [{ url: "https://gmail.googleapis.com/" }],
400− });
401− expect(gmailList?.responses).toMatchObject({
402− "200": {
403− content: {
404− "application/json": {
405− schema: { $ref: "#/components/schemas/gmail_v1_Message" },
406− },
407− },
408− },
409− });
410−
411− const chatSpaceGet = spec.paths["/v1/{name}"]?.get;
412− const chatMessageGet = spec.paths["/chat.spaces.messages.get"]?.get;
413− expect(chatSpaceGet).toMatchObject({
414− operationId: "chat.spaces.get",
415− "x-executor-pathTemplate": "/v1/{+name}",
416− servers: [{ url: "https://chat.googleapis.com/" }],
417− });
418− expect(chatMessageGet).toMatchObject({
419− operationId: "chat.spaces.messages.get",
420− "x-executor-pathTemplate": "/v1/{+name}",
421− servers: [{ url: "https://chat.googleapis.com/" }],
422− });
423− expect(chatSpaceGet?.parameters).toContainEqual(
424− expect.objectContaining({
425− name: "name",
426− in: "path",
427− allowReserved: true,
428− }),
429− );
430−
431− const parsed = yield* parse(result.specText);
432− const extracted = yield* extract(parsed);
433− const extractedGmail = extracted.operations.find(
434− (candidate) => candidate.operationId === "gmail.users.messages.list",
435− );
436− const extractedChatMessage = extracted.operations.find(
437− (candidate) => candidate.operationId === "chat.spaces.messages.get",
438− );
439− expect(extractedGmail?.servers[0]?.url).toBe("https://gmail.googleapis.com/");
440− expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441− expect(extractedChatMessage?.servers[0]?.url).toBe("https://chat.googleapis.com/");
442− // v2: the bundled oauth scopes are carried on the oauth auth template.
443− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444− expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445− "https://www.googleapis.com/auth/gmail.metadata",
446− "https://www.googleapis.com/auth/chat.spaces.readonly",
447− ]);
448− }),
449−);
450−
451−// ---------------------------------------------------------------------------
452−// The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes
453−// collapse under their broad parent (`gmail.*` → `mail.google.com/`,
454−// `calendar.*` → `calendar`, `userinfo.email` → `email`), and scopes a user
455−// OAuth consent screen can't show (`chat.bot`, `chat.app.*`, `keep`) are
456−// dropped. The persisted auth template, the spec `securitySchemes.googleOAuth2`
457−// flow scopes, and the root `security` entry all agree — so the preview the
458−// picker shows and the set `oauth.start` requests at connect are the same.
459−// Per-operation `x-google-scopes`/`security` stay RAW (they describe per-method
460−// scope needs, not consent).
461−// ---------------------------------------------------------------------------
462−
463−const ConvertedSpecSecurity = Schema.Struct({
464− components: Schema.Struct({
465− securitySchemes: Schema.Record(
466− Schema.String,
467− Schema.Struct({
468− type: Schema.String,
469− flows: Schema.Struct({
470− authorizationCode: Schema.Struct({
471− scopes: Schema.Record(Schema.String, Schema.String),
472− }),
473− }),
474− }),
475− ),
476− }),
477− security: Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),
478− paths: Schema.Record(
479− Schema.String,
480− Schema.Record(Schema.String, Schema.Struct({ "x-google-scopes": Schema.Array(Schema.String) })),
481− ),
482−});
483−const decodeConvertedSpecSecurity = Schema.decodeUnknownSync(
484− Schema.fromJsonString(ConvertedSpecSecurity),
485−);
486−
487−it.effect("compacts and filters the merged bundle scope set into a clean consent set", () =>
488− Effect.gen(function* () {
489− const result = yield* convertGoogleDiscoveryBundleToOpenApi({
490− documents: [
491− {
492− discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
493− // @effect-diagnostics-next-line preferSchemaOverJson:off
494− documentText: JSON.stringify({
495− name: "gmail",
496− version: "v1",
497− title: "Gmail API",
498− rootUrl: "https://gmail.googleapis.com/",
499− servicePath: "",
500− auth: {
501− oauth2: {
502− scopes: {
503− // Broad parent + a sub-scope that must collapse under it.
504− "https://mail.google.com/": { description: "Full Gmail access" },
505− "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
506− // Identity scope normalized to `email`.
507− "https://www.googleapis.com/auth/userinfo.email": { description: "Email" },
508− },
509− },
510− },
511− resources: {
512− users: {
513− resources: {
514− messages: {
515− methods: {
516− list: {
517− id: "gmail.users.messages.list",
518− httpMethod: "GET",
519− path: "gmail/v1/users/{userId}/messages",
520− scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
521− parameters: {
522− userId: { location: "path", required: true, type: "string" },
523− },
524− },
525− },
526− },
527− },
528− },
529− },
530− schemas: {},
531− }),
532− },
533− {
534− discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
535− // @effect-diagnostics-next-line preferSchemaOverJson:off
536− documentText: JSON.stringify({
537− name: "chat",
538− version: "v1",
539− title: "Google Chat API",
540− rootUrl: "https://chat.googleapis.com/",
541− servicePath: "",
542− auth: {
543− oauth2: {
544− scopes: {
545− // A keepable consent scope plus two that the user-consent filter
546− // must drop (`chat.bot`, `chat.app.*`).
547− "https://www.googleapis.com/auth/chat.spaces.readonly": { description: "Spaces" },
548− "https://www.googleapis.com/auth/chat.bot": { description: "Bot" },
549− "https://www.googleapis.com/auth/chat.app.spaces": { description: "App spaces" },
550− },
551− },
552− },
553− resources: {
554− spaces: {
555− methods: {
556− get: {
557− id: "chat.spaces.get",
558− httpMethod: "GET",
559− path: "v1/{+name}",
560− scopes: ["https://www.googleapis.com/auth/chat.bot"],
561− parameters: {
562− name: { location: "path", required: true, type: "string" },
563− },
564− },
565− },
566− },
567− },
568− schemas: {},
569− }),
570− },
571− ],
572− });
573−
574− const expectedConsentScopes = [
575− "https://mail.google.com/",
576− "email",
577− "https://www.googleapis.com/auth/chat.spaces.readonly",
578− ];
579−
580− // The derived oauth auth template carries the compacted/filtered set
581− // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583− expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584− [...expectedConsentScopes].sort(),
585− );
586−
587− const spec = decodeConvertedSpecSecurity(result.specText);
588− // The spec's securitySchemes flow scopes match the consent set exactly.
589− expect(
590− Object.keys(spec.components.securitySchemes["googleOAuth2"]!.flows.authorizationCode.scopes)
591− .slice()
592− .sort(),
593− ).toEqual([...expectedConsentScopes].sort());
594− // The root security entry references the same compacted set.
595− expect([...(spec.security[0]?.["googleOAuth2"] ?? [])].sort()).toEqual(
596− [...expectedConsentScopes].sort(),
597− );
598− // Per-operation x-google-scopes stay RAW — a dropped consent scope can still
599− // be the scope a given method advertises.
600− expect(spec.paths["/v1/{name}"]?.get?.["x-google-scopes"]).toEqual([
601− "https://www.googleapis.com/auth/chat.bot",
602− ]);
603− }),
604−);
packages/plugins/openapi/src/sdk/google-discovery.ts+0−854
@@ -1,854 +0,0 @@
1−// Converts Google Discovery documents directly into OpenAPI 3.x. Public
2−// Discovery converters currently target Swagger 2.0 or a broad conversion
3−// pipeline; this adapter emits the shape Executor parses while preserving
4−// Executor-specific tool ids and query semantics.
5−import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";
6−import { HttpClient, HttpClientRequest } from "effect/unstable/http";
7−
8−import { OpenApiParseError } from "./errors";
9−import { compactGoogleOAuthScopes } from "./google-oauth-scopes";
10−import type { SpecFetchCredentials } from "./parse";
11−import type { Authentication } from "./types";
12−import { AuthTemplateSlug } from "@executor-js/sdk/shared";
13−
14−const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
15−const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
16−const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
17−const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
18−const OPENAPI_SCHEMA_TYPES = new Set([
19− "array",
20− "boolean",
21− "integer",
22− "null",
23− "number",
24− "object",
25− "string",
26−]);
27−
28−type JsonPrimitive = string | number | boolean | null;
29−type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
30−
31−type OpenApiSchemaObject = {
32− readonly $ref?: string;
33− readonly type?: "array" | "boolean" | "integer" | "null" | "number" | "object" | "string";
34− readonly description?: string;
35− readonly title?: string;
36− readonly format?: string;
37− readonly readOnly?: boolean;
38− readonly default?: JsonValue;
39− readonly enum?: readonly JsonValue[];
40− readonly items?: OpenApiSchemaObject;
41− readonly properties?: Record<string, OpenApiSchemaObject>;
42− readonly required?: readonly string[];
43− readonly additionalProperties?: boolean | OpenApiSchemaObject;
44−};
45−
46−type OpenApiParameterObject = {
47− readonly name: string;
48− readonly in: "path" | "query" | "header";
49− readonly required: boolean;
50− readonly description?: string;
51− readonly schema: OpenApiSchemaObject;
52− readonly style?: "form";
53− readonly explode?: boolean;
54− readonly allowReserved?: boolean;
55−};
56−
57−type OpenApiOperationObject = {
58− readonly operationId: string;
59− readonly "x-executor-toolPath": string;
60− readonly "x-executor-pathTemplate"?: string;
61− readonly tags?: readonly string[];
62− readonly description?: string;
63− readonly servers?: readonly { readonly url: string }[];
64− readonly parameters: readonly OpenApiParameterObject[];
65− readonly requestBody?: {
66− readonly required: false;
67− readonly content: {
68− readonly "application/json": {
69− readonly schema: OpenApiSchemaObject;
70− };
71− };
72− };
73− readonly responses: {
74− readonly "200": {
75− readonly description: "Successful response";
76− readonly content: {
77− readonly "application/json": {
78− readonly schema: OpenApiSchemaObject;
79− };
80− };
81− };
82− };
83− readonly security?: readonly Record<string, readonly string[]>[];
84− readonly "x-google-scopes": readonly string[];
85−};
86−
87−type OpenApiDocument = {
88− readonly openapi: "3.1.0";
89− readonly info: {
90− readonly title: string;
91− readonly version: string;
92− };
93− readonly servers: readonly { readonly url: string }[];
94− readonly paths: Record<string, Record<string, OpenApiOperationObject>>;
95− readonly components: {
96− readonly schemas: Record<string, OpenApiSchemaObject>;
97− readonly securitySchemes?: Record<
98− string,
99− {
100− readonly type: "oauth2";
101− readonly flows: {
102− readonly authorizationCode: {
103− readonly authorizationUrl: string;
104− readonly tokenUrl: string;
105− readonly scopes: Record<string, string>;
106− };
107− };
108− }
109− >;
110− };
111− readonly security?: readonly Record<string, readonly string[]>[];
112− readonly "x-executor-origin":
113− | {
114− readonly kind: "googleDiscovery";
115− readonly discoveryUrl: string;
116− readonly service: string;
117− readonly version: string;
118− }
119− | {
120− readonly kind: "googleDiscoveryBundle";
121− readonly services: readonly {
122− readonly discoveryUrl: string;
123− readonly service: string;
124− readonly version: string;
125− }[];
126− };
127−};
128−
129−const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe(
130− Schema.decode({
131− decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)),
132− encode: SchemaGetter.transform((value) => value),
133− }),
134− Schema.withDecodingDefaultType(Effect.succeed(Option.none())),
135−);
136−const TextArray = Schema.optional(Schema.Array(Schema.String)).pipe(
137− Schema.withDecodingDefaultType(Effect.succeed([] as string[])),
138−);
139−const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
140−const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe(
141− Schema.withDecodingDefaultType(Effect.succeed({})),
142−);
143−
144−const DiscoveryParameter = Schema.Struct({
145− type: Schema.optional(Schema.String),
146− description: TextOption,
147− properties: UnknownRecordWithDefault,
148− items: Schema.optional(Schema.Unknown),
149− additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])),
150− enum: TextArray,
151− format: Schema.optional(Schema.String),
152− readOnly: Schema.optional(Schema.Boolean),
153− default: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Boolean])),
154− $ref: Schema.optional(Schema.String),
155− location: Schema.optional(Schema.Literals(["path", "query", "header"])),
156− required: Schema.optional(Schema.Boolean),
157− repeated: Schema.optional(Schema.Boolean),
158−});
159−type DiscoveryParameter = typeof DiscoveryParameter.Type;
160−
161−const DiscoveryRef = Schema.Struct({
162− $ref: Schema.optional(Schema.String),
163−});
164−
165−const DiscoveryMethod = Schema.Struct({
166− id: TextOption,
167− description: TextOption,
168− httpMethod: Schema.optional(Schema.String),
169− path: TextOption,
170− parameters: UnknownRecordWithDefault,
171− request: Schema.optional(DiscoveryRef),
172− response: Schema.optional(DiscoveryRef),
173− scopes: TextArray,
174−});
175−type DiscoveryMethod = typeof DiscoveryMethod.Type;
176−
177−const DiscoveryResource = Schema.Struct({
178− methods: UnknownRecordWithDefault,
179− resources: UnknownRecordWithDefault,
180−});
181−
182−const DiscoveryDocument = Schema.Struct({
183− name: TextOption,
184− version: TextOption,
185− title: TextOption,
186− rootUrl: TextOption,
187− servicePath: Schema.optional(Schema.Trim).pipe(
188− Schema.withDecodingDefaultType(Effect.succeed("")),
189− ),
190− parameters: UnknownRecordWithDefault,
191− methods: UnknownRecordWithDefault,
192− resources: UnknownRecordWithDefault,
193− schemas: UnknownRecordWithDefault,
194− auth: Schema.optional(
195− Schema.Struct({
196− oauth2: Schema.optional(
197− Schema.Struct({
198− scopes: Schema.optional(
199− Schema.Record(
200− Schema.String,
201− Schema.Struct({
202− description: TextOption,
203− }),
204− ),
205− ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))),
206− }),
207− ),
208− }),
209− ),
210−});
211−type DiscoveryDocument = typeof DiscoveryDocument.Type;
212−
213−export interface GoogleDiscoveryOpenApiConversion {
214− readonly specText: string;
215− readonly baseUrl: string;
216− readonly title: string;
217− readonly service: string;
218− readonly version: string;
219− readonly discoveryUrls?: readonly string[];
220− /** The v2 oauth auth template the converted integration declares, when the
221− * Discovery document advertises OAuth2 scopes. */
222− readonly authenticationTemplate?: readonly Authentication[];
223−}
224−
225−const decodeDiscoveryDocument = Schema.decodeUnknownSync(DiscoveryDocument);
226−const decodeDiscoveryParameter = Schema.decodeUnknownSync(DiscoveryParameter);
227−const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod);
228−const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
229−const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
230−
231−const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
232− const trimmed = discoveryUrl.trim();
233− if (!URL.canParse(trimmed)) return trimmed;
234− const parsed = new URL(trimmed);
235− if (parsed.pathname !== "/$discovery/rest") return trimmed;
236− const version = parsed.searchParams.get("version")?.trim();
237− if (!version) return trimmed;
238− const host = parsed.hostname.toLowerCase();
239− if (!host.endsWith(".googleapis.com")) return trimmed;
240− const rawService = host.slice(0, -".googleapis.com".length);
241− const service =
242− rawService === "calendar-json"
243− ? "calendar"
244− : rawService.endsWith("-json")
245− ? rawService.slice(0, -5)
246− : rawService;
247− return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed;
248−};
249−
250−export const isGoogleDiscoveryUrl = (url: string): boolean => {
251− const trimmed = url.trim();
252− if (!URL.canParse(trimmed)) return false;
253− const parsed = new URL(trimmed);
254− const host = parsed.hostname.toLowerCase();
255− if (!host.endsWith("googleapis.com")) return false;
256− return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");
257−};
258−
259−export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
260− function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
261− const client = yield* HttpClient.HttpClient;
262− const requestUrl = new URL(discoveryUrl);
263− for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
264− requestUrl.searchParams.set(name, value);
265− }
266− let request = HttpClientRequest.get(requestUrl.toString()).pipe(
267− HttpClientRequest.setHeader("Accept", "application/json, */*"),
268− );
269− for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
270− request = HttpClientRequest.setHeader(request, name, value);
271− }
272− const response = yield* client.execute(request).pipe(
273− Effect.mapError(
274− () =>
275− new OpenApiParseError({
276− message: "Failed to fetch Google Discovery document",
277− }),
278− ),
279− );
280− if (response.status < 200 || response.status >= 300) {
281− return yield* new OpenApiParseError({
282− message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
283− });
284− }
285− return yield* response.text.pipe(
286− Effect.mapError(
287− () =>
288− new OpenApiParseError({
289− message: "Failed to read Google Discovery document body",
290− }),
291− ),
292− );
293− },
294−);
295−
296−const schemaRef = (name: string) => `#/components/schemas/${name}`;
297−
298−const identitySchemaName = (name: string): string => name;
299−
300−const schemaComponentPart = (value: string): string =>
301− value
302− .trim()
303− .replace(/[^A-Za-z0-9._-]+/g, "_")
304− .replace(/^_+|_+$/g, "") || "schema";
305−
306−const normalizeDiscoveryPathTemplate = (pathTemplate: string): string =>
307− pathTemplate.replaceAll(/\{\+([^{}]+)\}/g, "{$1}");
308−
309−const pathUsesReservedExpansion = (pathTemplate: string, parameterName: string): boolean =>
310− pathTemplate.includes(`{+${parameterName}}`);
311−
312−const uniquePathKey = (
313− paths: Record<string, Record<string, OpenApiOperationObject>>,
314− preferredPath: string,
315− method: string,
316− toolPath: string,
317−): string => {
318− if (!paths[preferredPath]?.[method]) return preferredPath;
319− const disambiguated = `/${toolPath.replace(/[^A-Za-z0-9._~-]+/g, "/")}`;
320− if (!paths[disambiguated]?.[method]) return disambiguated;
321− let index = 2;
322− while (paths[`${disambiguated}/${index}`]?.[method]) index += 1;
323− return `${disambiguated}/${index}`;
324−};
325−
326−const discoveryDescription = (value: unknown): string | undefined =>
327− typeof value === "string"
328− ? value
329− : Option.isOption(value) && Option.isSome(value)
330− ? typeof value.value === "string"
331− ? value.value
332− : undefined
333− : undefined;
334−
335−const isRecord = (value: unknown): value is Record<string, unknown> =>
336− typeof value === "object" && value !== null && !Array.isArray(value);
337−
338−const jsonValue = (value: unknown): JsonValue | undefined => {
339− if (
340− value === null ||
341− typeof value === "string" ||
342− typeof value === "number" ||
343− typeof value === "boolean"
344− ) {
345− return value;
346− }
347− if (Array.isArray(value)) {
348− const values = value.map(jsonValue);
349− return values.every(Predicate.isNotUndefined) ? values : undefined;
350− }
351− if (!isRecord(value)) return undefined;
352− const entries = Object.entries(value).flatMap(([key, item]) => {
353− const converted = jsonValue(item);
354− return converted === undefined ? [] : [[key, converted] as const];
355− });
356− return Object.fromEntries(entries);
357−};
358−
359−const stringArray = (value: unknown): readonly string[] | undefined => {
360− if (!Array.isArray(value)) return undefined;
361− const strings = value.filter((item): item is string => typeof item === "string");
362− return strings.length === value.length ? strings : undefined;
363−};
364−
365−const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined =>
366− typeof value === "string" && OPENAPI_SCHEMA_TYPES.has(value)
367− ? (value as OpenApiSchemaObject["type"])
368− : undefined;
369−
370−const discoverySchemaToOpenApiSchema = (
371− raw: unknown,
372− schemaNameForRef: (name: string) => string = identitySchemaName,
373−): OpenApiSchemaObject => {
374− if (!isRecord(raw)) return {};
375− const schema = raw;
376− if (typeof schema.$ref === "string") return { $ref: schemaRef(schemaNameForRef(schema.$ref)) };
377−
378− const description = discoveryDescription(schema.description);
379− const title = discoveryDescription(schema.title);
380− const defaultValue = jsonValue(schema.default);
381− const enumValues = Array.isArray(schema.enum)
382− ? schema.enum.map(jsonValue).filter(Predicate.isNotUndefined)
383− : [];
384− const format = typeof schema.format === "string" ? schema.format : undefined;
385− const readOnly = typeof schema.readOnly === "boolean" ? schema.readOnly : undefined;
386− const type = schemaType(schema.type);
387−
388− const base = {
389− ...(description !== undefined ? { description } : {}),
390− ...(title !== undefined ? { title } : {}),
391− ...(format !== undefined ? { format } : {}),
392− ...(readOnly !== undefined ? { readOnly } : {}),
393− ...(defaultValue !== undefined ? { default: defaultValue } : {}),
394− ...(enumValues.length > 0 ? { enum: enumValues } : {}),
395− } satisfies OpenApiSchemaObject;
396−
397− if (type === "array") {
398− return {
399− ...base,
400− type: "array",
401− items: discoverySchemaToOpenApiSchema(schema.items, schemaNameForRef),
402− };
403− }
404−
405− const properties = schema.properties;
406− if (
407− type === "object" ||
408− (isRecord(properties) && Object.keys(properties).length > 0) ||
409− schema.additionalProperties !== undefined
410− ) {
411− const convertedProperties = isRecord(properties)
412− ? Object.fromEntries(
413− Object.entries(properties).map(([name, value]) => [
414− name,
415− discoverySchemaToOpenApiSchema(value, schemaNameForRef),
416− ]),
417− )
418− : undefined;
419− const required = stringArray(schema.required);
420− const additionalProperties =
421− schema.additionalProperties === undefined
422− ? undefined
423− : typeof schema.additionalProperties === "boolean"
424− ? schema.additionalProperties
425− : discoverySchemaToOpenApiSchema(schema.additionalProperties, schemaNameForRef);
426− return {
427− ...base,
428− type: "object",
429− ...(convertedProperties && Object.keys(convertedProperties).length > 0
430− ? { properties: convertedProperties }
431− : {}),
432− ...(required && required.length > 0 ? { required } : {}),
433− ...(additionalProperties !== undefined ? { additionalProperties } : {}),
434− };
435− }
436−
437− return type !== undefined ? { ...base, type } : base;
438−};
439−
440−const parameterSchema = (
441− parameter: DiscoveryParameter,
442− schemaNameForRef: (name: string) => string = identitySchemaName,
443−): OpenApiSchemaObject => {
444− const base = discoverySchemaToOpenApiSchema(parameter, schemaNameForRef);
445− return parameter.repeated
446− ? {
447− type: "array",
448− items: base,
449− }
450− : base;
451−};
452−
453−const methodToolPath = (service: string, methodId: string): string =>
454− methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId;
455−
456−const collectMethods = (resource: unknown): DiscoveryMethod[] => {
457− const decoded = decodeDiscoveryResource(resource);
458− const direct = Object.values(decoded.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw));
459− const nested = Object.values(decoded.resources ?? {}).flatMap(collectMethods);
460− return [...direct, ...nested];
461−};
462−
463−const discoveryScopes = (document: DiscoveryDocument): Record<string, string> =>
464− Object.fromEntries(
465− Object.entries(document.auth?.oauth2?.scopes ?? {}).map(([scope, value]) => [
466− scope,
467− Option.getOrElse(value.description, () => ""),
468− ]),
469− );
470−
471−// The scope set the converted integration DECLARES (and that `oauth.start`
472−// requests at connect) must match the consent the picker previews. Both run the
473−// raw Discovery union through `compactGoogleOAuthScopes`, which drops scopes a
474−// user OAuth consent screen can't show (`chat.bot`/`chat.app.*`/`keep`) and
475−// collapses sub-scopes under their broad parent (`gmail.*` → `mail.google.com`,
476−// `userinfo.email` → `email`). Descriptions are preserved where the raw map had
477−// them; compaction-introduced identity scopes (`email`/`profile`) fall back to
478−// the broad parent's description. Per-operation `x-google-scopes`/`security`
479−// stay RAW — they describe which scope each method needs, not consent.
480−const compactDiscoveryScopeMap = (raw: Record<string, string>): Record<string, string> => {
481− const descriptionFor = (scope: string): string => {
482− if (raw[scope] !== undefined) return raw[scope];
483− if (scope === "email") return raw["https://www.googleapis.com/auth/userinfo.email"] ?? "";
484− if (scope === "profile") return raw["https://www.googleapis.com/auth/userinfo.profile"] ?? "";
485− return "";
486− };
487− return Object.fromEntries(
488− compactGoogleOAuthScopes(Object.keys(raw)).map((scope) => [scope, descriptionFor(scope)]),
489− );
490−};
491−
492−const allDiscoveryMethods = (document: DiscoveryDocument): DiscoveryMethod[] => [
493− ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)),
494− ...Object.values(document.resources ?? {}).flatMap(collectMethods),
495−];
496−
497−type DiscoveryDocumentInfo = {
498− readonly discoveryUrl: string;
499− readonly document: DiscoveryDocument;
500− readonly service: string;
501− readonly version: string;
502− readonly rootUrl: string;
503− readonly baseUrl: string;
504− readonly title: string;
505−};
506−
507−const discoveryDocumentInfo = (
508− document: DiscoveryDocument,
509− discoveryUrl: string,
510−): Effect.Effect<DiscoveryDocumentInfo, OpenApiParseError> =>
511− Effect.gen(function* () {
512− const service = Option.getOrUndefined(document.name);
513− const version = Option.getOrUndefined(document.version);
514− const rootUrl = Option.getOrUndefined(document.rootUrl);
515− if (!service || !version || !rootUrl) {
516− return yield* new OpenApiParseError({
517− message: "Google Discovery document is missing one of: name, version, rootUrl",
518− });
519− }
520−
521− return {
522− discoveryUrl,
523− document,
524− service,
525− version,
526− rootUrl,
527− baseUrl: new URL(document.servicePath || "", rootUrl).toString(),
528− title: Option.getOrElse(document.title, () => `${service} ${version}`),
529− };
530− });
531−
532−const buildDiscoveryOperation = (input: {
533− readonly document: DiscoveryDocument;
534− readonly method: DiscoveryMethod;
535− readonly toolPath: string;
536− readonly pathTemplate: string;
537− readonly schemaNameForRef?: (name: string) => string;
538− readonly serverUrl?: string;
539− readonly tags?: readonly string[];
540−}): OpenApiOperationObject => {
541− const mergedParameters = new Map<string, DiscoveryParameter>();
542− for (const [name, raw] of Object.entries(input.document.parameters ?? {})) {
543− const parameter = decodeDiscoveryParameter(raw);
544− if (parameter.location) mergedParameters.set(name, parameter);
545− }
546− for (const [name, raw] of Object.entries(input.method.parameters ?? {})) {
547− const parameter = decodeDiscoveryParameter(raw);
548− if (parameter.location) mergedParameters.set(name, parameter);
549− }
550−
551− const methodScopes = input.method.scopes ?? [];
552− const methodDescription = Option.getOrUndefined(input.method.description);
553− const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName;
554−
555− return {
556− operationId: input.toolPath,
557− "x-executor-toolPath": input.toolPath,
558− "x-executor-pathTemplate": input.pathTemplate,
559− ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
560− ...(methodDescription !== undefined ? { description: methodDescription } : {}),
561− ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}),
562− parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => {
563− const location = parameter.location;
564− if (!location) return [];
565− const description = Option.getOrUndefined(parameter.description);
566− const allowReserved =
567− location === "path" && pathUsesReservedExpansion(input.pathTemplate, name);
568− return [
569− {
570− name,
571− in: location,
572− required: location === "path" ? true : parameter.required === true,
573− ...(description !== undefined ? { description } : {}),
574− schema: parameterSchema(parameter, schemaNameForRef),
575− ...(location === "query"
576− ? { style: "form" as const, explode: parameter.repeated === true }
577− : {}),
578− ...(allowReserved ? { allowReserved: true } : {}),
579− },
580− ];
581− }),
582− ...(input.method.request?.$ref
583− ? {
584− requestBody: {
585− required: false,
586− content: {
587− "application/json": {
588− schema: { $ref: schemaRef(schemaNameForRef(input.method.request.$ref)) },
589− },
590− },
591− },
592− }
593− : {}),
594− responses: {
595− "200": {
596− description: "Successful response",
597− content: {
598− "application/json": {
599− schema: input.method.response?.$ref
600− ? { $ref: schemaRef(schemaNameForRef(input.method.response.$ref)) }
601− : {},
602− },
603− },
604− },
605− },
606− ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}),
607− "x-google-scopes": methodScopes,
608− };
609−};
610−
611−const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
612−
613−/** The v2 oauth auth template for a Google-discovery integration. The spec
614− * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the
615− * catalog-level template a connection's access token renders through. */
616−const googleOauthTemplate = (scopes: Record<string, string>): readonly Authentication[] =>
617− // A Google-discovery integration is ALWAYS OAuth, so it ALWAYS declares its
618− // oauth method — even when the compacted scope set is empty (e.g. a bundle of
619− // only limited-consent APIs like Google Keep, whose scopes Google won't grant
620− // through standard consent). Without this the integration declares no auth
621− // method and the "Add account" / "Add a connection" actions are disabled, so
622− // you can't even start the flow. Empty `scopes` just requests no scope.
623− [
624− {
625− slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626− kind: "oauth2",
627− authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628− tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629− scopes: Object.keys(scopes),
630− },
631− ];
632−
633−export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(
634− function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {
635− const parsed = yield* parseJson(input.documentText).pipe(
636− Effect.mapError(
637− () =>
638− new OpenApiParseError({
639− message: "Failed to parse Google Discovery document",
640− }),
641− ),
642− );
643− const document = yield* Effect.try({
644− try: () => decodeDiscoveryDocument(parsed),
645− catch: () =>
646− new OpenApiParseError({
647− message: "Failed to decode Google Discovery document",
648− }),
649− });
650−
651− const info = yield* discoveryDocumentInfo(document, input.discoveryUrl);
652− const { service, version, baseUrl, title } = info;
653− const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
654−
655− for (const method of allDiscoveryMethods(document)) {
656− const methodId = Option.getOrUndefined(method.id);
657− const pathTemplate = Option.getOrUndefined(method.path);
658− if (!methodId || !pathTemplate || !method.httpMethod) continue;
659−
660− const toolPath = methodToolPath(service, methodId);
661− const path = normalizeDiscoveryPathTemplate(
662− pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
663− );
664− const methodKey = method.httpMethod.toLowerCase();
665− const pathKey = uniquePathKey(paths, path, methodKey, toolPath);
666−
667− paths[pathKey] ??= {};
668− paths[pathKey]![methodKey] = buildDiscoveryOperation({
669− document,
670− method,
671− toolPath,
672− pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
673− });
674− }
675−
676− const scopes = compactDiscoveryScopeMap(discoveryScopes(document));
677− const authenticationTemplate = googleOauthTemplate(scopes);
678−
679− const spec: OpenApiDocument = {
680− openapi: "3.1.0",
681− info: {
682− title,
683− version,
684− },
685− servers: [{ url: baseUrl }],
686− paths,
687− components: {
688− schemas: Object.fromEntries(
689− Object.entries(document.schemas ?? {}).map(([name, schema]) => [
690− name,
691− discoverySchemaToOpenApiSchema(schema),
692− ]),
693− ),
694− ...(authenticationTemplate
695− ? {
696− securitySchemes: {
697− googleOAuth2: {
698− type: "oauth2",
699− flows: {
700− authorizationCode: {
701− authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
702− tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
703− scopes,
704− },
705− },
706− },
707− },
708− }
709− : {}),
710− },
711− ...(authenticationTemplate ? { security: [{ googleOAuth2: Object.keys(scopes) }] } : {}),
712− "x-executor-origin": {
713− kind: "googleDiscovery",
714− discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
715− service,
716− version,
717− },
718− };
719−
720− return {
721− // @effect-diagnostics-next-line preferSchemaOverJson:off
722− specText: JSON.stringify(spec),
723− baseUrl,
724− title,
725− service,
726− version,
727− ...(authenticationTemplate ? { authenticationTemplate } : {}),
728− };
729− },
730−);
731−
732−export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
733− "OpenApi.convertGoogleDiscoveryBundle",
734−)(function* (input: {
735− readonly documents: readonly { readonly discoveryUrl: string; readonly documentText: string }[];
736−}) {
737− if (input.documents.length === 0) {
738− return yield* new OpenApiParseError({
739− message: "Google Discovery bundle requires at least one document",
740− });
741− }
742−
743− const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) =>
744− Effect.gen(function* () {
745− const parsed = yield* parseJson(documentText).pipe(
746− Effect.mapError(
747− () =>
748− new OpenApiParseError({
749− message: "Failed to parse Google Discovery document",
750− }),
751− ),
752− );
753− const document = yield* Effect.try({
754− try: () => decodeDiscoveryDocument(parsed),
755− catch: () =>
756− new OpenApiParseError({
757− message: "Failed to decode Google Discovery document",
758− }),
759− });
760− return yield* discoveryDocumentInfo(document, discoveryUrl);
761− }),
762− );
763−
764− const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
765− const schemas: Record<string, OpenApiSchemaObject> = {};
766− const rawScopes: Record<string, string> = {};
767−
768− for (const info of infos) {
769− const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
770− const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
771−
772− for (const [scope, description] of Object.entries(discoveryScopes(info.document))) {
773− rawScopes[scope] ??= description;
774− }
775−
776− for (const [name, schema] of Object.entries(info.document.schemas ?? {})) {
777− schemas[schemaNameForRef(name)] = discoverySchemaToOpenApiSchema(schema, schemaNameForRef);
778− }
779−
780− for (const method of allDiscoveryMethods(info.document)) {
781− const methodId = Option.getOrUndefined(method.id);
782− const rawPathTemplate = Option.getOrUndefined(method.path);
783− if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
784−
785− const toolPath = methodId;
786− const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`;
787− const openApiPath = normalizeDiscoveryPathTemplate(wirePath);
788− const methodKey = method.httpMethod.toLowerCase();
789− const pathKey = uniquePathKey(paths, openApiPath, methodKey, toolPath);
790−
791− paths[pathKey] ??= {};
792− paths[pathKey]![methodKey] = buildDiscoveryOperation({
793− document: info.document,
794− method,
795− toolPath,
796− pathTemplate: wirePath,
797− schemaNameForRef,
798− serverUrl: info.baseUrl,
799− tags: [info.title],
800− });
801− }
802− }
803−
804− const scopes = compactDiscoveryScopeMap(rawScopes);
805− const authenticationTemplate = googleOauthTemplate(scopes);
806− const spec: OpenApiDocument = {
807− openapi: "3.1.0",
808− info: {
809− title: "Google",
810− version: "google-discovery-bundle",
811− },
812− servers: [{ url: GOOGLE_BUNDLE_BASE_URL }],
813− paths,
814− components: {
815− schemas,
816− ...(authenticationTemplate
817− ? {
818− securitySchemes: {
819− googleOAuth2: {
820− type: "oauth2",
821− flows: {
822− authorizationCode: {
823− authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
824− tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
825− scopes,
826− },
827− },
828− },
829− },
830− }
831− : {}),
832− },
833− ...(authenticationTemplate ? { security: [{ googleOAuth2: Object.keys(scopes) }] } : {}),
834− "x-executor-origin": {
835− kind: "googleDiscoveryBundle",
836− services: infos.map((info) => ({
837− discoveryUrl: normalizeDiscoveryUrl(info.discoveryUrl),
838− service: info.service,
839− version: info.version,
840− })),
841− },
842− };
843−
844− return {
845− // @effect-diagnostics-next-line preferSchemaOverJson:off
846− specText: JSON.stringify(spec),
847− baseUrl: GOOGLE_BUNDLE_BASE_URL,
848− title: "Google",
849− service: "google",
850− version: "google-discovery-bundle",
851− discoveryUrls: infos.map((info) => normalizeDiscoveryUrl(info.discoveryUrl)),
852− ...(authenticationTemplate ? { authenticationTemplate } : {}),
853− };
854−});
packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import { googleOAuthConsentBatches } from "./google-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/openapi/src/sdk/google-oauth-batches.ts+0−69
@@ -1,69 +0,0 @@
1−import type { GoogleOpenApiOAuthAudience } from "./google-presets";
2−import { compactGoogleOAuthScopes } from "./google-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/openapi/src/sdk/google-oauth-scopes.test.ts+0−37
@@ -1,37 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import {
4− compactGoogleOAuthScopes,
5− filterGoogleUserConsentOAuthScopes,
6−} from "./google-oauth-scopes";
7−
8−it("filters Google scopes that cannot be shown on a user OAuth consent screen", () => {
9− expect(
10− filterGoogleUserConsentOAuthScopes([
11− "https://www.googleapis.com/auth/calendar",
12− "https://www.googleapis.com/auth/chat.app.messages.readonly",
13− "https://www.googleapis.com/auth/chat.bot",
14− "https://www.googleapis.com/auth/chat.import",
15− "https://www.googleapis.com/auth/chat.messages.readonly",
16− "https://www.googleapis.com/auth/keep",
17− "https://www.googleapis.com/auth/keep.readonly",
18− ]),
19− ).toEqual([
20− "https://www.googleapis.com/auth/calendar",
21− "https://www.googleapis.com/auth/chat.messages.readonly",
22− ]);
23−});
24−
25−it("compacts Google OAuth scopes after filtering user-consent-incompatible scopes", () => {
26− expect(
27− compactGoogleOAuthScopes([
28− "https://mail.google.com/",
29− "https://www.googleapis.com/auth/gmail.send",
30− "https://www.googleapis.com/auth/userinfo.email",
31− "https://www.googleapis.com/auth/userinfo.profile",
32− "openid",
33− "https://www.googleapis.com/auth/chat.app.spaces",
34− "https://www.googleapis.com/auth/keep.readonly",
35− ]),
36− ).toEqual(["https://mail.google.com/", "email", "profile", "openid"]);
37−});
packages/plugins/openapi/src/sdk/google-oauth-scopes.ts+0−66
@@ -1,66 +0,0 @@
1−const googleUserConsentBlockedScopes = new Set([
2− "https://www.googleapis.com/auth/chat.bot",
3− "https://www.googleapis.com/auth/chat.import",
4− "https://www.googleapis.com/auth/keep",
5− "https://www.googleapis.com/auth/keep.readonly",
6−]);
7−
8−const googleUserConsentBlockedScopePrefixes = ["https://www.googleapis.com/auth/chat.app."];
9−
10−const googleBroadScopeGroups: readonly {
11− readonly broad: string;
12− readonly prefixes: readonly string[];
13−}[] = [
14− {
15− broad: "https://mail.google.com/",
16− prefixes: ["https://www.googleapis.com/auth/gmail."],
17− },
18− {
19− broad: "https://www.googleapis.com/auth/calendar",
20− prefixes: ["https://www.googleapis.com/auth/calendar."],
21− },
22− {
23− broad: "https://www.googleapis.com/auth/drive",
24− prefixes: ["https://www.googleapis.com/auth/drive."],
25− },
26−];
27−
28−const normalizeGoogleIdentityScope = (scope: string): string =>
29− scope === "https://www.googleapis.com/auth/userinfo.email"
30− ? "email"
31− : scope === "https://www.googleapis.com/auth/userinfo.profile"
32− ? "profile"
33− : scope;
34−
35−const orderedUniqueScopes = (scopes: Iterable<string>): string[] => {
36− const ordered: string[] = [];
37− const seen = new Set<string>();
38− for (const scope of scopes) {
39− const trimmed = scope.trim();
40− if (!trimmed || seen.has(trimmed)) continue;
41− seen.add(trimmed);
42− ordered.push(trimmed);
43− }
44− return ordered;
45−};
46−
47−export const isGoogleUserConsentOAuthScope = (scope: string): boolean =>
48− !googleUserConsentBlockedScopes.has(scope) &&
49− !googleUserConsentBlockedScopePrefixes.some((prefix) => scope.startsWith(prefix));
50−
51−export const filterGoogleUserConsentOAuthScopes = (scopes: Iterable<string>): string[] =>
52− orderedUniqueScopes(scopes).filter(isGoogleUserConsentOAuthScope);
53−
54−export const compactGoogleOAuthScopes = (scopes: Iterable<string>): string[] => {
55− const ordered = filterGoogleUserConsentOAuthScopes([...scopes].map(normalizeGoogleIdentityScope));
56− const present = new Set(ordered);
57− return ordered.filter(
58− (scope) =>
59− !googleBroadScopeGroups.some(
60− (group) =>
61− scope !== group.broad &&
62− present.has(group.broad) &&
63− group.prefixes.some((prefix) => scope.startsWith(prefix)),
64− ),
65− );
66−};
packages/plugins/openapi/src/sdk/google-presets.test.ts+0−29
@@ -1,29 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-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/openapi/src/sdk/google-presets.ts+0−267
@@ -1,267 +0,0 @@
1−import type { OpenApiPreset } from "./presets";
2−
3−export type GoogleOpenApiOAuthAudience =
4− | "standard-user"
5− | "advanced-user"
6− | "workspace-admin"
7− | "unsupported-user";
8−
9−export type GoogleOpenApiPreset = OpenApiPreset & {
10− readonly oauthAudience: GoogleOpenApiOAuthAudience;
11−};
12−
13−const gd = (service: string, version: string) =>
14− `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
15−
16−const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
17−export const GOOGLE_BUNDLE_PRESET_ID = "google";
18−
19−export const googleOpenApiBundlePreset: OpenApiPreset = {
20− id: GOOGLE_BUNDLE_PRESET_ID,
21− name: "Google",
22− summary: "Bundle Gmail, Calendar, Drive, Docs, and other Google APIs into one source.",
23− icon: GOOGLE_G,
24− featured: true,
25−};
26−
27−export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [
28− {
29− id: "google-calendar",
30− name: "Google Calendar",
31− summary: "Calendars, events, ACLs, and scheduling.",
32− url: gd("calendar", "v3"),
33− icon: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg",
34− featured: true,
35− oauthAudience: "standard-user",
36− },
37− {
38− id: "google-gmail",
39− name: "Gmail",
40− summary: "Messages, threads, labels, and drafts.",
41− url: gd("gmail", "v1"),
42− icon: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png",
43− featured: true,
44− oauthAudience: "standard-user",
45− },
46− {
47− id: "google-sheets",
48− name: "Google Sheets",
49− summary: "Spreadsheets, values, ranges, and formatting.",
50− url: gd("sheets", "v4"),
51− icon: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg",
52− featured: true,
53− oauthAudience: "standard-user",
54− },
55− {
56− id: "google-drive",
57− name: "Google Drive",
58− summary: "Files, folders, permissions, and shared drives.",
59− url: gd("drive", "v3"),
60− icon: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg",
61− featured: true,
62− oauthAudience: "standard-user",
63− },
64− {
65− id: "google-docs",
66− name: "Google Docs",
67− summary: "Documents, structural edits, and formatting.",
68− url: gd("docs", "v1"),
69− icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg",
70− featured: true,
71− oauthAudience: "standard-user",
72− },
73− {
74− id: "google-slides",
75− name: "Google Slides",
76− summary: "Presentations, slides, page elements, and deck updates.",
77− url: gd("slides", "v1"),
78− icon: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg",
79− oauthAudience: "standard-user",
80− },
81− {
82− id: "google-forms",
83− name: "Google Forms",
84− summary: "Forms, questions, responses, and quizzes.",
85− url: "https://forms.googleapis.com/$discovery/rest?version=v1",
86− icon: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg",
87− oauthAudience: "standard-user",
88− },
89− {
90− id: "google-tasks",
91− name: "Google Tasks",
92− summary: "Task lists, task items, notes, and due dates.",
93− url: gd("tasks", "v1"),
94− icon: "https://fonts.gstatic.com/s/i/productlogos/tasks/v5/192px.svg",
95− oauthAudience: "standard-user",
96− },
97− {
98− id: "google-people",
99− name: "Google People",
100− summary: "Contacts, profiles, directory people, and contact groups.",
101− url: gd("people", "v1"),
102− icon: "https://fonts.gstatic.com/s/i/productlogos/contacts_2022/v2/192px.svg",
103− oauthAudience: "standard-user",
104− },
105− {
106− id: "google-chat",
107− name: "Google Chat",
108− summary: "Spaces, messages, members, reactions, and chat workflows.",
109− url: gd("chat", "v1"),
110− icon: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg",
111− oauthAudience: "workspace-admin",
112− },
113− {
114− id: "google-keep",
115− name: "Google Keep",
116− summary: "Notes, lists, attachments, and annotations.",
117− url: "https://keep.googleapis.com/$discovery/rest?version=v1",
118− icon: "https://fonts.gstatic.com/s/i/productlogos/keep_2020q4/v8/192px.svg",
119− oauthAudience: "unsupported-user",
120− },
121− {
122− id: "google-youtube-data",
123− name: "YouTube Data",
124− summary: "Channels, playlists, videos, comments, and uploads.",
125− url: gd("youtube", "v3"),
126− icon: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg",
127− oauthAudience: "advanced-user",
128− },
129− {
130− id: "google-search-console",
131− name: "Google Search Console",
132− summary: "Sites, sitemaps, URL inspection, and search performance.",
133− url: gd("searchconsole", "v1"),
134− icon: GOOGLE_G,
135− oauthAudience: "standard-user",
136− },
137− {
138− id: "google-classroom",
139− name: "Google Classroom",
140− summary: "Courses, rosters, coursework, and grading.",
141− url: gd("classroom", "v1"),
142− icon: "https://fonts.gstatic.com/s/i/productlogos/classroom/v7/192px.svg",
143− oauthAudience: "advanced-user",
144− },
145− {
146− id: "google-admin-directory",
147− name: "Google Admin Directory",
148− summary: "Users, groups, org units, roles, and domain resources.",
149− url: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
150− icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
151− oauthAudience: "workspace-admin",
152− },
153− {
154− id: "google-admin-reports",
155− name: "Google Admin Reports",
156− summary: "Audit events, usage reports, and admin activity logs.",
157− url: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
158− icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
159− oauthAudience: "workspace-admin",
160− },
161− {
162− id: "google-apps-script",
163− name: "Google Apps Script",
164− summary: "Projects, deployments, and script execution.",
165− url: gd("script", "v1"),
166− icon: "https://fonts.gstatic.com/s/i/productlogos/apps_script/v10/192px.svg",
167− oauthAudience: "advanced-user",
168− },
169− {
170− id: "google-bigquery",
171− name: "Google BigQuery",
172− summary: "Datasets, tables, jobs, and analytical queries.",
173− url: gd("bigquery", "v2"),
174− icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
175− oauthAudience: "advanced-user",
176− },
177− {
178− id: "google-cloud-resource-manager",
179− name: "Google Cloud Resource Manager",
180− summary: "Projects, folders, organizations, and IAM hierarchy.",
181− url: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
182− icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
183− oauthAudience: "advanced-user",
184− },
185−];
186−
187−export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter(
188− (preset) => preset.oauthAudience === "standard-user",
189−);
190−
191−// ---------------------------------------------------------------------------
192−// Representative consent scopes per preset.
193−//
194−// The picker shows the OAuth consent a user is about to grant BEFORE connecting
195−// (the "View scopes" panel), but the authoritative scope list only exists in
196−// each API's live Discovery document — which the add flow fetches lazily at
197−// `addSpec` time. To preview consent without N network round-trips, each preset
198−// declares the broad top-level scope(s) a full integration grants. These flow
199−// through `googleOAuthConsentBatches` (which compacts sub-scopes under their
200−// broad parent), so the previewed grant matches the unioned scopes the bundle
201−// converter ultimately stores. Grounded against each API's published Discovery
202−// `auth.oauth2.scopes`.
203−// ---------------------------------------------------------------------------
204−
205−export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
206− "google-calendar": ["https://www.googleapis.com/auth/calendar"],
207− "google-gmail": ["https://mail.google.com/"],
208− "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],
209− "google-drive": ["https://www.googleapis.com/auth/drive"],
210− "google-docs": ["https://www.googleapis.com/auth/documents"],
211− "google-slides": ["https://www.googleapis.com/auth/presentations"],
212− "google-forms": ["https://www.googleapis.com/auth/forms.body"],
213− "google-tasks": ["https://www.googleapis.com/auth/tasks"],
214− "google-people": ["https://www.googleapis.com/auth/contacts"],
215− "google-chat": ["https://www.googleapis.com/auth/chat.spaces"],
216− "google-keep": ["https://www.googleapis.com/auth/keep"],
217− "google-youtube-data": ["https://www.googleapis.com/auth/youtube"],
218− "google-search-console": ["https://www.googleapis.com/auth/webmasters"],
219− "google-classroom": ["https://www.googleapis.com/auth/classroom.courses"],
220− "google-admin-directory": ["https://www.googleapis.com/auth/admin.directory.user"],
221− "google-admin-reports": ["https://www.googleapis.com/auth/admin.reports.audit.readonly"],
222− "google-apps-script": ["https://www.googleapis.com/auth/script.projects"],
223− "google-bigquery": ["https://www.googleapis.com/auth/bigquery"],
224− "google-cloud-resource-manager": ["https://www.googleapis.com/auth/cloud-platform"],
225−};
226−
227−export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
228− googleOAuthConsentScopes[presetId] ?? [];
229−
230−// ---------------------------------------------------------------------------
231−// Resolve a stored/normalized Discovery URL back to its preset, so a bundled
232−// `google` integration can surface each selected API's `oauthAudience` (e.g. a
233−// caution on a connection's auth method when admin-only or unsupported-consent
234−// APIs are part of the bundle).
235−// ---------------------------------------------------------------------------
236−
237−const normalizeGooglePresetUrl = (url: string): string => {
238− const trimmed = url.trim();
239− if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
240− const parsed = new URL(trimmed);
241− parsed.hash = "";
242− parsed.searchParams.sort();
243− return parsed.toString().replace(/\/$/, "");
244−};
245−
246−const googlePresetsByNormalizedUrl: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(
247− googleOpenApiPresets.flatMap((preset) =>
248− preset.url ? [[normalizeGooglePresetUrl(preset.url), preset] as const] : [],
249− ),
250−);
251−
252−export const googlePresetForDiscoveryUrl = (url: string): GoogleOpenApiPreset | undefined =>
253− googlePresetsByNormalizedUrl.get(normalizeGooglePresetUrl(url));
254−
255−/** The distinct caution-tier audiences (`workspace-admin`, `unsupported-user`)
256− * among the supplied Discovery URLs — the ones whose consent the user should be
257− * warned about. Returns `[]` when every URL is a standard/advanced API. */
258−export const googleAudienceWarningsForUrls = (
259− urls: readonly string[],
260−): readonly GoogleOpenApiOAuthAudience[] => {
261− const seen = new Set<GoogleOpenApiOAuthAudience>();
262− for (const url of urls) {
263− const audience = googlePresetForDiscoveryUrl(url)?.oauthAudience;
264− if (audience === "workspace-admin" || audience === "unsupported-user") seen.add(audience);
265− }
266− return [...seen];
267−};
packages/plugins/openapi/src/sdk/google-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 "./google-presets";
21−import { googleOAuthConsentBatches } from "./google-oauth-batches";
22−import { compactGoogleOAuthScopes } from "./google-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/openapi/src/sdk/index.ts+0−7
@@ -1,11 +1,4 @@
11 export { parse, resolveSpecText, fetchSpecText } from "./parse";
2−export {
3− convertGoogleDiscoveryBundleToOpenApi,
4− convertGoogleDiscoveryToOpenApi,
5− fetchGoogleDiscoveryDocument,
6− isGoogleDiscoveryUrl,
7− type GoogleDiscoveryOpenApiConversion,
8−} from "./google-discovery";
92 export { extract } from "./extract";
103 export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";
114 export {
packages/plugins/openapi/src/sdk/plugin.test.ts+0−1
@@ -105,7 +105,6 @@ const TestApi = HttpApi.make("testApi").add(ItemsGroup); · 104 unmodified lines
105105 const testApiSpecText = () => {
106106 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
107107 if (spec.kind === "blob") return spec.value;
108− if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
109108 return spec.url;
110109 };
111110
packages/plugins/openapi/src/sdk/plugin.ts+14−133
@@ -23,12 +23,6 @@ import { · 22 unmodified lines
2323 import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
2424 import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";
2525 import { parse, resolveSpecText } from "./parse";
26−import {
27− convertGoogleDiscoveryBundleToOpenApi,
28− convertGoogleDiscoveryToOpenApi,
29− fetchGoogleDiscoveryDocument,
30− isGoogleDiscoveryUrl,
31−} from "./google-discovery";
3226 import { extract } from "./extract";
3327 import { previewSpecText, type SpecPreview } from "./preview";
3428 import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
@@ -103,7 +97,7 @@ export interface UpdateSpecResult { · 68 unmodified lines
10397
10498 export interface OpenApiUpdateSpecInput {
10599 /** New spec source. Omit to re-fetch from the integration's stored
106− * `sourceUrl` / Google Discovery bundle URLs. */
100+ * `sourceUrl`. */
107101 readonly spec?: OpenApiSpecInput;
108102 }
109103
@@ -233,14 +227,6 @@ type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; · 123 unmodified lines
233227 const OpenApiSpecInputSchema = Schema.Union([
234228 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
235229 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
236− Schema.Struct({
237− kind: Schema.Literal("googleDiscovery"),
238− url: Schema.String,
239− }),
240− Schema.Struct({
241− kind: Schema.Literal("googleDiscoveryBundle"),
242− urls: Schema.Array(Schema.String),
243− }),
244230 ]);
245231
246232 const AuthenticationSchema = Schema.Union([
@@ -357,10 +343,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 110 unmodified lines
357343 });
358344
359345 const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>
360− spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined;
361−
362−const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined =>
363− spec.kind === "googleDiscoveryBundle" ? spec.urls : undefined;
346+ spec.kind === "url" ? spec.url : undefined;
364347
365348 // ---------------------------------------------------------------------------
366349 // Declared auth methods - project the stored `authenticationTemplate` into the
@@ -410,20 +393,6 @@ export interface OpenApiPluginOptions { · 43 unmodified lines
410393 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
411394 }
412395
413−const fetchGoogleDiscoveryBundleConversion = (
414− urls: readonly string[],
415− httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
416−) =>
417− Effect.forEach(
418− urls,
419− (url) =>
420− fetchGoogleDiscoveryDocument(url).pipe(
421− Effect.provide(httpClientLayer),
422− Effect.map((documentText) => ({ discoveryUrl: url, documentText })),
423− ),
424− { concurrency: 4 },
425− ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents })));
426−
427396 export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
428397 const resolveSpecForInput = (
429398 spec: OpenApiSpecInput,
@@ -431,44 +400,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
431400 ): Effect.Effect<
432401 {
433402 readonly specText: string;
434− readonly baseUrl?: string;
435− // The Google Discovery converters derive the `googleOAuth2` oauth template
436− // straight from the spec's declared scopes. `addSpec` adopts it when the
437− // caller didn't pass an explicit `authenticationTemplate` (the bundle add
438− // path has no preview to detect auth from).
439− readonly authenticationTemplate?: readonly Authentication[];
440403 },
441404 OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError
442405 > =>
443406 Effect.gen(function* () {
444− if (spec.kind === "googleDiscovery") {
445− const conversion = yield* fetchGoogleDiscoveryDocument(spec.url).pipe(
446− Effect.provide(httpClientLayer),
447− Effect.flatMap((documentText) =>
448− convertGoogleDiscoveryToOpenApi({
449− discoveryUrl: spec.url,
450− documentText,
451− }),
452− ),
453− );
454− return {
455− specText: conversion.specText,
456− baseUrl: conversion.baseUrl,
457− ...(conversion.authenticationTemplate
458− ? { authenticationTemplate: conversion.authenticationTemplate }
459− : {}),
460− };
461− }
462− if (spec.kind === "googleDiscoveryBundle") {
463− const conversion = yield* fetchGoogleDiscoveryBundleConversion(spec.urls, httpClientLayer);
464− return {
465− specText: conversion.specText,
466− baseUrl: conversion.baseUrl,
467− ...(conversion.authenticationTemplate
468− ? { authenticationTemplate: conversion.authenticationTemplate }
469− : {}),
470− };
471− }
472407 if (spec.kind === "url") {
473408 const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));
474409 return { specText };
@@ -511,10 +446,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 36 unmodified lines
511446 // end with nowhere to paste a credential)
512447 // An explicit input always wins; for auth, an explicit EMPTY array
513448 // means "no auth methods" and suppresses the derivation.
514− const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;
449+ const explicitBaseUrl = config.baseUrl;
515450 const needsDerivedBaseUrl = explicitBaseUrl == null;
516− const needsDerivedAuth =
517− config.authenticationTemplate == null && resolved.authenticationTemplate == null;
451+ const needsDerivedAuth = config.authenticationTemplate == null;
518452 const preview =
519453 needsDerivedBaseUrl || needsDerivedAuth
520454 ? yield* previewSpecText(resolved.specText)
@@ -546,28 +480,21 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 25 unmodified lines
546480 ...(specInputToSourceUrl(config.spec) !== undefined
547481 ? { sourceUrl: specInputToSourceUrl(config.spec) }
548482 : {}),
549− ...(specInputToGoogleBundle(config.spec) !== undefined
550− ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
551− : {}),
552483 // baseUrl is an optional override only. The host is otherwise
553484 // resolved per call from the operation's `servers` (extracted from
554485 // the spec), so we never bake a derived base URL into the config.
555486 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
556487 ...(config.headers ? { headers: config.headers } : {}),
557488 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
558− // Prefer the caller's explicit template; otherwise adopt the one the
559− // Google Discovery converter derived from the spec (the bundle add
560− // path relies on this - it has no preview to detect auth from);
561− // otherwise derive from the spec's declared security schemes.
489+ // Prefer the caller's explicit template; otherwise derive from the
490+ // spec's declared security schemes.
562491 ...(config.authenticationTemplate
563492 ? {
564493 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
565494 }
566− : resolved.authenticationTemplate
567− ? { authenticationTemplate: resolved.authenticationTemplate }
568− : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
569− ? { authenticationTemplate: derivedAuthenticationTemplate }
570− : {}),
495+ : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
496+ ? { authenticationTemplate: derivedAuthenticationTemplate }
497+ : {}),
571498 };
572499
573500 // The spec blob is written OUTSIDE the transaction: it's
@@ -585,9 +512,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 11 unmodified lines
585512 config.description ?? compiled.description ?? compiled.title ?? config.slug,
586513 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
587514 canRemove: true,
588− canRefresh:
589− specInputToSourceUrl(config.spec) != null ||
590− specInputToGoogleBundle(config.spec) != null,
515+ canRefresh: specInputToSourceUrl(config.spec) != null,
591516 });
592517 yield* ctx.storage.putOperations(
593518 config.slug,
@@ -617,14 +542,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
617542 // where the spec originally came from. A pasted-blob integration has
618543 // no origin, so updating it requires a new input.
619544 const specInput: OpenApiSpecInput | null =
620− input?.spec ??
621− (current.googleDiscoveryUrls
622− ? { kind: "googleDiscoveryBundle", urls: current.googleDiscoveryUrls }
623− : current.sourceUrl
624− ? isGoogleDiscoveryUrl(current.sourceUrl)
625− ? { kind: "googleDiscovery", url: current.sourceUrl }
626− : { kind: "url", url: current.sourceUrl }
627− : null);
545+ input?.spec ?? (current.sourceUrl ? { kind: "url", url: current.sourceUrl } : null);
628546 if (specInput === null) {
629547 return yield* new OpenApiParseError({
630548 message:
@@ -654,9 +572,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
654572 ...(specInputToSourceUrl(specInput) !== undefined
655573 ? { sourceUrl: specInputToSourceUrl(specInput) }
656574 : {}),
657− ...(specInputToGoogleBundle(specInput) !== undefined
658− ? { googleDiscoveryUrls: specInputToGoogleBundle(specInput) }
659− : {}),
660575 };
661576
662577 yield* ctx.transaction(
@@ -709,18 +624,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 46 unmodified lines
709624 previewSpec: (input: string | OpenApiPreviewInput) =>
710625 Effect.gen(function* () {
711626 const previewInput = typeof input === "string" ? { spec: input } : input;
712− const specText = isGoogleDiscoveryUrl(previewInput.spec)
713− ? yield* fetchGoogleDiscoveryDocument(previewInput.spec).pipe(
714− Effect.provide(httpClientLayer),
715− Effect.flatMap((documentText) =>
716− convertGoogleDiscoveryToOpenApi({
717− discoveryUrl: previewInput.spec,
718− documentText,
719− }),
720− ),
721− Effect.map((conversion) => conversion.specText),
722− )
723− : yield* resolveSpecText(previewInput.spec).pipe(Effect.provide(httpClientLayer));
627+ const specText = yield* resolveSpecText(previewInput.spec).pipe(
628+ Effect.provide(httpClientLayer),
629+ );
724630 return yield* previewSpecText(specText);
725631 }),
726632
@@ -916,31 +822,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 189 unmodified lines
916822 catch: (error) => error,
917823 }).pipe(Effect.option);
918824 if (Option.isNone(parsed)) return null;
919− if (isGoogleDiscoveryUrl(trimmed)) {
920− const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe(
921− Effect.provide(httpClientLayer),
922− Effect.flatMap((documentText) =>
923− convertGoogleDiscoveryToOpenApi({
924− discoveryUrl: trimmed,
925− documentText,
926− }),
927− ),
928− Effect.catch(() => Effect.succeed(null)),
929− );
930− if (conversion) {
931− return IntegrationDetectionResult.make({
932− kind: "openapi",
933− confidence: "high",
934− endpoint: trimmed,
935− name: conversion.title,
936− slug:
937− conversion.title
938− .toLowerCase()
939− .replace(/[^a-z0-9]+/g, "_")
940− .replace(/^_+|_+$/g, "") || `google_${conversion.service}`,
941− });
942− }
943− }
944825 const specText = yield* resolveSpecText(trimmed).pipe(
945826 Effect.provide(httpClientLayer),
946827 Effect.catch(() => Effect.succeed(null)),
packages/plugins/openapi/src/sdk/spec-blob.test.ts+0−1
@@ -62,7 +62,6 @@ const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 61 unmodified lines
6262 const specText = () => {
6363 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
6464 if (spec.kind === "blob") return spec.value;
65− if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
6665 return spec.url;
6766 };
6867
packages/react/src/components/integration-favicon.test.tsx+4−4
@@ -41,8 +41,8 @@ describe("IntegrationFavicon", () => { · 40 unmodified lines
4141 },
4242 [
4343 {
44− key: "openapi",
45− label: "OpenAPI",
44+ key: "google",
45+ label: "Google",
4646 add: () => null,
4747 edit: () => null,
4848 presets: [
@@ -70,8 +70,8 @@ describe("IntegrationFavicon", () => { · 21 unmodified lines
7070 },
7171 [
7272 {
73− key: "openapi",
74− label: "OpenAPI",
73+ key: "google",
74+ label: "Google",
7575 add: () => null,
7676 edit: () => null,
7777 presets: [
packages/react/src/components/integration-favicon.tsx+1−1
@@ -24,7 +24,7 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 23 unmodified lines
2424 openapi: "openapi",
2525 mcp: "mcp",
2626 graphql: "graphql",
27− googleDiscovery: "openapi",
27+ googleDiscovery: "google",
2828 };
2929
3030 const normalizeUrl = (url: string | undefined): string | null => {
packages/react/src/pages/integrations.tsx+1−1
@@ -44,7 +44,7 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 43 unmodified lines
4444 openapi: "openapi",
4545 mcp: "mcp",
4646 graphql: "graphql",
47− googleDiscovery: "openapi",
47+ googleDiscovery: "google",
4848 };
4949
5050 const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
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/openapi/src/sdk/google-discovery";
13+} from "../packages/plugins/google/src/sdk/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";