History

connections: extract sign-in state into a first-class SDK primitive

Introduces a `Connection` resource on the core SDK (`ctx.connections`) that owns live OAuth access/refresh tokens and the plugin-specific providerState needed to refresh them. Plugins register a `ConnectionProvider` alongside their secret providers; source auth configs become thin pointers (`{kind: "oauth2", connectionId}`) instead of inlining secret/token state. - core/sdk: ConnectionId brand, ConnectionRef, CreateConnectionInput, ConnectionRefreshError, ConnectionRefreshInput, executor.connections (get/list/remove/accessToken), plugin.connectionProviders hook. - core/api: /scopes/:id/connections endpoints + handler group, secrets list filter that hides connection-owned rows. - storage: new `connection` table (drizzle migrations for cloud + local). - plugins (openapi, google-discovery, mcp): migrated onto ctx.connections, widened stored-auth schemas to carry the full OAuth config so re-sign-in is reproducible without the prior Connection row existing. - react: `/connections` page with removal; signIn slot on SourcePlugin; per-plugin SignInButton mounted on the source detail top bar with dynamic "Sign in" / "Reconnect" copy driven by `connectionsAtom`. - openapi store: tolerant oauth2 decode at the row boundary so legacy rows written before the schema widening surface without OAuth attached instead of crashing the source list. - oauth2 plugin: removes the dead standalone refresh module now that refresh lives behind ctx.connections.

Rhys Sullivan committed 6 months ago

Commit bd7faba1 parent 7909ef473 files · +6087 −2239Browse files

Unified · Split

73 files
  1. apps/cloud/drizzle/0003_add_connections.sql +20 −0
  2. apps/cloud/drizzle/meta/0003_snapshot.json +1421 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/src/api/layers.ts +7 −1
  5. apps/cloud/src/observability.ts +15 −4
  6. apps/cloud/src/routeTree.gen.ts +21 −0
  7. apps/cloud/src/routes/connections.tsx +6 −0
  8. apps/cloud/src/services/executor-schema.ts +21 −0
  9. apps/cloud/src/services/mcp-oauth.node.test.ts +16 −56
  10. apps/cloud/src/web/shell.tsx +2 −0
  11. apps/local/drizzle/0002_lively_sue_storm.sql +20 −0
  12. apps/local/drizzle/meta/0002_snapshot.json +1274 −0
  13. apps/local/drizzle/meta/_journal.json +7 −0
  14. apps/local/src/server/executor-schema.ts +21 −0
  15. apps/local/src/server/mcp-oauth.test.ts +4 −28
  16. bun.lock +1 −0
  17. packages/core/api/src/api.ts +2 −0
  18. packages/core/api/src/connections/api.ts +57 −0
  19. packages/core/api/src/handlers/connections.ts +47 −0
  20. packages/core/api/src/handlers/index.ts +3 −0
  21. packages/core/api/src/index.ts +1 −0
  22. packages/core/api/src/secrets/api.ts +12 −2
  23. packages/core/config/src/schema.ts +4 −3
  24. packages/core/sdk/src/connections.test.ts +0 −0
  25. packages/core/sdk/src/connections.ts +181 −0
  26. packages/core/sdk/src/core-schema.ts +64 −0
  27. packages/core/sdk/src/errors.ts +43 −2
  28. packages/core/sdk/src/executor.ts +647 −5
  29. packages/core/sdk/src/ids.ts +3 −0
  30. packages/core/sdk/src/index.ts +20 −1
  31. packages/core/sdk/src/plugin.ts +76 −1
  32. packages/core/storage-drizzle/src/adapter.ts +24 −3
  33. packages/plugins/google-discovery/src/api/group.ts +22 −10
  34. packages/plugins/google-discovery/src/api/handlers.test.ts +1 −0
  35. packages/plugins/google-discovery/src/api/handlers.ts +11 −0
  36. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +11 −13
  37. packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx +159 −0
  38. packages/plugins/google-discovery/src/react/atoms.ts +4 −0
  39. packages/plugins/google-discovery/src/react/source-plugin.ts +1 −0
  40. packages/plugins/google-discovery/src/sdk/binding-store.ts +38 −0
  41. packages/plugins/google-discovery/src/sdk/invoke.ts +17 −113
  42. packages/plugins/google-discovery/src/sdk/plugin.test.ts +42 −23
  43. packages/plugins/google-discovery/src/sdk/plugin.ts +281 −134
  44. packages/plugins/google-discovery/src/sdk/types.ts +23 −5
  45. packages/plugins/mcp/package.json +1 −0
  46. packages/plugins/mcp/src/api/group.ts +16 −12
  47. packages/plugins/mcp/src/api/handlers.ts +10 −16
  48. packages/plugins/mcp/src/react/AddMcpSource.tsx +15 −30
  49. packages/plugins/mcp/src/react/McpSignInButton.tsx +153 −0
  50. packages/plugins/mcp/src/react/source-plugin.tsx +2 −0
  51. packages/plugins/mcp/src/sdk/oauth.ts +10 −10
  52. packages/plugins/mcp/src/sdk/plugin.ts +326 −195
  53. packages/plugins/mcp/src/sdk/types.ts +8 −26
  54. packages/plugins/oauth2/src/index.ts +0 −14
  55. packages/plugins/oauth2/src/refresh.test.ts +0 −396
  56. packages/plugins/oauth2/src/refresh.ts +0 −238
  57. packages/plugins/openapi/src/api/group.ts +6 −6
  58. packages/plugins/openapi/src/api/handlers.ts +1 −3
  59. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +5 −44
  60. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +5 −380
  61. packages/plugins/openapi/src/react/OpenApiSignInButton.tsx +213 −0
  62. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +10 −31
  63. packages/plugins/openapi/src/react/source-plugin.ts +1 −0
  64. packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +0 −0
  65. packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +66 −122
  66. packages/plugins/openapi/src/sdk/plugin.ts +270 −271
  67. packages/plugins/openapi/src/sdk/store.ts +24 −5
  68. packages/plugins/openapi/src/sdk/types.ts +64 −38
  69. packages/react/src/api/atoms.tsx +12 −0
  70. packages/react/src/api/reactivity-keys.tsx +10 −0
  71. packages/react/src/pages/connections.tsx +185 −0
  72. packages/react/src/pages/source-detail.tsx +6 −0
  73. packages/react/src/plugins/source-plugin.tsx +13 −0
apps/cloud/drizzle/0003_add_connections.sql+20−0
@@ -0,0 +1,20 @@
1+CREATE TABLE "connection" (
2+ "id" text NOT NULL,
3+ "scope_id" text NOT NULL,
4+ "provider" text NOT NULL,
5+ "kind" text NOT NULL,
6+ "identity_label" text,
7+ "access_token_secret_id" text NOT NULL,
8+ "refresh_token_secret_id" text,
9+ "expires_at" bigint,
10+ "scope" text,
11+ "provider_state" jsonb,
12+ "created_at" timestamp NOT NULL,
13+ "updated_at" timestamp NOT NULL,
14+ CONSTRAINT "connection_scope_id_id_pk" PRIMARY KEY("scope_id","id")
15+);
16+--> statement-breakpoint
17+ALTER TABLE "secret" ADD COLUMN "owned_by_connection_id" text;--> statement-breakpoint
18+CREATE INDEX "connection_scope_id_idx" ON "connection" USING btree ("scope_id");--> statement-breakpoint
19+CREATE INDEX "connection_provider_idx" ON "connection" USING btree ("provider");--> statement-breakpoint
20+CREATE INDEX "secret_owned_by_connection_id_idx" ON "secret" USING btree ("owned_by_connection_id");
apps/cloud/drizzle/meta/0003_snapshot.json+1421−0
@@ -0,0 +1,1421 @@
1+{
2+ "id": "09d08343-8162-4e6b-91ab-ce0a9d6bad10",
3+ "prevId": "f029f809-1bf3-46e0-a31b-82df7c9c7171",
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": [
64+ "account_id"
65+ ],
66+ "columnsTo": [
67+ "id"
68+ ],
69+ "onDelete": "cascade",
70+ "onUpdate": "no action"
71+ },
72+ "memberships_organization_id_organizations_id_fk": {
73+ "name": "memberships_organization_id_organizations_id_fk",
74+ "tableFrom": "memberships",
75+ "tableTo": "organizations",
76+ "columnsFrom": [
77+ "organization_id"
78+ ],
79+ "columnsTo": [
80+ "id"
81+ ],
82+ "onDelete": "cascade",
83+ "onUpdate": "no action"
84+ }
85+ },
86+ "compositePrimaryKeys": {
87+ "memberships_account_id_organization_id_pk": {
88+ "name": "memberships_account_id_organization_id_pk",
89+ "columns": [
90+ "account_id",
91+ "organization_id"
92+ ]
93+ }
94+ },
95+ "uniqueConstraints": {},
96+ "policies": {},
97+ "checkConstraints": {},
98+ "isRLSEnabled": false
99+ },
100+ "public.organizations": {
101+ "name": "organizations",
102+ "schema": "",
103+ "columns": {
104+ "id": {
105+ "name": "id",
106+ "type": "text",
107+ "primaryKey": true,
108+ "notNull": true
109+ },
110+ "name": {
111+ "name": "name",
112+ "type": "text",
113+ "primaryKey": false,
114+ "notNull": true
115+ },
116+ "created_at": {
117+ "name": "created_at",
118+ "type": "timestamp with time zone",
119+ "primaryKey": false,
120+ "notNull": true,
121+ "default": "now()"
122+ }
123+ },
124+ "indexes": {},
125+ "foreignKeys": {},
126+ "compositePrimaryKeys": {},
127+ "uniqueConstraints": {},
128+ "policies": {},
129+ "checkConstraints": {},
130+ "isRLSEnabled": false
131+ },
132+ "public.blob": {
133+ "name": "blob",
134+ "schema": "",
135+ "columns": {
136+ "namespace": {
137+ "name": "namespace",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true
141+ },
142+ "key": {
143+ "name": "key",
144+ "type": "text",
145+ "primaryKey": false,
146+ "notNull": true
147+ },
148+ "value": {
149+ "name": "value",
150+ "type": "text",
151+ "primaryKey": false,
152+ "notNull": true
153+ }
154+ },
155+ "indexes": {},
156+ "foreignKeys": {},
157+ "compositePrimaryKeys": {
158+ "blob_namespace_key_pk": {
159+ "name": "blob_namespace_key_pk",
160+ "columns": [
161+ "namespace",
162+ "key"
163+ ]
164+ }
165+ },
166+ "uniqueConstraints": {},
167+ "policies": {},
168+ "checkConstraints": {},
169+ "isRLSEnabled": false
170+ },
171+ "public.connection": {
172+ "name": "connection",
173+ "schema": "",
174+ "columns": {
175+ "id": {
176+ "name": "id",
177+ "type": "text",
178+ "primaryKey": false,
179+ "notNull": true
180+ },
181+ "scope_id": {
182+ "name": "scope_id",
183+ "type": "text",
184+ "primaryKey": false,
185+ "notNull": true
186+ },
187+ "provider": {
188+ "name": "provider",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true
192+ },
193+ "kind": {
194+ "name": "kind",
195+ "type": "text",
196+ "primaryKey": false,
197+ "notNull": true
198+ },
199+ "identity_label": {
200+ "name": "identity_label",
201+ "type": "text",
202+ "primaryKey": false,
203+ "notNull": false
204+ },
205+ "access_token_secret_id": {
206+ "name": "access_token_secret_id",
207+ "type": "text",
208+ "primaryKey": false,
209+ "notNull": true
210+ },
211+ "refresh_token_secret_id": {
212+ "name": "refresh_token_secret_id",
213+ "type": "text",
214+ "primaryKey": false,
215+ "notNull": false
216+ },
217+ "expires_at": {
218+ "name": "expires_at",
219+ "type": "bigint",
220+ "primaryKey": false,
221+ "notNull": false
222+ },
223+ "scope": {
224+ "name": "scope",
225+ "type": "text",
226+ "primaryKey": false,
227+ "notNull": false
228+ },
229+ "provider_state": {
230+ "name": "provider_state",
231+ "type": "jsonb",
232+ "primaryKey": false,
233+ "notNull": false
234+ },
235+ "created_at": {
236+ "name": "created_at",
237+ "type": "timestamp",
238+ "primaryKey": false,
239+ "notNull": true
240+ },
241+ "updated_at": {
242+ "name": "updated_at",
243+ "type": "timestamp",
244+ "primaryKey": false,
245+ "notNull": true
246+ }
247+ },
248+ "indexes": {
249+ "connection_scope_id_idx": {
250+ "name": "connection_scope_id_idx",
251+ "columns": [
252+ {
253+ "expression": "scope_id",
254+ "isExpression": false,
255+ "asc": true,
256+ "nulls": "last"
257+ }
258+ ],
259+ "isUnique": false,
260+ "concurrently": false,
261+ "method": "btree",
262+ "with": {}
263+ },
264+ "connection_provider_idx": {
265+ "name": "connection_provider_idx",
266+ "columns": [
267+ {
268+ "expression": "provider",
269+ "isExpression": false,
270+ "asc": true,
271+ "nulls": "last"
272+ }
273+ ],
274+ "isUnique": false,
275+ "concurrently": false,
276+ "method": "btree",
277+ "with": {}
278+ }
279+ },
280+ "foreignKeys": {},
281+ "compositePrimaryKeys": {
282+ "connection_scope_id_id_pk": {
283+ "name": "connection_scope_id_id_pk",
284+ "columns": [
285+ "scope_id",
286+ "id"
287+ ]
288+ }
289+ },
290+ "uniqueConstraints": {},
291+ "policies": {},
292+ "checkConstraints": {},
293+ "isRLSEnabled": false
294+ },
295+ "public.definition": {
296+ "name": "definition",
297+ "schema": "",
298+ "columns": {
299+ "id": {
300+ "name": "id",
301+ "type": "text",
302+ "primaryKey": false,
303+ "notNull": true
304+ },
305+ "scope_id": {
306+ "name": "scope_id",
307+ "type": "text",
308+ "primaryKey": false,
309+ "notNull": true
310+ },
311+ "source_id": {
312+ "name": "source_id",
313+ "type": "text",
314+ "primaryKey": false,
315+ "notNull": true
316+ },
317+ "plugin_id": {
318+ "name": "plugin_id",
319+ "type": "text",
320+ "primaryKey": false,
321+ "notNull": true
322+ },
323+ "name": {
324+ "name": "name",
325+ "type": "text",
326+ "primaryKey": false,
327+ "notNull": true
328+ },
329+ "schema": {
330+ "name": "schema",
331+ "type": "jsonb",
332+ "primaryKey": false,
333+ "notNull": true
334+ },
335+ "created_at": {
336+ "name": "created_at",
337+ "type": "timestamp",
338+ "primaryKey": false,
339+ "notNull": true
340+ }
341+ },
342+ "indexes": {
343+ "definition_scope_id_idx": {
344+ "name": "definition_scope_id_idx",
345+ "columns": [
346+ {
347+ "expression": "scope_id",
348+ "isExpression": false,
349+ "asc": true,
350+ "nulls": "last"
351+ }
352+ ],
353+ "isUnique": false,
354+ "concurrently": false,
355+ "method": "btree",
356+ "with": {}
357+ },
358+ "definition_source_id_idx": {
359+ "name": "definition_source_id_idx",
360+ "columns": [
361+ {
362+ "expression": "source_id",
363+ "isExpression": false,
364+ "asc": true,
365+ "nulls": "last"
366+ }
367+ ],
368+ "isUnique": false,
369+ "concurrently": false,
370+ "method": "btree",
371+ "with": {}
372+ },
373+ "definition_plugin_id_idx": {
374+ "name": "definition_plugin_id_idx",
375+ "columns": [
376+ {
377+ "expression": "plugin_id",
378+ "isExpression": false,
379+ "asc": true,
380+ "nulls": "last"
381+ }
382+ ],
383+ "isUnique": false,
384+ "concurrently": false,
385+ "method": "btree",
386+ "with": {}
387+ }
388+ },
389+ "foreignKeys": {},
390+ "compositePrimaryKeys": {
391+ "definition_scope_id_id_pk": {
392+ "name": "definition_scope_id_id_pk",
393+ "columns": [
394+ "scope_id",
395+ "id"
396+ ]
397+ }
398+ },
399+ "uniqueConstraints": {},
400+ "policies": {},
401+ "checkConstraints": {},
402+ "isRLSEnabled": false
403+ },
404+ "public.graphql_operation": {
405+ "name": "graphql_operation",
406+ "schema": "",
407+ "columns": {
408+ "id": {
409+ "name": "id",
410+ "type": "text",
411+ "primaryKey": false,
412+ "notNull": true
413+ },
414+ "scope_id": {
415+ "name": "scope_id",
416+ "type": "text",
417+ "primaryKey": false,
418+ "notNull": true
419+ },
420+ "source_id": {
421+ "name": "source_id",
422+ "type": "text",
423+ "primaryKey": false,
424+ "notNull": true
425+ },
426+ "binding": {
427+ "name": "binding",
428+ "type": "jsonb",
429+ "primaryKey": false,
430+ "notNull": true
431+ }
432+ },
433+ "indexes": {
434+ "graphql_operation_scope_id_idx": {
435+ "name": "graphql_operation_scope_id_idx",
436+ "columns": [
437+ {
438+ "expression": "scope_id",
439+ "isExpression": false,
440+ "asc": true,
441+ "nulls": "last"
442+ }
443+ ],
444+ "isUnique": false,
445+ "concurrently": false,
446+ "method": "btree",
447+ "with": {}
448+ },
449+ "graphql_operation_source_id_idx": {
450+ "name": "graphql_operation_source_id_idx",
451+ "columns": [
452+ {
453+ "expression": "source_id",
454+ "isExpression": false,
455+ "asc": true,
456+ "nulls": "last"
457+ }
458+ ],
459+ "isUnique": false,
460+ "concurrently": false,
461+ "method": "btree",
462+ "with": {}
463+ }
464+ },
465+ "foreignKeys": {},
466+ "compositePrimaryKeys": {
467+ "graphql_operation_scope_id_id_pk": {
468+ "name": "graphql_operation_scope_id_id_pk",
469+ "columns": [
470+ "scope_id",
471+ "id"
472+ ]
473+ }
474+ },
475+ "uniqueConstraints": {},
476+ "policies": {},
477+ "checkConstraints": {},
478+ "isRLSEnabled": false
479+ },
480+ "public.graphql_source": {
481+ "name": "graphql_source",
482+ "schema": "",
483+ "columns": {
484+ "id": {
485+ "name": "id",
486+ "type": "text",
487+ "primaryKey": false,
488+ "notNull": true
489+ },
490+ "scope_id": {
491+ "name": "scope_id",
492+ "type": "text",
493+ "primaryKey": false,
494+ "notNull": true
495+ },
496+ "name": {
497+ "name": "name",
498+ "type": "text",
499+ "primaryKey": false,
500+ "notNull": true
501+ },
502+ "endpoint": {
503+ "name": "endpoint",
504+ "type": "text",
505+ "primaryKey": false,
506+ "notNull": true
507+ },
508+ "headers": {
509+ "name": "headers",
510+ "type": "jsonb",
511+ "primaryKey": false,
512+ "notNull": false
513+ }
514+ },
515+ "indexes": {
516+ "graphql_source_scope_id_idx": {
517+ "name": "graphql_source_scope_id_idx",
518+ "columns": [
519+ {
520+ "expression": "scope_id",
521+ "isExpression": false,
522+ "asc": true,
523+ "nulls": "last"
524+ }
525+ ],
526+ "isUnique": false,
527+ "concurrently": false,
528+ "method": "btree",
529+ "with": {}
530+ }
531+ },
532+ "foreignKeys": {},
533+ "compositePrimaryKeys": {
534+ "graphql_source_scope_id_id_pk": {
535+ "name": "graphql_source_scope_id_id_pk",
536+ "columns": [
537+ "scope_id",
538+ "id"
539+ ]
540+ }
541+ },
542+ "uniqueConstraints": {},
543+ "policies": {},
544+ "checkConstraints": {},
545+ "isRLSEnabled": false
546+ },
547+ "public.mcp_binding": {
548+ "name": "mcp_binding",
549+ "schema": "",
550+ "columns": {
551+ "id": {
552+ "name": "id",
553+ "type": "text",
554+ "primaryKey": false,
555+ "notNull": true
556+ },
557+ "scope_id": {
558+ "name": "scope_id",
559+ "type": "text",
560+ "primaryKey": false,
561+ "notNull": true
562+ },
563+ "source_id": {
564+ "name": "source_id",
565+ "type": "text",
566+ "primaryKey": false,
567+ "notNull": true
568+ },
569+ "binding": {
570+ "name": "binding",
571+ "type": "jsonb",
572+ "primaryKey": false,
573+ "notNull": true
574+ },
575+ "created_at": {
576+ "name": "created_at",
577+ "type": "timestamp",
578+ "primaryKey": false,
579+ "notNull": true
580+ }
581+ },
582+ "indexes": {
583+ "mcp_binding_scope_id_idx": {
584+ "name": "mcp_binding_scope_id_idx",
585+ "columns": [
586+ {
587+ "expression": "scope_id",
588+ "isExpression": false,
589+ "asc": true,
590+ "nulls": "last"
591+ }
592+ ],
593+ "isUnique": false,
594+ "concurrently": false,
595+ "method": "btree",
596+ "with": {}
597+ },
598+ "mcp_binding_source_id_idx": {
599+ "name": "mcp_binding_source_id_idx",
600+ "columns": [
601+ {
602+ "expression": "source_id",
603+ "isExpression": false,
604+ "asc": true,
605+ "nulls": "last"
606+ }
607+ ],
608+ "isUnique": false,
609+ "concurrently": false,
610+ "method": "btree",
611+ "with": {}
612+ }
613+ },
614+ "foreignKeys": {},
615+ "compositePrimaryKeys": {
616+ "mcp_binding_scope_id_id_pk": {
617+ "name": "mcp_binding_scope_id_id_pk",
618+ "columns": [
619+ "scope_id",
620+ "id"
621+ ]
622+ }
623+ },
624+ "uniqueConstraints": {},
625+ "policies": {},
626+ "checkConstraints": {},
627+ "isRLSEnabled": false
628+ },
629+ "public.mcp_oauth_session": {
630+ "name": "mcp_oauth_session",
631+ "schema": "",
632+ "columns": {
633+ "id": {
634+ "name": "id",
635+ "type": "text",
636+ "primaryKey": false,
637+ "notNull": true
638+ },
639+ "scope_id": {
640+ "name": "scope_id",
641+ "type": "text",
642+ "primaryKey": false,
643+ "notNull": true
644+ },
645+ "session": {
646+ "name": "session",
647+ "type": "jsonb",
648+ "primaryKey": false,
649+ "notNull": true
650+ },
651+ "expires_at": {
652+ "name": "expires_at",
653+ "type": "bigint",
654+ "primaryKey": false,
655+ "notNull": true
656+ },
657+ "created_at": {
658+ "name": "created_at",
659+ "type": "timestamp",
660+ "primaryKey": false,
661+ "notNull": true
662+ }
663+ },
664+ "indexes": {
665+ "mcp_oauth_session_scope_id_idx": {
666+ "name": "mcp_oauth_session_scope_id_idx",
667+ "columns": [
668+ {
669+ "expression": "scope_id",
670+ "isExpression": false,
671+ "asc": true,
672+ "nulls": "last"
673+ }
674+ ],
675+ "isUnique": false,
676+ "concurrently": false,
677+ "method": "btree",
678+ "with": {}
679+ }
680+ },
681+ "foreignKeys": {},
682+ "compositePrimaryKeys": {
683+ "mcp_oauth_session_scope_id_id_pk": {
684+ "name": "mcp_oauth_session_scope_id_id_pk",
685+ "columns": [
686+ "scope_id",
687+ "id"
688+ ]
689+ }
690+ },
691+ "uniqueConstraints": {},
692+ "policies": {},
693+ "checkConstraints": {},
694+ "isRLSEnabled": false
695+ },
696+ "public.mcp_source": {
697+ "name": "mcp_source",
698+ "schema": "",
699+ "columns": {
700+ "id": {
701+ "name": "id",
702+ "type": "text",
703+ "primaryKey": false,
704+ "notNull": true
705+ },
706+ "scope_id": {
707+ "name": "scope_id",
708+ "type": "text",
709+ "primaryKey": false,
710+ "notNull": true
711+ },
712+ "name": {
713+ "name": "name",
714+ "type": "text",
715+ "primaryKey": false,
716+ "notNull": true
717+ },
718+ "config": {
719+ "name": "config",
720+ "type": "jsonb",
721+ "primaryKey": false,
722+ "notNull": true
723+ },
724+ "created_at": {
725+ "name": "created_at",
726+ "type": "timestamp",
727+ "primaryKey": false,
728+ "notNull": true
729+ }
730+ },
731+ "indexes": {
732+ "mcp_source_scope_id_idx": {
733+ "name": "mcp_source_scope_id_idx",
734+ "columns": [
735+ {
736+ "expression": "scope_id",
737+ "isExpression": false,
738+ "asc": true,
739+ "nulls": "last"
740+ }
741+ ],
742+ "isUnique": false,
743+ "concurrently": false,
744+ "method": "btree",
745+ "with": {}
746+ }
747+ },
748+ "foreignKeys": {},
749+ "compositePrimaryKeys": {
750+ "mcp_source_scope_id_id_pk": {
751+ "name": "mcp_source_scope_id_id_pk",
752+ "columns": [
753+ "scope_id",
754+ "id"
755+ ]
756+ }
757+ },
758+ "uniqueConstraints": {},
759+ "policies": {},
760+ "checkConstraints": {},
761+ "isRLSEnabled": false
762+ },
763+ "public.openapi_oauth_session": {
764+ "name": "openapi_oauth_session",
765+ "schema": "",
766+ "columns": {
767+ "id": {
768+ "name": "id",
769+ "type": "text",
770+ "primaryKey": false,
771+ "notNull": true
772+ },
773+ "scope_id": {
774+ "name": "scope_id",
775+ "type": "text",
776+ "primaryKey": false,
777+ "notNull": true
778+ },
779+ "session": {
780+ "name": "session",
781+ "type": "jsonb",
782+ "primaryKey": false,
783+ "notNull": true
784+ },
785+ "created_at": {
786+ "name": "created_at",
787+ "type": "timestamp",
788+ "primaryKey": false,
789+ "notNull": true
790+ }
791+ },
792+ "indexes": {
793+ "openapi_oauth_session_scope_id_idx": {
794+ "name": "openapi_oauth_session_scope_id_idx",
795+ "columns": [
796+ {
797+ "expression": "scope_id",
798+ "isExpression": false,
799+ "asc": true,
800+ "nulls": "last"
801+ }
802+ ],
803+ "isUnique": false,
804+ "concurrently": false,
805+ "method": "btree",
806+ "with": {}
807+ }
808+ },
809+ "foreignKeys": {},
810+ "compositePrimaryKeys": {
811+ "openapi_oauth_session_scope_id_id_pk": {
812+ "name": "openapi_oauth_session_scope_id_id_pk",
813+ "columns": [
814+ "scope_id",
815+ "id"
816+ ]
817+ }
818+ },
819+ "uniqueConstraints": {},
820+ "policies": {},
821+ "checkConstraints": {},
822+ "isRLSEnabled": false
823+ },
824+ "public.openapi_operation": {
825+ "name": "openapi_operation",
826+ "schema": "",
827+ "columns": {
828+ "id": {
829+ "name": "id",
830+ "type": "text",
831+ "primaryKey": false,
832+ "notNull": true
833+ },
834+ "scope_id": {
835+ "name": "scope_id",
836+ "type": "text",
837+ "primaryKey": false,
838+ "notNull": true
839+ },
840+ "source_id": {
841+ "name": "source_id",
842+ "type": "text",
843+ "primaryKey": false,
844+ "notNull": true
845+ },
846+ "binding": {
847+ "name": "binding",
848+ "type": "jsonb",
849+ "primaryKey": false,
850+ "notNull": true
851+ }
852+ },
853+ "indexes": {
854+ "openapi_operation_scope_id_idx": {
855+ "name": "openapi_operation_scope_id_idx",
856+ "columns": [
857+ {
858+ "expression": "scope_id",
859+ "isExpression": false,
860+ "asc": true,
861+ "nulls": "last"
862+ }
863+ ],
864+ "isUnique": false,
865+ "concurrently": false,
866+ "method": "btree",
867+ "with": {}
868+ },
869+ "openapi_operation_source_id_idx": {
870+ "name": "openapi_operation_source_id_idx",
871+ "columns": [
872+ {
873+ "expression": "source_id",
874+ "isExpression": false,
875+ "asc": true,
876+ "nulls": "last"
877+ }
878+ ],
879+ "isUnique": false,
880+ "concurrently": false,
881+ "method": "btree",
882+ "with": {}
883+ }
884+ },
885+ "foreignKeys": {},
886+ "compositePrimaryKeys": {
887+ "openapi_operation_scope_id_id_pk": {
888+ "name": "openapi_operation_scope_id_id_pk",
889+ "columns": [
890+ "scope_id",
891+ "id"
892+ ]
893+ }
894+ },
895+ "uniqueConstraints": {},
896+ "policies": {},
897+ "checkConstraints": {},
898+ "isRLSEnabled": false
899+ },
900+ "public.openapi_source": {
901+ "name": "openapi_source",
902+ "schema": "",
903+ "columns": {
904+ "id": {
905+ "name": "id",
906+ "type": "text",
907+ "primaryKey": false,
908+ "notNull": true
909+ },
910+ "scope_id": {
911+ "name": "scope_id",
912+ "type": "text",
913+ "primaryKey": false,
914+ "notNull": true
915+ },
916+ "name": {
917+ "name": "name",
918+ "type": "text",
919+ "primaryKey": false,
920+ "notNull": true
921+ },
922+ "spec": {
923+ "name": "spec",
924+ "type": "text",
925+ "primaryKey": false,
926+ "notNull": true
927+ },
928+ "source_url": {
929+ "name": "source_url",
930+ "type": "text",
931+ "primaryKey": false,
932+ "notNull": false
933+ },
934+ "base_url": {
935+ "name": "base_url",
936+ "type": "text",
937+ "primaryKey": false,
938+ "notNull": false
939+ },
940+ "headers": {
941+ "name": "headers",
942+ "type": "jsonb",
943+ "primaryKey": false,
944+ "notNull": false
945+ },
946+ "oauth2": {
947+ "name": "oauth2",
948+ "type": "jsonb",
949+ "primaryKey": false,
950+ "notNull": false
951+ },
952+ "invocation_config": {
953+ "name": "invocation_config",
954+ "type": "jsonb",
955+ "primaryKey": false,
956+ "notNull": true
957+ }
958+ },
959+ "indexes": {
960+ "openapi_source_scope_id_idx": {
961+ "name": "openapi_source_scope_id_idx",
962+ "columns": [
963+ {
964+ "expression": "scope_id",
965+ "isExpression": false,
966+ "asc": true,
967+ "nulls": "last"
968+ }
969+ ],
970+ "isUnique": false,
971+ "concurrently": false,
972+ "method": "btree",
973+ "with": {}
974+ }
975+ },
976+ "foreignKeys": {},
977+ "compositePrimaryKeys": {
978+ "openapi_source_scope_id_id_pk": {
979+ "name": "openapi_source_scope_id_id_pk",
980+ "columns": [
981+ "scope_id",
982+ "id"
983+ ]
984+ }
985+ },
986+ "uniqueConstraints": {},
987+ "policies": {},
988+ "checkConstraints": {},
989+ "isRLSEnabled": false
990+ },
991+ "public.secret": {
992+ "name": "secret",
993+ "schema": "",
994+ "columns": {
995+ "id": {
996+ "name": "id",
997+ "type": "text",
998+ "primaryKey": false,
999+ "notNull": true
1000+ },
1001+ "scope_id": {
1002+ "name": "scope_id",
1003+ "type": "text",
1004+ "primaryKey": false,
1005+ "notNull": true
1006+ },
1007+ "name": {
1008+ "name": "name",
1009+ "type": "text",
1010+ "primaryKey": false,
1011+ "notNull": true
1012+ },
1013+ "provider": {
1014+ "name": "provider",
1015+ "type": "text",
1016+ "primaryKey": false,
1017+ "notNull": true
1018+ },
1019+ "owned_by_connection_id": {
1020+ "name": "owned_by_connection_id",
1021+ "type": "text",
1022+ "primaryKey": false,
1023+ "notNull": false
1024+ },
1025+ "created_at": {
1026+ "name": "created_at",
1027+ "type": "timestamp",
1028+ "primaryKey": false,
1029+ "notNull": true
1030+ }
1031+ },
1032+ "indexes": {
1033+ "secret_scope_id_idx": {
1034+ "name": "secret_scope_id_idx",
1035+ "columns": [
1036+ {
1037+ "expression": "scope_id",
1038+ "isExpression": false,
1039+ "asc": true,
1040+ "nulls": "last"
1041+ }
1042+ ],
1043+ "isUnique": false,
1044+ "concurrently": false,
1045+ "method": "btree",
1046+ "with": {}
1047+ },
1048+ "secret_provider_idx": {
1049+ "name": "secret_provider_idx",
1050+ "columns": [
1051+ {
1052+ "expression": "provider",
1053+ "isExpression": false,
1054+ "asc": true,
1055+ "nulls": "last"
1056+ }
1057+ ],
1058+ "isUnique": false,
1059+ "concurrently": false,
1060+ "method": "btree",
1061+ "with": {}
1062+ },
1063+ "secret_owned_by_connection_id_idx": {
1064+ "name": "secret_owned_by_connection_id_idx",
1065+ "columns": [
1066+ {
1067+ "expression": "owned_by_connection_id",
1068+ "isExpression": false,
1069+ "asc": true,
1070+ "nulls": "last"
1071+ }
1072+ ],
1073+ "isUnique": false,
1074+ "concurrently": false,
1075+ "method": "btree",
1076+ "with": {}
1077+ }
1078+ },
1079+ "foreignKeys": {},
1080+ "compositePrimaryKeys": {
1081+ "secret_scope_id_id_pk": {
1082+ "name": "secret_scope_id_id_pk",
1083+ "columns": [
1084+ "scope_id",
1085+ "id"
1086+ ]
1087+ }
1088+ },
1089+ "uniqueConstraints": {},
1090+ "policies": {},
1091+ "checkConstraints": {},
1092+ "isRLSEnabled": false
1093+ },
1094+ "public.source": {
1095+ "name": "source",
1096+ "schema": "",
1097+ "columns": {
1098+ "id": {
1099+ "name": "id",
1100+ "type": "text",
1101+ "primaryKey": false,
1102+ "notNull": true
1103+ },
1104+ "scope_id": {
1105+ "name": "scope_id",
1106+ "type": "text",
1107+ "primaryKey": false,
1108+ "notNull": true
1109+ },
1110+ "plugin_id": {
1111+ "name": "plugin_id",
1112+ "type": "text",
1113+ "primaryKey": false,
1114+ "notNull": true
1115+ },
1116+ "kind": {
1117+ "name": "kind",
1118+ "type": "text",
1119+ "primaryKey": false,
1120+ "notNull": true
1121+ },
1122+ "name": {
1123+ "name": "name",
1124+ "type": "text",
1125+ "primaryKey": false,
1126+ "notNull": true
1127+ },
1128+ "url": {
1129+ "name": "url",
1130+ "type": "text",
1131+ "primaryKey": false,
1132+ "notNull": false
1133+ },
1134+ "can_remove": {
1135+ "name": "can_remove",
1136+ "type": "boolean",
1137+ "primaryKey": false,
1138+ "notNull": true,
1139+ "default": true
1140+ },
1141+ "can_refresh": {
1142+ "name": "can_refresh",
1143+ "type": "boolean",
1144+ "primaryKey": false,
1145+ "notNull": true,
1146+ "default": false
1147+ },
1148+ "can_edit": {
1149+ "name": "can_edit",
1150+ "type": "boolean",
1151+ "primaryKey": false,
1152+ "notNull": true,
1153+ "default": false
1154+ },
1155+ "created_at": {
1156+ "name": "created_at",
1157+ "type": "timestamp",
1158+ "primaryKey": false,
1159+ "notNull": true
1160+ },
1161+ "updated_at": {
1162+ "name": "updated_at",
1163+ "type": "timestamp",
1164+ "primaryKey": false,
1165+ "notNull": true
1166+ }
1167+ },
1168+ "indexes": {
1169+ "source_scope_id_idx": {
1170+ "name": "source_scope_id_idx",
1171+ "columns": [
1172+ {
1173+ "expression": "scope_id",
1174+ "isExpression": false,
1175+ "asc": true,
1176+ "nulls": "last"
1177+ }
1178+ ],
1179+ "isUnique": false,
1180+ "concurrently": false,
1181+ "method": "btree",
1182+ "with": {}
1183+ },
1184+ "source_plugin_id_idx": {
1185+ "name": "source_plugin_id_idx",
1186+ "columns": [
1187+ {
1188+ "expression": "plugin_id",
1189+ "isExpression": false,
1190+ "asc": true,
1191+ "nulls": "last"
1192+ }
1193+ ],
1194+ "isUnique": false,
1195+ "concurrently": false,
1196+ "method": "btree",
1197+ "with": {}
1198+ }
1199+ },
1200+ "foreignKeys": {},
1201+ "compositePrimaryKeys": {
1202+ "source_scope_id_id_pk": {
1203+ "name": "source_scope_id_id_pk",
1204+ "columns": [
1205+ "scope_id",
1206+ "id"
1207+ ]
1208+ }
1209+ },
1210+ "uniqueConstraints": {},
1211+ "policies": {},
1212+ "checkConstraints": {},
1213+ "isRLSEnabled": false
1214+ },
1215+ "public.tool": {
1216+ "name": "tool",
1217+ "schema": "",
1218+ "columns": {
1219+ "id": {
1220+ "name": "id",
1221+ "type": "text",
1222+ "primaryKey": false,
1223+ "notNull": true
1224+ },
1225+ "scope_id": {
1226+ "name": "scope_id",
1227+ "type": "text",
1228+ "primaryKey": false,
1229+ "notNull": true
1230+ },
1231+ "source_id": {
1232+ "name": "source_id",
1233+ "type": "text",
1234+ "primaryKey": false,
1235+ "notNull": true
1236+ },
1237+ "plugin_id": {
1238+ "name": "plugin_id",
1239+ "type": "text",
1240+ "primaryKey": false,
1241+ "notNull": true
1242+ },
1243+ "name": {
1244+ "name": "name",
1245+ "type": "text",
1246+ "primaryKey": false,
1247+ "notNull": true
1248+ },
1249+ "description": {
1250+ "name": "description",
1251+ "type": "text",
1252+ "primaryKey": false,
1253+ "notNull": true
1254+ },
1255+ "input_schema": {
1256+ "name": "input_schema",
1257+ "type": "jsonb",
1258+ "primaryKey": false,
1259+ "notNull": false
1260+ },
1261+ "output_schema": {
1262+ "name": "output_schema",
1263+ "type": "jsonb",
1264+ "primaryKey": false,
1265+ "notNull": false
1266+ },
1267+ "created_at": {
1268+ "name": "created_at",
1269+ "type": "timestamp",
1270+ "primaryKey": false,
1271+ "notNull": true
1272+ },
1273+ "updated_at": {
1274+ "name": "updated_at",
1275+ "type": "timestamp",
1276+ "primaryKey": false,
1277+ "notNull": true
1278+ }
1279+ },
1280+ "indexes": {
1281+ "tool_scope_id_idx": {
1282+ "name": "tool_scope_id_idx",
1283+ "columns": [
1284+ {
1285+ "expression": "scope_id",
1286+ "isExpression": false,
1287+ "asc": true,
1288+ "nulls": "last"
1289+ }
1290+ ],
1291+ "isUnique": false,
1292+ "concurrently": false,
1293+ "method": "btree",
1294+ "with": {}
1295+ },
1296+ "tool_source_id_idx": {
1297+ "name": "tool_source_id_idx",
1298+ "columns": [
1299+ {
1300+ "expression": "source_id",
1301+ "isExpression": false,
1302+ "asc": true,
1303+ "nulls": "last"
1304+ }
1305+ ],
1306+ "isUnique": false,
1307+ "concurrently": false,
1308+ "method": "btree",
1309+ "with": {}
1310+ },
1311+ "tool_plugin_id_idx": {
1312+ "name": "tool_plugin_id_idx",
1313+ "columns": [
1314+ {
1315+ "expression": "plugin_id",
1316+ "isExpression": false,
1317+ "asc": true,
1318+ "nulls": "last"
1319+ }
1320+ ],
1321+ "isUnique": false,
1322+ "concurrently": false,
1323+ "method": "btree",
1324+ "with": {}
1325+ }
1326+ },
1327+ "foreignKeys": {},
1328+ "compositePrimaryKeys": {
1329+ "tool_scope_id_id_pk": {
1330+ "name": "tool_scope_id_id_pk",
1331+ "columns": [
1332+ "scope_id",
1333+ "id"
1334+ ]
1335+ }
1336+ },
1337+ "uniqueConstraints": {},
1338+ "policies": {},
1339+ "checkConstraints": {},
1340+ "isRLSEnabled": false
1341+ },
1342+ "public.workos_vault_metadata": {
1343+ "name": "workos_vault_metadata",
1344+ "schema": "",
1345+ "columns": {
1346+ "id": {
1347+ "name": "id",
1348+ "type": "text",
1349+ "primaryKey": false,
1350+ "notNull": true
1351+ },
1352+ "scope_id": {
1353+ "name": "scope_id",
1354+ "type": "text",
1355+ "primaryKey": false,
1356+ "notNull": true
1357+ },
1358+ "name": {
1359+ "name": "name",
1360+ "type": "text",
1361+ "primaryKey": false,
1362+ "notNull": true
1363+ },
1364+ "purpose": {
1365+ "name": "purpose",
1366+ "type": "text",
1367+ "primaryKey": false,
1368+ "notNull": false
1369+ },
1370+ "created_at": {
1371+ "name": "created_at",
1372+ "type": "timestamp",
1373+ "primaryKey": false,
1374+ "notNull": true
1375+ }
1376+ },
1377+ "indexes": {
1378+ "workos_vault_metadata_scope_id_idx": {
1379+ "name": "workos_vault_metadata_scope_id_idx",
1380+ "columns": [
1381+ {
1382+ "expression": "scope_id",
1383+ "isExpression": false,
1384+ "asc": true,
1385+ "nulls": "last"
1386+ }
1387+ ],
1388+ "isUnique": false,
1389+ "concurrently": false,
1390+ "method": "btree",
1391+ "with": {}
1392+ }
1393+ },
1394+ "foreignKeys": {},
1395+ "compositePrimaryKeys": {
1396+ "workos_vault_metadata_scope_id_id_pk": {
1397+ "name": "workos_vault_metadata_scope_id_id_pk",
1398+ "columns": [
1399+ "scope_id",
1400+ "id"
1401+ ]
1402+ }
1403+ },
1404+ "uniqueConstraints": {},
1405+ "policies": {},
1406+ "checkConstraints": {},
1407+ "isRLSEnabled": false
1408+ }
1409+ },
1410+ "enums": {},
1411+ "schemas": {},
1412+ "sequences": {},
1413+ "roles": {},
1414+ "policies": {},
1415+ "views": {},
1416+ "_meta": {
1417+ "columns": {},
1418+ "schemas": {},
1419+ "tables": {}
1420+ }
1421+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -22,6 +22,13 @@ · 21 unmodified lines
2222 "when": 1776709954401,
2323 "tag": "0002_fat_white_tiger",
2424 "breakpoints": true
25+ },
26+ {
27+ "idx": 3,
28+ "version": "7",
29+ "when": 1776728656793,
30+ "tag": "0003_add_connections",
31+ "breakpoints": true
2532 }
2633 ]
2734 }
apps/cloud/src/api/layers.ts+7−1
@@ -1,7 +1,7 @@
11 import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform";
22 import { Effect, Layer } from "effect";
33
4−import { CoreExecutorApi } from "@executor/api";
4+import { CoreExecutorApi, InternalError, observabilityMiddleware } from "@executor/api";
55 import { CoreHandlers } from "@executor/api/server";
66 import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
77 import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
@@ -19,6 +19,7 @@ import { DbService } from "../services/db"; · 11 unmodified lines
1919 import { TelemetryLive } from "../services/telemetry";
2020 import { OrgHttpApi } from "../org/compose";
2121 import { OrgHandlers } from "../org/handlers";
22+import { ErrorCaptureLive } from "../observability";
2223
2324 import { CoreSharedServices } from "./core-shared-services";
2425
@@ -27,8 +28,11 @@ export { CoreSharedServices }; · 2 unmodified lines
2728 const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
2829 .add(McpGroup)
2930 .add(GraphqlGroup)
31+ .addError(InternalError)
3032 .middleware(OrgAuth);
3133
34+const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
35+
3236 const DbLive = DbService.Live;
3337 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
3438
@@ -50,8 +54,10 @@ export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( · 15 unmodified lines
5054 McpHandlers,
5155 GraphqlHandlers,
5256 OrgAuthLive,
57+ ObservabilityLive,
5358 ),
5459 ),
60+ Layer.provide(ErrorCaptureLive),
5561 );
5662
5763 const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
apps/cloud/src/observability.ts+15−4
@@ -15,17 +15,28 @@ import { Cause, Effect, Layer } from "effect"; · 14 unmodified lines
1515
1616 import { ErrorCapture } from "@executor/api";
1717
18+// Drizzle/postgres-js include the failing SQL (params + bound values) in
19+// their error message. For OpenAPI source inserts that's 1MB+ of spec
20+// text which blows past terminal scrollback and hides the actual pg
21+// error. Sentry still receives the full, untruncated cause via
22+// `setExtra`; only the dev-console mirror is capped.
23+const MAX_CONSOLE_CAUSE_CHARS = 4_000;
24+
25+const truncate = (s: string): string =>
26+ s.length <= MAX_CONSOLE_CAUSE_CHARS
27+ ? s
28+ : `${s.slice(0, MAX_CONSOLE_CAUSE_CHARS)}\n…[truncated ${s.length - MAX_CONSOLE_CAUSE_CHARS} chars]`;
29+
1830 export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
1931 ErrorCapture,
2032 ErrorCapture.of({
2133 captureException: (cause) =>
2234 Effect.sync(() => {
2335 const squashed = Cause.squash(cause);
24− // Mirror to console so dev terminals see the defect; Sentry gets the
25− // full structured cause for production correlation.
26− console.error("[api] unhandled cause:", Cause.pretty(cause));
36+ const pretty = Cause.pretty(cause);
37+ console.error("[api] unhandled cause:", truncate(pretty));
2738 const eventId = Sentry.captureException(squashed, (scope) => {
28− scope.setExtra("cause", Cause.pretty(cause));
39+ scope.setExtra("cause", pretty);
2940 return scope;
3041 });
3142 return eventId ?? "";
apps/cloud/src/routeTree.gen.ts+21−0
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
1414 import { Route as OrgRouteImport } from './routes/org'
15+import { Route as ConnectionsRouteImport } from './routes/connections'
1516 import { Route as BillingRouteImport } from './routes/billing'
1617 import { Route as IndexRouteImport } from './routes/index'
1718 import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
@@ -33,6 +34,11 @@ const OrgRoute = OrgRouteImport.update({ · 15 unmodified lines
3334 path: '/org',
3435 getParentRoute: () => rootRouteImport,
3536 } as any)
37+const ConnectionsRoute = ConnectionsRouteImport.update({
38+ id: '/connections',
39+ path: '/connections',
40+ getParentRoute: () => rootRouteImport,
41+} as any)
3642 const BillingRoute = BillingRouteImport.update({
3743 id: '/billing',
3844 path: '/billing',
@@ -62,6 +68,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ · 23 unmodified lines
6268 export interface FileRoutesByFullPath {
6369 '/': typeof IndexRoute
6470 '/billing': typeof BillingRoute
71+ '/connections': typeof ConnectionsRoute
6572 '/org': typeof OrgRoute
6673 '/secrets': typeof SecretsRoute
6774 '/tools': typeof ToolsRoute
@@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
7279 export interface FileRoutesByTo {
7380 '/': typeof IndexRoute
7481 '/billing': typeof BillingRoute
82+ '/connections': typeof ConnectionsRoute
7583 '/org': typeof OrgRoute
7684 '/secrets': typeof SecretsRoute
7785 '/tools': typeof ToolsRoute
@@ -83,6 +91,7 @@ export interface FileRoutesById { · 5 unmodified lines
8391 __root__: typeof rootRouteImport
8492 '/': typeof IndexRoute
8593 '/billing': typeof BillingRoute
94+ '/connections': typeof ConnectionsRoute
8695 '/org': typeof OrgRoute
8796 '/secrets': typeof SecretsRoute
8897 '/tools': typeof ToolsRoute
@@ -95,6 +104,7 @@ export interface FileRouteTypes { · 6 unmodified lines
95104 fullPaths:
96105 | '/'
97106 | '/billing'
107+ | '/connections'
98108 | '/org'
99109 | '/secrets'
100110 | '/tools'
@@ -105,6 +115,7 @@ export interface FileRouteTypes { · 4 unmodified lines
105115 to:
106116 | '/'
107117 | '/billing'
118+ | '/connections'
108119 | '/org'
109120 | '/secrets'
110121 | '/tools'
@@ -115,6 +126,7 @@ export interface FileRouteTypes { · 4 unmodified lines
115126 | '__root__'
116127 | '/'
117128 | '/billing'
129+ | '/connections'
118130 | '/org'
119131 | '/secrets'
120132 | '/tools'
@@ -126,6 +138,7 @@ export interface FileRouteTypes { · 5 unmodified lines
126138 export interface RootRouteChildren {
127139 IndexRoute: typeof IndexRoute
128140 BillingRoute: typeof BillingRoute
141+ ConnectionsRoute: typeof ConnectionsRoute
129142 OrgRoute: typeof OrgRoute
130143 SecretsRoute: typeof SecretsRoute
131144 ToolsRoute: typeof ToolsRoute
@@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
157170 preLoaderRoute: typeof OrgRouteImport
158171 parentRoute: typeof rootRouteImport
159172 }
173+ '/connections': {
174+ id: '/connections'
175+ path: '/connections'
176+ fullPath: '/connections'
177+ preLoaderRoute: typeof ConnectionsRouteImport
178+ parentRoute: typeof rootRouteImport
179+ }
160180 '/billing': {
161181 id: '/billing'
162182 path: '/billing'
@@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { · 35 unmodified lines
198218 const rootRouteChildren: RootRouteChildren = {
199219 IndexRoute: IndexRoute,
200220 BillingRoute: BillingRoute,
221+ ConnectionsRoute: ConnectionsRoute,
201222 OrgRoute: OrgRoute,
202223 SecretsRoute: SecretsRoute,
203224 ToolsRoute: ToolsRoute,
apps/cloud/src/routes/connections.tsx+6−0
@@ -0,0 +1,6 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+import { ConnectionsPage } from "@executor/react/pages/connections";
3+
4+export const Route = createFileRoute("/connections")({
5+ component: () => <ConnectionsPage />,
6+});
apps/cloud/src/services/executor-schema.ts+21−0
@@ -56,11 +56,32 @@ export const secret = pgTable("secret", { · 55 unmodified lines
5656 scope_id: text('scope_id').notNull(),
5757 name: text('name').notNull(),
5858 provider: text('provider').notNull(),
59+ owned_by_connection_id: text('owned_by_connection_id'),
5960 created_at: timestamp('created_at').notNull()
6061 }, (table) => [
6162 primaryKey({ columns: [table.scope_id, table.id] }),
6263 index("secret_scope_id_idx").on(table.scope_id),
6364 index("secret_provider_idx").on(table.provider),
65+ index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id),
66+]);
67+
68+export const connection = pgTable("connection", {
69+ id: text('id').notNull(),
70+ scope_id: text('scope_id').notNull(),
71+ provider: text('provider').notNull(),
72+ kind: text('kind').notNull(),
73+ identity_label: text('identity_label'),
74+ access_token_secret_id: text('access_token_secret_id').notNull(),
75+ refresh_token_secret_id: text('refresh_token_secret_id'),
76+ expires_at: bigint('expires_at', { mode: 'number' }),
77+ scope: text('scope'),
78+ provider_state: jsonb('provider_state'),
79+ created_at: timestamp('created_at').notNull(),
80+ updated_at: timestamp('updated_at').notNull()
81+}, (table) => [
82+ primaryKey({ columns: [table.scope_id, table.id] }),
83+ index("connection_scope_id_idx").on(table.scope_id),
84+ index("connection_provider_idx").on(table.provider),
6485 ]);
6586
6687 export const openapi_source = pgTable("openapi_source", {
apps/cloud/src/services/mcp-oauth.node.test.ts+16−56
@@ -15,16 +15,16 @@ · 14 unmodified lines
1515 // Two scenarios:
1616 //
1717 // 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts
18−// tokens landed in the invoker's secret store under the stable
19−// `mcp_<ns>_*` ids and that source-level OAuth state (clientInfo,
20−// AS URL) is echoed back on the response.
18+// the response carries the Connection id the exchange minted and
19+// that source-level OAuth state (clientInfo, AS URL) is echoed back
20+// so subsequent users can skip DCR.
2121 //
2222 // 2. Two users, same source: second user's startOAuth passes the
2323 // clientInformation the first user's completeOAuth returned. The
2424 // DCR endpoint counter on the fake server must NOT tick — proof
25−// the DCR-hoist code path re-uses the existing client. Then both
26−// users' tokens must resolve independently at their own scope
27−// despite sharing the same secret id.
25+// the DCR-hoist code path re-uses the existing client. Both users
26+// end up with their own Connection (same id, different scope) via
27+// the SDK's innermost-wins shadowing.
2828 // ---------------------------------------------------------------------------
2929
3030 import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
@@ -33,7 +33,7 @@ import type { AddressInfo } from "node:net"; · 2 unmodified lines
3333 import { createHash, randomBytes } from "node:crypto";
3434
3535 import { Effect } from "effect";
36−import { ScopeId, SecretId } from "@executor/sdk";
36+import { ScopeId } from "@executor/sdk";
3737
3838 import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
3939
@@ -282,8 +282,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 242 unmodified lines
282282 const userId = `user_${crypto.randomUUID()}`;
283283 const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId));
284284 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
285− const accessTokenSecretId = `mcp_${namespace}_access_token`;
286− const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
285+ const connectionId = `mcp-oauth2-${namespace}`;
287286 const redirectUrl = "http://test.local/api/mcp/oauth/callback";
288287
289288 const started = yield* asUser(userId, orgId, (client) =>
@@ -292,8 +291,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 2 unmodified lines
292291 payload: {
293292 endpoint: `${fake.url}/mcp`,
294293 redirectUrl,
295− accessTokenSecretId,
296− refreshTokenSecretId,
294+ connectionId,
297295 },
298296 }),
299297 );
@@ -310,34 +308,12 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 10 unmodified lines
310308 payload: { state, code },
311309 }),
312310 );
313− expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);
314− expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
311+ expect(completed.connectionId).toBe(connectionId);
315312 expect(completed.tokenType).toBe("Bearer");
316313 // DCR hoist: the source-level state the UI will persist on the
317314 // source's auth config, so the next user skips DCR entirely.
318315 expect(completed.clientInformation).not.toBeNull();
319316 expect(completed.authorizationServerUrl).not.toBeNull();
320−
321− // Tokens landed at the user scope via the stable ids.
322− const access = yield* asUser(userId, orgId, (client) =>
323− client.secrets.resolve({
324− path: {
325− scopeId: userScope,
326− secretId: SecretId.make(accessTokenSecretId),
327− },
328− }),
329− );
330− expect(access.value).toMatch(/^at_/);
331−
332− const refresh = yield* asUser(userId, orgId, (client) =>
333− client.secrets.resolve({
334− path: {
335− scopeId: userScope,
336− secretId: SecretId.make(refreshTokenSecretId),
337− },
338− }),
339− );
340− expect(refresh.value).toMatch(/^rt_/);
341317 }),
342318 30_000,
343319 );
@@ -352,8 +328,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 8 unmodified lines
352328 const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId));
353329 const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId));
354330 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
355− const accessTokenSecretId = `mcp_${namespace}_access_token`;
356− const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
331+ const connectionId = `mcp-oauth2-${namespace}`;
357332 const endpoint = `${fake.url}/mcp`;
358333 const redirectUrl = "http://test.local/api/mcp/oauth/callback";
359334
@@ -366,8 +341,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines
366341 payload: {
367342 endpoint,
368343 redirectUrl,
369− accessTokenSecretId,
370− refreshTokenSecretId,
344+ connectionId,
371345 },
372346 }),
373347 );
@@ -380,6 +354,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines
380354 payload: { state: redirA.state, code: redirA.code },
381355 }),
382356 );
357+ expect(completedA.connectionId).toBe(connectionId);
383358 expect(completedA.clientInformation).not.toBeNull();
384359 expect(fake.registrations()).toBe(regsBefore + 1);
385360
@@ -390,8 +365,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 4 unmodified lines
390365 payload: {
391366 endpoint,
392367 redirectUrl,
393− accessTokenSecretId,
394− refreshTokenSecretId,
368+ connectionId,
395369 clientInformation: completedA.clientInformation,
396370 authorizationServerUrl: completedA.authorizationServerUrl,
397371 resourceMetadataUrl: completedA.resourceMetadataUrl,
@@ -401,28 +375,14 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 3 unmodified lines
401375 const redirB = yield* Effect.promise(() =>
402376 followAuthorize(startedB.authorizationUrl),
403377 );
404− yield* asUser(userB, orgId, (client) =>
378+ const completedB = yield* asUser(userB, orgId, (client) =>
405379 client.mcp.completeOAuth({
406380 path: { scopeId: scopeB },
407381 payload: { state: redirB.state, code: redirB.code },
408382 }),
409383 );
384+ expect(completedB.connectionId).toBe(connectionId);
410385 expect(fake.registrations()).toBe(regsBefore + 1);
411−
412− // --- Per-user tokens despite the shared secret id. ---
413− const tokenA = yield* asUser(userA, orgId, (client) =>
414− client.secrets.resolve({
415− path: { scopeId: scopeA, secretId: SecretId.make(accessTokenSecretId) },
416− }),
417− );
418− const tokenB = yield* asUser(userB, orgId, (client) =>
419− client.secrets.resolve({
420− path: { scopeId: scopeB, secretId: SecretId.make(accessTokenSecretId) },
421− }),
422− );
423− expect(tokenA.value).not.toBe(tokenB.value);
424− expect(tokenA.value).toMatch(/^at_/);
425− expect(tokenB.value).toMatch(/^at_/);
426386 }),
427387 30_000,
428388 );
apps/cloud/src/web/shell.tsx+2−0
@@ -360,6 +360,7 @@ function UserFooter() { · 359 unmodified lines
360360 function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {
361361 const isHome = props.pathname === "/";
362362 const isSecrets = props.pathname === "/secrets";
363+ const isConnections = props.pathname === "/connections";
363364 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
364365 const isOrg = props.pathname === "/org";
365366
@@ -375,6 +376,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 9 unmodified lines
375376
376377 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
377378 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
379+ <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} />
378380 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
379381 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />
380382 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
apps/local/drizzle/0002_lively_sue_storm.sql+20−0
@@ -0,0 +1,20 @@
1+CREATE TABLE `connection` (
2+ `id` text NOT NULL,
3+ `scope_id` text NOT NULL,
4+ `provider` text NOT NULL,
5+ `kind` text NOT NULL,
6+ `identity_label` text,
7+ `access_token_secret_id` text NOT NULL,
8+ `refresh_token_secret_id` text,
9+ `expires_at` integer,
10+ `scope` text,
11+ `provider_state` text,
12+ `created_at` integer NOT NULL,
13+ `updated_at` integer NOT NULL,
14+ PRIMARY KEY(`scope_id`, `id`)
15+);
16+--> statement-breakpoint
17+CREATE INDEX `connection_scope_id_idx` ON `connection` (`scope_id`);--> statement-breakpoint
18+CREATE INDEX `connection_provider_idx` ON `connection` (`provider`);--> statement-breakpoint
19+ALTER TABLE `secret` ADD `owned_by_connection_id` text;--> statement-breakpoint
20+CREATE INDEX `secret_owned_by_connection_id_idx` ON `secret` (`owned_by_connection_id`);
apps/local/drizzle/meta/0002_snapshot.json+1274−0
@@ -0,0 +1,1274 @@
1+{
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "8778ceaf-ae8f-4fae-8484-4b0b2e3abe66",
5+ "prevId": "acff0d94-170c-40bb-9271-dc3a429ba0ce",
6+ "tables": {
7+ "blob": {
8+ "name": "blob",
9+ "columns": {
10+ "namespace": {
11+ "name": "namespace",
12+ "type": "text",
13+ "primaryKey": false,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "key": {
18+ "name": "key",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "autoincrement": false
23+ },
24+ "value": {
25+ "name": "value",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false
30+ }
31+ },
32+ "indexes": {},
33+ "foreignKeys": {},
34+ "compositePrimaryKeys": {
35+ "blob_namespace_key_pk": {
36+ "columns": [
37+ "namespace",
38+ "key"
39+ ],
40+ "name": "blob_namespace_key_pk"
41+ }
42+ },
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "connection": {
47+ "name": "connection",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "scope_id": {
57+ "name": "scope_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "provider": {
64+ "name": "provider",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "kind": {
71+ "name": "kind",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": true,
75+ "autoincrement": false
76+ },
77+ "identity_label": {
78+ "name": "identity_label",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": false,
82+ "autoincrement": false
83+ },
84+ "access_token_secret_id": {
85+ "name": "access_token_secret_id",
86+ "type": "text",
87+ "primaryKey": false,
88+ "notNull": true,
89+ "autoincrement": false
90+ },
91+ "refresh_token_secret_id": {
92+ "name": "refresh_token_secret_id",
93+ "type": "text",
94+ "primaryKey": false,
95+ "notNull": false,
96+ "autoincrement": false
97+ },
98+ "expires_at": {
99+ "name": "expires_at",
100+ "type": "integer",
101+ "primaryKey": false,
102+ "notNull": false,
103+ "autoincrement": false
104+ },
105+ "scope": {
106+ "name": "scope",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": false,
110+ "autoincrement": false
111+ },
112+ "provider_state": {
113+ "name": "provider_state",
114+ "type": "text",
115+ "primaryKey": false,
116+ "notNull": false,
117+ "autoincrement": false
118+ },
119+ "created_at": {
120+ "name": "created_at",
121+ "type": "integer",
122+ "primaryKey": false,
123+ "notNull": true,
124+ "autoincrement": false
125+ },
126+ "updated_at": {
127+ "name": "updated_at",
128+ "type": "integer",
129+ "primaryKey": false,
130+ "notNull": true,
131+ "autoincrement": false
132+ }
133+ },
134+ "indexes": {
135+ "connection_scope_id_idx": {
136+ "name": "connection_scope_id_idx",
137+ "columns": [
138+ "scope_id"
139+ ],
140+ "isUnique": false
141+ },
142+ "connection_provider_idx": {
143+ "name": "connection_provider_idx",
144+ "columns": [
145+ "provider"
146+ ],
147+ "isUnique": false
148+ }
149+ },
150+ "foreignKeys": {},
151+ "compositePrimaryKeys": {
152+ "connection_scope_id_id_pk": {
153+ "columns": [
154+ "scope_id",
155+ "id"
156+ ],
157+ "name": "connection_scope_id_id_pk"
158+ }
159+ },
160+ "uniqueConstraints": {},
161+ "checkConstraints": {}
162+ },
163+ "definition": {
164+ "name": "definition",
165+ "columns": {
166+ "id": {
167+ "name": "id",
168+ "type": "text",
169+ "primaryKey": false,
170+ "notNull": true,
171+ "autoincrement": false
172+ },
173+ "scope_id": {
174+ "name": "scope_id",
175+ "type": "text",
176+ "primaryKey": false,
177+ "notNull": true,
178+ "autoincrement": false
179+ },
180+ "source_id": {
181+ "name": "source_id",
182+ "type": "text",
183+ "primaryKey": false,
184+ "notNull": true,
185+ "autoincrement": false
186+ },
187+ "plugin_id": {
188+ "name": "plugin_id",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true,
192+ "autoincrement": false
193+ },
194+ "name": {
195+ "name": "name",
196+ "type": "text",
197+ "primaryKey": false,
198+ "notNull": true,
199+ "autoincrement": false
200+ },
201+ "schema": {
202+ "name": "schema",
203+ "type": "text",
204+ "primaryKey": false,
205+ "notNull": true,
206+ "autoincrement": false
207+ },
208+ "created_at": {
209+ "name": "created_at",
210+ "type": "integer",
211+ "primaryKey": false,
212+ "notNull": true,
213+ "autoincrement": false
214+ }
215+ },
216+ "indexes": {
217+ "definition_scope_id_idx": {
218+ "name": "definition_scope_id_idx",
219+ "columns": [
220+ "scope_id"
221+ ],
222+ "isUnique": false
223+ },
224+ "definition_source_id_idx": {
225+ "name": "definition_source_id_idx",
226+ "columns": [
227+ "source_id"
228+ ],
229+ "isUnique": false
230+ },
231+ "definition_plugin_id_idx": {
232+ "name": "definition_plugin_id_idx",
233+ "columns": [
234+ "plugin_id"
235+ ],
236+ "isUnique": false
237+ }
238+ },
239+ "foreignKeys": {},
240+ "compositePrimaryKeys": {
241+ "definition_scope_id_id_pk": {
242+ "columns": [
243+ "scope_id",
244+ "id"
245+ ],
246+ "name": "definition_scope_id_id_pk"
247+ }
248+ },
249+ "uniqueConstraints": {},
250+ "checkConstraints": {}
251+ },
252+ "google_discovery_binding": {
253+ "name": "google_discovery_binding",
254+ "columns": {
255+ "id": {
256+ "name": "id",
257+ "type": "text",
258+ "primaryKey": false,
259+ "notNull": true,
260+ "autoincrement": false
261+ },
262+ "scope_id": {
263+ "name": "scope_id",
264+ "type": "text",
265+ "primaryKey": false,
266+ "notNull": true,
267+ "autoincrement": false
268+ },
269+ "source_id": {
270+ "name": "source_id",
271+ "type": "text",
272+ "primaryKey": false,
273+ "notNull": true,
274+ "autoincrement": false
275+ },
276+ "binding": {
277+ "name": "binding",
278+ "type": "text",
279+ "primaryKey": false,
280+ "notNull": true,
281+ "autoincrement": false
282+ },
283+ "created_at": {
284+ "name": "created_at",
285+ "type": "integer",
286+ "primaryKey": false,
287+ "notNull": true,
288+ "autoincrement": false
289+ }
290+ },
291+ "indexes": {
292+ "google_discovery_binding_scope_id_idx": {
293+ "name": "google_discovery_binding_scope_id_idx",
294+ "columns": [
295+ "scope_id"
296+ ],
297+ "isUnique": false
298+ },
299+ "google_discovery_binding_source_id_idx": {
300+ "name": "google_discovery_binding_source_id_idx",
301+ "columns": [
302+ "source_id"
303+ ],
304+ "isUnique": false
305+ }
306+ },
307+ "foreignKeys": {},
308+ "compositePrimaryKeys": {
309+ "google_discovery_binding_scope_id_id_pk": {
310+ "columns": [
311+ "scope_id",
312+ "id"
313+ ],
314+ "name": "google_discovery_binding_scope_id_id_pk"
315+ }
316+ },
317+ "uniqueConstraints": {},
318+ "checkConstraints": {}
319+ },
320+ "google_discovery_oauth_session": {
321+ "name": "google_discovery_oauth_session",
322+ "columns": {
323+ "id": {
324+ "name": "id",
325+ "type": "text",
326+ "primaryKey": false,
327+ "notNull": true,
328+ "autoincrement": false
329+ },
330+ "scope_id": {
331+ "name": "scope_id",
332+ "type": "text",
333+ "primaryKey": false,
334+ "notNull": true,
335+ "autoincrement": false
336+ },
337+ "session": {
338+ "name": "session",
339+ "type": "text",
340+ "primaryKey": false,
341+ "notNull": true,
342+ "autoincrement": false
343+ },
344+ "expires_at": {
345+ "name": "expires_at",
346+ "type": "integer",
347+ "primaryKey": false,
348+ "notNull": true,
349+ "autoincrement": false
350+ }
351+ },
352+ "indexes": {
353+ "google_discovery_oauth_session_scope_id_idx": {
354+ "name": "google_discovery_oauth_session_scope_id_idx",
355+ "columns": [
356+ "scope_id"
357+ ],
358+ "isUnique": false
359+ }
360+ },
361+ "foreignKeys": {},
362+ "compositePrimaryKeys": {
363+ "google_discovery_oauth_session_scope_id_id_pk": {
364+ "columns": [
365+ "scope_id",
366+ "id"
367+ ],
368+ "name": "google_discovery_oauth_session_scope_id_id_pk"
369+ }
370+ },
371+ "uniqueConstraints": {},
372+ "checkConstraints": {}
373+ },
374+ "google_discovery_source": {
375+ "name": "google_discovery_source",
376+ "columns": {
377+ "id": {
378+ "name": "id",
379+ "type": "text",
380+ "primaryKey": false,
381+ "notNull": true,
382+ "autoincrement": false
383+ },
384+ "scope_id": {
385+ "name": "scope_id",
386+ "type": "text",
387+ "primaryKey": false,
388+ "notNull": true,
389+ "autoincrement": false
390+ },
391+ "name": {
392+ "name": "name",
393+ "type": "text",
394+ "primaryKey": false,
395+ "notNull": true,
396+ "autoincrement": false
397+ },
398+ "config": {
399+ "name": "config",
400+ "type": "text",
401+ "primaryKey": false,
402+ "notNull": true,
403+ "autoincrement": false
404+ },
405+ "created_at": {
406+ "name": "created_at",
407+ "type": "integer",
408+ "primaryKey": false,
409+ "notNull": true,
410+ "autoincrement": false
411+ },
412+ "updated_at": {
413+ "name": "updated_at",
414+ "type": "integer",
415+ "primaryKey": false,
416+ "notNull": true,
417+ "autoincrement": false
418+ }
419+ },
420+ "indexes": {
421+ "google_discovery_source_scope_id_idx": {
422+ "name": "google_discovery_source_scope_id_idx",
423+ "columns": [
424+ "scope_id"
425+ ],
426+ "isUnique": false
427+ }
428+ },
429+ "foreignKeys": {},
430+ "compositePrimaryKeys": {
431+ "google_discovery_source_scope_id_id_pk": {
432+ "columns": [
433+ "scope_id",
434+ "id"
435+ ],
436+ "name": "google_discovery_source_scope_id_id_pk"
437+ }
438+ },
439+ "uniqueConstraints": {},
440+ "checkConstraints": {}
441+ },
442+ "graphql_operation": {
443+ "name": "graphql_operation",
444+ "columns": {
445+ "id": {
446+ "name": "id",
447+ "type": "text",
448+ "primaryKey": false,
449+ "notNull": true,
450+ "autoincrement": false
451+ },
452+ "scope_id": {
453+ "name": "scope_id",
454+ "type": "text",
455+ "primaryKey": false,
456+ "notNull": true,
457+ "autoincrement": false
458+ },
459+ "source_id": {
460+ "name": "source_id",
461+ "type": "text",
462+ "primaryKey": false,
463+ "notNull": true,
464+ "autoincrement": false
465+ },
466+ "binding": {
467+ "name": "binding",
468+ "type": "text",
469+ "primaryKey": false,
470+ "notNull": true,
471+ "autoincrement": false
472+ }
473+ },
474+ "indexes": {
475+ "graphql_operation_scope_id_idx": {
476+ "name": "graphql_operation_scope_id_idx",
477+ "columns": [
478+ "scope_id"
479+ ],
480+ "isUnique": false
481+ },
482+ "graphql_operation_source_id_idx": {
483+ "name": "graphql_operation_source_id_idx",
484+ "columns": [
485+ "source_id"
486+ ],
487+ "isUnique": false
488+ }
489+ },
490+ "foreignKeys": {},
491+ "compositePrimaryKeys": {
492+ "graphql_operation_scope_id_id_pk": {
493+ "columns": [
494+ "scope_id",
495+ "id"
496+ ],
497+ "name": "graphql_operation_scope_id_id_pk"
498+ }
499+ },
500+ "uniqueConstraints": {},
501+ "checkConstraints": {}
502+ },
503+ "graphql_source": {
504+ "name": "graphql_source",
505+ "columns": {
506+ "id": {
507+ "name": "id",
508+ "type": "text",
509+ "primaryKey": false,
510+ "notNull": true,
511+ "autoincrement": false
512+ },
513+ "scope_id": {
514+ "name": "scope_id",
515+ "type": "text",
516+ "primaryKey": false,
517+ "notNull": true,
518+ "autoincrement": false
519+ },
520+ "name": {
521+ "name": "name",
522+ "type": "text",
523+ "primaryKey": false,
524+ "notNull": true,
525+ "autoincrement": false
526+ },
527+ "endpoint": {
528+ "name": "endpoint",
529+ "type": "text",
530+ "primaryKey": false,
531+ "notNull": true,
532+ "autoincrement": false
533+ },
534+ "headers": {
535+ "name": "headers",
536+ "type": "text",
537+ "primaryKey": false,
538+ "notNull": false,
539+ "autoincrement": false
540+ }
541+ },
542+ "indexes": {
543+ "graphql_source_scope_id_idx": {
544+ "name": "graphql_source_scope_id_idx",
545+ "columns": [
546+ "scope_id"
547+ ],
548+ "isUnique": false
549+ }
550+ },
551+ "foreignKeys": {},
552+ "compositePrimaryKeys": {
553+ "graphql_source_scope_id_id_pk": {
554+ "columns": [
555+ "scope_id",
556+ "id"
557+ ],
558+ "name": "graphql_source_scope_id_id_pk"
559+ }
560+ },
561+ "uniqueConstraints": {},
562+ "checkConstraints": {}
563+ },
564+ "mcp_binding": {
565+ "name": "mcp_binding",
566+ "columns": {
567+ "id": {
568+ "name": "id",
569+ "type": "text",
570+ "primaryKey": false,
571+ "notNull": true,
572+ "autoincrement": false
573+ },
574+ "scope_id": {
575+ "name": "scope_id",
576+ "type": "text",
577+ "primaryKey": false,
578+ "notNull": true,
579+ "autoincrement": false
580+ },
581+ "source_id": {
582+ "name": "source_id",
583+ "type": "text",
584+ "primaryKey": false,
585+ "notNull": true,
586+ "autoincrement": false
587+ },
588+ "binding": {
589+ "name": "binding",
590+ "type": "text",
591+ "primaryKey": false,
592+ "notNull": true,
593+ "autoincrement": false
594+ },
595+ "created_at": {
596+ "name": "created_at",
597+ "type": "integer",
598+ "primaryKey": false,
599+ "notNull": true,
600+ "autoincrement": false
601+ }
602+ },
603+ "indexes": {
604+ "mcp_binding_scope_id_idx": {
605+ "name": "mcp_binding_scope_id_idx",
606+ "columns": [
607+ "scope_id"
608+ ],
609+ "isUnique": false
610+ },
611+ "mcp_binding_source_id_idx": {
612+ "name": "mcp_binding_source_id_idx",
613+ "columns": [
614+ "source_id"
615+ ],
616+ "isUnique": false
617+ }
618+ },
619+ "foreignKeys": {},
620+ "compositePrimaryKeys": {
621+ "mcp_binding_scope_id_id_pk": {
622+ "columns": [
623+ "scope_id",
624+ "id"
625+ ],
626+ "name": "mcp_binding_scope_id_id_pk"
627+ }
628+ },
629+ "uniqueConstraints": {},
630+ "checkConstraints": {}
631+ },
632+ "mcp_oauth_session": {
633+ "name": "mcp_oauth_session",
634+ "columns": {
635+ "id": {
636+ "name": "id",
637+ "type": "text",
638+ "primaryKey": false,
639+ "notNull": true,
640+ "autoincrement": false
641+ },
642+ "scope_id": {
643+ "name": "scope_id",
644+ "type": "text",
645+ "primaryKey": false,
646+ "notNull": true,
647+ "autoincrement": false
648+ },
649+ "session": {
650+ "name": "session",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true,
654+ "autoincrement": false
655+ },
656+ "expires_at": {
657+ "name": "expires_at",
658+ "type": "integer",
659+ "primaryKey": false,
660+ "notNull": true,
661+ "autoincrement": false
662+ },
663+ "created_at": {
664+ "name": "created_at",
665+ "type": "integer",
666+ "primaryKey": false,
667+ "notNull": true,
668+ "autoincrement": false
669+ }
670+ },
671+ "indexes": {
672+ "mcp_oauth_session_scope_id_idx": {
673+ "name": "mcp_oauth_session_scope_id_idx",
674+ "columns": [
675+ "scope_id"
676+ ],
677+ "isUnique": false
678+ }
679+ },
680+ "foreignKeys": {},
681+ "compositePrimaryKeys": {
682+ "mcp_oauth_session_scope_id_id_pk": {
683+ "columns": [
684+ "scope_id",
685+ "id"
686+ ],
687+ "name": "mcp_oauth_session_scope_id_id_pk"
688+ }
689+ },
690+ "uniqueConstraints": {},
691+ "checkConstraints": {}
692+ },
693+ "mcp_source": {
694+ "name": "mcp_source",
695+ "columns": {
696+ "id": {
697+ "name": "id",
698+ "type": "text",
699+ "primaryKey": false,
700+ "notNull": true,
701+ "autoincrement": false
702+ },
703+ "scope_id": {
704+ "name": "scope_id",
705+ "type": "text",
706+ "primaryKey": false,
707+ "notNull": true,
708+ "autoincrement": false
709+ },
710+ "name": {
711+ "name": "name",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": true,
715+ "autoincrement": false
716+ },
717+ "config": {
718+ "name": "config",
719+ "type": "text",
720+ "primaryKey": false,
721+ "notNull": true,
722+ "autoincrement": false
723+ },
724+ "created_at": {
725+ "name": "created_at",
726+ "type": "integer",
727+ "primaryKey": false,
728+ "notNull": true,
729+ "autoincrement": false
730+ }
731+ },
732+ "indexes": {
733+ "mcp_source_scope_id_idx": {
734+ "name": "mcp_source_scope_id_idx",
735+ "columns": [
736+ "scope_id"
737+ ],
738+ "isUnique": false
739+ }
740+ },
741+ "foreignKeys": {},
742+ "compositePrimaryKeys": {
743+ "mcp_source_scope_id_id_pk": {
744+ "columns": [
745+ "scope_id",
746+ "id"
747+ ],
748+ "name": "mcp_source_scope_id_id_pk"
749+ }
750+ },
751+ "uniqueConstraints": {},
752+ "checkConstraints": {}
753+ },
754+ "openapi_oauth_session": {
755+ "name": "openapi_oauth_session",
756+ "columns": {
757+ "id": {
758+ "name": "id",
759+ "type": "text",
760+ "primaryKey": false,
761+ "notNull": true,
762+ "autoincrement": false
763+ },
764+ "scope_id": {
765+ "name": "scope_id",
766+ "type": "text",
767+ "primaryKey": false,
768+ "notNull": true,
769+ "autoincrement": false
770+ },
771+ "session": {
772+ "name": "session",
773+ "type": "text",
774+ "primaryKey": false,
775+ "notNull": true,
776+ "autoincrement": false
777+ },
778+ "created_at": {
779+ "name": "created_at",
780+ "type": "integer",
781+ "primaryKey": false,
782+ "notNull": true,
783+ "autoincrement": false
784+ }
785+ },
786+ "indexes": {
787+ "openapi_oauth_session_scope_id_idx": {
788+ "name": "openapi_oauth_session_scope_id_idx",
789+ "columns": [
790+ "scope_id"
791+ ],
792+ "isUnique": false
793+ }
794+ },
795+ "foreignKeys": {},
796+ "compositePrimaryKeys": {
797+ "openapi_oauth_session_scope_id_id_pk": {
798+ "columns": [
799+ "scope_id",
800+ "id"
801+ ],
802+ "name": "openapi_oauth_session_scope_id_id_pk"
803+ }
804+ },
805+ "uniqueConstraints": {},
806+ "checkConstraints": {}
807+ },
808+ "openapi_operation": {
809+ "name": "openapi_operation",
810+ "columns": {
811+ "id": {
812+ "name": "id",
813+ "type": "text",
814+ "primaryKey": false,
815+ "notNull": true,
816+ "autoincrement": false
817+ },
818+ "scope_id": {
819+ "name": "scope_id",
820+ "type": "text",
821+ "primaryKey": false,
822+ "notNull": true,
823+ "autoincrement": false
824+ },
825+ "source_id": {
826+ "name": "source_id",
827+ "type": "text",
828+ "primaryKey": false,
829+ "notNull": true,
830+ "autoincrement": false
831+ },
832+ "binding": {
833+ "name": "binding",
834+ "type": "text",
835+ "primaryKey": false,
836+ "notNull": true,
837+ "autoincrement": false
838+ }
839+ },
840+ "indexes": {
841+ "openapi_operation_scope_id_idx": {
842+ "name": "openapi_operation_scope_id_idx",
843+ "columns": [
844+ "scope_id"
845+ ],
846+ "isUnique": false
847+ },
848+ "openapi_operation_source_id_idx": {
849+ "name": "openapi_operation_source_id_idx",
850+ "columns": [
851+ "source_id"
852+ ],
853+ "isUnique": false
854+ }
855+ },
856+ "foreignKeys": {},
857+ "compositePrimaryKeys": {
858+ "openapi_operation_scope_id_id_pk": {
859+ "columns": [
860+ "scope_id",
861+ "id"
862+ ],
863+ "name": "openapi_operation_scope_id_id_pk"
864+ }
865+ },
866+ "uniqueConstraints": {},
867+ "checkConstraints": {}
868+ },
869+ "openapi_source": {
870+ "name": "openapi_source",
871+ "columns": {
872+ "id": {
873+ "name": "id",
874+ "type": "text",
875+ "primaryKey": false,
876+ "notNull": true,
877+ "autoincrement": false
878+ },
879+ "scope_id": {
880+ "name": "scope_id",
881+ "type": "text",
882+ "primaryKey": false,
883+ "notNull": true,
884+ "autoincrement": false
885+ },
886+ "name": {
887+ "name": "name",
888+ "type": "text",
889+ "primaryKey": false,
890+ "notNull": true,
891+ "autoincrement": false
892+ },
893+ "spec": {
894+ "name": "spec",
895+ "type": "text",
896+ "primaryKey": false,
897+ "notNull": true,
898+ "autoincrement": false
899+ },
900+ "source_url": {
901+ "name": "source_url",
902+ "type": "text",
903+ "primaryKey": false,
904+ "notNull": false,
905+ "autoincrement": false
906+ },
907+ "base_url": {
908+ "name": "base_url",
909+ "type": "text",
910+ "primaryKey": false,
911+ "notNull": false,
912+ "autoincrement": false
913+ },
914+ "headers": {
915+ "name": "headers",
916+ "type": "text",
917+ "primaryKey": false,
918+ "notNull": false,
919+ "autoincrement": false
920+ },
921+ "oauth2": {
922+ "name": "oauth2",
923+ "type": "text",
924+ "primaryKey": false,
925+ "notNull": false,
926+ "autoincrement": false
927+ },
928+ "invocation_config": {
929+ "name": "invocation_config",
930+ "type": "text",
931+ "primaryKey": false,
932+ "notNull": true,
933+ "autoincrement": false
934+ }
935+ },
936+ "indexes": {
937+ "openapi_source_scope_id_idx": {
938+ "name": "openapi_source_scope_id_idx",
939+ "columns": [
940+ "scope_id"
941+ ],
942+ "isUnique": false
943+ }
944+ },
945+ "foreignKeys": {},
946+ "compositePrimaryKeys": {
947+ "openapi_source_scope_id_id_pk": {
948+ "columns": [
949+ "scope_id",
950+ "id"
951+ ],
952+ "name": "openapi_source_scope_id_id_pk"
953+ }
954+ },
955+ "uniqueConstraints": {},
956+ "checkConstraints": {}
957+ },
958+ "secret": {
959+ "name": "secret",
960+ "columns": {
961+ "id": {
962+ "name": "id",
963+ "type": "text",
964+ "primaryKey": false,
965+ "notNull": true,
966+ "autoincrement": false
967+ },
968+ "scope_id": {
969+ "name": "scope_id",
970+ "type": "text",
971+ "primaryKey": false,
972+ "notNull": true,
973+ "autoincrement": false
974+ },
975+ "name": {
976+ "name": "name",
977+ "type": "text",
978+ "primaryKey": false,
979+ "notNull": true,
980+ "autoincrement": false
981+ },
982+ "provider": {
983+ "name": "provider",
984+ "type": "text",
985+ "primaryKey": false,
986+ "notNull": true,
987+ "autoincrement": false
988+ },
989+ "owned_by_connection_id": {
990+ "name": "owned_by_connection_id",
991+ "type": "text",
992+ "primaryKey": false,
993+ "notNull": false,
994+ "autoincrement": false
995+ },
996+ "created_at": {
997+ "name": "created_at",
998+ "type": "integer",
999+ "primaryKey": false,
1000+ "notNull": true,
1001+ "autoincrement": false
1002+ }
1003+ },
1004+ "indexes": {
1005+ "secret_scope_id_idx": {
1006+ "name": "secret_scope_id_idx",
1007+ "columns": [
1008+ "scope_id"
1009+ ],
1010+ "isUnique": false
1011+ },
1012+ "secret_provider_idx": {
1013+ "name": "secret_provider_idx",
1014+ "columns": [
1015+ "provider"
1016+ ],
1017+ "isUnique": false
1018+ },
1019+ "secret_owned_by_connection_id_idx": {
1020+ "name": "secret_owned_by_connection_id_idx",
1021+ "columns": [
1022+ "owned_by_connection_id"
1023+ ],
1024+ "isUnique": false
1025+ }
1026+ },
1027+ "foreignKeys": {},
1028+ "compositePrimaryKeys": {
1029+ "secret_scope_id_id_pk": {
1030+ "columns": [
1031+ "scope_id",
1032+ "id"
1033+ ],
1034+ "name": "secret_scope_id_id_pk"
1035+ }
1036+ },
1037+ "uniqueConstraints": {},
1038+ "checkConstraints": {}
1039+ },
1040+ "source": {
1041+ "name": "source",
1042+ "columns": {
1043+ "id": {
1044+ "name": "id",
1045+ "type": "text",
1046+ "primaryKey": false,
1047+ "notNull": true,
1048+ "autoincrement": false
1049+ },
1050+ "scope_id": {
1051+ "name": "scope_id",
1052+ "type": "text",
1053+ "primaryKey": false,
1054+ "notNull": true,
1055+ "autoincrement": false
1056+ },
1057+ "plugin_id": {
1058+ "name": "plugin_id",
1059+ "type": "text",
1060+ "primaryKey": false,
1061+ "notNull": true,
1062+ "autoincrement": false
1063+ },
1064+ "kind": {
1065+ "name": "kind",
1066+ "type": "text",
1067+ "primaryKey": false,
1068+ "notNull": true,
1069+ "autoincrement": false
1070+ },
1071+ "name": {
1072+ "name": "name",
1073+ "type": "text",
1074+ "primaryKey": false,
1075+ "notNull": true,
1076+ "autoincrement": false
1077+ },
1078+ "url": {
1079+ "name": "url",
1080+ "type": "text",
1081+ "primaryKey": false,
1082+ "notNull": false,
1083+ "autoincrement": false
1084+ },
1085+ "can_remove": {
1086+ "name": "can_remove",
1087+ "type": "integer",
1088+ "primaryKey": false,
1089+ "notNull": true,
1090+ "autoincrement": false,
1091+ "default": true
1092+ },
1093+ "can_refresh": {
1094+ "name": "can_refresh",
1095+ "type": "integer",
1096+ "primaryKey": false,
1097+ "notNull": true,
1098+ "autoincrement": false,
1099+ "default": false
1100+ },
1101+ "can_edit": {
1102+ "name": "can_edit",
1103+ "type": "integer",
1104+ "primaryKey": false,
1105+ "notNull": true,
1106+ "autoincrement": false,
1107+ "default": false
1108+ },
1109+ "created_at": {
1110+ "name": "created_at",
1111+ "type": "integer",
1112+ "primaryKey": false,
1113+ "notNull": true,
1114+ "autoincrement": false
1115+ },
1116+ "updated_at": {
1117+ "name": "updated_at",
1118+ "type": "integer",
1119+ "primaryKey": false,
1120+ "notNull": true,
1121+ "autoincrement": false
1122+ }
1123+ },
1124+ "indexes": {
1125+ "source_scope_id_idx": {
1126+ "name": "source_scope_id_idx",
1127+ "columns": [
1128+ "scope_id"
1129+ ],
1130+ "isUnique": false
1131+ },
1132+ "source_plugin_id_idx": {
1133+ "name": "source_plugin_id_idx",
1134+ "columns": [
1135+ "plugin_id"
1136+ ],
1137+ "isUnique": false
1138+ }
1139+ },
1140+ "foreignKeys": {},
1141+ "compositePrimaryKeys": {
1142+ "source_scope_id_id_pk": {
1143+ "columns": [
1144+ "scope_id",
1145+ "id"
1146+ ],
1147+ "name": "source_scope_id_id_pk"
1148+ }
1149+ },
1150+ "uniqueConstraints": {},
1151+ "checkConstraints": {}
1152+ },
1153+ "tool": {
1154+ "name": "tool",
1155+ "columns": {
1156+ "id": {
1157+ "name": "id",
1158+ "type": "text",
1159+ "primaryKey": false,
1160+ "notNull": true,
1161+ "autoincrement": false
1162+ },
1163+ "scope_id": {
1164+ "name": "scope_id",
1165+ "type": "text",
1166+ "primaryKey": false,
1167+ "notNull": true,
1168+ "autoincrement": false
1169+ },
1170+ "source_id": {
1171+ "name": "source_id",
1172+ "type": "text",
1173+ "primaryKey": false,
1174+ "notNull": true,
1175+ "autoincrement": false
1176+ },
1177+ "plugin_id": {
1178+ "name": "plugin_id",
1179+ "type": "text",
1180+ "primaryKey": false,
1181+ "notNull": true,
1182+ "autoincrement": false
1183+ },
1184+ "name": {
1185+ "name": "name",
1186+ "type": "text",
1187+ "primaryKey": false,
1188+ "notNull": true,
1189+ "autoincrement": false
1190+ },
1191+ "description": {
1192+ "name": "description",
1193+ "type": "text",
1194+ "primaryKey": false,
1195+ "notNull": true,
1196+ "autoincrement": false
1197+ },
1198+ "input_schema": {
1199+ "name": "input_schema",
1200+ "type": "text",
1201+ "primaryKey": false,
1202+ "notNull": false,
1203+ "autoincrement": false
1204+ },
1205+ "output_schema": {
1206+ "name": "output_schema",
1207+ "type": "text",
1208+ "primaryKey": false,
1209+ "notNull": false,
1210+ "autoincrement": false
1211+ },
1212+ "created_at": {
1213+ "name": "created_at",
1214+ "type": "integer",
1215+ "primaryKey": false,
1216+ "notNull": true,
1217+ "autoincrement": false
1218+ },
1219+ "updated_at": {
1220+ "name": "updated_at",
1221+ "type": "integer",
1222+ "primaryKey": false,
1223+ "notNull": true,
1224+ "autoincrement": false
1225+ }
1226+ },
1227+ "indexes": {
1228+ "tool_scope_id_idx": {
1229+ "name": "tool_scope_id_idx",
1230+ "columns": [
1231+ "scope_id"
1232+ ],
1233+ "isUnique": false
1234+ },
1235+ "tool_source_id_idx": {
1236+ "name": "tool_source_id_idx",
1237+ "columns": [
1238+ "source_id"
1239+ ],
1240+ "isUnique": false
1241+ },
1242+ "tool_plugin_id_idx": {
1243+ "name": "tool_plugin_id_idx",
1244+ "columns": [
1245+ "plugin_id"
1246+ ],
1247+ "isUnique": false
1248+ }
1249+ },
1250+ "foreignKeys": {},
1251+ "compositePrimaryKeys": {
1252+ "tool_scope_id_id_pk": {
1253+ "columns": [
1254+ "scope_id",
1255+ "id"
1256+ ],
1257+ "name": "tool_scope_id_id_pk"
1258+ }
1259+ },
1260+ "uniqueConstraints": {},
1261+ "checkConstraints": {}
1262+ }
1263+ },
1264+ "views": {},
1265+ "enums": {},
1266+ "_meta": {
1267+ "schemas": {},
1268+ "tables": {},
1269+ "columns": {}
1270+ },
1271+ "internal": {
1272+ "indexes": {}
1273+ }
1274+}
apps/local/drizzle/meta/_journal.json+7−0
@@ -15,6 +15,13 @@ · 14 unmodified lines
1515 "when": 1776680432025,
1616 "tag": "0001_sour_catseye",
1717 "breakpoints": true
18+ },
19+ {
20+ "idx": 2,
21+ "version": "6",
22+ "when": 1776732062873,
23+ "tag": "0002_lively_sue_storm",
24+ "breakpoints": true
1825 }
1926 ]
2027 }
apps/local/src/server/executor-schema.ts+21−0
@@ -56,11 +56,32 @@ export const secret = sqliteTable("secret", { · 55 unmodified lines
5656 scope_id: text('scope_id').notNull(),
5757 name: text('name').notNull(),
5858 provider: text('provider').notNull(),
59+ owned_by_connection_id: text('owned_by_connection_id'),
5960 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()
6061 }, (table) => [
6162 primaryKey({ columns: [table.scope_id, table.id] }),
6263 index("secret_scope_id_idx").on(table.scope_id),
6364 index("secret_provider_idx").on(table.provider),
65+ index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id),
66+]);
67+
68+export const connection = sqliteTable("connection", {
69+ id: text('id').notNull(),
70+ scope_id: text('scope_id').notNull(),
71+ provider: text('provider').notNull(),
72+ kind: text('kind').notNull(),
73+ identity_label: text('identity_label'),
74+ access_token_secret_id: text('access_token_secret_id').notNull(),
75+ refresh_token_secret_id: text('refresh_token_secret_id'),
76+ expires_at: integer('expires_at'),
77+ scope: text('scope'),
78+ provider_state: text('provider_state', { mode: "json" }),
79+ created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
80+ updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
81+}, (table) => [
82+ primaryKey({ columns: [table.scope_id, table.id] }),
83+ index("connection_scope_id_idx").on(table.scope_id),
84+ index("connection_provider_idx").on(table.provider),
6485 ]);
6586
6687 export const openapi_source = sqliteTable("openapi_source", {
apps/local/src/server/mcp-oauth.test.ts+4−28
@@ -55,7 +55,6 @@ import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; · 54 unmodified lines
5555 import {
5656 Scope,
5757 ScopeId,
58− SecretId,
5958 collectSchemas,
6059 createExecutor,
6160 } from "@executor/sdk";
@@ -382,14 +381,13 @@ const followAuthorize = async ( · 320 unmodified lines
382381 // ---------------------------------------------------------------------------
383382
384383 describe("local mcp oauth (real OAuth + MCP server)", () => {
385− it("startOAuth → authorize → completeOAuth writes tokens at the scope", async () => {
384+ it("startOAuth → authorize → completeOAuth mints a Connection at the scope", async () => {
386385 const clientLayer = FetchHttpClient.layer.pipe(
387386 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)),
388387 );
389388
390389 const namespace = `ns_${randomBytes(4).toString("hex")}`;
391− const accessTokenSecretId = `mcp_${namespace}_access_token`;
392− const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
390+ const connectionId = `mcp-oauth2-${namespace}`;
393391 const redirectUrl = "http://local.test/api/mcp/oauth/callback";
394392 const scopeId = ScopeId.make(harness.scopeId);
395393
@@ -410,8 +408,7 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 14 unmodified lines
410408 payload: {
411409 endpoint: `${fake.url}/mcp`,
412410 redirectUrl,
413− accessTokenSecretId,
414− refreshTokenSecretId,
411+ connectionId,
415412 },
416413 }),
417414 ),
@@ -429,30 +426,9 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 11 unmodified lines
429426 }),
430427 ),
431428 );
432− expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);
433− expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
429+ expect(completed.connectionId).toBe(connectionId);
434430 expect(completed.tokenType).toBe("Bearer");
435431 expect(completed.clientInformation).not.toBeNull();
436432 expect(completed.authorizationServerUrl).not.toBeNull();
437−
438− // Tokens landed at the (single) local scope and resolve back through
439− // the same provider that the plugin wrote them to.
440− const access = await Effect.runPromise(
441− run((client) =>
442− client.secrets.resolve({
443− path: { scopeId, secretId: SecretId.make(accessTokenSecretId) },
444− }),
445− ),
446− );
447− expect(access.value).toMatch(/^at_/);
448−
449− const refresh = await Effect.runPromise(
450− run((client) =>
451− client.secrets.resolve({
452− path: { scopeId, secretId: SecretId.make(refreshTokenSecretId) },
453− }),
454− ),
455− );
456− expect(refresh.value).toMatch(/^rt_/);
457433 }, 30_000);
458434 });
bun.lock+1−0
@@ -614,6 +614,7 @@ · 613 unmodified lines
614614 "@effect/platform": "catalog:",
615615 "@effect/platform-node": "catalog:",
616616 "@executor/config": "workspace:*",
617+ "@executor/plugin-oauth2": "workspace:*",
617618 "@executor/sdk": "workspace:*",
618619 "@modelcontextprotocol/sdk": "^1.29.0",
619620 "effect": "catalog:",
packages/core/api/src/api.ts+2−0
@@ -4,6 +4,7 @@ import type { HttpApiGroup } from "@effect/platform"; · 3 unmodified lines
44 import { ToolsApi } from "./tools/api";
55 import { SourcesApi } from "./sources/api";
66 import { SecretsApi } from "./secrets/api";
7+import { ConnectionsApi } from "./connections/api";
78 import { ExecutionsApi } from "./executions/api";
89 import { ScopeApi } from "./scope/api";
910
@@ -11,6 +12,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 1 unmodified line
1112 .add(ToolsApi)
1213 .add(SourcesApi)
1314 .add(SecretsApi)
15+ .add(ConnectionsApi)
1416 .add(ExecutionsApi)
1517 .add(ScopeApi)
1618 .annotateContext(
packages/core/api/src/connections/api.ts+57−0
@@ -0,0 +1,57 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+
4+import {
5+ ConnectionId,
6+ ConnectionKind,
7+ ScopeId,
8+ SecretId,
9+} from "@executor/sdk";
10+
11+import { InternalError } from "../observability";
12+
13+// ---------------------------------------------------------------------------
14+// Params
15+// ---------------------------------------------------------------------------
16+
17+const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
18+const connectionIdParam = HttpApiSchema.param("connectionId", ConnectionId);
19+
20+// ---------------------------------------------------------------------------
21+// Response schemas
22+// ---------------------------------------------------------------------------
23+
24+const ConnectionRefResponse = Schema.Struct({
25+ id: ConnectionId,
26+ scopeId: ScopeId,
27+ provider: Schema.String,
28+ kind: ConnectionKind,
29+ identityLabel: Schema.NullOr(Schema.String),
30+ accessTokenSecretId: SecretId,
31+ refreshTokenSecretId: Schema.NullOr(SecretId),
32+ expiresAt: Schema.NullOr(Schema.Number),
33+ oauthScope: Schema.NullOr(Schema.String),
34+ createdAt: Schema.Number,
35+ updatedAt: Schema.Number,
36+});
37+
38+// ---------------------------------------------------------------------------
39+// Group
40+// ---------------------------------------------------------------------------
41+
42+export class ConnectionsApi extends HttpApiGroup.make("connections")
43+ .add(
44+ HttpApiEndpoint.get(
45+ "list",
46+ )`/scopes/${scopeIdParam}/connections`.addSuccess(
47+ Schema.Array(ConnectionRefResponse),
48+ ),
49+ )
50+ .add(
51+ HttpApiEndpoint.del(
52+ "remove",
53+ )`/scopes/${scopeIdParam}/connections/${connectionIdParam}`.addSuccess(
54+ Schema.Struct({ removed: Schema.Boolean }),
55+ ),
56+ )
57+ .addError(InternalError) {}
packages/core/api/src/handlers/connections.ts+47−0
@@ -0,0 +1,47 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Effect } from "effect";
3+
4+import { capture } from "@executor/api";
5+import type { ConnectionRef } from "@executor/sdk";
6+
7+import { ExecutorApi } from "../api";
8+import { ExecutorService } from "../services";
9+
10+const refToResponse = (ref: ConnectionRef) => ({
11+ id: ref.id,
12+ scopeId: ref.scopeId,
13+ provider: ref.provider,
14+ kind: ref.kind,
15+ identityLabel: ref.identityLabel,
16+ accessTokenSecretId: ref.accessTokenSecretId,
17+ refreshTokenSecretId: ref.refreshTokenSecretId,
18+ expiresAt: ref.expiresAt,
19+ oauthScope: ref.oauthScope,
20+ createdAt: ref.createdAt.getTime(),
21+ updatedAt: ref.updatedAt.getTime(),
22+});
23+
24+export const ConnectionsHandlers = HttpApiBuilder.group(
25+ ExecutorApi,
26+ "connections",
27+ (handlers) =>
28+ handlers
29+ .handle("list", () =>
30+ capture(
31+ Effect.gen(function* () {
32+ const executor = yield* ExecutorService;
33+ const refs = yield* executor.connections.list();
34+ return refs.map(refToResponse);
35+ }),
36+ ),
37+ )
38+ .handle("remove", ({ path }) =>
39+ capture(
40+ Effect.gen(function* () {
41+ const executor = yield* ExecutorService;
42+ yield* executor.connections.remove(path.connectionId);
43+ return { removed: true };
44+ }),
45+ ),
46+ ),
47+);
packages/core/api/src/handlers/index.ts+3−0
@@ -3,12 +3,14 @@ import { Layer } from "effect"; · 2 unmodified lines
33 import { ToolsHandlers } from "./tools";
44 import { SourcesHandlers } from "./sources";
55 import { SecretsHandlers } from "./secrets";
6+import { ConnectionsHandlers } from "./connections";
67 import { ScopeHandlers } from "./scope";
78 import { ExecutionsHandlers } from "./executions";
89
910 export { ToolsHandlers } from "./tools";
1011 export { SourcesHandlers } from "./sources";
1112 export { SecretsHandlers } from "./secrets";
13+export { ConnectionsHandlers } from "./connections";
1214 export { ScopeHandlers } from "./scope";
1315 export { ExecutionsHandlers } from "./executions";
1416
@@ -16,6 +18,7 @@ export const CoreHandlers = Layer.mergeAll( · 1 unmodified line
1618 ToolsHandlers,
1719 SourcesHandlers,
1820 SecretsHandlers,
21+ ConnectionsHandlers,
1922 ScopeHandlers,
2023 ExecutionsHandlers,
2124 );
packages/core/api/src/index.ts+1−0
@@ -2,6 +2,7 @@ export { ExecutorApi, CoreExecutorApi, addGroup } from "./api"; · 1 unmodified line
22 export { ToolsApi } from "./tools/api";
33 export { SourcesApi } from "./sources/api";
44 export { SecretsApi } from "./secrets/api";
5+export { ConnectionsApi } from "./connections/api";
56 export { ExecutionsApi } from "./executions/api";
67 export { ScopeApi } from "./scope/api";
78 export {
packages/core/api/src/secrets/api.ts+12−2
@@ -1,6 +1,12 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
3−import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";
3+import {
4+ ScopeId,
5+ SecretId,
6+ SecretNotFoundError,
7+ SecretOwnedByConnectionError,
8+ SecretResolutionError,
9+} from "@executor/sdk";
410
511 import { InternalError } from "../observability";
612
@@ -48,6 +54,9 @@ const SecretNotFound = SecretNotFoundError.annotations(HttpApiSchema.annotations · 41 unmodified lines
4854 const SecretResolution = SecretResolutionError.annotations(
4955 HttpApiSchema.annotations({ status: 500 }),
5056 );
57+const SecretOwnedByConnection = SecretOwnedByConnectionError.annotations(
58+ HttpApiSchema.annotations({ status: 409 }),
59+);
5160
5261 // ---------------------------------------------------------------------------
5362 // Group
@@ -79,6 +88,7 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 25 unmodified lines
7988 .add(
8089 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`
8190 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
82− .addError(SecretNotFound),
91+ .addError(SecretNotFound)
92+ .addError(SecretOwnedByConnection),
8393 )
8494 .addError(InternalError) {}
packages/core/config/src/schema.ts+4−3
@@ -59,9 +59,10 @@ export const McpAuthConfig = Schema.Union( · 58 unmodified lines
5959 }),
6060 Schema.Struct({
6161 kind: Schema.Literal("oauth2"),
62− accessTokenSecret: Schema.String,
63− refreshTokenSecret: Schema.optional(Schema.NullOr(Schema.String)),
64− tokenType: Schema.optional(Schema.String),
62+ /** Stable id of the SDK Connection holding access + refresh token
63+ * material. Scope shadowing means the same id resolves per-user
64+ * via the executor's innermost-wins lookup. */
65+ connectionId: Schema.String,
6566 }),
6667 );
6768 export type McpAuthConfig = typeof McpAuthConfig.Type;
packages/core/sdk/src/connections.test.ts+0−0

Binary file not shown.

packages/core/sdk/src/connections.ts+181−0
@@ -0,0 +1,181 @@
1+import { Data, Effect, Schema } from "effect";
2+
3+import { ConnectionId, ScopeId, SecretId } from "./ids";
4+
5+// ---------------------------------------------------------------------------
6+// Connections — the product-level "sign-in state" primitive. A Connection
7+// owns one or more backing `secret` rows (access + refresh tokens) via
8+// `secret.owned_by_connection_id`; the user sees the Connection, the SDK
9+// handles every refresh internally. Plugins register a refresh handler
10+// per provider via `plugin.connectionProviders`, mirroring the shape of
11+// `plugin.secretProviders` for ordinary secret backends.
12+// ---------------------------------------------------------------------------
13+
14+/** Minimal JSON-object carrier for the plugin-owned `providerState`
15+ * blob. The SDK never inspects its shape; plugins encode/decode their
16+ * own structure. Never sensitive — that's what the secret rows are
17+ * for. */
18+export const ConnectionProviderState = Schema.Record({
19+ key: Schema.String,
20+ value: Schema.Unknown,
21+});
22+export type ConnectionProviderState = typeof ConnectionProviderState.Type;
23+
24+export const ConnectionKind = Schema.Literal("user", "app");
25+export type ConnectionKind = typeof ConnectionKind.Type;
26+
27+// ---------------------------------------------------------------------------
28+// ConnectionRef — metadata projection returned from `ctx.connections.list`
29+// / `executor.connections.list`. Holds token secret ids (so a plugin can
30+// reference them from its source config) but not token values.
31+// ---------------------------------------------------------------------------
32+
33+export class ConnectionRef extends Schema.Class<ConnectionRef>("ConnectionRef")({
34+ id: ConnectionId,
35+ scopeId: ScopeId,
36+ provider: Schema.String,
37+ kind: ConnectionKind,
38+ identityLabel: Schema.NullOr(Schema.String),
39+ accessTokenSecretId: SecretId,
40+ refreshTokenSecretId: Schema.NullOr(SecretId),
41+ /** Epoch ms when the access token expires; null if not declared. */
42+ expiresAt: Schema.NullOr(Schema.Number),
43+ /** OAuth-style scope string as returned by the token endpoint. Named
44+ * `oauthScope` to avoid collision with the executor scope id. */
45+ oauthScope: Schema.NullOr(Schema.String),
46+ providerState: Schema.NullOr(ConnectionProviderState),
47+ createdAt: Schema.DateFromNumber,
48+ updatedAt: Schema.DateFromNumber,
49+}) {}
50+
51+// ---------------------------------------------------------------------------
52+// CreateConnectionInput — what a plugin passes to create a fresh
53+// Connection after a successful OAuth exchange. The SDK writes the
54+// backing secret rows and the connection row in one transaction, and
55+// stamps `owned_by_connection_id` so `ctx.secrets.list` automatically
56+// hides them from the bare-secrets UI.
57+//
58+// `provider` must match a registered `ConnectionProvider.key`. The SDK
59+// validates this at create time so a typo surfaces immediately instead
60+// of the first time a refresh is attempted.
61+// ---------------------------------------------------------------------------
62+
63+export class TokenMaterial extends Schema.Class<TokenMaterial>("TokenMaterial")({
64+ /** Target secret id. Plugins typically derive this from the source id
65+ * + a stable suffix (e.g. `${sourceId}.access_token`). */
66+ secretId: SecretId,
67+ /** Display name stamped on the secret row. Only visible to code — the
68+ * Connections UI hides connection-owned secrets. */
69+ name: Schema.String,
70+ value: Schema.String,
71+}) {}
72+
73+export class CreateConnectionInput extends Schema.Class<CreateConnectionInput>(
74+ "CreateConnectionInput",
75+)({
76+ id: ConnectionId,
77+ /** Executor scope id that will own this connection + its backing
78+ * secrets. Usually the innermost (per-user) scope for user
79+ * sign-ins, or an org scope for shared installs. */
80+ scope: ScopeId,
81+ provider: Schema.String,
82+ kind: ConnectionKind,
83+ identityLabel: Schema.NullOr(Schema.String),
84+ accessToken: TokenMaterial,
85+ refreshToken: Schema.NullOr(TokenMaterial),
86+ expiresAt: Schema.NullOr(Schema.Number),
87+ /** OAuth-style scope string. Distinct from the executor scope above. */
88+ oauthScope: Schema.NullOr(Schema.String),
89+ providerState: Schema.NullOr(ConnectionProviderState),
90+}) {}
91+
92+// ---------------------------------------------------------------------------
93+// ConnectionRefreshError — typed error surface for refresh handlers.
94+// Plugins either return a fresh token envelope or fail with this error;
95+// the SDK rethrows it from `ctx.connections.accessToken` callers.
96+// ---------------------------------------------------------------------------
97+
98+export class ConnectionRefreshError extends Data.TaggedError(
99+ "ConnectionRefreshError",
100+)<{
101+ readonly connectionId: ConnectionId;
102+ readonly message: string;
103+ readonly cause?: unknown;
104+}> {}
105+
106+// ---------------------------------------------------------------------------
107+// ConnectionRefreshInput — what the SDK hands to a provider's `refresh`
108+// callback. Includes the current refresh-token value (already resolved
109+// from the secret row) and the opaque providerState blob so handlers
110+// don't need to hit secrets themselves.
111+// ---------------------------------------------------------------------------
112+
113+export interface ConnectionRefreshInput {
114+ readonly connectionId: ConnectionId;
115+ readonly scopeId: ScopeId;
116+ readonly kind: ConnectionKind;
117+ readonly identityLabel: string | null;
118+ /** Resolved refresh token value, or null if the connection has none. */
119+ readonly refreshToken: string | null;
120+ /** Plugin-owned blob persisted at create / previous refresh. */
121+ readonly providerState: ConnectionProviderState | null;
122+ /** OAuth scope string from the last token issuance. */
123+ readonly oauthScope: string | null;
124+}
125+
126+// ---------------------------------------------------------------------------
127+// ConnectionRefreshResult — what a provider's `refresh` callback returns
128+// on success. The SDK writes the new token values through the secret
129+// providers, updates `expires_at` / `scope` / `provider_state` on the
130+// connection row, and returns the fresh access token to the caller.
131+//
132+// `refreshToken` is optional: if the AS rotates refresh tokens it's
133+// present, if the AS issues long-lived refresh tokens it's absent. The
134+// SDK updates the refresh secret only when a new value is supplied.
135+// ---------------------------------------------------------------------------
136+
137+export interface ConnectionRefreshResult {
138+ readonly accessToken: string;
139+ readonly refreshToken?: string | null;
140+ readonly expiresAt?: number | null;
141+ readonly oauthScope?: string | null;
142+ readonly providerState?: ConnectionProviderState | null;
143+}
144+
145+// ---------------------------------------------------------------------------
146+// ConnectionProvider — plugin contribution. Registered via
147+// `plugin.connectionProviders`. One per refresh strategy (oauth2
148+// authorization-code, oauth2 client-credentials, per-provider custom,
149+// etc). Keyed by `key`; the connection row's `provider` column
150+// references this key.
151+//
152+// Omitting `refresh` means "tokens minted by this provider never
153+// refresh" — accessToken(id) just returns the stored value. Useful for
154+// long-lived API tokens wrapped as connections for UX consistency.
155+// ---------------------------------------------------------------------------
156+
157+export interface ConnectionProvider {
158+ readonly key: string;
159+ readonly refresh?: (
160+ input: ConnectionRefreshInput,
161+ ) => Effect.Effect<ConnectionRefreshResult, ConnectionRefreshError>;
162+}
163+
164+// ---------------------------------------------------------------------------
165+// UpdateConnectionTokensInput — for flows that re-exchange tokens out
166+// of band (e.g. an OAuth re-auth where the user signs in again). The
167+// SDK overwrites the backing secrets and updates the connection row in
168+// one transaction.
169+// ---------------------------------------------------------------------------
170+
171+export class UpdateConnectionTokensInput extends Schema.Class<UpdateConnectionTokensInput>(
172+ "UpdateConnectionTokensInput",
173+)({
174+ id: ConnectionId,
175+ accessToken: Schema.String,
176+ refreshToken: Schema.optional(Schema.NullOr(Schema.String)),
177+ expiresAt: Schema.optional(Schema.NullOr(Schema.Number)),
178+ oauthScope: Schema.optional(Schema.NullOr(Schema.String)),
179+ providerState: Schema.optional(Schema.NullOr(ConnectionProviderState)),
180+ identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
181+}) {}
packages/core/sdk/src/core-schema.ts+64−0
@@ -84,15 +84,74 @@ export const coreSchema = { · 83 unmodified lines
8484 // provider key). Actual values never touch this table — they live in
8585 // the secret provider (keychain, 1password, file, etc.) and are
8686 // resolved on demand via `ctx.secrets.get(id)`.
87+ //
88+ // `owned_by_connection_id` ties the row to a connection. Connection-
89+ // owned secrets are plumbing, not user-facing values: `ctx.secrets.list`
90+ // filters them out (the user sees the Connection instead), and
91+ // `ctx.secrets.remove` refuses to delete them (Connection.remove is
92+ // the single owner of the lifecycle). The FK is nullable so existing
93+ // "bare" secrets (API keys entered by the user, pre-connection OAuth
94+ // rows during migration) remain visible and removable unchanged.
8795 secret: {
8896 fields: {
8997 id: { type: "string", required: true },
9098 scope_id: { type: "string", required: true, index: true },
9199 name: { type: "string", required: true },
92100 provider: { type: "string", required: true, index: true },
101+ owned_by_connection_id: {
102+ type: "string",
103+ required: false,
104+ index: true,
105+ },
93106 created_at: { type: "date", required: true },
94107 },
95108 },
109+ // Connections — sign-in state for one identity against one remote
110+ // provider. A Connection owns one or more `secret` rows (access +
111+ // refresh tokens, etc.) via `secret.owned_by_connection_id`, and the
112+ // SDK exposes `ctx.connections.accessToken(id)` which transparently
113+ // refreshes the backing secrets when they're near expiry. Plugins
114+ // contribute refresh behavior via `plugin.connectionProviders[].refresh`
115+ // keyed by `provider`, same pattern as `secretProviders`.
116+ //
117+ // `provider_state` is plugin-owned opaque JSON — token endpoint URL,
118+ // scopes, issuer, auth-server metadata — whatever the provider's
119+ // refresh handler needs to re-hit the token endpoint. It's NOT
120+ // sensitive (all secrets go through the provider-backed secret rows);
121+ // it's just enough metadata to drive a refresh without re-running
122+ // discovery.
123+ connection: {
124+ fields: {
125+ id: { type: "string", required: true },
126+ scope_id: { type: "string", required: true, index: true },
127+ /** Routing key into `plugin.connectionProviders`. Typical shape
128+ * is `${pluginId}:${kind}` (e.g. `openapi:oauth2`, `mcp:oauth2`,
129+ * `google-discovery:google`). Mirrors `secret.provider`. */
130+ provider: { type: "string", required: true, index: true },
131+ /** "user" = per-person sign-in; "app" = app-level client-credentials
132+ * style install. Affects UI copy only; the SDK treats both the
133+ * same for refresh. */
134+ kind: { type: "string", required: true },
135+ /** Display label shown in the Connections UI. Usually the account
136+ * email / handle / org name the user signed in as. */
137+ identity_label: { type: "string", required: false },
138+ /** Stable id of the access-token secret. Always present. */
139+ access_token_secret_id: { type: "string", required: true },
140+ /** Stable id of the refresh-token secret. Null for flows that
141+ * don't mint a refresh token (client_credentials, etc.). */
142+ refresh_token_secret_id: { type: "string", required: false },
143+ /** Epoch ms when the access token expires. Null if the provider
144+ * didn't declare an expiry. Used as the refresh trigger. */
145+ expires_at: { type: "number", required: false },
146+ /** Scope string as returned by the token endpoint. */
147+ scope: { type: "string", required: false },
148+ /** Opaque plugin-owned JSON — token endpoint URL, scopes list,
149+ * discovery hints, etc. Never sensitive. */
150+ provider_state: { type: "json", required: false },
151+ created_at: { type: "date", required: true },
152+ updated_at: { type: "date", required: true },
153+ },
154+ },
96155 } as const satisfies DBSchema;
97156
98157 export type CoreSchema = typeof coreSchema;
@@ -116,6 +175,11 @@ export type DefinitionRow = InferDBFieldsOutput< · 17 unmodified lines
116175 export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> &
117176 Record<string, unknown>;
118177
178+export type ConnectionRow = InferDBFieldsOutput<
179+ CoreSchema["connection"]["fields"]
180+> &
181+ Record<string, unknown>;
182+
119183 // ---------------------------------------------------------------------------
120184 // Tool annotations — default-policy metadata the executor consults
121185 // before invocation. Returned by `plugin.resolveAnnotations` (dynamic
packages/core/sdk/src/errors.ts+43−2
@@ -1,6 +1,6 @@
11 import { Data, Schema } from "effect";
22
3−import { ToolId, SecretId } from "./ids";
3+import { ConnectionId, ToolId, SecretId } from "./ids";
44
55 // ---------------------------------------------------------------------------
66 // Tool lifecycle
@@ -74,6 +74,43 @@ export class SecretResolutionError extends Schema.TaggedError<SecretResolutionEr · 67 unmodified lines
7474 },
7575 ) {}
7676
77+/** Raised when `secrets.remove(id)` is called on a secret whose row has
78+ * `owned_by_connection_id` set. The connection owns the lifecycle —
79+ * callers must go through `connections.remove(connectionId)` to
80+ * delete it along with its siblings. */
81+export class SecretOwnedByConnectionError extends Schema.TaggedError<SecretOwnedByConnectionError>()(
82+ "SecretOwnedByConnectionError",
83+ {
84+ secretId: SecretId,
85+ connectionId: ConnectionId,
86+ },
87+) {}
88+
89+// ---------------------------------------------------------------------------
90+// Connections
91+// ---------------------------------------------------------------------------
92+
93+export class ConnectionNotFoundError extends Schema.TaggedError<ConnectionNotFoundError>()(
94+ "ConnectionNotFoundError",
95+ { connectionId: ConnectionId },
96+) {}
97+
98+export class ConnectionProviderNotRegisteredError extends Schema.TaggedError<ConnectionProviderNotRegisteredError>()(
99+ "ConnectionProviderNotRegisteredError",
100+ {
101+ provider: Schema.String,
102+ connectionId: Schema.optional(ConnectionId),
103+ },
104+) {}
105+
106+export class ConnectionRefreshNotSupportedError extends Schema.TaggedError<ConnectionRefreshNotSupportedError>()(
107+ "ConnectionRefreshNotSupportedError",
108+ {
109+ connectionId: ConnectionId,
110+ provider: Schema.String,
111+ },
112+) {}
113+
77114 // ---------------------------------------------------------------------------
78115 // Union type for convenience in signatures.
79116 // ---------------------------------------------------------------------------
@@ -86,4 +123,8 @@ export type ExecutorError = · 6 unmodified lines
86123 | SourceNotFoundError
87124 | SourceRemovalNotAllowedError
88125 | SecretNotFoundError
89− | SecretResolutionError;
126+ | SecretResolutionError
127+ | SecretOwnedByConnectionError
128+ | ConnectionNotFoundError
129+ | ConnectionProviderNotRegisteredError
130+ | ConnectionRefreshNotSupportedError;
packages/core/sdk/src/executor.ts+647−5
@@ -13,8 +13,17 @@ import { · 12 unmodified lines
1313 pluginBlobStore,
1414 type BlobStore,
1515 } from "./blob";
16+import {
17+ ConnectionProviderState,
18+ ConnectionRef,
19+ ConnectionRefreshError,
20+ type ConnectionProvider,
21+ type CreateConnectionInput,
22+ type UpdateConnectionTokensInput,
23+} from "./connections";
1624 import {
1725 coreSchema,
26+ type ConnectionRow,
1827 type CoreSchema,
1928 type DefinitionsInput,
2029 type SourceInput,
@@ -30,13 +39,17 @@ import { · 9 unmodified lines
3039 type ElicitationRequest,
3140 } from "./elicitation";
3241 import {
42+ ConnectionNotFoundError,
43+ ConnectionProviderNotRegisteredError,
44+ ConnectionRefreshNotSupportedError,
3345 NoHandlerError,
3446 PluginNotLoadedError,
47+ SecretOwnedByConnectionError,
3548 SourceRemovalNotAllowedError,
3649 ToolInvocationError,
3750 ToolNotFoundError,
3851 } from "./errors";
39−import { ScopeId, SecretId, ToolId } from "./ids";
52+import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids";
4053 import type {
4154 AnyPlugin,
4255 Elicit,
@@ -168,11 +181,51 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 125 unmodified lines
168181 readonly set: (
169182 input: SetSecretInput,
170183 ) => Effect.Effect<SecretRef, StorageFailure>;
171− readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
184+ /** Delete a bare (non-connection-owned) secret. Connection-owned
185+ * secrets are rejected with `SecretOwnedByConnectionError` — use
186+ * `connections.remove` instead. */
187+ readonly remove: (
188+ id: string,
189+ ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;
172190 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
173191 readonly providers: () => Effect.Effect<readonly string[]>;
174192 };
175193
194+ readonly connections: {
195+ readonly get: (
196+ id: string,
197+ ) => Effect.Effect<ConnectionRef | null, StorageFailure>;
198+ readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>;
199+ readonly create: (
200+ input: CreateConnectionInput,
201+ ) => Effect.Effect<
202+ ConnectionRef,
203+ ConnectionProviderNotRegisteredError | StorageFailure
204+ >;
205+ readonly updateTokens: (
206+ input: UpdateConnectionTokensInput,
207+ ) => Effect.Effect<
208+ ConnectionRef,
209+ ConnectionNotFoundError | StorageFailure
210+ >;
211+ readonly setIdentityLabel: (
212+ id: string,
213+ label: string | null,
214+ ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;
215+ readonly accessToken: (
216+ id: string,
217+ ) => Effect.Effect<
218+ string,
219+ | ConnectionNotFoundError
220+ | ConnectionProviderNotRegisteredError
221+ | ConnectionRefreshNotSupportedError
222+ | ConnectionRefreshError
223+ | StorageFailure
224+ >;
225+ readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
226+ readonly providers: () => Effect.Effect<readonly string[]>;
227+ };
228+
176229 readonly close: () => Effect.Effect<void, StorageFailure>;
177230 } & PluginExtensions<TPlugins>;
178231
@@ -547,6 +600,9 @@ export const createExecutor = < · 368 unmodified lines
547600 const runtimes = new Map<string, PluginRuntime>();
548601 // Secret providers keyed by `provider.key`.
549602 const secretProviders = new Map<string, SecretProvider>();
603+ // Connection providers keyed by `provider.key` — drive the refresh
604+ // lifecycle for connection-owned tokens.
605+ const connectionProviders = new Map<string, ConnectionProvider>();
550606 const extensions: Record<string, object> = {};
551607
552608 // ------------------------------------------------------------------
@@ -743,7 +799,9 @@ export const createExecutor = < · 190 unmodified lines
743799 });
744800 });
745801
746− const secretsRemove = (id: string): Effect.Effect<void, StorageFailure> =>
802+ const secretsRemove = (
803+ id: string,
804+ ): Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure> =>
747805 Effect.gen(function* () {
748806 // Remove is shadowing-aware: drop only the innermost-scope row.
749807 // Removing a user-scope override on a secret that also has an
@@ -757,6 +815,18 @@ export const createExecutor = < · 7 unmodified lines
757815 where: [{ field: "id", value: id }],
758816 });
759817 const target = findInnermost(rows);
818+ // Refuse to delete connection-owned secrets. The connection owns
819+ // the lifecycle — callers must go through connections.remove.
820+ if (target && target.owned_by_connection_id) {
821+ return yield* Effect.fail(
822+ new SecretOwnedByConnectionError({
823+ secretId: SecretId.make(id),
824+ connectionId: ConnectionId.make(
825+ target.owned_by_connection_id as string,
826+ ),
827+ }),
828+ );
829+ }
760830 const targetScope = (target?.scope_id as string | undefined) ??
761831 scopeIds[0]!;
762832
@@ -808,8 +878,18 @@ export const createExecutor = < · 45 unmodified lines
808878
809879 // Core routing rows first. Adapter returns rows from every
810880 // scope in the stack; resolve collisions using the caller's
811− // precedence order (innermost first).
812− const rows = yield* core.findMany({ model: "secret" });
881+ // precedence order (innermost first). Rows owned by a
882+ // connection are filtered out — the user sees the Connection
883+ // entry, not its backing token secrets. Their ids go in a
884+ // deny-set so provider `list()` results for the same id can't
885+ // leak them back in below.
886+ const allRows = yield* core.findMany({ model: "secret" });
887+ const connectionOwnedIds = new Set(
888+ allRows
889+ .filter((r) => r.owned_by_connection_id)
890+ .map((r) => r.id as string),
891+ );
892+ const rows = allRows.filter((r) => !r.owned_by_connection_id);
813893 const precedence = new Map<string, number>();
814894 scopeIds.forEach((id, index) => precedence.set(id, index));
815895 const pick = (row: typeof rows[number]) => {
@@ -859,6 +939,7 @@ export const createExecutor = < · 43 unmodified lines
859939 for (const { key, entries } of lists) {
860940 for (const entry of entries) {
861941 if (byId.has(entry.id)) continue; // core row wins
942+ if (connectionOwnedIds.has(entry.id)) continue; // hidden by connection
862943 byId.set(
863944 entry.id,
864945 new SecretRef({
@@ -887,6 +968,525 @@ export const createExecutor = < · 22 unmodified lines
887968 }));
888969 });
889970
971+ // ------------------------------------------------------------------
972+ // Connections facade — sign-in state as a first-class primitive.
973+ // Connection rows own one or more backing `secret` rows via
974+ // `secret.owned_by_connection_id`; the SDK orchestrates refresh via
975+ // the registered provider keyed by `connection.provider`.
976+ // ------------------------------------------------------------------
977+
978+ // Refresh skew: treat the access token as "about to expire" when
979+ // we're within this many ms of the expiry the AS declared.
980+ // Matches the value the old per-plugin refresh code used, so
981+ // behavior under the new SDK orchestration stays identical.
982+ const CONNECTION_REFRESH_SKEW_MS = 60_000;
983+
984+ const toJsonObject = (
985+ value: unknown,
986+ ): ConnectionProviderState | null => {
987+ if (value === null || value === undefined) return null;
988+ if (typeof value === "string") {
989+ try {
990+ const parsed = JSON.parse(value);
991+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
992+ ? (parsed as ConnectionProviderState)
993+ : null;
994+ } catch {
995+ return null;
996+ }
997+ }
998+ if (typeof value !== "object" || Array.isArray(value)) return null;
999+ return value as ConnectionProviderState;
1000+ };
1001+
1002+ const rowToConnection = (row: ConnectionRow): ConnectionRef =>
1003+ new ConnectionRef({
1004+ id: ConnectionId.make(row.id as string),
1005+ scopeId: ScopeId.make(row.scope_id as string),
1006+ provider: row.provider as string,
1007+ kind: (row.kind as "user" | "app") ?? "user",
1008+ identityLabel: (row.identity_label as string | null | undefined) ?? null,
1009+ accessTokenSecretId: SecretId.make(row.access_token_secret_id as string),
1010+ refreshTokenSecretId:
1011+ row.refresh_token_secret_id != null
1012+ ? SecretId.make(row.refresh_token_secret_id as string)
1013+ : null,
1014+ expiresAt:
1015+ row.expires_at != null ? Number(row.expires_at as number) : null,
1016+ oauthScope: (row.scope as string | null | undefined) ?? null,
1017+ providerState: toJsonObject(row.provider_state),
1018+ createdAt:
1019+ row.created_at instanceof Date
1020+ ? row.created_at
1021+ : new Date(row.created_at as string),
1022+ updatedAt:
1023+ row.updated_at instanceof Date
1024+ ? row.updated_at
1025+ : new Date(row.updated_at as string),
1026+ });
1027+
1028+ const findInnermostConnectionRow = (
1029+ id: string,
1030+ ): Effect.Effect<ConnectionRow | null, StorageFailure> =>
1031+ Effect.gen(function* () {
1032+ const rows = yield* core.findMany({
1033+ model: "connection",
1034+ where: [{ field: "id", value: id }],
1035+ });
1036+ return findInnermost(rows as readonly ConnectionRow[]);
1037+ });
1038+
1039+ const connectionsGet = (
1040+ id: string,
1041+ ): Effect.Effect<ConnectionRef | null, StorageFailure> =>
1042+ Effect.gen(function* () {
1043+ const row = yield* findInnermostConnectionRow(id);
1044+ return row ? rowToConnection(row) : null;
1045+ });
1046+
1047+ const connectionsList = (): Effect.Effect<
1048+ readonly ConnectionRef[],
1049+ StorageFailure
1050+ > =>
1051+ Effect.gen(function* () {
1052+ const rows = yield* core.findMany({ model: "connection" });
1053+ // Dedup by id, innermost scope wins — same rule as sources/tools.
1054+ const byId = new Map<string, ConnectionRow>();
1055+ const byIdRank = new Map<string, number>();
1056+ for (const row of rows as readonly ConnectionRow[]) {
1057+ const rank = scopeRank(row as { scope_id: unknown });
1058+ const existing = byIdRank.get(row.id as string);
1059+ if (existing === undefined || rank < existing) {
1060+ byId.set(row.id as string, row);
1061+ byIdRank.set(row.id as string, rank);
1062+ }
1063+ }
1064+ return [...byId.values()].map(rowToConnection);
1065+ });
1066+
1067+ // Write a secret value through a specific provider, bypassing the
1068+ // bare-secrets ownership check so the SDK can stamp
1069+ // `owned_by_connection_id` atomically alongside a connection row.
1070+ const writeOwnedSecret = (
1071+ params: {
1072+ id: string;
1073+ scope: string;
1074+ name: string;
1075+ value: string;
1076+ provider: string;
1077+ ownedByConnectionId: string;
1078+ },
1079+ ): Effect.Effect<void, StorageFailure> =>
1080+ Effect.gen(function* () {
1081+ const target = secretProviders.get(params.provider);
1082+ if (!target) {
1083+ return yield* Effect.fail(
1084+ new StorageError({
1085+ message: `Unknown secret provider: ${params.provider}`,
1086+ cause: undefined,
1087+ }),
1088+ );
1089+ }
1090+ if (!target.writable || !target.set) {
1091+ return yield* Effect.fail(
1092+ new StorageError({
1093+ message: `Secret provider "${target.key}" is read-only`,
1094+ cause: undefined,
1095+ }),
1096+ );
1097+ }
1098+ yield* target.set(params.id, params.value, params.scope);
1099+
1100+ const now = new Date();
1101+ yield* core.delete({
1102+ model: "secret",
1103+ where: [
1104+ { field: "id", value: params.id },
1105+ { field: "scope_id", value: params.scope },
1106+ ],
1107+ });
1108+ yield* core.create({
1109+ model: "secret",
1110+ data: {
1111+ id: params.id,
1112+ scope_id: params.scope,
1113+ name: params.name,
1114+ provider: target.key,
1115+ owned_by_connection_id: params.ownedByConnectionId,
1116+ created_at: now,
1117+ },
1118+ forceAllowId: true,
1119+ });
1120+ });
1121+
1122+ const pickWritableProvider = (
1123+ requested?: string,
1124+ ): Effect.Effect<SecretProvider, StorageFailure> =>
1125+ Effect.gen(function* () {
1126+ if (requested) {
1127+ const p = secretProviders.get(requested);
1128+ if (!p) {
1129+ return yield* Effect.fail(
1130+ new StorageError({
1131+ message: `Unknown secret provider: ${requested}`,
1132+ cause: undefined,
1133+ }),
1134+ );
1135+ }
1136+ return p;
1137+ }
1138+ for (const p of secretProviders.values()) {
1139+ if (p.writable && p.set) return p;
1140+ }
1141+ return yield* Effect.fail(
1142+ new StorageError({
1143+ message: "No writable secret providers registered",
1144+ cause: undefined,
1145+ }),
1146+ );
1147+ });
1148+
1149+ const connectionsCreate = (
1150+ input: CreateConnectionInput,
1151+ ): Effect.Effect<
1152+ ConnectionRef,
1153+ ConnectionProviderNotRegisteredError | StorageFailure
1154+ > =>
1155+ Effect.gen(function* () {
1156+ if (!scopeIds.includes(input.scope as string)) {
1157+ return yield* Effect.fail(
1158+ new StorageError({
1159+ message:
1160+ `connections.create targets scope "${input.scope}" which is not ` +
1161+ `in the executor's scope stack [${scopeIds.join(", ")}].`,
1162+ cause: undefined,
1163+ }),
1164+ );
1165+ }
1166+ if (!connectionProviders.has(input.provider)) {
1167+ return yield* Effect.fail(
1168+ new ConnectionProviderNotRegisteredError({
1169+ provider: input.provider,
1170+ connectionId: input.id,
1171+ }),
1172+ );
1173+ }
1174+
1175+ const writable = yield* pickWritableProvider();
1176+ const now = new Date();
1177+
1178+ return yield* adapter.transaction(() =>
1179+ Effect.gen(function* () {
1180+ // Drop any existing connection row at this scope first so a
1181+ // re-auth replaces cleanly. Owned-secret rows for the old
1182+ // connection are removed by the cascade below (we delete
1183+ // both old + new token secret ids explicitly).
1184+ yield* core.delete({
1185+ model: "connection",
1186+ where: [
1187+ { field: "id", value: input.id as string },
1188+ { field: "scope_id", value: input.scope as string },
1189+ ],
1190+ });
1191+
1192+ yield* writeOwnedSecret({
1193+ id: input.accessToken.secretId as string,
1194+ scope: input.scope as string,
1195+ name: input.accessToken.name,
1196+ value: input.accessToken.value,
1197+ provider: writable.key,
1198+ ownedByConnectionId: input.id as string,
1199+ });
1200+ if (input.refreshToken) {
1201+ yield* writeOwnedSecret({
1202+ id: input.refreshToken.secretId as string,
1203+ scope: input.scope as string,
1204+ name: input.refreshToken.name,
1205+ value: input.refreshToken.value,
1206+ provider: writable.key,
1207+ ownedByConnectionId: input.id as string,
1208+ });
1209+ }
1210+
1211+ yield* core.create({
1212+ model: "connection",
1213+ data: {
1214+ id: input.id as string,
1215+ scope_id: input.scope as string,
1216+ provider: input.provider,
1217+ kind: input.kind,
1218+ identity_label: input.identityLabel ?? undefined,
1219+ access_token_secret_id: input.accessToken.secretId as string,
1220+ refresh_token_secret_id:
1221+ input.refreshToken?.secretId ?? undefined,
1222+ expires_at: input.expiresAt ?? undefined,
1223+ scope: input.oauthScope ?? undefined,
1224+ provider_state: input.providerState ?? undefined,
1225+ created_at: now,
1226+ updated_at: now,
1227+ },
1228+ forceAllowId: true,
1229+ });
1230+
1231+ return new ConnectionRef({
1232+ id: input.id,
1233+ scopeId: input.scope,
1234+ provider: input.provider,
1235+ kind: input.kind,
1236+ identityLabel: input.identityLabel,
1237+ accessTokenSecretId: input.accessToken.secretId,
1238+ refreshTokenSecretId:
1239+ input.refreshToken?.secretId ?? null,
1240+ expiresAt: input.expiresAt,
1241+ oauthScope: input.oauthScope,
1242+ providerState: input.providerState,
1243+ createdAt: now,
1244+ updatedAt: now,
1245+ });
1246+ }),
1247+ );
1248+ });
1249+
1250+ // Write new token material into the existing secret rows and bump
1251+ // the connection row's expiry / scope / providerState. Never
1252+ // mutates `access_token_secret_id` or `refresh_token_secret_id` —
1253+ // those stay pinned so consumers that stashed them in source
1254+ // configs still resolve.
1255+ const connectionsUpdateTokens = (
1256+ input: UpdateConnectionTokensInput,
1257+ ): Effect.Effect<
1258+ ConnectionRef,
1259+ ConnectionNotFoundError | StorageFailure
1260+ > =>
1261+ Effect.gen(function* () {
1262+ const row = yield* findInnermostConnectionRow(input.id as string);
1263+ if (!row) {
1264+ return yield* Effect.fail(
1265+ new ConnectionNotFoundError({ connectionId: input.id }),
1266+ );
1267+ }
1268+ const writable = yield* pickWritableProvider();
1269+ const accessName =
1270+ `Connection ${input.id as string} access token`;
1271+ const refreshName =
1272+ `Connection ${input.id as string} refresh token`;
1273+
1274+ return yield* adapter.transaction(() =>
1275+ Effect.gen(function* () {
1276+ yield* writeOwnedSecret({
1277+ id: row.access_token_secret_id as string,
1278+ scope: row.scope_id as string,
1279+ name: accessName,
1280+ value: input.accessToken,
1281+ provider: writable.key,
1282+ ownedByConnectionId: row.id as string,
1283+ });
1284+ const rotatedRefresh = input.refreshToken ?? undefined;
1285+ if (
1286+ rotatedRefresh &&
1287+ row.refresh_token_secret_id
1288+ ) {
1289+ yield* writeOwnedSecret({
1290+ id: row.refresh_token_secret_id as string,
1291+ scope: row.scope_id as string,
1292+ name: refreshName,
1293+ value: rotatedRefresh,
1294+ provider: writable.key,
1295+ ownedByConnectionId: row.id as string,
1296+ });
1297+ }
1298+ const now = new Date();
1299+ const patch: Record<string, unknown> = { updated_at: now };
1300+ if (input.expiresAt !== undefined)
1301+ patch.expires_at = input.expiresAt ?? undefined;
1302+ if (input.oauthScope !== undefined)
1303+ patch.scope = input.oauthScope ?? undefined;
1304+ if (input.providerState !== undefined)
1305+ patch.provider_state = input.providerState ?? undefined;
1306+ if (input.identityLabel !== undefined)
1307+ patch.identity_label = input.identityLabel ?? undefined;
1308+ yield* core.update({
1309+ model: "connection",
1310+ where: [
1311+ { field: "id", value: row.id as string },
1312+ { field: "scope_id", value: row.scope_id as string },
1313+ ],
1314+ update: patch,
1315+ });
1316+ const updated = yield* findInnermostConnectionRow(
1317+ row.id as string,
1318+ );
1319+ if (!updated) {
1320+ return yield* Effect.fail(
1321+ new ConnectionNotFoundError({ connectionId: input.id }),
1322+ );
1323+ }
1324+ return rowToConnection(updated);
1325+ }),
1326+ );
1327+ });
1328+
1329+ const connectionsSetIdentityLabel = (
1330+ id: string,
1331+ label: string | null,
1332+ ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> =>
1333+ Effect.gen(function* () {
1334+ const row = yield* findInnermostConnectionRow(id);
1335+ if (!row) {
1336+ return yield* Effect.fail(
1337+ new ConnectionNotFoundError({
1338+ connectionId: ConnectionId.make(id),
1339+ }),
1340+ );
1341+ }
1342+ yield* core.update({
1343+ model: "connection",
1344+ where: [
1345+ { field: "id", value: id },
1346+ { field: "scope_id", value: row.scope_id as string },
1347+ ],
1348+ update: {
1349+ identity_label: label ?? undefined,
1350+ updated_at: new Date(),
1351+ },
1352+ });
1353+ });
1354+
1355+ const connectionsRemove = (
1356+ id: string,
1357+ ): Effect.Effect<void, StorageFailure> =>
1358+ Effect.gen(function* () {
1359+ const row = yield* findInnermostConnectionRow(id);
1360+ if (!row) return;
1361+ const scope = row.scope_id as string;
1362+ yield* adapter.transaction(() =>
1363+ Effect.gen(function* () {
1364+ // Find every owned secret at this scope and drop through
1365+ // its provider + the core row. We look up by
1366+ // `owned_by_connection_id` rather than just the two ids on
1367+ // the connection row so any accidentally-orphaned siblings
1368+ // get cleaned up too.
1369+ const owned = yield* core.findMany({
1370+ model: "secret",
1371+ where: [
1372+ { field: "owned_by_connection_id", value: id },
1373+ { field: "scope_id", value: scope },
1374+ ],
1375+ });
1376+ const deleters = [...secretProviders.values()].filter(
1377+ (p): p is typeof p & { delete: NonNullable<typeof p.delete> } =>
1378+ !!(p.writable && p.delete),
1379+ );
1380+ for (const secret of owned) {
1381+ yield* Effect.all(
1382+ deleters.map((p) => p.delete(secret.id as string, scope)),
1383+ { concurrency: "unbounded" },
1384+ );
1385+ }
1386+ yield* core.deleteMany({
1387+ model: "secret",
1388+ where: [
1389+ { field: "owned_by_connection_id", value: id },
1390+ { field: "scope_id", value: scope },
1391+ ],
1392+ });
1393+ yield* core.delete({
1394+ model: "connection",
1395+ where: [
1396+ { field: "id", value: id },
1397+ { field: "scope_id", value: scope },
1398+ ],
1399+ });
1400+ }),
1401+ );
1402+ });
1403+
1404+ // accessToken(id) — the single surface plugins use at invoke time.
1405+ // Resolves the backing secret, checks expiry, calls the provider's
1406+ // refresh handler if we're inside the skew window. New tokens are
1407+ // written back through the same provider and the connection row is
1408+ // patched with the new expiry.
1409+ const connectionsAccessToken = (
1410+ id: string,
1411+ ): Effect.Effect<
1412+ string,
1413+ | ConnectionNotFoundError
1414+ | ConnectionProviderNotRegisteredError
1415+ | ConnectionRefreshNotSupportedError
1416+ | ConnectionRefreshError
1417+ | StorageFailure
1418+ > =>
1419+ Effect.gen(function* () {
1420+ const row = yield* findInnermostConnectionRow(id);
1421+ if (!row) {
1422+ return yield* Effect.fail(
1423+ new ConnectionNotFoundError({
1424+ connectionId: ConnectionId.make(id),
1425+ }),
1426+ );
1427+ }
1428+ const ref = rowToConnection(row);
1429+ const now = Date.now();
1430+ const needsRefresh =
1431+ ref.expiresAt !== null &&
1432+ ref.expiresAt - CONNECTION_REFRESH_SKEW_MS <= now;
1433+
1434+ if (!needsRefresh) {
1435+ const current = yield* secretsGet(
1436+ ref.accessTokenSecretId as unknown as string,
1437+ );
1438+ if (current !== null) return current;
1439+ // Fall through to refresh if the stored token vanished — a
1440+ // genuinely-missing secret with no way to refresh is a
1441+ // hard-failure, same behavior as if `expires_at` had passed.
1442+ }
1443+
1444+ const provider = connectionProviders.get(ref.provider);
1445+ if (!provider) {
1446+ return yield* Effect.fail(
1447+ new ConnectionProviderNotRegisteredError({
1448+ provider: ref.provider,
1449+ connectionId: ref.id,
1450+ }),
1451+ );
1452+ }
1453+ if (!provider.refresh) {
1454+ return yield* Effect.fail(
1455+ new ConnectionRefreshNotSupportedError({
1456+ connectionId: ref.id,
1457+ provider: ref.provider,
1458+ }),
1459+ );
1460+ }
1461+
1462+ const refreshTokenValue = ref.refreshTokenSecretId
1463+ ? yield* secretsGet(ref.refreshTokenSecretId as unknown as string)
1464+ : null;
1465+
1466+ const result = yield* provider.refresh({
1467+ connectionId: ref.id,
1468+ scopeId: ref.scopeId,
1469+ kind: ref.kind,
1470+ identityLabel: ref.identityLabel,
1471+ refreshToken: refreshTokenValue,
1472+ providerState: ref.providerState,
1473+ oauthScope: ref.oauthScope,
1474+ });
1475+
1476+ yield* connectionsUpdateTokens({
1477+ id: ref.id,
1478+ accessToken: result.accessToken,
1479+ refreshToken: result.refreshToken,
1480+ expiresAt: result.expiresAt,
1481+ oauthScope: result.oauthScope,
1482+ providerState: result.providerState,
1483+ } as UpdateConnectionTokensInput);
1484+
1485+ return result.accessToken;
1486+ });
1487+
1488+ const connectionsListForCtx = () => connectionsList();
1489+
8901490 // ------------------------------------------------------------------
8911491 // Plugin wiring — build ctx, run extension, populate static pools,
8921492 // register secret providers. No adapter reads here.
@@ -996,6 +1596,16 @@ export const createExecutor = < · 103 unmodified lines
9961596 set: (input) => secretsSet(input),
9971597 remove: (id) => secretsRemove(id),
9981598 },
1599+ connections: {
1600+ get: (id) => connectionsGet(id),
1601+ list: () => connectionsListForCtx(),
1602+ create: (input) => connectionsCreate(input),
1603+ updateTokens: (input) => connectionsUpdateTokens(input),
1604+ setIdentityLabel: (id, label) =>
1605+ connectionsSetIdentityLabel(id, label),
1606+ accessToken: (id) => connectionsAccessToken(id),
1607+ remove: (id) => connectionsRemove(id),
1608+ },
9991609 // Open one real tx boundary and route every nested write inside
10001610 // `effect` through that same handle via the activeAdapterRef —
10011611 // see buildAdapterRouter above. Caller-typed errors (`E`)
@@ -1070,6 +1680,24 @@ export const createExecutor = < · 68 unmodified lines
10701680 secretProviders.set(provider.key, provider);
10711681 }
10721682 }
1683+
1684+ if (plugin.connectionProviders) {
1685+ const raw =
1686+ typeof plugin.connectionProviders === "function"
1687+ ? plugin.connectionProviders(ctx)
1688+ : plugin.connectionProviders;
1689+ const providers = Effect.isEffect(raw) ? yield* raw : raw;
1690+ for (const provider of providers) {
1691+ if (connectionProviders.has(provider.key)) {
1692+ return yield* Effect.fail(
1693+ new Error(
1694+ `Duplicate connection provider key: ${provider.key} (from plugin ${plugin.id})`,
1695+ ),
1696+ );
1697+ }
1698+ connectionProviders.set(provider.key, provider);
1699+ }
1700+ }
10731701 }
10741702
10751703 // ------------------------------------------------------------------
@@ -1647,6 +2275,20 @@ export const createExecutor = < · 571 unmodified lines
16472275 () => Array.from(secretProviders.keys()) as readonly string[],
16482276 ),
16492277 },
2278+ connections: {
2279+ get: connectionsGet,
2280+ list: connectionsList,
2281+ create: connectionsCreate,
2282+ updateTokens: connectionsUpdateTokens,
2283+ setIdentityLabel: connectionsSetIdentityLabel,
2284+ accessToken: connectionsAccessToken,
2285+ remove: connectionsRemove,
2286+ providers: () =>
2287+ Effect.sync(
2288+ () =>
2289+ Array.from(connectionProviders.keys()) as readonly string[],
2290+ ),
2291+ },
16502292 close,
16512293 };
16522294
packages/core/sdk/src/ids.ts+3−0
@@ -11,3 +11,6 @@ export type SecretId = typeof SecretId.Type; · 10 unmodified lines
1111
1212 export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId"));
1313 export type PolicyId = typeof PolicyId.Type;
14+
15+export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId"));
16+export type ConnectionId = typeof ConnectionId.Type;
packages/core/sdk/src/index.ts+20−1
@@ -23,7 +23,7 @@ export { typedAdapter } from "@executor/storage-core"; · 22 unmodified lines
2323 export { StorageError, UniqueViolationError } from "@executor/storage-core";
2424
2525 // IDs (branded)
26−export { ScopeId, ToolId, SecretId, PolicyId } from "./ids";
26+export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids";
2727
2828 // Scope
2929 export { Scope } from "./scope";
@@ -38,6 +38,10 @@ export { · 8 unmodified lines
3838 PluginNotLoadedError,
3939 SecretNotFoundError,
4040 SecretResolutionError,
41+ SecretOwnedByConnectionError,
42+ ConnectionNotFoundError,
43+ ConnectionProviderNotRegisteredError,
44+ ConnectionRefreshNotSupportedError,
4145 type ExecutorError,
4246 } from "./errors";
4347
@@ -60,6 +64,7 @@ export { · 16 unmodified lines
6064 type ToolRow,
6165 type DefinitionRow,
6266 type SecretRow,
67+ type ConnectionRow,
6368 type DefinitionsInput,
6469 type ToolAnnotations,
6570 } from "./core-schema";
@@ -71,6 +76,20 @@ export { · 5 unmodified lines
7176 type SecretProvider,
7277 } from "./secrets";
7378
79+// Connections
80+export {
81+ ConnectionRef,
82+ ConnectionKind,
83+ ConnectionProviderState,
84+ CreateConnectionInput,
85+ UpdateConnectionTokensInput,
86+ TokenMaterial,
87+ ConnectionRefreshError,
88+ type ConnectionProvider,
89+ type ConnectionRefreshInput,
90+ type ConnectionRefreshResult,
91+} from "./connections";
92+
7493 // Elicitation
7594 export {
7695 FormElicitation,
packages/core/sdk/src/plugin.ts+76−1
@@ -7,6 +7,13 @@ import type { · 6 unmodified lines
77 } from "@executor/storage-core";
88
99 import type { PluginBlobStore } from "./blob";
10+import type {
11+ ConnectionProvider,
12+ ConnectionRef,
13+ ConnectionRefreshError,
14+ CreateConnectionInput,
15+ UpdateConnectionTokensInput,
16+} from "./connections";
1017 import type {
1118 DefinitionsInput,
1219 SourceInput,
@@ -20,6 +27,12 @@ import type { · 7 unmodified lines
2027 ElicitationRequest,
2128 ElicitationResponse,
2229 } from "./elicitation";
30+import type {
31+ ConnectionNotFoundError,
32+ ConnectionProviderNotRegisteredError,
33+ ConnectionRefreshNotSupportedError,
34+ SecretOwnedByConnectionError,
35+} from "./errors";
2336 import type { Scope } from "./scope";
2437 import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";
2538
@@ -119,6 +132,9 @@ export interface PluginCtx<TStore = unknown> { · 93 unmodified lines
119132 readonly get: (
120133 id: string,
121134 ) => Effect.Effect<string | null, StorageFailure>;
135+ /** List user-visible secrets. Connection-owned secrets (rows with
136+ * `owned_by_connection_id` set) are filtered out so they don't
137+ * clutter the UI — users see the Connection instead. */
122138 readonly list: () => Effect.Effect<
123139 readonly { readonly id: string; readonly name: string; readonly provider: string }[],
124140 StorageFailure
@@ -132,7 +148,54 @@ export interface PluginCtx<TStore = unknown> { · 7 unmodified lines
132148 readonly set: (
133149 input: SetSecretInput,
134150 ) => Effect.Effect<SecretRef, StorageFailure>;
135− /** Delete a secret from its pinned provider and the core table. */
151+ /** Delete a secret from its pinned provider and the core table.
152+ * Rejects with `SecretOwnedByConnectionError` if the row is owned
153+ * by a connection — callers must go through `connections.remove`
154+ * to drop the whole sign-in. */
155+ readonly remove: (
156+ id: string,
157+ ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;
158+ };
159+
160+ /** Connections — product-level sign-in state. Owns backing secret
161+ * rows via `secret.owned_by_connection_id`. Plugins call
162+ * `connections.accessToken(id)` at invoke time to get a guaranteed-
163+ * fresh token (the SDK handles refresh via the registered provider
164+ * keyed by `connection.provider`). */
165+ readonly connections: {
166+ readonly get: (
167+ id: string,
168+ ) => Effect.Effect<ConnectionRef | null, StorageFailure>;
169+ readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>;
170+ readonly create: (
171+ input: CreateConnectionInput,
172+ ) => Effect.Effect<
173+ ConnectionRef,
174+ ConnectionProviderNotRegisteredError | StorageFailure
175+ >;
176+ readonly updateTokens: (
177+ input: UpdateConnectionTokensInput,
178+ ) => Effect.Effect<
179+ ConnectionRef,
180+ ConnectionNotFoundError | StorageFailure
181+ >;
182+ readonly setIdentityLabel: (
183+ id: string,
184+ label: string | null,
185+ ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;
186+ /** Get a guaranteed-fresh access token. Calls the provider's
187+ * `refresh` handler if `expires_at` is in the past / within the
188+ * refresh skew window. */
189+ readonly accessToken: (
190+ id: string,
191+ ) => Effect.Effect<
192+ string,
193+ | ConnectionNotFoundError
194+ | ConnectionProviderNotRegisteredError
195+ | ConnectionRefreshNotSupportedError
196+ | ConnectionRefreshError
197+ | StorageFailure
198+ >;
136199 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
137200 };
138201
@@ -331,6 +394,18 @@ export interface PluginSpec< · 192 unmodified lines
331394 ctx: PluginCtx<TStore>,
332395 ) => Effect.Effect<readonly SecretProvider[]>);
333396
397+ /** Connection providers contributed by this plugin. Same registration
398+ * shape as `secretProviders`. Each provider's `key` is what
399+ * `connection.provider` references in the core table; the `refresh`
400+ * handler is the SDK's single entry point for token lifecycle —
401+ * plugins don't run their own refresh loops anymore. */
402+ readonly connectionProviders?:
403+ | readonly ConnectionProvider[]
404+ | ((ctx: PluginCtx<TStore>) => readonly ConnectionProvider[])
405+ | ((
406+ ctx: PluginCtx<TStore>,
407+ ) => Effect.Effect<readonly ConnectionProvider[]>);
408+
334409 readonly close?: () => Effect.Effect<void, unknown>;
335410 }
336411
packages/core/storage-drizzle/src/adapter.ts+24−3
@@ -260,19 +260,40 @@ const isUniqueViolation = (cause: unknown): boolean => { · 259 unmodified lines
260260 return false;
261261 };
262262
263+// drizzle-orm wraps driver errors as `DrizzleQueryError` with a synthetic
264+// `"Failed query: <SQL>\nparams: <values>"` message and the real driver
265+// error on `.cause`. Walk down so classification + logging see the
266+// server-side code/message (`23505`, `value too long`, etc.) instead of
267+// the SQL+bound-values blob, which for OpenAPI specs is 1MB+ of spec text.
268+const unwrapDriverCause = (cause: unknown): unknown => {
269+ let cur = cause;
270+ for (let i = 0; i < 5; i++) {
271+ if (!cur || typeof cur !== "object") return cur;
272+ const c = cur as { cause?: unknown; code?: unknown; message?: unknown };
273+ if (typeof c.code === "string" && c.code.length > 0) return cur;
274+ if (c.cause && c.cause !== cur) {
275+ cur = c.cause;
276+ continue;
277+ }
278+ return cur;
279+ }
280+ return cur;
281+};
282+
263283 const classifyError = (
264284 op: string,
265285 model: string | undefined,
266286 cause: unknown,
267287 ): StorageFailure => {
268− if (isUniqueViolation(cause)) {
288+ const driverCause = unwrapDriverCause(cause);
289+ if (isUniqueViolation(driverCause)) {
269290 return model !== undefined
270291 ? new UniqueViolationError({ model })
271292 : new UniqueViolationError({});
272293 }
273294 return new StorageError({
274− message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
275− cause,
295+ message: `[storage-drizzle] ${op} failed: ${driverCause instanceof Error ? driverCause.message : String(driverCause)}`,
296+ cause: driverCause,
276297 });
277298 };
278299
packages/plugins/google-discovery/src/api/group.ts+22−10
@@ -21,13 +21,9 @@ const AuthPayload = Schema.Union( · 20 unmodified lines
2121 }),
2222 Schema.Struct({
2323 kind: Schema.Literal("oauth2"),
24+ connectionId: Schema.String,
2425 clientIdSecretId: Schema.String,
2526 clientSecretSecretId: Schema.NullOr(Schema.String),
26− accessTokenSecretId: Schema.String,
27− refreshTokenSecretId: Schema.NullOr(Schema.String),
28− tokenType: Schema.optional(Schema.String),
29− expiresAt: Schema.NullOr(Schema.Number),
30− scope: Schema.NullOr(Schema.String),
3127 scopes: Schema.Array(Schema.String),
3228 }),
3329 );
@@ -65,6 +61,15 @@ const AddSourceResponse = Schema.Struct({ · 31 unmodified lines
6561 namespace: Schema.String,
6662 });
6763
64+const UpdateSourcePayload = Schema.Struct({
65+ name: Schema.optional(Schema.String),
66+ auth: Schema.optional(AuthPayload),
67+});
68+
69+const UpdateSourceResponse = Schema.Struct({
70+ updated: Schema.Boolean,
71+});
72+
6873 const StartOAuthPayload = Schema.Struct({
6974 name: Schema.String,
7075 discoveryUrl: Schema.String,
@@ -72,6 +77,8 @@ const StartOAuthPayload = Schema.Struct({ · 1 unmodified line
7277 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),
7378 redirectUrl: Schema.String,
7479 scopes: Schema.optional(Schema.Array(Schema.String)),
80+ /** Optional executor scope that will own the resulting Connection. */
81+ tokenScope: Schema.optional(Schema.String),
7582 });
7683
7784 const StartOAuthResponse = Schema.Struct({
@@ -88,13 +95,11 @@ const CompleteOAuthPayload = Schema.Struct({ · 10 unmodified lines
8895
8996 const CompleteOAuthResponse = Schema.Struct({
9097 kind: Schema.Literal("oauth2"),
98+ /** Id of the Connection the SDK minted. The caller stores it on the
99+ * source's `oauth2` field and resolves tokens via `ctx.connections`. */
100+ connectionId: Schema.String,
91101 clientIdSecretId: Schema.String,
92102 clientSecretSecretId: Schema.NullOr(Schema.String),
93− accessTokenSecretId: Schema.String,
94− refreshTokenSecretId: Schema.NullOr(Schema.String),
95− tokenType: Schema.String,
96− expiresAt: Schema.NullOr(Schema.Number),
97− scope: Schema.NullOr(Schema.String),
98103 scopes: Schema.Array(Schema.String),
99104 });
100105
@@ -138,6 +143,13 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 37 unmodified lines
138143 .setPayload(AddSourcePayload)
139144 .addSuccess(AddSourceResponse),
140145 )
146+ .add(
147+ HttpApiEndpoint.patch(
148+ "updateSource",
149+ )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
150+ .setPayload(UpdateSourcePayload)
151+ .addSuccess(UpdateSourceResponse),
152+ )
141153 .add(
142154 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
143155 .setPayload(StartOAuthPayload)
packages/plugins/google-discovery/src/api/handlers.test.ts+1−0
@@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = { · 25 unmodified lines
2626 startOAuth: () => unused,
2727 completeOAuth: () => Effect.die(new Error("Not implemented")),
2828 getSource: (_namespace: string, _scope: string) => Effect.succeed(null),
29+ updateSource: () => unused,
2930 };
3031
3132 const Api = addGroup(GoogleDiscoveryGroup);
packages/plugins/google-discovery/src/api/handlers.ts+11−0
@@ -84,6 +84,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 83 unmodified lines
8484 clientSecretSecretId: payload.clientSecretSecretId,
8585 redirectUrl: payload.redirectUrl,
8686 scopes: payload.scopes,
87+ tokenScope: payload.tokenScope,
8788 });
8889 })),
8990 )
@@ -103,6 +104,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines
103104 return yield* ext.getSource(path.namespace, path.scopeId);
104105 })),
105106 )
107+ .handle("updateSource", ({ path, payload }) =>
108+ capture(Effect.gen(function* () {
109+ const ext = yield* GoogleDiscoveryExtensionService;
110+ yield* ext.updateSource(path.namespace, path.scopeId, {
111+ name: payload.name,
112+ auth: payload.auth,
113+ });
114+ return { updated: true };
115+ })),
116+ )
106117 .handle("oauthCallback", ({ urlParams }) =>
107118 capture(Effect.gen(function* () {
108119 const ext = yield* GoogleDiscoveryExtensionService;
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+11−13
@@ -368,13 +368,9 @@ type ProbeResult = { · 367 unmodified lines
368368
369369 type OAuthAuth = {
370370 kind: "oauth2";
371+ connectionId: string;
371372 clientIdSecretId: string;
372373 clientSecretSecretId: string | null;
373− accessTokenSecretId: string;
374− refreshTokenSecretId: string | null;
375− tokenType: string;
376− expiresAt: number | null;
377− scope: string | null;
378374 scopes: string[];
379375 };
380376
@@ -522,13 +518,9 @@ export default function AddGoogleDiscoverySource(props: { · 141 unmodified lines
522518 if (result.ok) {
523519 setOauthAuth({
524520 kind: "oauth2",
521+ connectionId: result.connectionId,
525522 clientIdSecretId: result.clientIdSecretId,
526523 clientSecretSecretId: result.clientSecretSecretId,
527− accessTokenSecretId: result.accessTokenSecretId,
528− refreshTokenSecretId: result.refreshTokenSecretId,
529− tokenType: result.tokenType,
530− expiresAt: result.expiresAt,
531− scope: result.scope,
532524 scopes: [...result.scopes],
533525 });
534526 setError(null);
@@ -573,8 +565,14 @@ export default function AddGoogleDiscoverySource(props: { · 38 unmodified lines
573565 discoveryUrl: discoveryUrl.trim(),
574566 namespace: slugifyNamespace(identity.namespace) || undefined,
575567 auth:
576− authKind === "oauth2"
577− ? (oauthAuth ?? { kind: "none" as const })
568+ authKind === "oauth2" && oauthAuth
569+ ? {
570+ kind: "oauth2" as const,
571+ connectionId: oauthAuth.connectionId,
572+ clientIdSecretId: oauthAuth.clientIdSecretId,
573+ clientSecretSecretId: oauthAuth.clientSecretSecretId,
574+ scopes: oauthAuth.scopes,
575+ }
578576 : { kind: "none" as const },
579577 },
580578 reactivityKeys: [...sourceWriteKeys],
@@ -790,7 +788,7 @@ export default function AddGoogleDiscoverySource(props: { · 209 unmodified lines
790788 </Collapsible>
791789 {oauthAuth && (
792790 <div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
793− Connected. Access token stored as secret `{oauthAuth.accessTokenSecretId}`.
791+ Connected. Manage this connection from the Connections page.
794792 </div>
795793 )}
796794 </div>
packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx+159−0
@@ -0,0 +1,159 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3+
4+import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5+import { useScope } from "@executor/react/api/scope-context";
6+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7+import { connectionsAtom } from "@executor/react/api/atoms";
8+import { Button } from "@executor/react/components/button";
9+
10+import {
11+ googleDiscoverySourceAtom,
12+ startGoogleDiscoveryOAuth,
13+ updateGoogleDiscoverySource,
14+} from "./atoms";
15+
16+// ---------------------------------------------------------------------------
17+// GoogleDiscoverySignInButton — top-bar action on the source detail page.
18+//
19+// Reads the source's stored `GoogleDiscoveryAuth`, re-runs the authorization
20+// code flow via popup using the same `clientIdSecretId` / `clientSecretSecretId`
21+// / `scopes` the source was originally configured with, and on success
22+// rewrites the source's auth pointer to the freshly minted connection id.
23+// Works whether or not the previous Connection still exists — source-owned
24+// OAuth config is the source of truth.
25+// ---------------------------------------------------------------------------
26+
27+const CALLBACK_PATH = "/api/google-discovery/oauth/callback";
28+const POPUP_NAME = "google-discovery-oauth";
29+const CHANNEL_NAME = "executor:google-discovery-oauth-result";
30+
31+type GoogleOAuthPopupPayload = {
32+ kind: "oauth2";
33+ connectionId: string;
34+ clientIdSecretId: string;
35+ clientSecretSecretId: string | null;
36+ scopes: readonly string[];
37+};
38+
39+export default function GoogleDiscoverySignInButton(props: { sourceId: string }) {
40+ const scopeId = useScope();
41+ const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, props.sourceId));
42+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
43+ const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" });
44+ const doUpdate = useAtomSet(updateGoogleDiscoverySource, { mode: "promise" });
45+
46+ const [busy, setBusy] = useState(false);
47+ const [error, setError] = useState<string | null>(null);
48+ const cleanupRef = useRef<(() => void) | null>(null);
49+
50+ useEffect(() => () => cleanupRef.current?.(), []);
51+
52+ const source =
53+ Result.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null;
54+ const auth = source?.config.auth;
55+ const oauth2 = auth && auth.kind === "oauth2" ? auth : null;
56+ const connections = Result.isSuccess(connectionsResult)
57+ ? connectionsResult.value
58+ : null;
59+ const isConnected =
60+ oauth2 !== null &&
61+ connections !== null &&
62+ connections.some((c) => c.id === oauth2.connectionId);
63+
64+ const redirectUrl =
65+ typeof window !== "undefined"
66+ ? `${window.location.origin}${CALLBACK_PATH}`
67+ : CALLBACK_PATH;
68+
69+ const handleSignIn = useCallback(async () => {
70+ if (!oauth2 || !source) return;
71+ cleanupRef.current?.();
72+ cleanupRef.current = null;
73+ setBusy(true);
74+ setError(null);
75+ try {
76+ const response = await doStartOAuth({
77+ path: { scopeId },
78+ payload: {
79+ name: source.name,
80+ discoveryUrl: source.config.discoveryUrl,
81+ clientIdSecretId: oauth2.clientIdSecretId,
82+ clientSecretSecretId: oauth2.clientSecretSecretId,
83+ redirectUrl,
84+ scopes: [...oauth2.scopes],
85+ },
86+ });
87+
88+ cleanupRef.current = openOAuthPopup<GoogleOAuthPopupPayload>({
89+ url: response.authorizationUrl,
90+ popupName: POPUP_NAME,
91+ channelName: CHANNEL_NAME,
92+ onResult: async (result: OAuthPopupResult<GoogleOAuthPopupPayload>) => {
93+ cleanupRef.current = null;
94+ if (!result.ok) {
95+ setBusy(false);
96+ setError(result.error);
97+ return;
98+ }
99+ try {
100+ await doUpdate({
101+ path: { scopeId, namespace: props.sourceId },
102+ payload: {
103+ auth: {
104+ kind: "oauth2",
105+ connectionId: result.connectionId,
106+ clientIdSecretId: result.clientIdSecretId,
107+ clientSecretSecretId: result.clientSecretSecretId,
108+ scopes: result.scopes,
109+ },
110+ },
111+ reactivityKeys: sourceWriteKeys,
112+ });
113+ setBusy(false);
114+ } catch (e) {
115+ setBusy(false);
116+ setError(
117+ e instanceof Error ? e.message : "Failed to persist new connection",
118+ );
119+ }
120+ },
121+ onClosed: () => {
122+ cleanupRef.current = null;
123+ setBusy(false);
124+ setError("Sign-in cancelled — popup was closed before completing the flow.");
125+ },
126+ onOpenFailed: () => {
127+ cleanupRef.current = null;
128+ setBusy(false);
129+ setError("Sign-in popup was blocked by the browser");
130+ },
131+ });
132+ } catch (e) {
133+ setBusy(false);
134+ setError(e instanceof Error ? e.message : "Failed to start sign-in");
135+ }
136+ }, [oauth2, source, scopeId, props.sourceId, redirectUrl, doStartOAuth, doUpdate]);
137+
138+ if (!oauth2) return null;
139+
140+ return (
141+ <div className="flex items-center gap-2">
142+ {error && <span className="text-xs text-destructive">{error}</span>}
143+ <Button
144+ variant="outline"
145+ size="sm"
146+ onClick={() => void handleSignIn()}
147+ disabled={busy}
148+ >
149+ {busy
150+ ? isConnected
151+ ? "Reconnecting…"
152+ : "Signing in…"
153+ : isConnected
154+ ? "Reconnect"
155+ : "Sign in"}
156+ </Button>
157+ </div>
158+ );
159+}
packages/plugins/google-discovery/src/react/atoms.ts+4−0
@@ -17,6 +17,10 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 16 unmodified lines
1717 "googleDiscovery",
1818 "addSource",
1919 );
20+export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
21+ "googleDiscovery",
22+ "updateSource",
23+);
2024 export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
2125 "googleDiscovery",
2226 "startOAuth",
packages/plugins/google-discovery/src/react/source-plugin.ts+1−0
@@ -8,5 +8,6 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 7 unmodified lines
88 add: lazy(() => import("./AddGoogleDiscoverySource")),
99 edit: lazy(() => import("./EditGoogleDiscoverySource")),
1010 summary: lazy(() => import("./GoogleDiscoverySourceSummary")),
11+ signIn: lazy(() => import("./GoogleDiscoverySignInButton")),
1112 presets: googleDiscoveryPresets,
1213 };
packages/plugins/google-discovery/src/sdk/binding-store.ts+38−0
@@ -154,6 +154,14 @@ export interface GoogleDiscoveryStore { · 153 unmodified lines
154154 readonly putSource: (
155155 source: GoogleDiscoveryStoredSource,
156156 ) => Effect.Effect<void, StorageFailure>;
157+ readonly updateSourceMeta: (
158+ sourceId: string,
159+ scope: string,
160+ update: {
161+ readonly name?: string;
162+ readonly auth?: import("./types").GoogleDiscoveryAuth;
163+ },
164+ ) => Effect.Effect<void, StorageFailure>;
157165 readonly removeSource: (
158166 sourceId: string,
159167 scope: string,
@@ -288,6 +296,36 @@ export const makeGoogleDiscoveryStore = ( · 128 unmodified lines
288296 });
289297 }),
290298
299+ updateSourceMeta: (sourceId, scope, update) =>
300+ Effect.gen(function* () {
301+ const row = yield* db.findOne({
302+ model: "google_discovery_source",
303+ where: [
304+ { field: "id", value: sourceId },
305+ { field: "scope_id", value: scope },
306+ ],
307+ });
308+ if (!row) return;
309+ const config = decodeStoredSourceData(decodeJson(row.config));
310+ const nextConfig = new GoogleDiscoveryStoredSourceData({
311+ ...config,
312+ name: update.name ?? config.name,
313+ auth: update.auth ?? config.auth,
314+ });
315+ yield* db.update({
316+ model: "google_discovery_source",
317+ where: [
318+ { field: "id", value: sourceId },
319+ { field: "scope_id", value: scope },
320+ ],
321+ update: {
322+ name: update.name ?? (row.name as string),
323+ config: encodeStoredSourceData(nextConfig) as unknown as Record<string, unknown>,
324+ updated_at: new Date(),
325+ },
326+ });
327+ }),
328+
291329 removeSource: (sourceId, scope) =>
292330 db
293331 .delete({
packages/plugins/google-discovery/src/sdk/invoke.ts+17−113
@@ -1,15 +1,7 @@
11 import { Effect, Layer, Option } from "effect";
22 import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
33
4−import {
5− withRefreshedAccessToken,
6− type OAuth2SecretsIO,
7−} from "@executor/plugin-oauth2";
8−
94 import type { PluginCtx, StorageFailure } from "@executor/sdk";
10−import { SetSecretInput } from "@executor/sdk";
11−
12−import { GOOGLE_TOKEN_URL } from "./oauth";
135
146 import {
157 GoogleDiscoveryInvocationError,
@@ -18,8 +10,8 @@ import { · 2 unmodified lines
1810 import type { GoogleDiscoveryStore } from "./binding-store";
1911 import {
2012 GoogleDiscoveryInvocationResult,
21− GoogleDiscoveryStoredSourceData,
2213 type GoogleDiscoveryParameter,
14+ type GoogleDiscoveryStoredSourceData,
2315 } from "./types";
2416
2517 const SAFE_METHODS = new Set(["get", "head", "options"]);
@@ -35,36 +27,6 @@ export const annotationsForOperation = ( · 9 unmodified lines
3527 };
3628 };
3729
38−// ---------------------------------------------------------------------------
39−// OAuth2 secrets adapter — wraps ctx.secrets.get / ctx.secrets.set so
40−// the shared `@executor/plugin-oauth2` helpers can read/write token
41−// secrets without knowing about PluginCtx.
42−// ---------------------------------------------------------------------------
43−
44−const makeSecretsIO = (ctx: PluginCtx<GoogleDiscoveryStore>): OAuth2SecretsIO => ({
45− resolve: (id) =>
46− ctx.secrets.get(id).pipe(
47− Effect.flatMap((value) =>
48− value === null
49− ? Effect.fail(new Error(`Secret not found: ${id}`))
50− : Effect.succeed(value),
51− ),
52− ),
53− setValue: ({ secretId, value, name }) =>
54− ctx.secrets
55− .set(
56− new SetSecretInput({
57− id: secretId as SetSecretInput["id"],
58− // Refresh writes land at the innermost (user) scope — same
59− // partition as the original mint.
60− scope: ctx.scopes[0]!.id,
61− name,
62− value,
63− }),
64− )
65− .pipe(Effect.asVoid),
66−});
67−
6830 // ---------------------------------------------------------------------------
6931 // Path / query parameter helpers (unchanged from the old invoker)
7032 // ---------------------------------------------------------------------------
@@ -113,65 +75,6 @@ const isJsonContentType = (contentType: string | null | undefined): boolean => { · 42 unmodified lines
11375 );
11476 };
11577
116−// ---------------------------------------------------------------------------
117−// Resolve (and lazily refresh) an OAuth2 access token for a stored source.
118−// ---------------------------------------------------------------------------
119−
120−const resolveOAuthAccessToken = (input: {
121− ctx: PluginCtx<GoogleDiscoveryStore>;
122− sourceId: string;
123− sourceScope: string;
124− source: GoogleDiscoveryStoredSourceData;
125−}): Effect.Effect<string, GoogleDiscoveryOAuthError> =>
126− Effect.gen(function* () {
127− if (input.source.auth.kind !== "oauth2") return "";
128− const auth = input.source.auth;
129−
130− return yield* withRefreshedAccessToken({
131− auth: {
132− clientIdSecretId: auth.clientIdSecretId,
133− clientSecretSecretId: auth.clientSecretSecretId,
134− accessTokenSecretId: auth.accessTokenSecretId,
135− refreshTokenSecretId: auth.refreshTokenSecretId,
136− tokenType: auth.tokenType,
137− expiresAt: auth.expiresAt,
138− scopes: auth.scopes,
139− },
140− tokenUrl: GOOGLE_TOKEN_URL,
141− secrets: makeSecretsIO(input.ctx),
142− displayName: input.source.name,
143− accessTokenPurpose: "google_oauth_access_token",
144− refreshTokenPurpose: "google_oauth_refresh_token",
145− persistAuth: (snapshot) =>
146− Effect.gen(function* () {
147− const updated = new GoogleDiscoveryStoredSourceData({
148− ...input.source,
149− auth: {
150− kind: "oauth2",
151− clientIdSecretId: auth.clientIdSecretId,
152− clientSecretSecretId: auth.clientSecretSecretId,
153− accessTokenSecretId: auth.accessTokenSecretId,
154− refreshTokenSecretId: auth.refreshTokenSecretId,
155− tokenType: snapshot.tokenType,
156− expiresAt: snapshot.expiresAt,
157− scope: snapshot.scope ?? auth.scope,
158− scopes: auth.scopes,
159− },
160− });
161− yield* input.ctx.storage.putSource({
162− namespace: input.sourceId,
163− scope: input.sourceScope,
164− name: input.source.name,
165− config: updated,
166− });
167− }),
168− }).pipe(
169− Effect.mapError(
170− (error) => new GoogleDiscoveryOAuthError({ message: error.message }),
171− ),
172− );
173− });
174−
17578 // ---------------------------------------------------------------------------
17679 // HTTP request builder / executor
17780 // ---------------------------------------------------------------------------
@@ -280,10 +183,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 102 unmodified lines
280183 export const invokeGoogleDiscoveryTool = (input: {
281184 ctx: PluginCtx<GoogleDiscoveryStore>;
282185 toolId: string;
283− /** Resolved owning scope of the tool row (innermost-wins from the
284− * executor's stack). The matching binding + source rows live at the
285− * same scope, so pin every store lookup to this instead of relying
286− * on the scoped adapter's stack-wide fall-through. */
186+ /** Resolved owning scope of the tool row. */
287187 toolScope: string;
288188 args: unknown;
289189 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
@@ -314,18 +214,22 @@ export const invokeGoogleDiscoveryTool = (input: { · 24 unmodified lines
314214 }
315215 const source = stored.config;
316216
317− const accessToken =
318− source.auth.kind === "oauth2"
319− ? yield* resolveOAuthAccessToken({
320− ctx: input.ctx,
321− sourceId: entry.namespace,
322− sourceScope: stored.scope,
323− source,
324− })
325− : "";
326−
327217 const authHeader =
328− source.auth.kind === "oauth2" ? `${source.auth.tokenType} ${accessToken}` : undefined;
218+ source.auth.kind === "oauth2"
219+ ? `Bearer ${yield* input.ctx.connections
220+ .accessToken(source.auth.connectionId)
221+ .pipe(
222+ Effect.mapError(
223+ (err) =>
224+ new GoogleDiscoveryOAuthError({
225+ message:
226+ "message" in err
227+ ? (err as { message: string }).message
228+ : String(err),
229+ }),
230+ ),
231+ )}`
232+ : undefined;
329233
330234 const layer = input.httpClientLayer ?? FetchHttpClient.layer;
331235
packages/plugins/google-discovery/src/sdk/plugin.test.ts+42−23
@@ -7,12 +7,15 @@ import { Effect } from "effect"; · 6 unmodified lines
77 import { vi } from "vitest";
88
99 import {
10+ ConnectionId,
11+ CreateConnectionInput,
1012 createExecutor,
1113 makeTestConfig,
1214 Scope,
1315 ScopeId,
1416 SecretId,
1517 SetSecretInput,
18+ TokenMaterial,
1619 type InvokeOptions,
1720 } from "@executor/sdk";
1821
@@ -300,13 +303,22 @@ describe("Google Discovery plugin", () => { · 281 unmodified lines
300303 });
301304
302305 expect(auth.kind).toBe("oauth2");
303− expect(auth.clientIdSecretId).toBe("google-client-id");
304− expect(auth.refreshTokenSecretId).not.toBeNull();
306+ expect(auth.connectionId).toMatch(/^google-discovery-oauth2-/);
305307
306− const accessToken = yield* executor.secrets.get(auth.accessTokenSecretId);
308+ // Tokens live on the SDK connection — resolving via
309+ // ctx.connections.accessToken returns the minted value.
310+ const accessToken = yield* executor.connections.accessToken(
311+ auth.connectionId as Parameters<typeof executor.connections.accessToken>[0],
312+ );
307313 expect(accessToken).toBe("access-token-value");
308− const refreshToken = yield* executor.secrets.get(auth.refreshTokenSecretId!);
309− expect(refreshToken).toBe("refresh-token-value");
314+
315+ // Backing access-token secret is owned by the connection, so
316+ // it's filtered out of the user-facing secret list.
317+ const secretIds = new Set(
318+ (yield* executor.secrets.list()).map((s) => s.id as unknown as string),
319+ );
320+ expect(secretIds).not.toContain(`${auth.connectionId}.access_token`);
321+ expect(secretIds).not.toContain(`${auth.connectionId}.refresh_token`);
310322 } finally {
311323 fetchMock.mockRestore();
312324 yield* executor.close();
@@ -328,20 +340,31 @@ describe("Google Discovery plugin", () => { · 15 unmodified lines
328340 );
329341
330342 try {
331− yield* executor.secrets.set(
332− new SetSecretInput({
333− id: SecretId.make("drive-access-token"),
334− scope: "test-scope" as SetSecretInput["scope"],
335− name: "Drive Access Token",
336− value: "secret-token",
337− }),
343+ // A connection wraps the access token (+ optional refresh) and
344+ // the invoke path resolves via ctx.connections.accessToken.
345+ const connectionId = ConnectionId.make(
346+ "google-discovery-oauth2-test",
338347 );
339− yield* executor.secrets.set(
340− new SetSecretInput({
341− id: SecretId.make("drive-client-id"),
342− scope: "test-scope" as SetSecretInput["scope"],
343− name: "Drive Client ID",
344− value: "client-123",
348+ yield* executor.connections.create(
349+ new CreateConnectionInput({
350+ id: connectionId,
351+ scope: ScopeId.make("test-scope"),
352+ provider: "google-discovery:oauth2",
353+ kind: "user",
354+ identityLabel: "Drive Test",
355+ accessToken: new TokenMaterial({
356+ secretId: SecretId.make(`${connectionId}.access_token`),
357+ name: "Drive Access Token",
358+ value: "secret-token",
359+ }),
360+ refreshToken: null,
361+ expiresAt: null,
362+ oauthScope: null,
363+ providerState: {
364+ clientIdSecretId: "drive-client-id",
365+ clientSecretSecretId: null,
366+ scopes: ["https://www.googleapis.com/auth/drive.readonly"],
367+ },
345368 }),
346369 );
347370
@@ -352,13 +375,9 @@ describe("Google Discovery plugin", () => { · 4 unmodified lines
352375 namespace: "drive",
353376 auth: {
354377 kind: "oauth2",
378+ connectionId,
355379 clientIdSecretId: "drive-client-id",
356380 clientSecretSecretId: null,
357− accessTokenSecretId: "drive-access-token",
358− refreshTokenSecretId: null,
359− tokenType: "Bearer",
360− expiresAt: null,
361− scope: null,
362381 scopes: ["https://www.googleapis.com/auth/drive.readonly"],
363382 },
364383 });
packages/plugins/google-discovery/src/sdk/plugin.ts+281−134
@@ -1,13 +1,25 @@
11 import { randomUUID } from "node:crypto";
22
3−import { Effect, Option } from "effect";
3+import { Effect, Option, Schema } from "effect";
44
5−import { storeOAuthTokens } from "@executor/plugin-oauth2";
5+import {
6+ OAuth2Error,
7+ refreshAccessToken,
8+ type OAuth2TokenResponse,
9+} from "@executor/plugin-oauth2";
610
711 import {
8− definePlugin,
9− SetSecretInput,
12+ ConnectionId,
13+ ConnectionRefreshError,
14+ CreateConnectionInput,
15+ ScopeId,
16+ SecretId,
1017 SourceDetectionResult,
18+ TokenMaterial,
19+ definePlugin,
20+ type ConnectionProvider,
21+ type ConnectionRefreshInput,
22+ type ConnectionRefreshResult,
1123 type PluginCtx,
1224 type StorageFailure,
1325 type ToolAnnotations,
@@ -30,6 +42,7 @@ import { · 16 unmodified lines
3042 buildGoogleAuthorizationUrl,
3143 createPkceCodeVerifier,
3244 exchangeAuthorizationCode,
45+ GOOGLE_TOKEN_URL,
3346 } from "./oauth";
3447 import type {
3548 GoogleDiscoveryAuth,
@@ -41,7 +54,7 @@ import type { · 5 unmodified lines
4154 import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
4255
4356 // ---------------------------------------------------------------------------
44−// Public input / output shapes (unchanged from the old plugin)
57+// Public input / output shapes
4558 // ---------------------------------------------------------------------------
4659
4760 export interface GoogleDiscoveryProbeOperation {
@@ -69,6 +82,13 @@ export interface GoogleDiscoveryAddSourceInput { · 21 unmodified lines
6982 readonly auth: GoogleDiscoveryAuth;
7083 }
7184
85+export interface GoogleDiscoveryUpdateSourceInput {
86+ readonly name?: string;
87+ /** Rewrite the source's auth — typically after a successful
88+ * re-authenticate, to point at a freshly minted Connection. */
89+ readonly auth?: GoogleDiscoveryAuth;
90+}
91+
7292 export interface GoogleDiscoveryOAuthStartInput {
7393 readonly name: string;
7494 readonly discoveryUrl: string;
@@ -76,6 +96,9 @@ export interface GoogleDiscoveryOAuthStartInput { · 1 unmodified line
7696 readonly clientSecretSecretId?: string | null;
7797 readonly redirectUrl: string;
7898 readonly scopes?: readonly string[];
99+ /** Executor scope that will own the resulting Connection + its backing
100+ * secrets. Defaults to `ctx.scopes[0].id` (innermost / per-user). */
101+ readonly tokenScope?: string;
79102 }
80103
81104 export interface GoogleDiscoveryOAuthStartResponse {
@@ -90,28 +113,23 @@ export interface GoogleDiscoveryOAuthCompleteInput { · 8 unmodified lines
90113 readonly error?: string;
91114 }
92115
116+/**
117+ * Completed OAuth auth handed back to the caller (either an HTTP client or
118+ * a static-tool handler). Carries the full API-level OAuth config so the
119+ * caller can stamp it onto the source's `GoogleDiscoveryAuth` — that makes
120+ * sign-in reproducible from the source detail page without depending on
121+ * the prior Connection still existing.
122+ */
93123 export interface GoogleDiscoveryOAuthAuthResult {
94124 readonly kind: "oauth2";
125+ readonly connectionId: string;
95126 readonly clientIdSecretId: string;
96127 readonly clientSecretSecretId: string | null;
97− readonly accessTokenSecretId: string;
98− readonly refreshTokenSecretId: string | null;
99− readonly tokenType: string;
100− readonly expiresAt: number | null;
101− readonly scope: string | null;
102128 readonly scopes: readonly string[];
103129 }
104130
105131 /**
106− * Errors any Google Discovery extension method may surface. The first
107− * three are plugin-domain tagged errors that flow directly to clients
108− * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the
109− * API edge). `StorageFailure` covers raw backend failures (`StorageError`)
110− * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s
111− * `withCapture`) translates `StorageError` to the opaque
112− * `InternalError({ traceId })` at Layer composition. `UniqueViolationError`
113− * passes through — plugins can `Effect.catchTag` it if they want a
114− * friendlier user-facing error.
132+ * Errors any Google Discovery extension method may surface.
115133 */
116134 export type GoogleDiscoveryExtensionFailure =
117135 | GoogleDiscoveryParseError
@@ -155,6 +173,11 @@ export interface GoogleDiscoveryPluginExtension { · 37 unmodified lines
155173 namespace: string,
156174 scope: string,
157175 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
176+ readonly updateSource: (
177+ namespace: string,
178+ scope: string,
179+ input: GoogleDiscoveryUpdateSourceInput,
180+ ) => Effect.Effect<void, StorageFailure>;
158181 }
159182
160183 // ---------------------------------------------------------------------------
@@ -220,6 +243,29 @@ const deriveNamespace = (input: { name: string; service: string; version: string · 59 unmodified lines
220243 input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
221244 ) || `google_${input.service}`;
222245
246+// ---------------------------------------------------------------------------
247+// Connection provider_state — every knob needed to drive a refresh. NOT
248+// sensitive — client credentials live in secrets and are resolved by the
249+// refresh handler at runtime.
250+// ---------------------------------------------------------------------------
251+
252+const GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY = "google-discovery:oauth2" as const;
253+
254+const OAuth2ProviderState = Schema.Struct({
255+ clientIdSecretId: Schema.String,
256+ clientSecretSecretId: Schema.NullOr(Schema.String),
257+ scopes: Schema.Array(Schema.String),
258+});
259+type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
260+
261+const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
262+const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
263+
264+const toProviderStateRecord = (
265+ state: OAuth2ProviderState,
266+): Record<string, unknown> =>
267+ encodeProviderState(state) as unknown as Record<string, unknown>;
268+
223269 // ---------------------------------------------------------------------------
224270 // Register a parsed manifest against the executor core + plugin storage.
225271 // Runs inside a transaction.
@@ -233,13 +279,9 @@ const registerManifest = ( · 7 unmodified lines
233279 sourceData: GoogleDiscoveryStoredSourceData,
234280 ) =>
235281 Effect.gen(function* () {
236− // 1. Clear any previous manifest for this namespace at this scope.
237− // Scope-pinned so a refresh/re-add at a user scope can't wipe
238− // bindings from an org-level shadow of the same namespace.
239282 yield* ctx.storage.removeBindingsBySource(namespace, scope);
240283 yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore);
241284
242− // 2. Register the source + tool rows in core.
243285 yield* ctx.core.sources.register({
244286 id: namespace,
245287 scope,
@@ -257,7 +299,6 @@ const registerManifest = ( · 11 unmodified lines
257299 })),
258300 });
259301
260− // 3. Register shared $defs, if any.
261302 if (Object.keys(manifest.schemaDefinitions).length > 0) {
262303 yield* ctx.core.definitions.register({
263304 sourceId: namespace,
@@ -266,8 +307,6 @@ const registerManifest = ( · 2 unmodified lines
266307 });
267308 }
268309
269− // 4. Write per-tool bindings to plugin storage (keyed by the same
270− // ${source_id}.${name} tool id the executor synthesizes).
271310 yield* Effect.forEach(
272311 manifest.methods,
273312 (method) =>
@@ -280,7 +319,6 @@ const registerManifest = ( · 6 unmodified lines
280319 { discard: true },
281320 );
282321
283− // 5. Write the source config blob.
284322 yield* ctx.storage.putSource({
285323 namespace,
286324 scope,
@@ -291,32 +329,6 @@ const registerManifest = ( · 4 unmodified lines
291329 return manifest.methods.length;
292330 });
293331
294−// ---------------------------------------------------------------------------
295−// Mint a fresh secret via ctx.secrets.set and return {id} for storeOAuthTokens.
296−// ---------------------------------------------------------------------------
297−
298−const createSecretForOAuth = (
299− ctx: PluginCtx<GoogleDiscoveryStore>,
300− input: {
301− readonly idPrefix: string;
302− readonly name: string;
303− readonly value: string;
304− readonly purpose: string;
305− },
306−) =>
307− ctx.secrets
308− .set(
309− new SetSecretInput({
310− id: `${input.idPrefix}_${randomUUID().slice(0, 8)}` as SetSecretInput["id"],
311− // OAuth tokens are per-user: pin to the innermost scope so Alice
312− // and Bob don't overwrite each other's tokens.
313− scope: ctx.scopes[0]!.id,
314− name: input.name,
315− value: input.value,
316− }),
317− )
318− .pipe(Effect.map((ref) => ({ id: ref.id as string })));
319−
320332 // ---------------------------------------------------------------------------
321333 // Plugin
322334 // ---------------------------------------------------------------------------
@@ -418,6 +430,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 95 unmodified lines
418430 }
419431 const sessionId = randomUUID();
420432 const codeVerifier = createPkceCodeVerifier();
433+ const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
434+ const connectionId = `google-discovery-oauth2-${randomUUID()}`;
421435 yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {
422436 discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
423437 name: input.name,
@@ -426,6 +440,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 2 unmodified lines
426440 redirectUrl: input.redirectUrl,
427441 scopes,
428442 codeVerifier,
443+ tokenScope,
444+ connectionId,
429445 });
430446 return {
431447 sessionId,
@@ -441,102 +457,138 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 9 unmodified lines
441457 }),
442458
443459 completeOAuth: (input) =>
444− Effect.gen(function* () {
445− const session = yield* ctx.storage.getOAuthSession(input.state);
446− if (!session) {
447− return yield* new GoogleDiscoveryOAuthError({
448− message: "OAuth session not found or has expired",
449− });
450− }
451− yield* ctx.storage.deleteOAuthSession(input.state);
452−
453− if (input.error) {
454− return yield* new GoogleDiscoveryOAuthError({ message: input.error });
455− }
456− if (!input.code) {
457− return yield* new GoogleDiscoveryOAuthError({
458− message: "OAuth callback did not include an authorization code",
459− });
460− }
460+ ctx.transaction(
461+ Effect.gen(function* () {
462+ const session = yield* ctx.storage.getOAuthSession(input.state);
463+ if (!session) {
464+ return yield* new GoogleDiscoveryOAuthError({
465+ message: "OAuth session not found or has expired",
466+ });
467+ }
468+ yield* ctx.storage.deleteOAuthSession(input.state);
469+
470+ if (input.error) {
471+ return yield* new GoogleDiscoveryOAuthError({ message: input.error });
472+ }
473+ if (!input.code) {
474+ return yield* new GoogleDiscoveryOAuthError({
475+ message: "OAuth callback did not include an authorization code",
476+ });
477+ }
461478
462− const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId);
463− if (clientIdValue === null) {
464− return yield* new GoogleDiscoveryOAuthError({
465− message: `OAuth client ID secret not found: ${session.clientIdSecretId}`,
479+ const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId);
480+ if (clientIdValue === null) {
481+ return yield* new GoogleDiscoveryOAuthError({
482+ message: `OAuth client ID secret not found: ${session.clientIdSecretId}`,
483+ });
484+ }
485+
486+ const clientSecretValue =
487+ session.clientSecretSecretId === null
488+ ? null
489+ : yield* ctx.secrets.get(session.clientSecretSecretId).pipe(
490+ Effect.flatMap((v) =>
491+ v === null
492+ ? Effect.fail(
493+ new GoogleDiscoveryOAuthError({
494+ message: `OAuth client secret not found: ${session.clientSecretSecretId}`,
495+ }),
496+ )
497+ : Effect.succeed(v),
498+ ),
499+ );
500+
501+ const tokenResponse = yield* exchangeAuthorizationCode({
502+ clientId: clientIdValue,
503+ clientSecret: clientSecretValue,
504+ redirectUrl: session.redirectUrl,
505+ codeVerifier: session.codeVerifier,
506+ code: input.code,
466507 });
467− }
468508
469− const clientSecretValue =
470− session.clientSecretSecretId === null
471− ? null
472− : yield* ctx.secrets.get(session.clientSecretSecretId).pipe(
473− Effect.flatMap((v) =>
474− v === null
475− ? Effect.fail(
476− new GoogleDiscoveryOAuthError({
477− message: `OAuth client secret not found: ${session.clientSecretSecretId}`,
478− }),
479− )
480− : Effect.succeed(v),
481− ),
482− );
483−
484− const tokenResponse = yield* exchangeAuthorizationCode({
485− clientId: clientIdValue,
486− clientSecret: clientSecretValue,
487− redirectUrl: session.redirectUrl,
488− codeVerifier: session.codeVerifier,
489− code: input.code,
490− });
509+ const expiresAt =
510+ typeof tokenResponse.expires_in === "number"
511+ ? Date.now() + tokenResponse.expires_in * 1000
512+ : null;
513+
514+ const providerState: OAuth2ProviderState = {
515+ clientIdSecretId: session.clientIdSecretId,
516+ clientSecretSecretId: session.clientSecretSecretId,
517+ scopes: [...session.scopes],
518+ };
519+
520+ yield* ctx.connections
521+ .create(
522+ new CreateConnectionInput({
523+ id: ConnectionId.make(session.connectionId),
524+ scope: ScopeId.make(session.tokenScope),
525+ provider: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY,
526+ kind: "user",
527+ identityLabel: session.name,
528+ accessToken: new TokenMaterial({
529+ secretId: SecretId.make(`${session.connectionId}.access_token`),
530+ name: `${session.name} Access Token`,
531+ value: tokenResponse.access_token,
532+ }),
533+ refreshToken: tokenResponse.refresh_token
534+ ? new TokenMaterial({
535+ secretId: SecretId.make(`${session.connectionId}.refresh_token`),
536+ name: `${session.name} Refresh Token`,
537+ value: tokenResponse.refresh_token,
538+ })
539+ : null,
540+ expiresAt,
541+ oauthScope: tokenResponse.scope ?? null,
542+ providerState: toProviderStateRecord(providerState),
543+ }),
544+ )
545+ .pipe(
546+ Effect.mapError(
547+ (err) =>
548+ new GoogleDiscoveryOAuthError({
549+ message:
550+ "message" in err
551+ ? (err as { message: string }).message
552+ : String(err),
553+ }),
554+ ),
555+ );
556+
557+ return {
558+ kind: "oauth2" as const,
559+ connectionId: session.connectionId,
560+ clientIdSecretId: session.clientIdSecretId,
561+ clientSecretSecretId: session.clientSecretSecretId,
562+ scopes: [...session.scopes],
563+ };
564+ }),
565+ ).pipe(
566+ Effect.mapError((err) =>
567+ err instanceof GoogleDiscoveryOAuthError
568+ ? err
569+ : new GoogleDiscoveryOAuthError({ message: err.message }),
570+ ),
571+ ),
491572
492− const stored = yield* storeOAuthTokens({
493− tokens: tokenResponse,
494− slug: `${normalizeSlug(session.name)}_google`,
495− displayName: session.name,
496− accessTokenPurpose: "google_oauth_access_token",
497− refreshTokenPurpose: "google_oauth_refresh_token",
498− createSecret: (args) => createSecretForOAuth(ctx, args),
499− }).pipe(
500− Effect.mapError((error) => new GoogleDiscoveryOAuthError({ message: error.message })),
501− );
573+ getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope),
502574
503− return {
504− kind: "oauth2" as const,
505− clientIdSecretId: session.clientIdSecretId,
506− clientSecretSecretId: session.clientSecretSecretId,
507− accessTokenSecretId: stored.accessTokenSecretId,
508− refreshTokenSecretId: stored.refreshTokenSecretId,
509− tokenType: stored.tokenType,
510− expiresAt: stored.expiresAt,
511− scope: stored.scope,
512− scopes: [...session.scopes],
513− };
575+ updateSource: (namespace, scope, input) =>
576+ ctx.storage.updateSourceMeta(namespace, scope, {
577+ name: input.name?.trim() || undefined,
578+ auth: input.auth,
514579 }),
515−
516− getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope),
517580 } satisfies GoogleDiscoveryPluginExtension),
518581
519582 invokeTool: ({ ctx, toolRow, args }) =>
520583 invokeGoogleDiscoveryTool({
521584 ctx: ctx as PluginCtx<GoogleDiscoveryStore>,
522585 toolId: toolRow.id,
523− // toolRow.scope_id is the resolved owning scope of the tool
524− // (innermost-wins from the executor's stack). The matching
525− // binding + source rows live at the same scope, so pin every
526− // store lookup to it instead of relying on the scoped adapter's
527− // stack-wide fall-through.
528586 toolScope: toolRow.scope_id as string,
529587 args,
530588 }),
531589
532590 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
533591 Effect.gen(function* () {
534− // toolRows for a single (plugin_id, source_id) group can still
535− // straddle multiple scopes when the source is shadowed (e.g. an
536− // org-level source plus a per-user override that re-registers
537− // the same tool ids). Run one getBindingsForSource per distinct
538− // scope so each lookup pins {source_id, scope_id} and we don't
539− // fall through to the wrong scope's bindings.
540592 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
541593 const scopes = new Set<string>();
542594 for (const row of toolRows) scopes.add(row.scope_id as string);
@@ -605,9 +657,6 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 62 unmodified lines
605657 refreshSource: ({ ctx, sourceId, scope }) =>
606658 Effect.gen(function* () {
607659 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
608− // Pin the initial read to the source's owning scope — otherwise
609− // fall-through could surface a shadowed row at another scope and
610− // we'd refresh the wrong one.
611660 const existing = yield* typedCtx.storage.getSource(sourceId, scope);
612661 if (!existing) return;
613662 const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl);
@@ -621,4 +670,102 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 7 unmodified lines
621670 });
622671 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);
623672 }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))),
673+
674+ // SDK's `ctx.connections.accessToken(id)` dispatches here when a token
675+ // is near expiry. All knobs (client secret id, scopes) come from
676+ // providerState; the refresh_token value is pre-resolved by the SDK.
677+ connectionProviders: (ctx): readonly ConnectionProvider[] => [
678+ {
679+ key: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY,
680+ refresh: (input: ConnectionRefreshInput) =>
681+ Effect.gen(function* () {
682+ if (!input.providerState) {
683+ return yield* new ConnectionRefreshError({
684+ connectionId: input.connectionId,
685+ message:
686+ "google-discovery:oauth2 connection is missing providerState",
687+ });
688+ }
689+ const state = yield* Effect.try({
690+ try: () => decodeProviderState(input.providerState),
691+ catch: (cause) =>
692+ new ConnectionRefreshError({
693+ connectionId: input.connectionId,
694+ message: `google-discovery:oauth2 providerState is malformed: ${
695+ cause instanceof Error ? cause.message : String(cause)
696+ }`,
697+ cause,
698+ }),
699+ });
700+
701+ if (input.refreshToken === null) {
702+ return yield* new ConnectionRefreshError({
703+ connectionId: input.connectionId,
704+ message:
705+ "google-discovery:oauth2 connection has no refresh token",
706+ });
707+ }
708+
709+ const clientId = yield* ctx.secrets.get(state.clientIdSecretId).pipe(
710+ Effect.mapError(
711+ (err) =>
712+ new ConnectionRefreshError({
713+ connectionId: input.connectionId,
714+ message: `Failed to resolve client id secret: ${err.message}`,
715+ cause: err,
716+ }),
717+ ),
718+ );
719+ if (clientId === null) {
720+ return yield* new ConnectionRefreshError({
721+ connectionId: input.connectionId,
722+ message: `Missing client id secret: ${state.clientIdSecretId}`,
723+ });
724+ }
725+
726+ const clientSecret = state.clientSecretSecretId
727+ ? yield* ctx.secrets.get(state.clientSecretSecretId).pipe(
728+ Effect.mapError(
729+ (err) =>
730+ new ConnectionRefreshError({
731+ connectionId: input.connectionId,
732+ message: `Failed to resolve client secret: ${err.message}`,
733+ cause: err,
734+ }),
735+ ),
736+ )
737+ : null;
738+
739+ const tokenResponse: OAuth2TokenResponse = yield* refreshAccessToken({
740+ tokenUrl: GOOGLE_TOKEN_URL,
741+ clientId,
742+ clientSecret,
743+ refreshToken: input.refreshToken,
744+ scopes: state.scopes,
745+ }).pipe(
746+ Effect.mapError(
747+ (err: OAuth2Error) =>
748+ new ConnectionRefreshError({
749+ connectionId: input.connectionId,
750+ message: err.message,
751+ cause: err,
752+ }),
753+ ),
754+ );
755+
756+ const expiresAt =
757+ typeof tokenResponse.expires_in === "number"
758+ ? Date.now() + tokenResponse.expires_in * 1000
759+ : null;
760+
761+ const result: ConnectionRefreshResult = {
762+ accessToken: tokenResponse.access_token,
763+ refreshToken: tokenResponse.refresh_token ?? undefined,
764+ expiresAt,
765+ oauthScope: tokenResponse.scope ?? input.oauthScope,
766+ };
767+ return result;
768+ }),
769+ },
770+ ],
624771 }));
packages/plugins/google-discovery/src/sdk/types.ts+23−5
@@ -60,19 +60,31 @@ export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifes · 59 unmodified lines
6060 methods: Schema.Array(GoogleDiscoveryManifestMethod),
6161 }) {}
6262
63+// ---------------------------------------------------------------------------
64+// Auth — a source either runs unauthenticated or is backed by a Connection.
65+//
66+// The source owns the API-level OAuth config (client credential secret
67+// ids + scopes) so a stale sign-in can always be re-run from the source
68+// detail page without needing the prior Connection to still exist. The
69+// Connection owns live tokens + refresh state (and caches the same
70+// config on `providerState` for the refresh path). This small
71+// duplication keeps reconnect fully source-driven.
72+// ---------------------------------------------------------------------------
73+
6374 export const GoogleDiscoveryAuth = Schema.Union(
6475 Schema.Struct({
6576 kind: Schema.Literal("none"),
6677 }),
6778 Schema.Struct({
6879 kind: Schema.Literal("oauth2"),
80+ /** Connection id; resolve via `ctx.connections.accessToken(id)`.
81+ * Rewritten on sign-in to point at the freshly minted connection. */
82+ connectionId: Schema.String,
83+ /** Secret id holding the OAuth client_id. */
6984 clientIdSecretId: Schema.String,
85+ /** Secret id holding the OAuth client_secret. Null for public clients. */
7086 clientSecretSecretId: Schema.NullOr(Schema.String),
71− accessTokenSecretId: Schema.String,
72− refreshTokenSecretId: Schema.NullOr(Schema.String),
73− tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
74− expiresAt: Schema.NullOr(Schema.Number),
75− scope: Schema.NullOr(Schema.String),
87+ /** Scopes requested on sign-in. */
7688 scopes: Schema.Array(Schema.String),
7789 }),
7890 );
@@ -113,5 +125,11 @@ export const GoogleDiscoveryOAuthSession = Schema.Struct({ · 34 unmodified lines
113125 redirectUrl: Schema.String,
114126 scopes: Schema.Array(Schema.String),
115127 codeVerifier: Schema.String,
128+ /** Executor scope that will own the resulting Connection + its backing
129+ * secrets. Typically the innermost (per-user) scope. */
130+ tokenScope: Schema.String,
131+ /** Pre-decided Connection id stamped at completeOAuth time so a retried
132+ * callback lands on the same id. */
133+ connectionId: Schema.String,
116134 });
117135 export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type;
packages/plugins/mcp/package.json+1−0
@@ -50,6 +50,7 @@ · 49 unmodified lines
5050 "@effect/platform": "catalog:",
5151 "@effect/platform-node": "catalog:",
5252 "@executor/config": "workspace:*",
53+ "@executor/plugin-oauth2": "workspace:*",
5354 "@executor/sdk": "workspace:*",
5455 "@modelcontextprotocol/sdk": "^1.29.0",
5556 "effect": "catalog:"
packages/plugins/mcp/src/api/group.ts+16−12
@@ -36,14 +36,10 @@ const AuthPayload = Schema.Union( · 35 unmodified lines
3636 }),
3737 Schema.Struct({
3838 kind: Schema.Literal("oauth2"),
39− accessTokenSecretId: Schema.String,
40− refreshTokenSecretId: Schema.NullOr(Schema.String),
41− tokenType: Schema.optional(Schema.String),
42− expiresAt: Schema.NullOr(Schema.Number),
43− scope: Schema.NullOr(Schema.String),
44− clientInformation: Schema.optional(Schema.NullOr(JsonObject)),
45− authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)),
46− resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)),
39+ /** Stable id of the SDK Connection minted by `completeOAuth`. The
40+ * backing access/refresh secrets live on the connection row; the
41+ * source only needs this pointer. */
42+ connectionId: Schema.String,
4743 }),
4844 );
4945
@@ -113,8 +109,12 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
113109 endpoint: Schema.String,
114110 redirectUrl: Schema.String,
115111 queryParams: Schema.optional(Schema.NullOr(StringMap)),
116− accessTokenSecretId: Schema.String,
117− refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),
112+ /** Pre-decided SDK connection id the exchange will mint. Caller
113+ * passes a stable value (`mcp-oauth2-${namespace}`) so every user
114+ * signing in against the same source writes to the same id at their
115+ * own scope — the source's stored `{kind: "oauth2", connectionId}`
116+ * then resolves per-user via shadowing. */
117+ connectionId: Schema.String,
118118 /** Source-level OAuth state captured by a previous user's flow. When
119119 * passed, DCR is skipped — the same client_id is re-used so the
120120 * source's auth config stays stable across users. */
@@ -161,11 +161,15 @@ const StartOAuthResponse = Schema.Struct({ · 40 unmodified lines
161161 });
162162
163163 const CompleteOAuthResponse = Schema.Struct({
164− accessTokenSecretId: Schema.String,
165− refreshTokenSecretId: Schema.NullOr(Schema.String),
164+ /** Id of the SDK Connection minted by the exchange. The UI stores it
165+ * on the source's auth config as `{kind: "oauth2", connectionId}`. */
166+ connectionId: Schema.String,
166167 tokenType: Schema.String,
167168 expiresAt: Schema.NullOr(Schema.Number),
168169 scope: Schema.NullOr(Schema.String),
170+ /** DCR client + discovery URLs captured during the flow. Persisted
171+ * on the source's auth config so subsequent users skip DCR + re-
172+ * discovery. */
169173 clientInformation: Schema.NullOr(JsonObject),
170174 authorizationServerUrl: Schema.NullOr(Schema.String),
171175 resourceMetadataUrl: Schema.NullOr(Schema.String),
packages/plugins/mcp/src/api/handlers.ts+10−16
@@ -34,11 +34,13 @@ type OAuthPopupResult = · 33 unmodified lines
3434 type: "executor:oauth-result";
3535 ok: true;
3636 sessionId: string;
37− accessTokenSecretId: string;
38− refreshTokenSecretId: string | null;
37+ connectionId: string;
3938 tokenType: string;
4039 expiresAt: number | null;
4140 scope: string | null;
41+ clientInformation: Record<string, unknown> | null;
42+ authorizationServerUrl: string | null;
43+ resourceMetadataUrl: string | null;
4244 }
4345 | {
4446 type: "executor:oauth-result";
@@ -130,15 +132,6 @@ const toSourceConfig = ( · 85 unmodified lines
130132 auth?: { kind: string } & Record<string, unknown>;
131133 };
132134
133− const auth = p.auth
134− ? p.auth.kind === "oauth2"
135− ? {
136− ...p.auth,
137− tokenType: (p.auth as { tokenType?: string }).tokenType ?? "Bearer",
138− }
139− : p.auth
140− : undefined;
141−
142135 return {
143136 transport: "remote",
144137 scope,
@@ -148,7 +141,7 @@ const toSourceConfig = ( · 3 unmodified lines
148141 queryParams: p.queryParams,
149142 headers: p.headers,
150143 namespace: p.namespace,
151− auth: auth as McpSourceConfig extends { auth?: infer A } ? A : never,
144+ auth: p.auth as McpSourceConfig extends { auth?: infer A } ? A : never,
152145 };
153146 };
154147
@@ -204,8 +197,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 49 unmodified lines
204197 endpoint: payload.endpoint,
205198 redirectUrl: payload.redirectUrl,
206199 queryParams: payload.queryParams,
207− accessTokenSecretId: payload.accessTokenSecretId,
208− refreshTokenSecretId: payload.refreshTokenSecretId,
200+ connectionId: payload.connectionId,
209201 clientInformation: payload.clientInformation,
210202 authorizationServerUrl: payload.authorizationServerUrl,
211203 resourceMetadataUrl: payload.resourceMetadataUrl,
@@ -259,11 +251,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 47 unmodified lines
259251 type: "executor:oauth-result",
260252 ok: true,
261253 sessionId: urlParams.state,
262− accessTokenSecretId: c.accessTokenSecretId,
263− refreshTokenSecretId: c.refreshTokenSecretId,
254+ connectionId: c.connectionId,
264255 tokenType: c.tokenType,
265256 expiresAt: c.expiresAt,
266257 scope: c.scope,
258+ clientInformation: c.clientInformation,
259+ authorizationServerUrl: c.authorizationServerUrl,
260+ resourceMetadataUrl: c.resourceMetadataUrl,
267261 }),
268262 onFailure: (cause) =>
269263 Effect.succeed<OAuthPopupResult>({
packages/plugins/mcp/src/react/AddMcpSource.tsx+15−30
@@ -49,29 +49,23 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines
4949 return mcpPresets.find((p) => p.id === id);
5050 }
5151
52−// Stable secret ids for an MCP source's OAuth access/refresh tokens.
53−// The same ids are passed into `startOAuth` AND stored on the source's
54−// auth config, so at invoke time the per-user scope (which actually
55−// holds the value) resolves via shadowing over the source-level id.
56−// Keyed by namespace so reconnecting overwrites the previous token
57−// rather than leaking orphaned secret rows.
58−const mcpOAuthTokenSecretIds = (
59− namespaceSlug: string,
60−): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => {
61− const prefix = `mcp_${namespaceSlug}`;
62− return {
63− accessTokenSecretId: `${prefix}_access_token`,
64− refreshTokenSecretId: `${prefix}_refresh_token`,
65− };
66−};
52+// Stable SDK connection id for an MCP source's OAuth sign-in. The same
53+// id is passed into `startOAuth` AND stored on the source's auth
54+// config, so at invoke time the per-user scope (which owns the user's
55+// own secrets) resolves via shadowing over the source-level pointer.
56+// Keyed by namespace so reconnecting mints a new connection that
57+// shadows the previous one at the same scope.
58+const mcpOAuthConnectionId = (namespaceSlug: string): string =>
59+ `mcp-oauth2-${namespaceSlug || "default"}`;
6760
6861 // ---------------------------------------------------------------------------
6962 // State machine (remote flow)
7063 // ---------------------------------------------------------------------------
7164
7265 type OAuthTokens = {
73− accessTokenSecretId: string;
74− refreshTokenSecretId: string | null;
66+ /** Id of the SDK Connection minted by the exchange. The UI stores it
67+ * on the source's auth config as `{kind: "oauth2", connectionId}`. */
68+ connectionId: string;
7569 tokenType: string;
7670 expiresAt: number | null;
7771 scope: string | null;
@@ -445,14 +439,13 @@ export default function AddMcpSource(props: { · 367 unmodified lines
445439 slugifyNamespace(remoteIdentity.namespace) ||
446440 slugifyNamespace(probe?.namespace ?? "") ||
447441 "mcp";
448− const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug);
442+ const connectionId = mcpOAuthConnectionId(namespaceSlug);
449443 const result = await doStartOAuth({
450444 path: { scopeId },
451445 payload: {
452446 endpoint: state.url.trim(),
453447 redirectUrl,
454− accessTokenSecretId: tokenIds.accessTokenSecretId,
455− refreshTokenSecretId: tokenIds.refreshTokenSecretId,
448+ connectionId,
456449 },
457450 });
458451 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
@@ -464,8 +457,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines
464457 dispatch({
465458 type: "oauth-ok",
466459 tokens: {
467− accessTokenSecretId: data.accessTokenSecretId,
468− refreshTokenSecretId: data.refreshTokenSecretId,
460+ connectionId: data.connectionId,
469461 tokenType: data.tokenType,
470462 expiresAt: data.expiresAt,
471463 scope: data.scope,
@@ -512,14 +504,7 @@ export default function AddMcpSource(props: { · 40 unmodified lines
512504 : remoteAuthMode === "oauth2" && tokens
513505 ? {
514506 kind: "oauth2" as const,
515− accessTokenSecretId: tokens.accessTokenSecretId,
516− refreshTokenSecretId: tokens.refreshTokenSecretId,
517− tokenType: tokens.tokenType,
518− expiresAt: tokens.expiresAt,
519− scope: tokens.scope,
520− clientInformation: tokens.clientInformation,
521− authorizationServerUrl: tokens.authorizationServerUrl,
522− resourceMetadataUrl: tokens.resourceMetadataUrl,
507+ connectionId: tokens.connectionId,
523508 }
524509 : { kind: "none" as const };
525510 const headers = Object.fromEntries(
packages/plugins/mcp/src/react/McpSignInButton.tsx+153−0
@@ -0,0 +1,153 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3+
4+import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5+import { useScope } from "@executor/react/api/scope-context";
6+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7+import { connectionsAtom } from "@executor/react/api/atoms";
8+import { Button } from "@executor/react/components/button";
9+import { slugifyNamespace } from "@executor/react/plugins/source-identity";
10+
11+import { mcpSourceAtom, startMcpOAuth, updateMcpSource } from "./atoms";
12+
13+// ---------------------------------------------------------------------------
14+// McpSignInButton — top-bar action on the source detail page.
15+//
16+// Reads the source's stored endpoint + oauth2 pointer, re-runs the DCR /
17+// authorization-code flow against a stable `mcp-oauth2-${namespace}`
18+// connection id, and on success rewrites the source's auth pointer to
19+// the freshly minted connection. Works whether or not the previous
20+// Connection still exists — source-owned config is the source of truth.
21+// ---------------------------------------------------------------------------
22+
23+const CALLBACK_PATH = "/api/mcp/oauth/callback";
24+const POPUP_NAME = "mcp-oauth";
25+const CHANNEL_NAME = "executor:mcp-oauth-result";
26+
27+type McpOAuthPopupPayload = {
28+ connectionId: string;
29+ tokenType: string;
30+ expiresAt: number | null;
31+ scope: string | null;
32+ clientInformation: Record<string, unknown> | null;
33+ authorizationServerUrl: string | null;
34+ resourceMetadataUrl: string | null;
35+};
36+
37+const mcpOAuthConnectionId = (namespaceSlug: string): string =>
38+ `mcp-oauth2-${namespaceSlug || "default"}`;
39+
40+export default function McpSignInButton(props: { sourceId: string }) {
41+ const scopeId = useScope();
42+ const sourceResult = useAtomValue(mcpSourceAtom(scopeId, props.sourceId));
43+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
44+ const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });
45+ const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
46+
47+ const [busy, setBusy] = useState(false);
48+ const [error, setError] = useState<string | null>(null);
49+ const cleanupRef = useRef<(() => void) | null>(null);
50+
51+ useEffect(() => () => cleanupRef.current?.(), []);
52+
53+ const source =
54+ Result.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null;
55+ const remote = source && source.config.transport === "remote" ? source.config : null;
56+ const oauth2 = remote && remote.auth.kind === "oauth2" ? remote.auth : null;
57+ const connections = Result.isSuccess(connectionsResult)
58+ ? connectionsResult.value
59+ : null;
60+ const isConnected =
61+ oauth2 !== null &&
62+ connections !== null &&
63+ connections.some((c) => c.id === oauth2.connectionId);
64+
65+ const redirectUrl =
66+ typeof window !== "undefined"
67+ ? `${window.location.origin}${CALLBACK_PATH}`
68+ : CALLBACK_PATH;
69+
70+ const handleSignIn = useCallback(async () => {
71+ if (!remote || !oauth2 || !source) return;
72+ cleanupRef.current?.();
73+ cleanupRef.current = null;
74+ setBusy(true);
75+ setError(null);
76+ try {
77+ const namespaceSlug = slugifyNamespace(source.namespace) || "mcp";
78+ const connectionId = mcpOAuthConnectionId(namespaceSlug);
79+ const response = await doStartOAuth({
80+ path: { scopeId },
81+ payload: {
82+ endpoint: remote.endpoint,
83+ redirectUrl,
84+ connectionId,
85+ },
86+ });
87+
88+ cleanupRef.current = openOAuthPopup<McpOAuthPopupPayload>({
89+ url: response.authorizationUrl,
90+ popupName: POPUP_NAME,
91+ channelName: CHANNEL_NAME,
92+ onResult: async (result: OAuthPopupResult<McpOAuthPopupPayload>) => {
93+ cleanupRef.current = null;
94+ if (!result.ok) {
95+ setBusy(false);
96+ setError(result.error);
97+ return;
98+ }
99+ try {
100+ await doUpdate({
101+ path: { scopeId, namespace: props.sourceId },
102+ payload: {
103+ auth: { kind: "oauth2", connectionId: result.connectionId },
104+ },
105+ reactivityKeys: sourceWriteKeys,
106+ });
107+ setBusy(false);
108+ } catch (e) {
109+ setBusy(false);
110+ setError(
111+ e instanceof Error ? e.message : "Failed to persist new connection",
112+ );
113+ }
114+ },
115+ onClosed: () => {
116+ cleanupRef.current = null;
117+ setBusy(false);
118+ setError("Sign-in cancelled — popup was closed before completing the flow.");
119+ },
120+ onOpenFailed: () => {
121+ cleanupRef.current = null;
122+ setBusy(false);
123+ setError("Sign-in popup was blocked by the browser");
124+ },
125+ });
126+ } catch (e) {
127+ setBusy(false);
128+ setError(e instanceof Error ? e.message : "Failed to start sign-in");
129+ }
130+ }, [remote, oauth2, source, scopeId, props.sourceId, redirectUrl, doStartOAuth, doUpdate]);
131+
132+ if (!oauth2) return null;
133+
134+ return (
135+ <div className="flex items-center gap-2">
136+ {error && <span className="text-xs text-destructive">{error}</span>}
137+ <Button
138+ variant="outline"
139+ size="sm"
140+ onClick={() => void handleSignIn()}
141+ disabled={busy}
142+ >
143+ {busy
144+ ? isConnected
145+ ? "Reconnecting…"
146+ : "Signing in…"
147+ : isConnected
148+ ? "Reconnect"
149+ : "Sign in"}
150+ </Button>
151+ </div>
152+ );
153+}
packages/plugins/mcp/src/react/source-plugin.tsx+2−0
@@ -5,6 +5,7 @@ import { mcpPresets } from "../sdk/presets"; · 4 unmodified lines
55 const LazyAddMcpSource = lazy(() => import("./AddMcpSource"));
66 const LazyEditMcpSource = lazy(() => import("./EditMcpSource"));
77 const LazySummary = lazy(() => import("./McpSourceSummary"));
8+const LazyMcpSignInButton = lazy(() => import("./McpSignInButton"));
89
910 type AddProps = ComponentProps<SourcePlugin["add"]>;
1011
@@ -40,6 +41,7 @@ export const createMcpSourcePlugin = ( · 29 unmodified lines
4041 add: AddWithFlag,
4142 edit: LazyEditMcpSource,
4243 summary: LazySummary,
44+ signIn: LazyMcpSignInButton,
4345 presets,
4446 };
4547 };
packages/plugins/mcp/src/sdk/oauth.ts+10−10
@@ -38,18 +38,18 @@ export const McpOAuthSession = Schema.Struct({ · 37 unmodified lines
3838 redirectUrl: Schema.String,
3939 codeVerifier: Schema.String,
4040 /**
41− * Executor scope id where the minted access/refresh tokens will land.
42− * Pinned at `startOAuth` time so token writes target the same scope
43− * regardless of who's currently invoking. For per-user OAuth this is
44− * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it
45− * can be the org scope.
41+ * Executor scope id where the minted Connection (and its owned
42+ * access/refresh secrets) will land. Pinned at `startOAuth` time so
43+ * token writes target the same scope regardless of who's currently
44+ * invoking. For per-user OAuth this is the innermost
45+ * (`ctx.scopes[0]`) scope; for org-shared installs it can be the org
46+ * scope.
4647 */
4748 tokenScope: Schema.String,
48− /** Stable secret ids the minted tokens are written to. Stored once on
49− * the source's auth config so per-user scope shadowing resolves to
50− * the calling user's tokens at invoke time. */
51− accessTokenSecretId: Schema.String,
52− refreshTokenSecretId: Schema.NullOr(Schema.String),
49+ /** Stable id of the SDK Connection the exchange will mint. Stored
50+ * once on the source's auth config so per-user scope shadowing
51+ * resolves to the calling user's connection at invoke time. */
52+ connectionId: Schema.String,
5353 });
5454 export type McpOAuthSession = typeof McpOAuthSession.Type;
5555
packages/plugins/mcp/src/sdk/plugin.ts+326−195
@@ -1,20 +1,28 @@
1−import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";
1+import { Duration, Effect, Exit, Schema, Scope, ScopedCache } from "effect";
22
3−import type {
4− OAuthClientProvider,
5− OAuthDiscoveryState,
3+import {
4+ refreshAuthorization,
5+ type OAuthClientProvider,
6+ type OAuthDiscoveryState,
67 } from "@modelcontextprotocol/sdk/client/auth.js";
78 import type {
89 OAuthClientInformationMixed,
10+ OAuthMetadata,
911 OAuthTokens,
1012 } from "@modelcontextprotocol/sdk/shared/auth.js";
1113
1214 import {
13− definePlugin,
15+ ConnectionId,
16+ ConnectionRefreshError,
17+ CreateConnectionInput,
1418 ScopeId,
1519 SecretId,
16− SetSecretInput,
1720 SourceDetectionResult,
21+ TokenMaterial,
22+ definePlugin,
23+ type ConnectionProvider,
24+ type ConnectionRefreshInput,
25+ type ConnectionRefreshResult,
1826 type PluginCtx,
1927 type StorageFailure,
2028 } from "@executor/sdk";
@@ -42,7 +50,12 @@ import { · 21 unmodified lines
4250 type McpToolManifestEntry,
4351 } from "./manifest";
4452 import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth";
45−import { McpToolBinding, type McpConnectionAuth, type McpStoredSourceData } from "./types";
53+import {
54+ McpToolBinding,
55+ McpJsonObject,
56+ type McpConnectionAuth,
57+ type McpStoredSourceData,
58+} from "./types";
4659
4760 import {
4861 SECRET_REF_PREFIX,
@@ -97,20 +110,20 @@ export interface McpOAuthStartInput { · 48 unmodified lines
97110 readonly redirectUrl: string;
98111 readonly queryParams?: Record<string, string> | null;
99112 /**
100− * Executor scope id where the minted access/refresh tokens will land.
101− * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack
102− * `[user, org]` that pins tokens to the user scope so the source's
103− * stored `accessTokenSecretId` resolves per-user via shadowing.
113+ * Executor scope id where the minted Connection will land. Defaults
114+ * to `ctx.scopes[0].id` (innermost) — for a per-user stack
115+ * `[user, org]` that pins the connection to the user scope so the
116+ * source's stored `connectionId` resolves per-user via shadowing.
104117 */
105118 readonly tokenScope?: string;
106119 /**
107− * Pre-decided secret ids for the minted tokens. Mint deterministically
108− * (e.g. `mcp_${namespace}_access_token`) so the source's stored
109− * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get`
110− * resolves per-user via scope fall-through.
120+ * Pre-decided SDK connection id the exchange will mint. Stable per
121+ * source (typically `mcp-oauth2-${namespace}`) so multiple users
122+ * signing in against the same MCP source all write to the same id at
123+ * their own scope — `ctx.connections.accessToken(id)` then resolves
124+ * innermost-first.
111125 */
112− readonly accessTokenSecretId: string;
113− readonly refreshTokenSecretId?: string | null;
126+ readonly connectionId: string;
114127 /**
115128 * Source-level OAuth state captured by a previous user's flow. Pass
116129 * the values stored on the source's auth config to skip Dynamic
@@ -134,14 +147,16 @@ export interface McpOAuthCompleteInput { · 17 unmodified lines
134147 }
135148
136149 export interface McpOAuthCompleteResponse {
137− readonly accessTokenSecretId: string;
138− readonly refreshTokenSecretId: string | null;
150+ /** Id of the SDK Connection the exchange minted. The caller stores it
151+ * on the source's `oauth2` auth field and resolves tokens via
152+ * `ctx.connections`. */
153+ readonly connectionId: string;
139154 readonly tokenType: string;
140155 readonly expiresAt: number | null;
141156 readonly scope: string | null;
142157 /** DCR client + discovery URLs captured during the flow. The UI
143− * stores them on the source's auth config so refreshes don't
144− * re-register or re-discover. */
158+ * stores them on the source's auth config so subsequent users can
159+ * skip DCR and re-discovery. */
145160 readonly clientInformation: Record<string, unknown> | null;
146161 readonly authorizationServerUrl: string | null;
147162 readonly resourceMetadataUrl: string | null;
@@ -208,31 +223,20 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 60 unmodified lines
208223 interface OAuthProviderInputs {
209224 readonly accessToken: string;
210225 readonly tokenType: string;
211− readonly refreshToken?: string;
212− readonly expiresAt?: number | null;
213− readonly scope?: string | null;
214− /** Source-level state — same for every user. */
226+ /** Source-level state — same for every user. Surfaced to the MCP
227+ * transport so internal DCR/discovery lookups find a populated
228+ * provider, but never used to mint tokens: refresh is owned by the
229+ * SDK via `ctx.connections.accessToken`. */
215230 readonly clientInformation?: OAuthClientInformationMixed | null;
216231 readonly authorizationServerUrl?: string | null;
217232 readonly resourceMetadataUrl?: string | null;
218233 readonly endpoint: string;
219− /**
220− * Called when the SDK refreshes tokens (grant_type=refresh_token).
221− * Persisting new tokens back to per-user secrets is what closes the
222− * refresh loop — without it the next invocation reads stale values.
223− */
224− readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void;
225234 }
226235
227236 const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
228− let currentTokens: OAuthTokens = {
237+ const currentTokens: OAuthTokens = {
229238 access_token: inputs.accessToken,
230239 token_type: inputs.tokenType,
231− ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}),
232− ...(inputs.expiresAt
233− ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) }
234− : {}),
235− ...(inputs.scope ? { scope: inputs.scope } : {}),
236240 };
237241 let clientInformation: OAuthClientInformationMixed | undefined =
238242 inputs.clientInformation ?? undefined;
@@ -263,10 +267,13 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 24 unmodified lines
263267 clientInformation = ci;
264268 },
265269 tokens: () => currentTokens,
266− saveTokens: async (t) => {
267− currentTokens = t;
268− if (inputs.onRefresh) await inputs.onRefresh(t);
269− },
270+ // No-op: token refresh is driven by the SDK through
271+ // `ctx.connections.accessToken`, and the access token resolved at
272+ // `resolveConnectorInput` time is the only one this provider ever
273+ // hands to the transport. A 401 from the server flips the
274+ // invokeMcpTool retry path, which invalidates the cached connection
275+ // and re-resolves — picking up a freshly refreshed token.
276+ saveTokens: () => undefined,
270277 redirectToAuthorization: async () => {
271278 throw new Error("MCP OAuth re-authorization required");
272279 },
@@ -281,6 +288,38 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 8 unmodified lines
281288 };
282289 };
283290
291+// ---------------------------------------------------------------------------
292+// Connection `provider_state` shape for mcp-oauth2.
293+//
294+// Carries everything the SDK hands to the refresh handler so it can
295+// re-hit the token endpoint without re-reading plugin storage. The DCR
296+// client info is NOT sensitive on its own — the MCP SDK's DCR flow uses
297+// `token_endpoint_auth_method: "none"` (PKCE), so there's no
298+// client_secret to protect. Having it here lets the refresh handler
299+// skip discovery entirely.
300+// ---------------------------------------------------------------------------
301+
302+const MCP_OAUTH2_PROVIDER_KEY = "mcp:oauth2" as const;
303+
304+const OAuth2ProviderState = Schema.Struct({
305+ endpoint: Schema.String,
306+ tokenType: Schema.String,
307+ clientInformation: McpJsonObject,
308+ authorizationServerUrl: Schema.NullOr(Schema.String),
309+ authorizationServerMetadata: Schema.NullOr(McpJsonObject),
310+ resourceMetadataUrl: Schema.NullOr(Schema.String),
311+ resourceMetadata: Schema.NullOr(McpJsonObject),
312+});
313+type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
314+
315+const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
316+const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
317+
318+const toProviderStateRecord = (
319+ state: OAuth2ProviderState,
320+): Record<string, unknown> =>
321+ encodeProviderState(state) as unknown as Record<string, unknown>;
322+
284323 const remoteConnectionError = (message: string) =>
285324 new McpConnectionError({ transport: "remote", message });
286325
@@ -331,66 +370,47 @@ const resolveConnectorInput = ( · 44 unmodified lines
331370 }
332371 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val;
333372 } else if (auth.kind === "oauth2") {
334− const accessToken = yield* ctx.secrets.get(auth.accessTokenSecretId);
335− if (accessToken === null) {
336− return yield* Effect.fail(
337− remoteConnectionError("Failed to resolve OAuth access token"),
373+ // `accessToken(id)` handles refresh internally — by the time we
374+ // hand the value to the MCP transport it's guaranteed fresh. The
375+ // connection row carries the DCR client + discovery metadata in
376+ // `providerState`, but we read it off the connection here only
377+ // for the `OAuthClientProvider` surface; the actual token
378+ // material is what matters at the wire.
379+ const accessToken = yield* ctx.connections
380+ .accessToken(auth.connectionId)
381+ .pipe(
382+ Effect.mapError((err) =>
383+ remoteConnectionError(
384+ `Failed to resolve OAuth connection "${auth.connectionId}": ${
385+ "message" in err
386+ ? (err as { message: string }).message
387+ : String(err)
388+ }`,
389+ ),
390+ ),
338391 );
339− }
340− const refreshToken = auth.refreshTokenSecretId
341− ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined
342− : undefined;
343− // Capture the resolved owning scope of these secrets so refreshed
344− // tokens land back at the same per-user scope. `ctx.secrets.get`
345− // walks the executor scope stack innermost-first, so the existing
346− // value lives at whichever scope shadowed the source-level id —
347− // we mirror that with `ctx.scopes[0]!.id`, matching the scope
348− // chosen at startOAuth time.
349− const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string);
350− const accessSecretId = auth.accessTokenSecretId;
351− const refreshSecretId = auth.refreshTokenSecretId;
392+
393+ const connection = yield* ctx.connections.get(auth.connectionId);
394+ const providerState: OAuth2ProviderState | null =
395+ connection?.providerState
396+ ? yield* Effect.sync(() => {
397+ try {
398+ return decodeProviderState(connection.providerState);
399+ } catch {
400+ return null;
401+ }
402+ })
403+ : null;
404+
352405 authProvider = makeOAuthProvider({
353406 accessToken,
354− tokenType: auth.tokenType ?? "Bearer",
355− refreshToken,
356− expiresAt: auth.expiresAt,
357− scope: auth.scope,
358− clientInformation: auth.clientInformation as
359− | OAuthClientInformationMixed
360− | null
361− | undefined,
362− authorizationServerUrl: auth.authorizationServerUrl,
363− resourceMetadataUrl: auth.resourceMetadataUrl,
407+ tokenType: providerState?.tokenType ?? "Bearer",
408+ clientInformation:
409+ (providerState?.clientInformation as OAuthClientInformationMixed | undefined) ??
410+ null,
411+ authorizationServerUrl: providerState?.authorizationServerUrl ?? null,
412+ resourceMetadataUrl: providerState?.resourceMetadataUrl ?? null,
364413 endpoint: sd.endpoint,
365− onRefresh: async (tokens) => {
366− // Persist refreshed tokens back to the calling user's scope
367− // so subsequent invocations see the new value rather than
368− // re-refreshing on every request. Uses runPromise because
369− // OAuthClientProvider.saveTokens is an async callback, not
370− // an Effect.
371− await Effect.runPromise(
372− ctx.secrets.set(
373− new SetSecretInput({
374− id: SecretId.make(accessSecretId),
375− scope: tokenScope,
376− name: "MCP OAuth Access Token",
377− value: tokens.access_token,
378− }),
379− ),
380− );
381− if (tokens.refresh_token && refreshSecretId) {
382− await Effect.runPromise(
383− ctx.secrets.set(
384− new SetSecretInput({
385− id: SecretId.make(refreshSecretId),
386− scope: tokenScope,
387− name: "MCP OAuth Refresh Token",
388− value: tokens.refresh_token,
389− }),
390− ),
391− );
392− }
393− },
394414 });
395415 }
396416
@@ -488,11 +508,7 @@ const authToConfig = (auth: McpConnectionAuth | undefined): McpAuthConfig | unde · 91 unmodified lines
488508 }
489509 return {
490510 kind: "oauth2",
491− accessTokenSecret: secretRef(auth.accessTokenSecretId),
492− refreshTokenSecret: auth.refreshTokenSecretId
493− ? secretRef(auth.refreshTokenSecretId)
494− : null,
495− tokenType: auth.tokenType,
511+ connectionId: auth.connectionId,
496512 };
497513 };
498514
@@ -889,8 +905,7 @@ export const mcpPlugin = definePlugin( · 390 unmodified lines
889905 authorizationServerMetadata: started.authorizationServerMetadata,
890906 clientInformation: started.clientInformation,
891907 tokenScope,
892− accessTokenSecretId: input.accessTokenSecretId,
893− refreshTokenSecretId: input.refreshTokenSecretId ?? null,
908+ connectionId: input.connectionId,
894909 })
895910 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
896911
@@ -901,104 +916,139 @@ export const mcpPlugin = definePlugin( · 4 unmodified lines
901916 }).pipe(Effect.withSpan("mcp.plugin.start_oauth"));
902917
903918 const completeOAuth = (input: McpOAuthCompleteInput) =>
904− Effect.gen(function* () {
905− if (input.error) {
906− return yield* Effect.fail(
907− mcpOAuthError(`OAuth error: ${input.error}`),
908− );
909− }
910− if (!input.code) {
911− return yield* Effect.fail(
912− mcpOAuthError("Missing OAuth authorization code"),
913− );
914− }
915−
916− const session = yield* ctx.storage.getOAuthSession(input.state);
917− if (!session) {
918− return yield* Effect.fail(
919− mcpOAuthError(`OAuth session not found: ${input.state}`),
920− );
921− }
922−
923− const exchanged = yield* exchangeMcpOAuthCode({
924− session,
925− code: input.code,
926− }).pipe(
927− Effect.mapError((e) =>
928− mcpOAuthError(`OAuth exchange failed: ${e.message}`),
929− ),
930− Effect.withSpan("mcp.plugin.oauth.exchange_code"),
931− );
919+ ctx
920+ .transaction(
921+ Effect.gen(function* () {
922+ if (input.error) {
923+ return yield* Effect.fail(
924+ mcpOAuthError(`OAuth error: ${input.error}`),
925+ );
926+ }
927+ if (!input.code) {
928+ return yield* Effect.fail(
929+ mcpOAuthError("Missing OAuth authorization code"),
930+ );
931+ }
932932
933− // Token storage is fully driven by the session: scope and
934− // secret ids were chosen at startOAuth time and pinned to
935− // the row. That keeps shadowing deterministic — every
936− // user's OAuth flow on the same source writes to the same
937− // ids, so the source's stored OAuth2 auth resolves
938− // per-user via scope fall-through.
939− const tokenScope = ScopeId.make(session.tokenScope);
940− const accessSecretId = session.accessTokenSecretId;
941− yield* ctx.secrets
942− .set(
943− new SetSecretInput({
944− id: SecretId.make(accessSecretId),
945− scope: tokenScope,
946− name: "MCP OAuth Access Token",
947− value: exchanged.tokens.access_token,
948− }),
949− )
950− .pipe(
951− Effect.mapError((e) =>
952− mcpOAuthError(`Failed to store access token: ${String(e)}`),
953− ),
954− );
933+ const session = yield* ctx.storage.getOAuthSession(input.state);
934+ if (!session) {
935+ return yield* Effect.fail(
936+ mcpOAuthError(`OAuth session not found: ${input.state}`),
937+ );
938+ }
955939
956− let refreshTokenSecretId: string | null = null;
957− if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {
958− const refreshId = session.refreshTokenSecretId;
959− yield* ctx.secrets
960− .set(
961− new SetSecretInput({
962− id: SecretId.make(refreshId),
963− scope: tokenScope,
964− name: "MCP OAuth Refresh Token",
965− value: exchanged.tokens.refresh_token,
966− }),
967− )
968− .pipe(
940+ const exchanged = yield* exchangeMcpOAuthCode({
941+ session,
942+ code: input.code,
943+ }).pipe(
969944 Effect.mapError((e) =>
970− mcpOAuthError(
971− `Failed to store refresh token: ${String(e)}`,
972− ),
945+ mcpOAuthError(`OAuth exchange failed: ${e.message}`),
973946 ),
947+ Effect.withSpan("mcp.plugin.oauth.exchange_code"),
974948 );
975− refreshTokenSecretId = refreshId;
976− }
977−
978− yield* ctx.storage.deleteOAuthSession(input.state);
979949
980− const expiresAt =
981− typeof exchanged.tokens.expires_in === "number"
982− ? Date.now() + exchanged.tokens.expires_in * 1000
983− : null;
950+ yield* ctx.storage.deleteOAuthSession(input.state);
951+
952+ const tokenType = exchanged.tokens.token_type ?? "Bearer";
953+ const expiresAt =
954+ typeof exchanged.tokens.expires_in === "number"
955+ ? Date.now() + exchanged.tokens.expires_in * 1000
956+ : null;
957+
958+ // Derive a friendly label for the Connections page. MCP
959+ // OAuth tokens don't include identity claims, so fall back
960+ // to the endpoint host (e.g., "mcp.axiom.co"), which is
961+ // what the user recognises from the source they added.
962+ let derivedLabel: string | null = null;
963+ try {
964+ derivedLabel = new URL(session.endpoint).host;
965+ } catch {
966+ derivedLabel = null;
967+ }
968+
969+ const providerState: OAuth2ProviderState = {
970+ endpoint: session.endpoint,
971+ tokenType,
972+ clientInformation:
973+ (exchanged.clientInformation as Record<string, unknown> | null) ??
974+ (session.clientInformation ?? {}),
975+ authorizationServerUrl:
976+ exchanged.authorizationServerUrl ??
977+ session.authorizationServerUrl,
978+ authorizationServerMetadata:
979+ exchanged.authorizationServerMetadata ??
980+ session.authorizationServerMetadata,
981+ resourceMetadataUrl:
982+ exchanged.resourceMetadataUrl ?? session.resourceMetadataUrl,
983+ resourceMetadata:
984+ exchanged.resourceMetadata ?? session.resourceMetadata,
985+ };
986+
987+ // Mint the connection + its owned access/refresh secret
988+ // rows in one transactional write. The session-pinned
989+ // `connectionId` stays stable across re-auths by the same
990+ // source, which means the UI's stored `{kind: "oauth2",
991+ // connectionId}` on the source auth config resolves
992+ // per-user via scope shadowing once each user completes
993+ // their own OAuth.
994+ yield* ctx.connections
995+ .create(
996+ new CreateConnectionInput({
997+ id: ConnectionId.make(session.connectionId),
998+ scope: ScopeId.make(session.tokenScope),
999+ provider: MCP_OAUTH2_PROVIDER_KEY,
1000+ kind: "user",
1001+ identityLabel: derivedLabel,
1002+ accessToken: new TokenMaterial({
1003+ secretId: SecretId.make(
1004+ `${session.connectionId}.access_token`,
1005+ ),
1006+ name: "MCP OAuth Access Token",
1007+ value: exchanged.tokens.access_token,
1008+ }),
1009+ refreshToken: exchanged.tokens.refresh_token
1010+ ? new TokenMaterial({
1011+ secretId: SecretId.make(
1012+ `${session.connectionId}.refresh_token`,
1013+ ),
1014+ name: "MCP OAuth Refresh Token",
1015+ value: exchanged.tokens.refresh_token,
1016+ })
1017+ : null,
1018+ expiresAt,
1019+ oauthScope: exchanged.tokens.scope ?? null,
1020+ providerState: toProviderStateRecord(providerState),
1021+ }),
1022+ )
1023+ .pipe(
1024+ Effect.mapError((err) =>
1025+ mcpOAuthError(
1026+ `Failed to create OAuth connection: ${
1027+ "message" in err
1028+ ? (err as { message: string }).message
1029+ : String(err)
1030+ }`,
1031+ ),
1032+ ),
1033+ );
9841034
985− return {
986− accessTokenSecretId: accessSecretId,
987− refreshTokenSecretId,
988− tokenType: exchanged.tokens.token_type ?? "Bearer",
989− expiresAt,
990− scope: exchanged.tokens.scope ?? null,
991− // Echo the source-level OAuth state captured during this
992− // flow. The UI persists it on the source's auth config so
993− // refreshes (and any future user's OAuth) re-use the same
994− // DCR client + skip discovery.
995− clientInformation: exchanged.clientInformation as
996− | Record<string, unknown>
997− | null,
998− authorizationServerUrl: exchanged.authorizationServerUrl,
999− resourceMetadataUrl: exchanged.resourceMetadataUrl,
1000− };
1001− }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
1035+ return {
1036+ connectionId: session.connectionId,
1037+ tokenType,
1038+ expiresAt,
1039+ scope: exchanged.tokens.scope ?? null,
1040+ // Echo the source-level OAuth state captured during this
1041+ // flow. The UI persists it on the source's auth config
1042+ // so subsequent users can skip DCR + re-discovery.
1043+ clientInformation: exchanged.clientInformation as
1044+ | Record<string, unknown>
1045+ | null,
1046+ authorizationServerUrl: exchanged.authorizationServerUrl,
1047+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
1048+ };
1049+ }),
1050+ )
1051+ .pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
10021052
10031053 const updateSource = (
10041054 namespace: string,
@@ -1205,6 +1255,87 @@ export const mcpPlugin = definePlugin( · 200 unmodified lines
12051255
12061256 refreshSource: () => Effect.void,
12071257
1258+ // The SDK's `ctx.connections.accessToken(id)` dispatches here when
1259+ // a connection with `provider: "mcp:oauth2"` is near expiry. The
1260+ // providerState blob — captured in completeOAuth — carries
1261+ // everything the MCP SDK needs to skip discovery + DCR and go
1262+ // straight to the refresh token endpoint.
1263+ connectionProviders: (ctx): readonly ConnectionProvider[] => [
1264+ {
1265+ key: MCP_OAUTH2_PROVIDER_KEY,
1266+ refresh: (input: ConnectionRefreshInput) =>
1267+ Effect.gen(function* () {
1268+ if (!input.providerState) {
1269+ return yield* new ConnectionRefreshError({
1270+ connectionId: input.connectionId,
1271+ message: "mcp:oauth2 connection is missing providerState",
1272+ });
1273+ }
1274+ const refreshToken = input.refreshToken;
1275+ if (!refreshToken) {
1276+ return yield* new ConnectionRefreshError({
1277+ connectionId: input.connectionId,
1278+ message: "mcp:oauth2 connection has no refresh token",
1279+ });
1280+ }
1281+ const state = yield* Effect.try({
1282+ try: () => decodeProviderState(input.providerState),
1283+ catch: (cause) =>
1284+ new ConnectionRefreshError({
1285+ connectionId: input.connectionId,
1286+ message: `mcp:oauth2 providerState is malformed: ${
1287+ cause instanceof Error ? cause.message : String(cause)
1288+ }`,
1289+ cause,
1290+ }),
1291+ });
1292+
1293+ const authServerUrl =
1294+ state.authorizationServerUrl ??
1295+ new URL("/", state.endpoint).toString();
1296+
1297+ const tokens = yield* Effect.tryPromise({
1298+ try: () =>
1299+ refreshAuthorization(authServerUrl, {
1300+ metadata:
1301+ (state.authorizationServerMetadata as
1302+ | OAuthMetadata
1303+ | null) ?? undefined,
1304+ clientInformation:
1305+ state.clientInformation as OAuthClientInformationMixed,
1306+ refreshToken,
1307+ }),
1308+ catch: (cause) =>
1309+ new ConnectionRefreshError({
1310+ connectionId: input.connectionId,
1311+ message: `MCP OAuth refresh failed: ${
1312+ cause instanceof Error ? cause.message : String(cause)
1313+ }`,
1314+ cause,
1315+ }),
1316+ });
1317+
1318+ const expiresAt =
1319+ typeof tokens.expires_in === "number"
1320+ ? Date.now() + tokens.expires_in * 1000
1321+ : null;
1322+
1323+ // The MCP SDK preserves the original refresh_token if the
1324+ // AS doesn't rotate (see refreshAuthorization doc-comment),
1325+ // so we forward whatever it returned. Undefined here would
1326+ // mean "keep the stored one", but we always pass the
1327+ // current value through for consistency.
1328+ const result: ConnectionRefreshResult = {
1329+ accessToken: tokens.access_token,
1330+ refreshToken: tokens.refresh_token ?? undefined,
1331+ expiresAt,
1332+ oauthScope: tokens.scope ?? input.oauthScope,
1333+ };
1334+ return result;
1335+ }),
1336+ },
1337+ ],
1338+
12081339 close: () =>
12091340 Effect.gen(function* () {
12101341 const runtime = runtimeRef.current;
packages/plugins/mcp/src/sdk/types.ts+8−26
@@ -13,10 +13,17 @@ export type McpTransport = typeof McpTransport.Type; · 12 unmodified lines
1313
1414 // ---------------------------------------------------------------------------
1515 // Connection auth (only applies to remote sources)
16+//
17+// `oauth2` is a thin pointer to an SDK Connection (`ctx.connections`) —
18+// the access/refresh secrets, expiry, DCR client info, and authorization-
19+// server discovery URLs all live on the connection row. Scope shadowing
20+// means the same `connectionId` resolves per-user via the executor's
21+// innermost-wins lookup.
1622 // ---------------------------------------------------------------------------
1723
1824 /** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
1925 const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
26+export { JsonObject as McpJsonObject };
2027
2128 export const McpConnectionAuth = Schema.Union(
2229 Schema.Struct({ kind: Schema.Literal("none") }),
@@ -28,32 +35,7 @@ export const McpConnectionAuth = Schema.Union( · 5 unmodified lines
2835 }),
2936 Schema.Struct({
3037 kind: Schema.Literal("oauth2"),
31− accessTokenSecretId: Schema.String,
32− refreshTokenSecretId: Schema.NullOr(Schema.String),
33− tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
34− expiresAt: Schema.NullOr(Schema.Number),
35− scope: Schema.NullOr(Schema.String),
36− /**
37− * Source-level OAuth state shared by every user. Lives on the
38− * source row (org scope) so DCR runs once per source instead of
39− * once per user, and so refresh can use the same client_id the
40− * upstream auth server originally registered.
41− *
42− * - `clientInformation`: DCR-issued client credentials (client_id,
43− * optional client_secret). When present, no DCR happens on
44− * subsequent OAuth flows or refreshes.
45− * - `authorizationServerUrl` / `resourceMetadataUrl`: discovery
46− * URLs captured at first OAuth so refreshes don't re-discover.
47− */
48− clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), {
49− default: () => null,
50− }),
51− authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
52− default: () => null,
53− }),
54− resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
55− default: () => null,
56− }),
38+ connectionId: Schema.String,
5739 }),
5840 );
5941 export type McpConnectionAuth = typeof McpConnectionAuth.Type;
packages/plugins/oauth2/src/index.ts+0−14
@@ -399,17 +399,3 @@ export const shouldRefreshToken = (input: { · 398 unmodified lines
399399 return input.expiresAt <= now + skew;
400400 };
401401
402−// ---------------------------------------------------------------------------
403−// Re-exports from sibling modules
404−// ---------------------------------------------------------------------------
405−
406−export {
407− type OAuth2AuthRefs,
408− type OAuth2SecretsIO,
409− type RefreshedAuthSnapshot,
410− type StoreOAuthTokensInput,
411− type StoredOAuthTokens,
412− type WithRefreshedAccessTokenInput,
413− storeOAuthTokens,
414− withRefreshedAccessToken,
415−} from "./refresh";
packages/plugins/oauth2/src/refresh.test.ts+0−396
@@ -1,396 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Fidelity tests for storeOAuthTokens + withRefreshedAccessToken.
3−// Uses an in-memory secrets adapter so we can assert exactly which calls
4−// happen in which order across the "no refresh needed", "refresh needed",
5−// "refresh rotates the refresh_token", and "secret resolution fails" paths.
6−// ---------------------------------------------------------------------------
7−
8−import { afterEach, describe, expect, it, vi } from "vitest";
9−import { Effect, Exit } from "effect";
10−
11−import {
12− OAUTH2_REFRESH_SKEW_MS,
13− storeOAuthTokens,
14− withRefreshedAccessToken,
15− type OAuth2AuthRefs,
16− type OAuth2SecretsIO,
17− type RefreshedAuthSnapshot,
18−} from "./index";
19−
20−// ---------------------------------------------------------------------------
21−// In-memory secrets adapter
22−// ---------------------------------------------------------------------------
23−
24−const makeSecrets = (initial: Record<string, string>): {
25− io: OAuth2SecretsIO;
26− state: Map<string, { value: string; name: string; purpose: string }>;
27− missing: Set<string>;
28−} => {
29− const state = new Map<string, { value: string; name: string; purpose: string }>();
30− for (const [id, v] of Object.entries(initial))
31− state.set(id, { value: v, name: "", purpose: "" });
32− const missing = new Set<string>();
33− const io: OAuth2SecretsIO = {
34− resolve: (secretId) =>
35− Effect.gen(function* () {
36− if (missing.has(secretId))
37− return yield* Effect.fail(new Error(`secret ${secretId} not found`));
38− const entry = state.get(secretId);
39− if (!entry)
40− return yield* Effect.fail(new Error(`secret ${secretId} not found`));
41− return entry.value;
42− }),
43− setValue: ({ secretId, value, name, purpose }) =>
44− Effect.sync(() => {
45− state.set(secretId, { value, name, purpose });
46− }),
47− };
48− return { io, state, missing };
49−};
50−
51−// ---------------------------------------------------------------------------
52−// fetch capture for refresh path
53−// ---------------------------------------------------------------------------
54−
55−const originalFetch = globalThis.fetch;
56−
57−const captureFetch = (
58− response: Response | (() => Response),
59−): { calls: Array<{ url: string; init: RequestInit }> } => {
60− const calls: Array<{ url: string; init: RequestInit }> = [];
61− globalThis.fetch = vi
62− .fn()
63− .mockImplementation(async (url: string, init: RequestInit) => {
64− calls.push({ url, init });
65− return typeof response === "function" ? response() : response;
66− }) as unknown as typeof fetch;
67− return { calls };
68−};
69−
70−const jsonResponse = (status: number, body: unknown): Response =>
71− new Response(JSON.stringify(body), {
72− status,
73− headers: { "content-type": "application/json" },
74− });
75−
76−afterEach(() => {
77− globalThis.fetch = originalFetch;
78−});
79−
80−// ---------------------------------------------------------------------------
81−// storeOAuthTokens
82−// ---------------------------------------------------------------------------
83−
84−describe("storeOAuthTokens", () => {
85− const baseInput = {
86− slug: "acme_api",
87− displayName: "Acme API",
88− accessTokenPurpose: "acme_oauth_access_token",
89− refreshTokenPurpose: "acme_oauth_refresh_token",
90− };
91−
92− it("stores both access and refresh tokens as new secrets", async () => {
93− const calls: Array<{ idPrefix: string; name: string; purpose: string; value: string }> = [];
94− const result = await Effect.runPromise(
95− storeOAuthTokens({
96− ...baseInput,
97− tokens: {
98− access_token: "tok",
99− refresh_token: "rtok",
100− token_type: "Bearer",
101− expires_in: 3600,
102− scope: "read write",
103− },
104− createSecret: (input) => {
105− calls.push(input);
106− return Effect.succeed({ id: `id_${calls.length}` });
107− },
108− }),
109− );
110− expect(calls).toHaveLength(2);
111− expect(calls[0]!.idPrefix).toBe("acme_api_access_token");
112− expect(calls[0]!.name).toBe("Acme API Access Token");
113− expect(calls[0]!.purpose).toBe("acme_oauth_access_token");
114− expect(calls[0]!.value).toBe("tok");
115− expect(calls[1]!.idPrefix).toBe("acme_api_refresh_token");
116− expect(calls[1]!.value).toBe("rtok");
117− expect(result.accessTokenSecretId).toBe("id_1");
118− expect(result.refreshTokenSecretId).toBe("id_2");
119− expect(result.tokenType).toBe("Bearer");
120− expect(result.scope).toBe("read write");
121− expect(result.expiresAt).toBeGreaterThan(Date.now());
122− });
123−
124− it("omits the refresh-token secret when the response did not return one", async () => {
125− let calls = 0;
126− const result = await Effect.runPromise(
127− storeOAuthTokens({
128− ...baseInput,
129− tokens: { access_token: "tok" },
130− createSecret: () => {
131− calls++;
132− return Effect.succeed({ id: `id_${calls}` });
133− },
134− }),
135− );
136− expect(calls).toBe(1);
137− expect(result.refreshTokenSecretId).toBeNull();
138− expect(result.tokenType).toBe("Bearer");
139− expect(result.expiresAt).toBeNull();
140− expect(result.scope).toBeNull();
141− });
142−
143− it("wraps createSecret failure in an OAuth2Error with a descriptive message", async () => {
144− const exit = await Effect.runPromiseExit(
145− storeOAuthTokens({
146− ...baseInput,
147− tokens: { access_token: "tok", refresh_token: "rtok" },
148− createSecret: () => Effect.fail(new Error("kv write failed")),
149− }),
150− );
151− expect(Exit.isFailure(exit)).toBe(true);
152− if (Exit.isFailure(exit)) {
153− const json = JSON.stringify(exit.cause);
154− expect(json).toContain("OAuth2Error");
155− expect(json).toContain("Failed to store access token");
156− expect(json).toContain("kv write failed");
157− }
158− });
159−});
160−
161−// ---------------------------------------------------------------------------
162−// withRefreshedAccessToken
163−// ---------------------------------------------------------------------------
164−
165−describe("withRefreshedAccessToken", () => {
166− const makeAuth = (overrides: Partial<OAuth2AuthRefs> = {}): OAuth2AuthRefs => ({
167− clientIdSecretId: "cid-secret",
168− clientSecretSecretId: "csecret-secret",
169− accessTokenSecretId: "atok-secret",
170− refreshTokenSecretId: "rtok-secret",
171− tokenType: "Bearer",
172− expiresAt: null,
173− scopes: ["read", "write"],
174− ...overrides,
175− });
176−
177− const baseInput = {
178− tokenUrl: "https://api.example.com/oauth/token",
179− displayName: "Acme",
180− accessTokenPurpose: "acme_access",
181− refreshTokenPurpose: "acme_refresh",
182− };
183−
184− it("returns the current access token without refreshing when expiresAt is null", async () => {
185− const { io } = makeSecrets({
186− "atok-secret": "current-access-token",
187− "rtok-secret": "stored-refresh",
188− "cid-secret": "cid",
189− "csecret-secret": "csecret",
190− });
191− const fetchSpy = vi.fn();
192− globalThis.fetch = fetchSpy as unknown as typeof fetch;
193− const persistAuth = vi.fn(() => Effect.void);
194−
195− const result = await Effect.runPromise(
196− withRefreshedAccessToken({
197− ...baseInput,
198− auth: makeAuth({ expiresAt: null }),
199− secrets: io,
200− persistAuth,
201− }),
202− );
203− expect(result).toBe("current-access-token");
204− expect(fetchSpy).not.toHaveBeenCalled();
205− expect(persistAuth).not.toHaveBeenCalled();
206− });
207−
208− it("returns the current access token when expiresAt is comfortably in the future", async () => {
209− const { io } = makeSecrets({
210− "atok-secret": "current",
211− "rtok-secret": "r",
212− "cid-secret": "c",
213− "csecret-secret": "s",
214− });
215− const fetchSpy = vi.fn();
216− globalThis.fetch = fetchSpy as unknown as typeof fetch;
217−
218− const result = await Effect.runPromise(
219− withRefreshedAccessToken({
220− ...baseInput,
221− auth: makeAuth({ expiresAt: Date.now() + 10 * 60_000 }),
222− secrets: io,
223− persistAuth: () => Effect.void,
224− }),
225− );
226− expect(result).toBe("current");
227− expect(fetchSpy).not.toHaveBeenCalled();
228− });
229−
230− it("refreshes when expiresAt is within the skew window, persists new token, calls persistAuth", async () => {
231− const { io, state } = makeSecrets({
232− "atok-secret": "old-access",
233− "rtok-secret": "stored-refresh",
234− "cid-secret": "client-1",
235− "csecret-secret": "secret-1",
236− });
237− const { calls } = captureFetch(
238− jsonResponse(200, {
239− access_token: "new-access",
240− token_type: "Bearer",
241− expires_in: 3600,
242− }),
243− );
244− const persistedSnapshots: RefreshedAuthSnapshot[] = [];
245− const persistAuth = (snapshot: RefreshedAuthSnapshot) =>
246− Effect.sync(() => {
247− persistedSnapshots.push(snapshot);
248− });
249−
250− const result = await Effect.runPromise(
251− withRefreshedAccessToken({
252− ...baseInput,
253− auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
254− secrets: io,
255− persistAuth,
256− }),
257− );
258−
259− expect(result).toBe("new-access");
260− expect(calls).toHaveLength(1);
261− const body = calls[0]!.init.body as URLSearchParams;
262− expect(body.get("grant_type")).toBe("refresh_token");
263− expect(body.get("refresh_token")).toBe("stored-refresh");
264− expect(body.get("client_id")).toBe("client-1");
265− expect(body.get("client_secret")).toBe("secret-1");
266− expect(body.get("scope")).toBe("read write");
267−
268− // Access-token secret was updated in place with the new value.
269− expect(state.get("atok-secret")!.value).toBe("new-access");
270− // persistAuth called with updated snapshot.
271− expect(persistedSnapshots).toHaveLength(1);
272− expect(persistedSnapshots[0]!.tokenType).toBe("Bearer");
273− expect(persistedSnapshots[0]!.expiresAt).toBeGreaterThan(Date.now());
274− });
275−
276− it("rotates the refresh_token secret when the server returns a new one", async () => {
277− const { io, state } = makeSecrets({
278− "atok-secret": "old",
279− "rtok-secret": "old-refresh",
280− "cid-secret": "c",
281− "csecret-secret": "s",
282− });
283− captureFetch(
284− jsonResponse(200, {
285− access_token: "new",
286− refresh_token: "new-refresh",
287− expires_in: 3600,
288− }),
289− );
290− await Effect.runPromise(
291− withRefreshedAccessToken({
292− ...baseInput,
293− auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
294− secrets: io,
295− persistAuth: () => Effect.void,
296− }),
297− );
298− expect(state.get("rtok-secret")!.value).toBe("new-refresh");
299− });
300−
301− it("does NOT touch the refresh_token secret when the server omits it", async () => {
302− const { io, state } = makeSecrets({
303− "atok-secret": "old",
304− "rtok-secret": "old-refresh",
305− "cid-secret": "c",
306− "csecret-secret": "s",
307− });
308− captureFetch(jsonResponse(200, { access_token: "new", expires_in: 3600 }));
309− await Effect.runPromise(
310− withRefreshedAccessToken({
311− ...baseInput,
312− auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
313− secrets: io,
314− persistAuth: () => Effect.void,
315− }),
316− );
317− expect(state.get("rtok-secret")!.value).toBe("old-refresh");
318− });
319−
320− it("skips the refresh path entirely when refreshTokenSecretId is null, even if expired", async () => {
321− const { io } = makeSecrets({
322− "atok-secret": "only-access",
323− "cid-secret": "c",
324− "csecret-secret": "s",
325− });
326− const fetchSpy = vi.fn();
327− globalThis.fetch = fetchSpy as unknown as typeof fetch;
328− const result = await Effect.runPromise(
329− withRefreshedAccessToken({
330− ...baseInput,
331− auth: makeAuth({
332− refreshTokenSecretId: null,
333− expiresAt: Date.now() - 10_000,
334− }),
335− secrets: io,
336− persistAuth: () => Effect.void,
337− }),
338− );
339− expect(result).toBe("only-access");
340− expect(fetchSpy).not.toHaveBeenCalled();
341− });
342−
343− it("omits client_secret from the refresh call when clientSecretSecretId is null", async () => {
344− const { io } = makeSecrets({
345− "atok-secret": "old",
346− "rtok-secret": "rtok",
347− "cid-secret": "public-client",
348− });
349− const { calls } = captureFetch(
350− jsonResponse(200, { access_token: "new", expires_in: 3600 }),
351− );
352− await Effect.runPromise(
353− withRefreshedAccessToken({
354− ...baseInput,
355− auth: makeAuth({
356− clientSecretSecretId: null,
357− expiresAt: Date.now() + 30_000,
358− }),
359− secrets: io,
360− persistAuth: () => Effect.void,
361− }),
362− );
363− const body = calls[0]!.init.body as URLSearchParams;
364− expect(body.get("client_id")).toBe("public-client");
365− expect(body.has("client_secret")).toBe(false);
366− });
367−
368− it("wraps secret-resolve failures in OAuth2Error with descriptive messages", async () => {
369− const { io, missing } = makeSecrets({
370− "atok-secret": "old",
371− "rtok-secret": "rtok",
372− "cid-secret": "c",
373− "csecret-secret": "s",
374− });
375− missing.add("rtok-secret");
376− captureFetch(jsonResponse(200, { access_token: "new" }));
377− const exit = await Effect.runPromiseExit(
378− withRefreshedAccessToken({
379− ...baseInput,
380− auth: makeAuth({ expiresAt: Date.now() + 30_000 }),
381− secrets: io,
382− persistAuth: () => Effect.void,
383− }),
384− );
385− expect(Exit.isFailure(exit)).toBe(true);
386− if (Exit.isFailure(exit)) {
387− const json = JSON.stringify(exit.cause);
388− expect(json).toContain("OAuth2Error");
389− expect(json).toContain("Failed to resolve OAuth refresh token");
390− }
391− });
392−
393− it("uses the default 60s refresh skew window", () => {
394− expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000);
395− });
396−});
packages/plugins/oauth2/src/refresh.ts+0−238
@@ -1,238 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Generic helpers for storing OAuth token secrets and transparently
3−// refreshing access tokens before a request.
4−//
5−// Plugins (google-discovery, openapi) supply an adapter for their secrets
6−// API and a `persistAuth` callback so this module stays decoupled from any
7−// specific plugin SDK or store shape.
8−// ---------------------------------------------------------------------------
9−
10−import { Effect } from "effect";
11−
12−import { OAuth2Error, type OAuth2TokenResponse } from "./index";
13−import {
14− OAUTH2_DEFAULT_TIMEOUT_MS,
15− refreshAccessToken,
16− shouldRefreshToken,
17− type ClientAuthMethod,
18−} from "./index";
19−
20−// ---------------------------------------------------------------------------
21−// Secrets I/O adapter — plain string IDs, no plugin types.
22−// ---------------------------------------------------------------------------
23−
24−export type OAuth2SecretsIO = {
25− readonly resolve: (secretId: string) => Effect.Effect<string, unknown>;
26− readonly setValue: (input: {
27− readonly secretId: string;
28− readonly value: string;
29− readonly name: string;
30− readonly purpose: string;
31− }) => Effect.Effect<void, unknown>;
32−};
33−
34−const wrapSecretError =
35− (operation: string) =>
36− (cause: unknown): OAuth2Error =>
37− new OAuth2Error({
38− message: `${operation}: ${cause instanceof Error ? cause.message : String(cause)}`,
39− cause,
40− });
41−
42−// ---------------------------------------------------------------------------
43−// storeOAuthTokens — freshly store access + refresh tokens as new secrets.
44−// ---------------------------------------------------------------------------
45−
46−/** Shape of a newly persisted OAuth2 auth descriptor. */
47−export type StoredOAuthTokens = {
48− readonly accessTokenSecretId: string;
49− readonly refreshTokenSecretId: string | null;
50− readonly tokenType: string;
51− readonly expiresAt: number | null;
52− readonly scope: string | null;
53−};
54−
55−export type StoreOAuthTokensInput = {
56− readonly tokens: OAuth2TokenResponse;
57− /** Slug used as prefix for new secret IDs, e.g. `acme_api`. */
58− readonly slug: string;
59− /** Human-readable name for the source, used in secret labels. */
60− readonly displayName: string;
61− /** `purpose` label stored on the access-token secret. */
62− readonly accessTokenPurpose: string;
63− /** `purpose` label stored on the refresh-token secret. */
64− readonly refreshTokenPurpose: string;
65− /** Adapter that creates a new secret and returns its freshly-minted ID. */
66− readonly createSecret: (input: {
67− readonly idPrefix: string;
68− readonly name: string;
69− readonly value: string;
70− readonly purpose: string;
71− }) => Effect.Effect<{ readonly id: string }, unknown>;
72−};
73−
74−/**
75− * Persist access + refresh tokens from an OAuth2 token response as new
76− * secrets and return a `StoredOAuthTokens` descriptor ready to write to a
77− * source config.
78− */
79−export const storeOAuthTokens = (
80− input: StoreOAuthTokensInput,
81−): Effect.Effect<StoredOAuthTokens, OAuth2Error> =>
82− Effect.gen(function* () {
83− const accessRef = yield* input
84− .createSecret({
85− idPrefix: `${input.slug}_access_token`,
86− name: `${input.displayName} Access Token`,
87− value: input.tokens.access_token,
88− purpose: input.accessTokenPurpose,
89− })
90− .pipe(Effect.mapError(wrapSecretError("Failed to store access token")));
91−
92− const refreshRef = input.tokens.refresh_token
93− ? yield* input
94− .createSecret({
95− idPrefix: `${input.slug}_refresh_token`,
96− name: `${input.displayName} Refresh Token`,
97− value: input.tokens.refresh_token,
98− purpose: input.refreshTokenPurpose,
99− })
100− .pipe(Effect.mapError(wrapSecretError("Failed to store refresh token")))
101− : null;
102−
103− return {
104− accessTokenSecretId: accessRef.id,
105− refreshTokenSecretId: refreshRef?.id ?? null,
106− tokenType: input.tokens.token_type ?? "Bearer",
107− expiresAt:
108− typeof input.tokens.expires_in === "number"
109− ? Date.now() + input.tokens.expires_in * 1000
110− : null,
111− scope: input.tokens.scope ?? null,
112− };
113− });
114−
115−// ---------------------------------------------------------------------------
116−// withRefreshedAccessToken — return the current access token, refreshing
117−// first if it's within the skew window.
118−// ---------------------------------------------------------------------------
119−
120−/** The subset of a stored OAuth2 auth descriptor this helper needs. */
121−export type OAuth2AuthRefs = {
122− readonly clientIdSecretId: string;
123− readonly clientSecretSecretId: string | null;
124− readonly accessTokenSecretId: string;
125− readonly refreshTokenSecretId: string | null;
126− readonly tokenType: string;
127− readonly expiresAt: number | null;
128− readonly scopes: readonly string[];
129−};
130−
131−/** Snapshot of auth values written back after a successful refresh. */
132−export type RefreshedAuthSnapshot = {
133− readonly tokenType: string;
134− readonly expiresAt: number | null;
135− readonly scope: string | null;
136−};
137−
138−export type WithRefreshedAccessTokenInput = {
139− readonly auth: OAuth2AuthRefs;
140− readonly tokenUrl: string;
141− readonly secrets: OAuth2SecretsIO;
142− /** Display name used when re-setting the access-token secret after refresh. */
143− readonly displayName: string;
144− readonly accessTokenPurpose: string;
145− readonly refreshTokenPurpose: string;
146− readonly clientAuth?: ClientAuthMethod;
147− readonly timeoutMs?: number;
148− /**
149− * Called after a successful refresh with the new expiry / tokenType /
150− * scope so the plugin can write the updated auth back to its source
151− * config. The accessTokenSecretId does NOT change — the value behind
152− * it is overwritten in place.
153− */
154− readonly persistAuth: (
155− snapshot: RefreshedAuthSnapshot,
156− ) => Effect.Effect<void, unknown>;
157−};
158−
159−/**
160− * Resolve the current access token for a source — if it's within the
161− * refresh-skew window, first exchange the refresh token for a new access
162− * token, persist it, and write the updated expiry back via `persistAuth`.
163− *
164− * Returns the access-token string to be used for the upcoming request.
165− */
166−export const withRefreshedAccessToken = (
167− input: WithRefreshedAccessTokenInput,
168−): Effect.Effect<string, OAuth2Error> =>
169− Effect.gen(function* () {
170− const { auth, secrets } = input;
171− const needsRefresh =
172− auth.refreshTokenSecretId !== null && shouldRefreshToken({ expiresAt: auth.expiresAt });
173−
174− if (!needsRefresh) {
175− return yield* secrets
176− .resolve(auth.accessTokenSecretId)
177− .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth access token")));
178− }
179−
180− // Proactive refresh path.
181− const refreshToken = yield* secrets
182− .resolve(auth.refreshTokenSecretId!)
183− .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth refresh token")));
184−
185− const clientId = yield* secrets
186− .resolve(auth.clientIdSecretId)
187− .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client ID")));
188−
189− const clientSecret =
190− auth.clientSecretSecretId === null
191− ? null
192− : yield* secrets
193− .resolve(auth.clientSecretSecretId)
194− .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client secret")));
195−
196− const refreshed = yield* refreshAccessToken({
197− tokenUrl: input.tokenUrl,
198− clientId,
199− clientSecret,
200− refreshToken,
201− scopes: auth.scopes,
202− clientAuth: input.clientAuth,
203− timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
204− });
205−
206− yield* secrets
207− .setValue({
208− secretId: auth.accessTokenSecretId,
209− value: refreshed.access_token,
210− name: `${input.displayName} Access Token`,
211− purpose: input.accessTokenPurpose,
212− })
213− .pipe(Effect.mapError(wrapSecretError("Failed to persist refreshed access token")));
214−
215− if (refreshed.refresh_token && auth.refreshTokenSecretId) {
216− yield* secrets
217− .setValue({
218− secretId: auth.refreshTokenSecretId,
219− value: refreshed.refresh_token,
220− name: `${input.displayName} Refresh Token`,
221− purpose: input.refreshTokenPurpose,
222− })
223− .pipe(Effect.mapError(wrapSecretError("Failed to persist rotated refresh token")));
224− }
225−
226− yield* input
227− .persistAuth({
228− tokenType: refreshed.token_type ?? auth.tokenType,
229− expiresAt:
230− typeof refreshed.expires_in === "number"
231− ? Date.now() + refreshed.expires_in * 1000
232− : auth.expiresAt,
233− scope: refreshed.scope ?? null,
234− })
235− .pipe(Effect.mapError(wrapSecretError("Failed to persist updated OAuth auth")));
236−
237− return refreshed.access_token;
238− });
packages/plugins/openapi/src/api/group.ts+6−6
@@ -40,6 +40,9 @@ const UpdateSourcePayload = Schema.Struct({ · 39 unmodified lines
4040 name: Schema.optional(Schema.String),
4141 baseUrl: Schema.optional(Schema.String),
4242 headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
43+ // Set after a successful re-authenticate to rewrite the source's
44+ // OAuth2Auth pointer to the freshly minted connection.
45+ oauth2: Schema.optional(OAuth2Auth),
4346 });
4447
4548 const UpdateSourceResponse = Schema.Struct({
@@ -60,10 +63,9 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines
6063 // ---------------------------------------------------------------------------
6164
6265 // Shared identity fields for both OAuth2 flows. `tokenScope` names which
63−// executor scope will own the minted tokens (typically the per-user scope).
64−// The token secret ids are pre-decided so the source's stored `OAuth2Auth`
65−// can reference the same ids across every user — per-user values shadow
66−// org-level fallbacks via secret fall-through on read.
66+// executor scope will own the resulting Connection (typically the per-user
67+// scope). Token secret ids are minted by the SDK from the Connection id —
68+// callers never pick them.
6769 const StartOAuthIdentityFields = {
6870 displayName: Schema.String,
6971 securitySchemeName: Schema.String,
@@ -71,7 +73,6 @@ const StartOAuthIdentityFields = { · 1 unmodified line
7173 clientIdSecretId: Schema.String,
7274 scopes: Schema.Array(Schema.String),
7375 tokenScope: Schema.optional(ScopeId),
74− accessTokenSecretId: Schema.String,
7576 } as const;
7677
7778 const StartOAuthPayload = Schema.Union(
@@ -81,7 +82,6 @@ const StartOAuthPayload = Schema.Union( · 3 unmodified lines
8182 authorizationUrl: Schema.String,
8283 redirectUrl: Schema.String,
8384 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),
84− refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),
8585 }),
8686 // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The
8787 // plugin exchanges tokens inline and returns a completed auth. The
packages/plugins/openapi/src/api/handlers.ts+1−3
@@ -91,6 +91,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 90 unmodified lines
9191 name: payload.name,
9292 baseUrl: payload.baseUrl,
9393 headers: payload.headers as Record<string, HeaderValue> | undefined,
94+ oauth2: payload.oauth2,
9495 } as OpenApiUpdateSourceInput);
9596 return { updated: true };
9697 })),
@@ -112,7 +113,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines
112113 clientSecretSecretId: payload.clientSecretSecretId,
113114 scopes: [...payload.scopes],
114115 tokenScope,
115− accessTokenSecretId: payload.accessTokenSecretId,
116116 });
117117 }
118118 return yield* ext.startOAuth({
@@ -126,8 +126,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
126126 clientSecretSecretId: payload.clientSecretSecretId ?? null,
127127 scopes: [...payload.scopes],
128128 tokenScope,
129− accessTokenSecretId: payload.accessTokenSecretId,
130− refreshTokenSecretId: payload.refreshTokenSecretId ?? null,
131129 });
132130 })),
133131 )
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+5−44
@@ -61,26 +61,6 @@ export const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result"; · 60 unmodified lines
6161 export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
6262 export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/oauth/callback";
6363
64−// Stable secret ids for the access/refresh tokens a given OAuth2 security
65−// scheme on a given source mints. The same ids must be passed into
66−// `startOAuth` AND stored on the source's `OAuth2Auth`, so at invoke time
67−// the per-user scope (which actually holds the token value) resolves via
68−// shadowing over the source-level reference. Keyed by source namespace +
69−// scheme name so a single spec can host multiple schemes without
70−// colliding, and so reconnecting OAuth overwrites the previous token
71−// rather than leaking orphaned secret rows.
72−const oauthTokenSecretIds = (
73− namespaceSlug: string,
74− securitySchemeName: string,
75−): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => {
76− const slugScheme = securitySchemeName.replace(/[^a-zA-Z0-9_-]/g, "_");
77− const prefix = `openapi_${namespaceSlug}_${slugScheme}`;
78− return {
79− accessTokenSecretId: `${prefix}_access_token`,
80− refreshTokenSecretId: `${prefix}_refresh_token`,
81− };
82−};
83−
8464 const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
8565 let out = url;
8666 for (const [name, value] of Object.entries(values)) {
@@ -402,15 +382,6 @@ export default function AddOpenApiSource(props: { · 315 unmodified lines
402382 const displayName =
403383 identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
404384
405− const namespaceSlug =
406− slugifyNamespace(identity.namespace) ||
407− slugifyNamespace(Option.getOrElse(preview.title, () => "")) ||
408− "openapi";
409− const tokenIds = oauthTokenSecretIds(
410− namespaceSlug,
411− selectedOAuth2Preset.securitySchemeName,
412− );
413−
414385 const tokenUrl = resolveOAuthUrl(
415386 selectedOAuth2Preset.tokenUrl,
416387 resolvedBaseUrl,
@@ -435,7 +406,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
435406 clientIdSecretId: oauth2ClientIdSecretId,
436407 clientSecretSecretId: oauth2ClientSecretSecretId,
437408 scopes: [...oauth2SelectedScopes],
438− accessTokenSecretId: tokenIds.accessTokenSecretId,
439409 },
440410 });
441411 setStartingOAuth(false);
@@ -465,8 +435,6 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
465435 clientIdSecretId: oauth2ClientIdSecretId,
466436 clientSecretSecretId: oauth2ClientSecretSecretId,
467437 scopes: [...oauth2SelectedScopes],
468− accessTokenSecretId: tokenIds.accessTokenSecretId,
469− refreshTokenSecretId: tokenIds.refreshTokenSecretId,
470438 },
471439 });
472440
@@ -484,23 +452,17 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
484452 oauthCleanup.current = null;
485453 setStartingOAuth(false);
486454 if (result.ok) {
487− // The popup payload is an OAuthPopupResult wrapping the
488− // backend's OAuth2Auth descriptor; strip the envelope and
489− // reconstruct the OAuth2Auth class instance for type safety.
490455 setOauth2Auth(
491456 new OAuth2Auth({
492457 kind: "oauth2",
458+ connectionId: result.connectionId,
493459 securitySchemeName: result.securitySchemeName,
494460 flow: result.flow,
495461 tokenUrl: result.tokenUrl,
462+ authorizationUrl: result.authorizationUrl,
496463 clientIdSecretId: result.clientIdSecretId,
497464 clientSecretSecretId: result.clientSecretSecretId,
498− accessTokenSecretId: result.accessTokenSecretId,
499− refreshTokenSecretId: result.refreshTokenSecretId,
500− tokenType: result.tokenType,
501− expiresAt: result.expiresAt,
502− scope: result.scope,
503− scopes: [...result.scopes],
465+ scopes: result.scopes,
504466 }),
505467 );
506468 setOauth2Error(null);
@@ -535,7 +497,6 @@ export default function AddOpenApiSource(props: { · 28 unmodified lines
535497 doStartOAuth,
536498 scopeId,
537499 identity.name,
538− identity.namespace,
539500 ]);
540501
541502 const handleCancelOAuth2 = useCallback(() => {
@@ -974,8 +935,8 @@ export default function AddOpenApiSource(props: { · 432 unmodified lines
974935 {oauth2Auth ? (
975936 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
976937 <div className="text-[11px] text-green-700 dark:text-green-400">
977− Connected · {oauth2Auth.scopes.length} scope
978− {oauth2Auth.scopes.length === 1 ? "" : "s"} granted
938+ Connected · {oauth2SelectedScopes.size} scope
939+ {oauth2SelectedScopes.size === 1 ? "" : "s"} granted
979940 </div>
980941 <Button
981942 variant="ghost"
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+5−380
@@ -1,30 +1,16 @@
1−import { useCallback, useEffect, useRef, useState } from "react";
1+import { useState } from "react";
22 import {
33 useAtomValue,
44 useAtomSet,
5− useAtomRefresh,
65 Result,
76 } from "@effect-atom/atom-react";
8−import { Option } from "effect";
9−
10−import { SecretId } from "@executor/sdk";
11−import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
127
138 import {
149 openApiSourceAtom,
15− previewOpenApiSpec,
16− startOpenApiOAuth,
1710 updateOpenApiSource,
1811 } from "./atoms";
1912 import { useScope } from "@executor/react/api/scope-context";
20−import {
21− secretStatusAtom,
22− removeSecret,
23−} from "@executor/react/api/atoms";
24−import {
25− secretWriteKeys,
26− sourceWriteKeys,
27−} from "@executor/react/api/reactivity-keys";
13+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
2814 import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
2915 import {
3016 headerValueToState,
@@ -45,357 +31,12 @@ import { · 14 unmodified lines
4531 import { FieldLabel } from "@executor/react/components/field";
4632 import { Input } from "@executor/react/components/input";
4733 import { Badge } from "@executor/react/components/badge";
48−import { Spinner } from "@executor/react/components/spinner";
49−import {
50− OPENAPI_OAUTH_CALLBACK_PATH,
51− OPENAPI_OAUTH_CHANNEL,
52− OPENAPI_OAUTH_POPUP_NAME,
53− resolveOAuthUrl,
54−} from "./AddOpenApiSource";
55−import type { SpecPreview, OAuth2Preset } from "../sdk/preview";
56−import { OAuth2Auth } from "../sdk/types";
5734 import type { StoredSourceSchemaType } from "../sdk/store";
5835
5936 // ---------------------------------------------------------------------------
60−// Connections — one row per OAuth2Auth on the source
61−// ---------------------------------------------------------------------------
62−
63−/**
64− * Single OAuth2Auth connection row. Each org member sees the status of
65− * their own tokens (access/refresh secrets resolve via per-user scope
66− * fall-through) and can trigger the same OAuth flow another member used
67− * when first onboarding the source.
68− */
69−function ConnectionRow(props: {
70− readonly auth: OAuth2Auth;
71− readonly sourceName: string;
72− readonly sourceBaseUrl: string;
73− readonly preset: OAuth2Preset | null;
74− readonly previewError: string | null;
75−}) {
76− const scopeId = useScope();
77− const { auth, preset } = props;
78−
79− const statusAtom = secretStatusAtom(
80− scopeId,
81− SecretId.make(auth.accessTokenSecretId),
82− );
83− const accessStatus = useAtomValue(statusAtom);
84− const refreshStatus = useAtomRefresh(statusAtom);
85− const isConnected =
86− Result.isSuccess(accessStatus) && accessStatus.value.status === "resolved";
87−
88− const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" });
89− const doRemoveSecret = useAtomSet(removeSecret, { mode: "promise" });
90−
91− const [busy, setBusy] = useState(false);
92− const [error, setError] = useState<string | null>(null);
93− const cleanupRef = useRef<(() => void) | null>(null);
94−
95− useEffect(() => () => cleanupRef.current?.(), []);
96−
97− const redirectUrl =
98− typeof window !== "undefined"
99− ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}`
100− : OPENAPI_OAUTH_CALLBACK_PATH;
101−
102− const handleConnect = useCallback(async () => {
103− if (!preset) return;
104− cleanupRef.current?.();
105− cleanupRef.current = null;
106− setBusy(true);
107− setError(null);
108− try {
109− const tokenUrl = resolveOAuthUrl(auth.tokenUrl, props.sourceBaseUrl);
110−
111− if (auth.flow === "clientCredentials") {
112− // No popup, no session — the backend exchanges tokens inline.
113− if (!auth.clientSecretSecretId) {
114− setBusy(false);
115− setError("client_credentials requires a client secret");
116− return;
117− }
118− const response = await doStartOAuth({
119− path: { scopeId },
120− payload: {
121− displayName: props.sourceName || auth.securitySchemeName,
122− securitySchemeName: auth.securitySchemeName,
123− flow: "clientCredentials",
124− tokenUrl,
125− clientIdSecretId: auth.clientIdSecretId,
126− clientSecretSecretId: auth.clientSecretSecretId,
127− scopes: [...auth.scopes],
128− accessTokenSecretId: auth.accessTokenSecretId,
129− },
130− });
131− setBusy(false);
132− if (response.flow !== "clientCredentials") {
133− setError("Unexpected response flow from server");
134− return;
135− }
136− setError(null);
137− refreshStatus();
138− return;
139− }
140−
141− const authorizationUrl = resolveOAuthUrl(
142− Option.getOrElse(preset.authorizationUrl, () => ""),
143− props.sourceBaseUrl,
144− );
145−
146− const response = await doStartOAuth({
147− path: { scopeId },
148− payload: {
149− displayName: props.sourceName || auth.securitySchemeName,
150− securitySchemeName: auth.securitySchemeName,
151− flow: "authorizationCode",
152− authorizationUrl,
153− tokenUrl,
154− redirectUrl,
155− clientIdSecretId: auth.clientIdSecretId,
156− clientSecretSecretId: auth.clientSecretSecretId,
157− // Reuse the source-wide scopes granted at onboarding time —
158− // per-member connects should land the same capabilities.
159− scopes: [...auth.scopes],
160− // Reuse the same secret ids the source already references.
161− // On completeOAuth the plugin writes tokens at the innermost
162− // (per-user) scope by default; same id + inner scope shadows
163− // any org-level fallback so each member's bearer is theirs.
164− accessTokenSecretId: auth.accessTokenSecretId,
165− refreshTokenSecretId: auth.refreshTokenSecretId,
166− },
167− });
168−
169− if (response.flow !== "authorizationCode") {
170− setBusy(false);
171− setError("Unexpected response flow from server");
172− return;
173− }
174−
175− cleanupRef.current = openOAuthPopup<OAuth2Auth>({
176− url: response.authorizationUrl,
177− popupName: OPENAPI_OAUTH_POPUP_NAME,
178− channelName: OPENAPI_OAUTH_CHANNEL,
179− onResult: (result: OAuthPopupResult<OAuth2Auth>) => {
180− cleanupRef.current = null;
181− setBusy(false);
182− if (result.ok) {
183− setError(null);
184− // completeOAuth ran server-side (inside the popup's callback
185− // HTML handler) and wrote the access/refresh tokens via
186− // ctx.secrets.set — that bypasses our atom-layer mutation,
187− // so we refresh the status atom here to flip the badge.
188− refreshStatus();
189− } else {
190− setError(result.error);
191− }
192− },
193− onClosed: () => {
194− cleanupRef.current = null;
195− setBusy(false);
196− setError("OAuth cancelled — popup was closed before completing the flow.");
197− },
198− onOpenFailed: () => {
199− cleanupRef.current = null;
200− setBusy(false);
201− setError("OAuth popup was blocked by the browser");
202− },
203− });
204− } catch (e) {
205− setBusy(false);
206− setError(e instanceof Error ? e.message : "Failed to start OAuth");
207− }
208− }, [
209− preset,
210− auth.flow,
211− auth.securitySchemeName,
212− auth.tokenUrl,
213− auth.clientIdSecretId,
214− auth.clientSecretSecretId,
215− auth.accessTokenSecretId,
216− auth.refreshTokenSecretId,
217− auth.scopes,
218− doStartOAuth,
219− redirectUrl,
220− scopeId,
221− props.sourceName,
222− props.sourceBaseUrl,
223− refreshStatus,
224− ]);
225−
226− const handleDisconnect = useCallback(async () => {
227− setBusy(true);
228− setError(null);
229− try {
230− await doRemoveSecret({
231− path: {
232− scopeId,
233− secretId: SecretId.make(auth.accessTokenSecretId),
234− },
235− reactivityKeys: secretWriteKeys,
236− });
237− if (auth.refreshTokenSecretId) {
238− await doRemoveSecret({
239− path: {
240− scopeId,
241− secretId: SecretId.make(auth.refreshTokenSecretId),
242− },
243− reactivityKeys: secretWriteKeys,
244− }).catch(() => {
245− // The refresh secret may not exist for this user — ignore.
246− });
247− }
248− } catch (e) {
249− setError(e instanceof Error ? e.message : "Failed to disconnect");
250− } finally {
251− setBusy(false);
252− }
253− }, [
254− doRemoveSecret,
255− scopeId,
256− auth.accessTokenSecretId,
257− auth.refreshTokenSecretId,
258− ]);
259−
260− const canConnect = preset !== null && Option.isSome(preset.authorizationUrl);
261−
262− return (
263− <div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-background/40 p-3">
264− <div className="flex items-center justify-between gap-3">
265− <div className="min-w-0 flex-1">
266− <div className="text-xs font-medium text-foreground">
267− {auth.securitySchemeName}
268− </div>
269− <div className="mt-0.5 text-[10px] text-muted-foreground">
270− {auth.scopes.length} scope{auth.scopes.length === 1 ? "" : "s"}
271− </div>
272− </div>
273− {isConnected ? (
274− <Badge
275− variant="outline"
276− className="border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400"
277− >
278− Connected
279− </Badge>
280− ) : (
281− <Badge variant="outline" className="text-muted-foreground">
282− Not connected
283− </Badge>
284− )}
285− </div>
286−
287− <div className="flex items-center justify-end gap-2">
288− {isConnected ? (
289− <Button
290− variant="ghost"
291− size="sm"
292− onClick={handleDisconnect}
293− disabled={busy}
294− >
295− {busy && <Spinner className="size-3.5" />}
296− Disconnect
297− </Button>
298− ) : (
299− <Button
300− variant="secondary"
301− size="sm"
302− onClick={handleConnect}
303− disabled={busy || !canConnect}
304− >
305− {busy && <Spinner className="size-3.5" />}
306− Connect
307− </Button>
308− )}
309− </div>
310−
311− {!canConnect && props.previewError && (
312− <p className="text-[11px] text-amber-600 dark:text-amber-400">
313− {props.previewError}
314− </p>
315− )}
316− {!canConnect && !props.previewError && preset === null && (
317− <p className="text-[11px] text-muted-foreground">
318− Loading OAuth configuration…
319− </p>
320− )}
321− {error && (
322− <div className="rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-1.5">
323− <p className="text-[11px] text-destructive">{error}</p>
324− </div>
325− )}
326− </div>
327− );
328−}
329−
330−function ConnectionsSection(props: {
331− readonly sourceName: string;
332− readonly sourceBaseUrl: string;
333− readonly spec: string;
334− readonly oauth2Entries: readonly OAuth2Auth[];
335−}) {
336− const scopeId = useScope();
337− const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
338− const [preview, setPreview] = useState<SpecPreview | null>(null);
339− const [previewError, setPreviewError] = useState<string | null>(null);
340−
341− useEffect(() => {
342− let cancelled = false;
343− doPreview({ path: { scopeId }, payload: { spec: props.spec } })
344− .then((result) => {
345− if (!cancelled) setPreview(result);
346− })
347− .catch((e: unknown) => {
348− if (!cancelled) {
349− setPreviewError(
350− e instanceof Error
351− ? `Couldn't load OAuth config from spec: ${e.message}`
352− : "Couldn't load OAuth config from spec",
353− );
354− }
355− });
356− return () => {
357− cancelled = true;
358− };
359− }, [doPreview, scopeId, props.spec]);
360−
361− const presetFor = (securitySchemeName: string): OAuth2Preset | null => {
362− if (!preview) return null;
363− return (
364− preview.oauth2Presets.find(
365− (p) =>
366− p.securitySchemeName === securitySchemeName &&
367− p.flow === "authorizationCode",
368− ) ?? null
369− );
370− };
371−
372− return (
373− <section className="space-y-2.5">
374− <div>
375− <FieldLabel>Connections</FieldLabel>
376− <p className="mt-1 text-[11px] text-muted-foreground">
377− Each member of this organization connects with their own OAuth
378− credentials.
379− </p>
380− </div>
381− <div className="space-y-2">
382− {props.oauth2Entries.map((auth) => (
383− <ConnectionRow
384− key={auth.securitySchemeName}
385− auth={auth}
386− sourceName={props.sourceName}
387− sourceBaseUrl={props.sourceBaseUrl}
388− preset={presetFor(auth.securitySchemeName)}
389− previewError={previewError}
390− />
391− ))}
392− </div>
393− </section>
394− );
395−}
396−
397−// ---------------------------------------------------------------------------
398−// Edit form
37+// Edit form — source config only. Sign-in / reconnect lives on the source
38+// detail top bar (see source-plugin.signIn) and connection lifecycle lives
39+// on the Connections page.
39940 // ---------------------------------------------------------------------------
40041
40142 function EditForm(props: {
@@ -423,13 +64,6 @@ function EditForm(props: { · 21 unmodified lines
42364
42465 const identityDirty = identity.name.trim() !== props.initial.name.trim();
42566
426− // A source may have zero or one stored OAuth2Auth today, but the
427− // "Connections" section is written to iterate so future multi-scheme
428− // specs surface without another pass.
429− const oauth2Entries: readonly OAuth2Auth[] = props.initial.config.oauth2
430− ? [props.initial.config.oauth2]
431− : [];
432−
43367 const handleHeadersChange = (next: HeaderState[]) => {
43468 setHeaders(next);
43569 setDirty(true);
@@ -503,15 +137,6 @@ function EditForm(props: { · 67 unmodified lines
503137 />
504138 </section>
505139
506− {oauth2Entries.length > 0 && (
507− <ConnectionsSection
508− sourceName={props.initial.name}
509− sourceBaseUrl={baseUrl.trim() || props.initial.config.baseUrl || ""}
510− spec={props.initial.config.spec}
511− oauth2Entries={oauth2Entries}
512− />
513− )}
514−
515140 {error && (
516141 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
517142 <p className="text-sm text-destructive">{error}</p>
packages/plugins/openapi/src/react/OpenApiSignInButton.tsx+213−0
@@ -0,0 +1,213 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3+
4+import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5+import { useScope } from "@executor/react/api/scope-context";
6+import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7+import { connectionsAtom } from "@executor/react/api/atoms";
8+import { Button } from "@executor/react/components/button";
9+
10+import {
11+ openApiSourceAtom,
12+ startOpenApiOAuth,
13+ updateOpenApiSource,
14+} from "./atoms";
15+import {
16+ OPENAPI_OAUTH_CALLBACK_PATH,
17+ OPENAPI_OAUTH_CHANNEL,
18+ OPENAPI_OAUTH_POPUP_NAME,
19+} from "./AddOpenApiSource";
20+import { OAuth2Auth } from "../sdk/types";
21+
22+// ---------------------------------------------------------------------------
23+// OpenApiSignInButton — top-bar action on the source detail page
24+//
25+// Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add
26+// (authorizationCode via popup, clientCredentials inline), and on success
27+// rewrites the source's OAuth2Auth pointer to the freshly minted
28+// connection id. Works whether or not the previous connection still
29+// exists — source-owned OAuth config is the source of truth.
30+// ---------------------------------------------------------------------------
31+
32+export default function OpenApiSignInButton(props: { sourceId: string }) {
33+ const scopeId = useScope();
34+ const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId));
35+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
36+ const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" });
37+ const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" });
38+
39+ const [busy, setBusy] = useState(false);
40+ const [error, setError] = useState<string | null>(null);
41+ const cleanupRef = useRef<(() => void) | null>(null);
42+
43+ useEffect(() => () => cleanupRef.current?.(), []);
44+
45+ const source =
46+ Result.isSuccess(sourceResult) && sourceResult.value
47+ ? sourceResult.value
48+ : null;
49+ const oauth2 = source?.config.oauth2 ?? null;
50+ const connections = Result.isSuccess(connectionsResult)
51+ ? connectionsResult.value
52+ : null;
53+ const isConnected =
54+ oauth2 !== null &&
55+ connections !== null &&
56+ connections.some((c) => c.id === oauth2.connectionId);
57+
58+ const redirectUrl =
59+ typeof window !== "undefined"
60+ ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}`
61+ : OPENAPI_OAUTH_CALLBACK_PATH;
62+
63+ const handleSignIn = useCallback(async () => {
64+ if (!oauth2) return;
65+ cleanupRef.current?.();
66+ cleanupRef.current = null;
67+ setBusy(true);
68+ setError(null);
69+ try {
70+ const displayName = source?.name ?? oauth2.securitySchemeName;
71+
72+ if (oauth2.flow === "clientCredentials") {
73+ if (!oauth2.clientSecretSecretId) {
74+ setBusy(false);
75+ setError("client_credentials requires a client secret");
76+ return;
77+ }
78+ const response = await doStartOAuth({
79+ path: { scopeId },
80+ payload: {
81+ displayName,
82+ securitySchemeName: oauth2.securitySchemeName,
83+ flow: "clientCredentials",
84+ tokenUrl: oauth2.tokenUrl,
85+ clientIdSecretId: oauth2.clientIdSecretId,
86+ clientSecretSecretId: oauth2.clientSecretSecretId,
87+ scopes: [...oauth2.scopes],
88+ },
89+ });
90+ if (response.flow !== "clientCredentials") {
91+ setBusy(false);
92+ setError("Unexpected response flow from server");
93+ return;
94+ }
95+ await doUpdate({
96+ path: { scopeId, namespace: props.sourceId },
97+ payload: { oauth2: response.auth },
98+ reactivityKeys: sourceWriteKeys,
99+ });
100+ setBusy(false);
101+ return;
102+ }
103+
104+ if (!oauth2.authorizationUrl) {
105+ setBusy(false);
106+ setError("authorizationCode flow is missing its authorization URL");
107+ return;
108+ }
109+
110+ const response = await doStartOAuth({
111+ path: { scopeId },
112+ payload: {
113+ displayName,
114+ securitySchemeName: oauth2.securitySchemeName,
115+ flow: "authorizationCode",
116+ authorizationUrl: oauth2.authorizationUrl,
117+ tokenUrl: oauth2.tokenUrl,
118+ redirectUrl,
119+ clientIdSecretId: oauth2.clientIdSecretId,
120+ clientSecretSecretId: oauth2.clientSecretSecretId ?? undefined,
121+ scopes: [...oauth2.scopes],
122+ },
123+ });
124+
125+ if (response.flow !== "authorizationCode") {
126+ setBusy(false);
127+ setError("Unexpected response flow from server");
128+ return;
129+ }
130+
131+ cleanupRef.current = openOAuthPopup<OAuth2Auth>({
132+ url: response.authorizationUrl,
133+ popupName: OPENAPI_OAUTH_POPUP_NAME,
134+ channelName: OPENAPI_OAUTH_CHANNEL,
135+ onResult: async (result: OAuthPopupResult<OAuth2Auth>) => {
136+ cleanupRef.current = null;
137+ if (!result.ok) {
138+ setBusy(false);
139+ setError(result.error);
140+ return;
141+ }
142+ try {
143+ const nextAuth = new OAuth2Auth({
144+ kind: "oauth2",
145+ connectionId: result.connectionId,
146+ securitySchemeName: result.securitySchemeName,
147+ flow: result.flow,
148+ tokenUrl: result.tokenUrl,
149+ authorizationUrl: result.authorizationUrl,
150+ clientIdSecretId: result.clientIdSecretId,
151+ clientSecretSecretId: result.clientSecretSecretId,
152+ scopes: result.scopes,
153+ });
154+ await doUpdate({
155+ path: { scopeId, namespace: props.sourceId },
156+ payload: { oauth2: nextAuth },
157+ reactivityKeys: sourceWriteKeys,
158+ });
159+ setBusy(false);
160+ } catch (e) {
161+ setBusy(false);
162+ setError(
163+ e instanceof Error ? e.message : "Failed to persist new connection",
164+ );
165+ }
166+ },
167+ onClosed: () => {
168+ cleanupRef.current = null;
169+ setBusy(false);
170+ setError("Sign-in cancelled — popup was closed before completing the flow.");
171+ },
172+ onOpenFailed: () => {
173+ cleanupRef.current = null;
174+ setBusy(false);
175+ setError("Sign-in popup was blocked by the browser");
176+ },
177+ });
178+ } catch (e) {
179+ setBusy(false);
180+ setError(e instanceof Error ? e.message : "Failed to start sign-in");
181+ }
182+ }, [
183+ oauth2,
184+ source?.name,
185+ scopeId,
186+ props.sourceId,
187+ redirectUrl,
188+ doStartOAuth,
189+ doUpdate,
190+ ]);
191+
192+ if (!oauth2) return null;
193+
194+ return (
195+ <div className="flex items-center gap-2">
196+ {error && <span className="text-xs text-destructive">{error}</span>}
197+ <Button
198+ variant="outline"
199+ size="sm"
200+ onClick={() => void handleSignIn()}
201+ disabled={busy}
202+ >
203+ {busy
204+ ? isConnected
205+ ? "Reconnecting…"
206+ : "Signing in…"
207+ : isConnected
208+ ? "Reconnect"
209+ : "Sign in"}
210+ </Button>
211+ </div>
212+ );
213+}
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+10−31
@@ -1,40 +1,19 @@
11 import { Result, useAtomValue } from "@effect-atom/atom-react";
22
3−import { SecretId } from "@executor/sdk";
4−import { useScope } from "@executor/react/api/scope-context";
5−import { secretStatusAtom } from "@executor/react/api/atoms";
63 import { Badge } from "@executor/react/components/badge";
7−import { Skeleton } from "@executor/react/components/skeleton";
84
95 import { openApiSourceAtom } from "./atoms";
6+import { useScope } from "@executor/react/api/scope-context";
107
11−function ConnectedBadge(props: { accessTokenSecretId: string }) {
12− const scopeId = useScope();
13− const status = useAtomValue(
14− secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)),
8+function ConnectedBadge() {
9+ return (
10+ <Badge
11+ variant="outline"
12+ className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400"
13+ >
14+ Connected
15+ </Badge>
1516 );
16−
17− return Result.match(status, {
18− onInitial: () => <Skeleton className="h-5 w-20 rounded-full" />,
19− onFailure: () => (
20− <Badge variant="outline" className="text-[10px] text-muted-foreground">
21− Not connected
22− </Badge>
23− ),
24− onSuccess: ({ value }) =>
25− value.status === "resolved" ? (
26− <Badge
27− variant="outline"
28− className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400"
29− >
30− Connected
31− </Badge>
32− ) : (
33− <Badge variant="outline" className="text-[10px] text-muted-foreground">
34− Not connected
35− </Badge>
36− ),
37− });
3817 }
3918
4019 // The entry row already renders name + id + kind, so this summary
@@ -51,5 +30,5 @@ export default function OpenApiSourceSummary(props: { sourceId: string }) { · 10 unmodified lines
5130 : undefined;
5231
5332 if (!oauth2) return null;
54− return <ConnectedBadge accessTokenSecretId={oauth2.accessTokenSecretId} />;
33+ return <ConnectedBadge />;
5534 }
packages/plugins/openapi/src/react/source-plugin.ts+1−0
@@ -8,5 +8,6 @@ export const openApiSourcePlugin: SourcePlugin = { · 7 unmodified lines
88 add: lazy(() => import("./AddOpenApiSource")),
99 edit: lazy(() => import("./EditOpenApiSource")),
1010 summary: lazy(() => import("./OpenApiSourceSummary")),
11+ signIn: lazy(() => import("./OpenApiSignInButton")),
1112 presets: openApiPresets,
1213 };
packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts+0−0

Binary file not shown.

packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+66−122
@@ -3,26 +3,9 @@ · 2 unmodified lines
33 //
44 // Models the production scenario: an org-level admin uploads the shared
55 // client credentials, each member of the org runs their own OAuth flow,
6−// and each member's access token is stamped at the per-user scope so the
7−// org fallback provides client id/secret and the user scope provides
8−// the access token the invoker actually injects.
9−//
10−// Three executors share a single backing adapter + blob store + secret
11−// provider:
12−// - admin: scopes = [org]
13−// - alice: scopes = [user-a, org]
14−// - bob: scopes = [user-b, org]
15−//
16−// Expectations:
17−// 1. Admin writes `petstore_client_id`/`petstore_client_secret` at org.
18−// 2. Both users addSpec at their own scope with OAuth2Auth referencing
19−// the shared org client secrets + user-unique access/refresh token ids.
20−// 3. Each user runs startOAuth / completeOAuth; tokens land at their
21−// own scope via the session's `tokenScope`.
22−// 4. Invoking `petstore.items.echoHeaders` through alice returns
23−// alice's token; through bob returns bob's.
24−// 5. `secrets.list()` for alice surfaces the org client creds + her
25−// own tokens but NOT bob's.
6+// and each member's access token lives on a per-user Connection. The
7+// Connections primitive owns every secret — they're filtered out of the
8+// user-facing `secrets.list()` automatically.
269 // ---------------------------------------------------------------------------
2710
2811 import { afterEach } from "vitest";
@@ -53,7 +36,6 @@ import { · 24 unmodified lines
5336 } from "@executor/sdk";
5437 import { makeMemoryAdapter } from "@executor/storage-core/testing/memory";
5538
56−import { OAuth2Auth } from "./types";
5739 import { openApiPlugin } from "./plugin";
5840
5941 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -101,7 +83,7 @@ const TestLayer = HttpApiBuilder.serve().pipe( · 41 unmodified lines
10183 const originalFetch = globalThis.fetch;
10284
10385 const mockTokenFetch = (tokenByCode: Record<string, string>) => {
104− globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
86+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
10587 const bodyText =
10688 init?.body instanceof URLSearchParams
10789 ? init.body.toString()
@@ -117,9 +99,6 @@ const mockTokenFetch = (tokenByCode: Record<string, string>) => { · 9 unmodified lines
11799 { status: 400, headers: { "content-type": "application/json" } },
118100 );
119101 }
120− // Omit `expires_in` so the resulting OAuth2Auth has expiresAt=null.
121− // That sidesteps the refresh path during invocation — the invoker
122− // returns the stored access token directly.
123102 return new Response(
124103 JSON.stringify({
125104 access_token: token,
@@ -141,18 +120,11 @@ afterEach(() => { · 15 unmodified lines
141120
142121 layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => {
143122 it.effect(
144− "per-user access tokens coexist with a shared org-level client credential",
123+ "per-user Connections coexist with a shared org-level client credential",
145124 () =>
146125 Effect.gen(function* () {
147− // Shared secret provider. No `list()` implementation — we don't
148− // want the scope-agnostic provider-enumeration fallback to leak
149− // user-scoped ids across executors during this test. In
150− // production, providers that enumerate (1password, workos-vault)
151− // partition by their own tenancy; the in-memory provider here
152− // is flat, so removing `list()` keeps the core-table walk the
153− // only resolver.
154126 const secretStore = new Map<string, string>();
155− const key = (scope: string, id: string) => `${scope}\u0000${id}`;
127+ const key = (scope: string, id: string) => `${scope} ${id}`;
156128 const memoryProvider: SecretProvider = {
157129 key: "memory",
158130 writable: true,
@@ -171,7 +143,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines
171143 secretProviders: [memoryProvider],
172144 }));
173145
174− // Route OpenAPI tool invocations through the Effect test server.
175146 const httpClient = yield* HttpClient.HttpClient;
176147 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
177148 const plugins = [
@@ -179,8 +150,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 1 unmodified line
179150 memorySecretsPlugin(),
180151 ] as const;
181152
182− // One adapter + blob store behind all three executors — exactly
183− // what a multi-tenant deployment looks like in production.
184153 const schema = collectSchemas(plugins);
185154 const adapter = makeMemoryAdapter({ schema });
186155 const blobs = makeInMemoryBlobStore();
@@ -242,56 +211,15 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 55 unmodified lines
242211 );
243212
244213 // -------------------------------------------------------------
245− // 2. Each user adds the petstore source at their own scope,
246− // pre-populating OAuth2Auth that points at:
247− // - shared org client creds (read via scope fallthrough)
248− // - user-unique access/refresh token ids (read directly
249− // from the user scope).
250− // `expiresAt: null` keeps the invoker on the no-refresh
251− // path — it returns the stored access token verbatim.
252− // -------------------------------------------------------------
253− const makeAuth = (user: "alice" | "bob"): OAuth2Auth =>
254− new OAuth2Auth({
255− kind: "oauth2",
256− securitySchemeName: "oauth2",
257− flow: "authorizationCode",
258− tokenUrl: "https://token.example.com/token",
259− clientIdSecretId: "petstore_client_id",
260− clientSecretSecretId: "petstore_client_secret",
261− accessTokenSecretId: `petstore_access_token_${user}`,
262− refreshTokenSecretId: `petstore_refresh_token_${user}`,
263− tokenType: "Bearer",
264− expiresAt: null,
265− scope: null,
266− scopes: ["read"],
267− });
268−
269− yield* aliceExec.openapi.addSpec({
270− spec: specJson,
271− scope: aliceScope.id as string,
272− namespace: "petstore",
273− baseUrl: "",
274− oauth2: makeAuth("alice"),
275− });
276− yield* bobExec.openapi.addSpec({
277− spec: specJson,
278− scope: bobScope.id as string,
279− namespace: "petstore",
280− baseUrl: "",
281− oauth2: makeAuth("bob"),
282− });
283−
284− // -------------------------------------------------------------
285− // 3. Each user runs the OAuth flow. Mock the token endpoint to
286− // return a distinct access_token per `code` so we can tell
287− // them apart downstream.
214+ // 2. Each user runs startOAuth + completeOAuth to mint a
215+ // per-user Connection.
288216 // -------------------------------------------------------------
289217 mockTokenFetch({
290218 "code-alice": "alice-token",
291219 "code-bob": "bob-token",
292220 });
293221
294− const startInputFor = (user: "alice" | "bob", scope: ScopeId) => ({
222+ const startInputFor = (user: string, scope: ScopeId) => ({
295223 displayName: `Petstore (${user})`,
296224 securitySchemeName: "oauth2",
297225 flow: "authorizationCode" as const,
@@ -302,8 +230,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 4 unmodified lines
302230 clientSecretSecretId: "petstore_client_secret",
303231 scopes: ["read"],
304232 tokenScope: scope as unknown as string,
305− accessTokenSecretId: `petstore_access_token_${user}`,
306− refreshTokenSecretId: `petstore_refresh_token_${user}`,
307233 });
308234
309235 const aliceStart = yield* aliceExec.openapi.startOAuth(
@@ -319,18 +245,38 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 9 unmodified lines
319245 throw new Error("expected authorizationCode flow for bob");
320246 }
321247
322− yield* aliceExec.openapi.completeOAuth({
248+ const aliceAuth = yield* aliceExec.openapi.completeOAuth({
323249 state: aliceStart.sessionId,
324250 code: "code-alice",
325251 });
326− yield* bobExec.openapi.completeOAuth({
252+ const bobAuth = yield* bobExec.openapi.completeOAuth({
327253 state: bobStart.sessionId,
328254 code: "code-bob",
329255 });
330256
257+ expect(aliceAuth.connectionId).not.toBe(bobAuth.connectionId);
258+
259+ // -------------------------------------------------------------
260+ // 3. Each user adds the spec with the auth they just minted.
261+ // -------------------------------------------------------------
262+ yield* aliceExec.openapi.addSpec({
263+ spec: specJson,
264+ scope: aliceScope.id as string,
265+ namespace: "petstore",
266+ baseUrl: "",
267+ oauth2: aliceAuth,
268+ });
269+ yield* bobExec.openapi.addSpec({
270+ spec: specJson,
271+ scope: bobScope.id as string,
272+ namespace: "petstore",
273+ baseUrl: "",
274+ oauth2: bobAuth,
275+ });
276+
331277 // -------------------------------------------------------------
332− // 4. Invoke the tool through each executor. The injected
333− // Authorization header must carry THAT user's token.
278+ // 4. Invoke through each exec — Authorization must carry that
279+ // user's token.
334280 // -------------------------------------------------------------
335281 const aliceResult = (yield* aliceExec.tools.invoke(
336282 "petstore.items.echoHeaders",
@@ -349,51 +295,49 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines
349295 expect(bobResult.data?.authorization).toBe("Bearer bob-token");
350296
351297 // -------------------------------------------------------------
352− // 5. Scope isolation on `secrets.list()`:
353− // - alice sees org client creds + her own tokens.
354− // - alice does NOT see bob's tokens.
355− // - bob symmetrically sees only the org creds + his tokens.
356− // - admin sees only the org creds.
298+ // 5. Each user's Connection is scoped to them; admin sees none.
357299 // -------------------------------------------------------------
358− const aliceIds = new Set(
359− (yield* aliceExec.secrets.list()).map((s) => s.id as unknown as string),
300+ const aliceConnections = yield* aliceExec.connections.list();
301+ const aliceConn = aliceConnections.find(
302+ (c) => c.id === aliceAuth.connectionId,
360303 );
361− expect(aliceIds).toContain("petstore_client_id");
362− expect(aliceIds).toContain("petstore_client_secret");
363− expect(aliceIds).toContain("petstore_access_token_alice");
364− expect(aliceIds).toContain("petstore_refresh_token_alice");
365− expect(aliceIds).not.toContain("petstore_access_token_bob");
366− expect(aliceIds).not.toContain("petstore_refresh_token_bob");
367−
368− const bobIds = new Set(
369− (yield* bobExec.secrets.list()).map((s) => s.id as unknown as string),
304+ expect(aliceConn?.scopeId as unknown as string).toBe("user-alice");
305+ expect(aliceConn?.kind).toBe("user");
306+
307+ const bobConnections = yield* bobExec.connections.list();
308+ const bobConn = bobConnections.find(
309+ (c) => c.id === bobAuth.connectionId,
370310 );
371− expect(bobIds).toContain("petstore_client_id");
372− expect(bobIds).toContain("petstore_access_token_bob");
373− expect(bobIds).toContain("petstore_refresh_token_bob");
374− expect(bobIds).not.toContain("petstore_access_token_alice");
311+ expect(bobConn?.scopeId as unknown as string).toBe("user-bob");
375312
376− const adminIds = new Set(
377− (yield* adminExec.secrets.list()).map((s) => s.id as unknown as string),
313+ const adminConnectionIds = new Set(
314+ (yield* adminExec.connections.list()).map((c) => c.id as string),
315+ );
316+ expect(adminConnectionIds).not.toContain(
317+ aliceAuth.connectionId as unknown as string,
318+ );
319+ expect(adminConnectionIds).not.toContain(
320+ bobAuth.connectionId as unknown as string,
378321 );
379− expect(adminIds).toContain("petstore_client_id");
380− expect(adminIds).toContain("petstore_client_secret");
381− expect(adminIds).not.toContain("petstore_access_token_alice");
382− expect(adminIds).not.toContain("petstore_access_token_bob");
383322
384323 // -------------------------------------------------------------
385− // 6. Secret-row scope attribution: alice's tokens are pinned to
386− // her scope, not smuggled in under the org fallback.
324+ // 6. Connection-owned secrets are filtered from secrets.list().
325+ // Alice only sees the org client creds; her access / refresh
326+ // tokens are hidden behind the Connection primitive.
387327 // -------------------------------------------------------------
388− const aliceRows = yield* aliceExec.secrets.list();
389− const aliceAccess = aliceRows.find(
390− (r) => (r.id as unknown as string) === "petstore_access_token_alice",
328+ const aliceSecretIds = new Set(
329+ (yield* aliceExec.secrets.list()).map(
330+ (s) => s.id as unknown as string,
331+ ),
332+ );
333+ expect(aliceSecretIds).toContain("petstore_client_id");
334+ expect(aliceSecretIds).toContain("petstore_client_secret");
335+ expect(aliceSecretIds).not.toContain(
336+ `${aliceAuth.connectionId}.access_token`,
391337 );
392− expect(aliceAccess?.scopeId as unknown as string).toBe("user-alice");
393− const aliceClient = aliceRows.find(
394− (r) => (r.id as unknown as string) === "petstore_client_id",
338+ expect(aliceSecretIds).not.toContain(
339+ `${aliceAuth.connectionId}.refresh_token`,
395340 );
396− expect(aliceClient?.scopeId as unknown as string).toBe("org");
397341 }),
398342 );
399343 });
packages/plugins/openapi/src/sdk/plugin.ts+270−271
@@ -5,21 +5,27 @@ import { FetchHttpClient, HttpClient } from "@effect/platform"; · 4 unmodified lines
55 import type { Layer } from "effect";
66
77 import {
8+ OAuth2Error,
89 buildAuthorizationUrl,
910 createPkceCodeVerifier,
1011 exchangeAuthorizationCode,
1112 exchangeClientCredentials,
12− shouldRefreshToken,
13− withRefreshedAccessToken,
13+ refreshAccessToken,
1414 type OAuth2TokenResponse,
1515 } from "@executor/plugin-oauth2";
1616
1717 import {
18+ ConnectionId,
19+ ConnectionRefreshError,
20+ CreateConnectionInput,
1821 ScopeId,
1922 SecretId,
20− SetSecretInput,
2123 SourceDetectionResult,
24+ TokenMaterial,
2225 definePlugin,
26+ type ConnectionProvider,
27+ type ConnectionRefreshInput,
28+ type ConnectionRefreshResult,
2329 type PluginCtx,
2430 type StorageFailure,
2531 type ToolAnnotations,
@@ -90,27 +96,26 @@ export interface OpenApiUpdateSourceInput { · 64 unmodified lines
9096 readonly name?: string;
9197 readonly baseUrl?: string;
9298 readonly headers?: Record<string, HeaderValue>;
99+ /** Rewrite the source's OAuth2Auth — typically after a successful
100+ * re-authenticate, to point at a freshly minted connection. */
101+ readonly oauth2?: OAuth2Auth;
93102 }
94103
95104 // ---------------------------------------------------------------------------
96−// OAuth2 onboarding inputs / outputs
105+// OAuth2 onboarding inputs / outputs — callers pre-decide identity knobs
106+// (display name, scheme name, scopes, target scope) and the SDK mints a
107+// Connection when the flow completes. The caller receives an OAuth2Auth
108+// carrying just the resulting connection id.
97109 // ---------------------------------------------------------------------------
98110
99−/**
100− * Shared caller-owned token-identity knobs. `tokenScope` names which
101− * executor scope will own the minted tokens (typically the per-user
102− * scope, defaulting to `ctx.scopes[0].id`). The token secret ids are
103− * pre-decided so the source's stored `OAuth2Auth` can reference the
104− * same ids across every user — per-user values shadow org-level
105− * fallbacks via secret fall-through on read.
106− */
107111 interface StartOAuthIdentity {
108112 readonly displayName: string;
109113 readonly securitySchemeName: string;
110114 readonly clientIdSecretId: string;
111115 readonly scopes: readonly string[];
116+ /** Executor scope that will own the resulting Connection (and its
117+ * backing token secrets). Defaults to `ctx.scopes[0].id`. */
112118 readonly tokenScope?: string;
113− readonly accessTokenSecretId: string;
114119 }
115120
116121 export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {
@@ -119,16 +124,13 @@ export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { · 2 unmodified lines
119124 readonly tokenUrl: string;
120125 readonly redirectUrl: string;
121126 readonly clientSecretSecretId?: string | null;
122− readonly refreshTokenSecretId?: string | null;
123127 }
124128
125129 /**
126130 * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges
127− * tokens inline and writes the access token at
128− * `accessTokenSecretId` + `tokenScope`, returning a completed
129− * `OAuth2Auth`. No `authorizationUrl`, no session row, no
130− * `completeOAuth`. Re-exchange happens at invoke time when the token
131− * is near expiry.
131+ * tokens inline, creates the Connection, and returns a completed
132+ * `OAuth2Auth` pointing at it. No `authorizationUrl`, no session row,
133+ * no `completeOAuth`.
132134 */
133135 export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity {
134136 readonly flow: "clientCredentials";
@@ -280,89 +282,35 @@ const descriptionFor = (def: ToolDefinition): string => { · 145 unmodified lines
280282 };
281283
282284 // ---------------------------------------------------------------------------
283−// clientCredentials access-token resolver.
285+// Connection `provider_state` shape for openapi-oauth2.
284286 //
285−// RFC 6749 §4.4.3 forbids refresh tokens for this grant, so the only way to
286−// recover from an expired access token is a fresh client_credentials
287−// exchange. We re-exchange proactively (when `shouldRefreshToken` fires
288−// within skew) and persist the new token at the same id+scope the source's
289−// OAuth2Auth already references, keeping per-user shadowing intact.
287+// Every field needed to re-hit the token endpoint on refresh lives here,
288+// so the SDK's `ctx.connections.accessToken(id)` can drive both grant
289+// types without the plugin keeping its own refresh state. The flow
290+// literal chooses between `refresh_token` (authorizationCode) and
291+// re-`exchange client_credentials` at refresh time. NONE of this is
292+// sensitive — client credentials themselves still live in secrets and
293+// are resolved via `ctx.secrets.get` inside the refresh handler.
290294 // ---------------------------------------------------------------------------
291295
292−type ResolveClientCredentialsArgs = {
293− readonly auth: OAuth2Auth;
294− readonly source: StoredSource;
295− readonly scopeId: string;
296− readonly secretsGet: (id: string) => Effect.Effect<string | null, Error>;
297− readonly secretsSet: (args: {
298− readonly id: string;
299− readonly scope: string;
300− readonly name: string;
301− readonly value: string;
302− }) => Effect.Effect<void, Error>;
303− readonly updateSourceMeta: (oauth2: OAuth2Auth) => Effect.Effect<void, Error>;
304−};
296+const OPENAPI_OAUTH2_PROVIDER_KEY = "openapi:oauth2" as const;
305297
306−const resolveClientCredentialsAccessToken = (args: ResolveClientCredentialsArgs) =>
307− Effect.gen(function* () {
308− const { auth } = args;
309− const needsRefresh = shouldRefreshToken({ expiresAt: auth.expiresAt });
310−
311− if (!needsRefresh) {
312− const current = yield* args.secretsGet(auth.accessTokenSecretId);
313− if (current !== null) return current;
314− // Secret was deleted out from under us — fall through to re-exchange.
315− }
316−
317− if (auth.clientSecretSecretId === null) {
318− return yield* Effect.fail(
319− new Error("client_credentials flow requires clientSecretSecretId"),
320− );
321− }
322−
323− const clientId = yield* args.secretsGet(auth.clientIdSecretId);
324− if (clientId === null) {
325− return yield* Effect.fail(
326− new Error(`Missing client ID secret: ${auth.clientIdSecretId}`),
327− );
328− }
329− const clientSecret = yield* args.secretsGet(auth.clientSecretSecretId);
330− if (clientSecret === null) {
331− return yield* Effect.fail(
332− new Error(`Missing client secret: ${auth.clientSecretSecretId}`),
333− );
334− }
335−
336− const tokenResponse = yield* exchangeClientCredentials({
337− tokenUrl: auth.tokenUrl,
338− clientId,
339− clientSecret,
340− scopes: auth.scopes,
341− }).pipe(Effect.mapError((err) => new Error(err.message)));
342−
343− yield* args.secretsSet({
344− id: auth.accessTokenSecretId,
345− scope: args.scopeId,
346− name: `${args.source.name} Access Token`,
347− value: tokenResponse.access_token,
348− });
298+const OAuth2ProviderState = Schema.Struct({
299+ flow: Schema.Literal("authorizationCode", "clientCredentials"),
300+ tokenUrl: Schema.String,
301+ clientIdSecretId: Schema.String,
302+ clientSecretSecretId: Schema.NullOr(Schema.String),
303+ scopes: Schema.Array(Schema.String),
304+});
305+type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
349306
350− const expiresAt =
351− typeof tokenResponse.expires_in === "number"
352− ? Date.now() + tokenResponse.expires_in * 1000
353− : null;
354−
355− yield* args.updateSourceMeta(
356− new OAuth2Auth({
357− ...auth,
358− tokenType: tokenResponse.token_type ?? auth.tokenType,
359− expiresAt,
360− scope: tokenResponse.scope ?? auth.scope,
361− }),
362− );
363−
364− return tokenResponse.access_token;
365− });
307+const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
308+const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
309+
310+const toProviderStateRecord = (
311+ state: OAuth2ProviderState,
312+): Record<string, unknown> =>
313+ encodeProviderState(state) as unknown as Record<string, unknown>;
366314
367315 // ---------------------------------------------------------------------------
368316 // Plugin factory
@@ -540,28 +488,6 @@ export const openApiPlugin = definePlugin( · 171 unmodified lines
540488 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
541489
542490 extension: (ctx) => {
543− // Write a token secret at a caller-pinned id and scope. The
544− // session persisted by `startOAuth` names both, so the plugin
545− // never picks them itself — that keeps shadowing by id
546− // deterministic (user scope overrides org scope for the same
547− // secret id) instead of minting random suffixes per completion.
548− const writeOAuthSecret = (args: {
549− readonly id: string;
550− readonly scope: string;
551− readonly name: string;
552− readonly value: string;
553− }) =>
554− ctx.secrets
555− .set(
556− new SetSecretInput({
557− id: SecretId.make(args.id),
558− scope: ScopeId.make(args.scope),
559− name: args.name,
560− value: args.value,
561− }),
562− )
563− .pipe(Effect.map((ref) => ({ id: ref.id as string })));
564−
565491 const addSpecInternal = (config: OpenApiSpecConfig) =>
566492 Effect.gen(function* () {
567493 // Resolve URL → text and parse BEFORE opening a transaction.
@@ -619,6 +545,7 @@ export const openApiPlugin = definePlugin( · 51 unmodified lines
619545 name: input.name?.trim() || undefined,
620546 baseUrl: input.baseUrl,
621547 headers: input.headers,
548+ oauth2: input.oauth2,
622549 }),
623550
624551 startOAuth: (input) =>
@@ -663,35 +590,61 @@ export const openApiPlugin = definePlugin( · 38 unmodified lines
663590 ),
664591 );
665592
666− const accessRef = yield* writeOAuthSecret({
667− id: input.accessTokenSecretId,
668− scope: tokenScope,
669− name: `${input.displayName} Access Token`,
670− value: tokenResponse.access_token,
671− }).pipe(
672− Effect.mapError(
673− (err) => new OpenApiOAuthError({ message: err.message }),
674− ),
675− );
676−
593+ const connectionId = `openapi-oauth2-${randomUUID()}`;
677594 const expiresAt =
678595 typeof tokenResponse.expires_in === "number"
679596 ? Date.now() + tokenResponse.expires_in * 1000
680597 : null;
681598
599+ const providerState: OAuth2ProviderState = {
600+ flow: "clientCredentials",
601+ tokenUrl: input.tokenUrl,
602+ clientIdSecretId: input.clientIdSecretId,
603+ clientSecretSecretId: input.clientSecretSecretId,
604+ scopes: scopesArray,
605+ };
606+
607+ yield* ctx.connections
608+ .create(
609+ new CreateConnectionInput({
610+ id: ConnectionId.make(connectionId),
611+ scope: ScopeId.make(tokenScope),
612+ provider: OPENAPI_OAUTH2_PROVIDER_KEY,
613+ kind: "app",
614+ identityLabel: input.displayName,
615+ accessToken: new TokenMaterial({
616+ secretId: SecretId.make(`${connectionId}.access_token`),
617+ name: `${input.displayName} Access Token`,
618+ value: tokenResponse.access_token,
619+ }),
620+ // RFC 6749 §4.4.3: no refresh tokens for this grant.
621+ refreshToken: null,
622+ expiresAt,
623+ oauthScope: tokenResponse.scope ?? null,
624+ providerState: toProviderStateRecord(providerState),
625+ }),
626+ )
627+ .pipe(
628+ Effect.mapError(
629+ (err) =>
630+ new OpenApiOAuthError({
631+ message:
632+ "message" in err
633+ ? (err as { message: string }).message
634+ : String(err),
635+ }),
636+ ),
637+ );
638+
682639 const auth = new OAuth2Auth({
683640 kind: "oauth2",
641+ connectionId,
684642 securitySchemeName: input.securitySchemeName,
685643 flow: "clientCredentials",
686644 tokenUrl: input.tokenUrl,
645+ authorizationUrl: null,
687646 clientIdSecretId: input.clientIdSecretId,
688− clientSecretSecretId: input.clientSecretSecretId,
689− accessTokenSecretId: accessRef.id,
690− // RFC 6749 §4.4.3: refresh tokens SHOULD NOT be issued.
691− refreshTokenSecretId: null,
692− tokenType: tokenResponse.token_type ?? "Bearer",
693− expiresAt,
694− scope: tokenResponse.scope ?? null,
647+ clientSecretSecretId: input.clientSecretSecretId ?? null,
695648 scopes: scopesArray,
696649 });
697650
@@ -705,6 +658,7 @@ export const openApiPlugin = definePlugin( · 7 unmodified lines
705658 // authorizationCode path.
706659 const sessionId = randomUUID();
707660 const codeVerifier = createPkceCodeVerifier();
661+ const connectionId = `openapi-oauth2-${randomUUID()}`;
708662
709663 yield* ctx.storage
710664 .putOAuthSession(
@@ -714,12 +668,14 @@ export const openApiPlugin = definePlugin( · 3 unmodified lines
714668 securitySchemeName: input.securitySchemeName,
715669 flow: input.flow,
716670 tokenUrl: input.tokenUrl,
671+ authorizationUrl: input.authorizationUrl,
717672 redirectUrl: input.redirectUrl,
718673 clientIdSecretId: input.clientIdSecretId,
719674 clientSecretSecretId: input.clientSecretSecretId ?? null,
720675 tokenScope,
721− accessTokenSecretId: input.accessTokenSecretId,
722− refreshTokenSecretId: input.refreshTokenSecretId ?? null,
676+ connectionId,
677+ accessTokenSecretId: `${connectionId}.access_token`,
678+ refreshTokenSecretId: `${connectionId}.refresh_token`,
723679 scopes: scopesArray,
724680 codeVerifier,
725681 }),
@@ -800,57 +756,65 @@ export const openApiPlugin = definePlugin( · 74 unmodified lines
800756 ),
801757 );
802758
803− // Write tokens at the exact id + scope named by the
804− // session. Shadowing by id (per-user scope wins over
805− // org fall-through) is what makes a single stored
806− // `OAuth2Auth` on the source resolve to per-user
807− // values at invoke time.
808− const accessRef = yield* writeOAuthSecret({
809− id: session.accessTokenSecretId,
810− scope: session.tokenScope,
811− name: `${session.displayName} Access Token`,
812− value: tokenResponse.access_token,
813− }).pipe(
814− Effect.mapError(
815− (err) => new OpenApiOAuthError({ message: err.message }),
816− ),
817− );
818−
819− let refreshSecretId: string | null = null;
820− if (
821− tokenResponse.refresh_token &&
822− session.refreshTokenSecretId
823− ) {
824− const refreshRef = yield* writeOAuthSecret({
825− id: session.refreshTokenSecretId,
826− scope: session.tokenScope,
827− name: `${session.displayName} Refresh Token`,
828− value: tokenResponse.refresh_token,
829− }).pipe(
830− Effect.mapError(
831− (err) => new OpenApiOAuthError({ message: err.message }),
832− ),
833− );
834− refreshSecretId = refreshRef.id;
835− }
836−
837759 const expiresAt =
838760 typeof tokenResponse.expires_in === "number"
839761 ? Date.now() + tokenResponse.expires_in * 1000
840762 : null;
841763
764+ const providerState: OAuth2ProviderState = {
765+ flow: "authorizationCode",
766+ tokenUrl: session.tokenUrl,
767+ clientIdSecretId: session.clientIdSecretId,
768+ clientSecretSecretId: session.clientSecretSecretId,
769+ scopes: [...session.scopes],
770+ };
771+
772+ yield* ctx.connections
773+ .create(
774+ new CreateConnectionInput({
775+ id: ConnectionId.make(session.connectionId),
776+ scope: ScopeId.make(session.tokenScope),
777+ provider: OPENAPI_OAUTH2_PROVIDER_KEY,
778+ kind: "user",
779+ identityLabel: session.displayName,
780+ accessToken: new TokenMaterial({
781+ secretId: SecretId.make(session.accessTokenSecretId),
782+ name: `${session.displayName} Access Token`,
783+ value: tokenResponse.access_token,
784+ }),
785+ refreshToken: tokenResponse.refresh_token
786+ ? new TokenMaterial({
787+ secretId: SecretId.make(session.refreshTokenSecretId),
788+ name: `${session.displayName} Refresh Token`,
789+ value: tokenResponse.refresh_token,
790+ })
791+ : null,
792+ expiresAt,
793+ oauthScope: tokenResponse.scope ?? null,
794+ providerState: toProviderStateRecord(providerState),
795+ }),
796+ )
797+ .pipe(
798+ Effect.mapError(
799+ (err) =>
800+ new OpenApiOAuthError({
801+ message:
802+ "message" in err
803+ ? (err as { message: string }).message
804+ : String(err),
805+ }),
806+ ),
807+ );
808+
842809 return new OAuth2Auth({
843810 kind: "oauth2",
811+ connectionId: session.connectionId,
844812 securitySchemeName: session.securitySchemeName,
845− flow: session.flow,
813+ flow: "authorizationCode",
846814 tokenUrl: session.tokenUrl,
815+ authorizationUrl: session.authorizationUrl,
847816 clientIdSecretId: session.clientIdSecretId,
848817 clientSecretSecretId: session.clientSecretSecretId,
849− accessTokenSecretId: accessRef.id,
850− refreshTokenSecretId: refreshSecretId,
851− tokenType: tokenResponse.token_type ?? "Bearer",
852− expiresAt,
853− scope: tokenResponse.scope ?? null,
854818 scopes: [...session.scopes],
855819 });
856820 }),
@@ -946,101 +910,27 @@ export const openApiPlugin = definePlugin( · 89 unmodified lines
946910 { get: ctx.secrets.get },
947911 );
948912
949− // If the source has OAuth2 auth, resolve/refresh access token and
950− // inject Authorization header (wins over a manually-set one).
913+ // If the source has OAuth2 auth, resolve a guaranteed-fresh
914+ // access token from the backing Connection and inject the
915+ // Authorization header (wins over a manually-set one). All the
916+ // refresh complexity lives in the SDK — the plugin just asks.
951917 if (Option.isSome(config.oauth2)) {
952918 const auth = config.oauth2.value;
953− const accessToken =
954− auth.flow === "clientCredentials"
955− ? yield* resolveClientCredentialsAccessToken({
956− auth,
957− source,
958− scopeId: ctx.scopes[0]!.id as string,
959− secretsGet: (id) =>
960− ctx.secrets
961− .get(id)
962− .pipe(Effect.mapError((err) => new Error(err.message))),
963− secretsSet: (args) =>
964− ctx.secrets
965− .set(
966− new SetSecretInput({
967− id: SecretId.make(args.id),
968− scope: ScopeId.make(args.scope),
969− name: args.name,
970− value: args.value,
971− }),
972− )
973− .pipe(
974− Effect.asVoid,
975− Effect.mapError((err) => new Error(err.message)),
976− ),
977− updateSourceMeta: (oauth2) =>
978− ctx.storage
979− .updateSourceMeta(source.namespace, source.scope, {
980− oauth2,
981− })
982− .pipe(Effect.mapError((err) => new Error(err.message))),
983− })
984− : yield* withRefreshedAccessToken({
985− auth: {
986− clientIdSecretId: auth.clientIdSecretId,
987− clientSecretSecretId: auth.clientSecretSecretId,
988− accessTokenSecretId: auth.accessTokenSecretId,
989− refreshTokenSecretId: auth.refreshTokenSecretId,
990− tokenType: auth.tokenType,
991− expiresAt: auth.expiresAt,
992− scopes: auth.scopes,
993− },
994− tokenUrl: auth.tokenUrl,
995− secrets: {
996− resolve: (id) =>
997− ctx.secrets.get(id).pipe(
998− Effect.flatMap((v) =>
999− v === null
1000− ? Effect.fail(new Error(`Missing secret: ${id}`))
1001− : Effect.succeed(v),
919+ const accessToken = yield* ctx.connections
920+ .accessToken(auth.connectionId)
921+ .pipe(
922+ Effect.mapError(
923+ (err) =>
924+ new Error(
925+ `OAuth connection resolution failed: ${
926+ "message" in err
927+ ? (err as { message: string }).message
928+ : String(err)
929+ }`,
1002930 ),
1003− ),
1004− setValue: ({ secretId, value, name }) =>
1005− // Refresh writes land at the innermost scope of the
1006− // executor that invoked the tool. When a per-user
1007− // stack calls, that's the user scope — their token
1008− // shadows the org fall-through on the same id.
1009− ctx.secrets
1010− .set(
1011− new SetSecretInput({
1012− id: SecretId.make(secretId),
1013− scope: ctx.scopes[0]!.id,
1014− name,
1015− value,
1016− }),
1017− )
1018− .pipe(Effect.asVoid),
1019− },
1020− displayName: source.name,
1021− accessTokenPurpose: "openapi_oauth_access_token",
1022− refreshTokenPurpose: "openapi_oauth_refresh_token",
1023− persistAuth: (snapshot) =>
1024− Effect.gen(function* () {
1025− const updatedOAuth = new OAuth2Auth({
1026− ...auth,
1027− tokenType: snapshot.tokenType,
1028− expiresAt: snapshot.expiresAt,
1029− scope: snapshot.scope ?? auth.scope,
1030− });
1031− // Pin the meta update to the source's own scope so a
1032− // refreshed token snapshot can't accidentally land on
1033− // a shadowed source row at another scope in the stack.
1034− yield* ctx.storage.updateSourceMeta(source.namespace, source.scope, {
1035− oauth2: updatedOAuth,
1036− });
1037− }),
1038− }).pipe(
1039− Effect.mapError(
1040− (err) => new Error(`OAuth token refresh failed: ${err.message}`),
1041− ),
1042− );
1043− resolvedHeaders["Authorization"] = `${auth.tokenType || "Bearer"} ${accessToken}`;
931+ ),
932+ );
933+ resolvedHeaders["Authorization"] = `Bearer ${accessToken}`;
1044934 }
1045935
1046936 const result = yield* invokeWithLayer(
@@ -1129,6 +1019,115 @@ export const openApiPlugin = definePlugin( · 82 unmodified lines
11291019 namespace,
11301020 });
11311021 }),
1022+
1023+ // The SDK's `ctx.connections.accessToken(id)` dispatches here when a
1024+ // token is near expiry. Both flows share one provider key — the
1025+ // concrete refresh strategy is selected from `providerState.flow`
1026+ // because the caller already persisted all the knobs we need.
1027+ connectionProviders: (ctx): readonly ConnectionProvider[] => [
1028+ {
1029+ key: OPENAPI_OAUTH2_PROVIDER_KEY,
1030+ refresh: (input: ConnectionRefreshInput) =>
1031+ Effect.gen(function* () {
1032+ if (!input.providerState) {
1033+ return yield* new ConnectionRefreshError({
1034+ connectionId: input.connectionId,
1035+ message:
1036+ "openapi:oauth2 connection is missing providerState",
1037+ });
1038+ }
1039+ const state = yield* Effect.try({
1040+ try: () => decodeProviderState(input.providerState),
1041+ catch: (cause) =>
1042+ new ConnectionRefreshError({
1043+ connectionId: input.connectionId,
1044+ message: `openapi:oauth2 providerState is malformed: ${
1045+ cause instanceof Error ? cause.message : String(cause)
1046+ }`,
1047+ cause,
1048+ }),
1049+ });
1050+
1051+ const clientId = yield* ctx.secrets.get(state.clientIdSecretId).pipe(
1052+ Effect.mapError(
1053+ (err) =>
1054+ new ConnectionRefreshError({
1055+ connectionId: input.connectionId,
1056+ message: `Failed to resolve client id secret: ${err.message}`,
1057+ cause: err,
1058+ }),
1059+ ),
1060+ );
1061+ if (clientId === null) {
1062+ return yield* new ConnectionRefreshError({
1063+ connectionId: input.connectionId,
1064+ message: `Missing client id secret: ${state.clientIdSecretId}`,
1065+ });
1066+ }
1067+
1068+ const clientSecret = state.clientSecretSecretId
1069+ ? yield* ctx.secrets.get(state.clientSecretSecretId).pipe(
1070+ Effect.mapError(
1071+ (err) =>
1072+ new ConnectionRefreshError({
1073+ connectionId: input.connectionId,
1074+ message: `Failed to resolve client secret: ${err.message}`,
1075+ cause: err,
1076+ }),
1077+ ),
1078+ )
1079+ : null;
1080+
1081+ const toRefreshError = (err: OAuth2Error) =>
1082+ new ConnectionRefreshError({
1083+ connectionId: input.connectionId,
1084+ message: err.message,
1085+ cause: err,
1086+ });
1087+
1088+ const tokenResponse = yield* (state.flow === "clientCredentials"
1089+ ? exchangeClientCredentials({
1090+ tokenUrl: state.tokenUrl,
1091+ clientId,
1092+ clientSecret: clientSecret ?? "",
1093+ scopes: state.scopes,
1094+ })
1095+ : (() => {
1096+ if (input.refreshToken === null) {
1097+ return Effect.fail(
1098+ new OAuth2Error({
1099+ message:
1100+ "authorizationCode connection has no refresh token",
1101+ }),
1102+ );
1103+ }
1104+ return refreshAccessToken({
1105+ tokenUrl: state.tokenUrl,
1106+ clientId,
1107+ clientSecret,
1108+ refreshToken: input.refreshToken,
1109+ scopes: state.scopes,
1110+ });
1111+ })()
1112+ ).pipe(Effect.mapError(toRefreshError));
1113+
1114+ const expiresAt =
1115+ typeof tokenResponse.expires_in === "number"
1116+ ? Date.now() + tokenResponse.expires_in * 1000
1117+ : null;
1118+
1119+ const result: ConnectionRefreshResult = {
1120+ accessToken: tokenResponse.access_token,
1121+ // Rotated refresh token (RFC 6749 §6) — undefined means
1122+ // "keep the stored one"; null means "AS didn't issue one".
1123+ refreshToken: tokenResponse.refresh_token ?? undefined,
1124+ expiresAt,
1125+ oauthScope: tokenResponse.scope ?? input.oauthScope,
1126+ };
1127+ return result;
1128+ }),
1129+ },
1130+ ],
11321131 };
11331132 },
11341133 );
packages/plugins/openapi/src/sdk/store.ts+24−5
@@ -111,9 +111,6 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 110 unmodified lines
111111 // securitySchemeName, with the secret ids that back its tokens.
112112 oauth2: Schema.optional(OAuth2Auth),
113113 }),
114− // TODO(migration): make required once all rows have been migrated to
115− // carry invocationConfig. Left optional for decode compat with rows
116− // written before the source-level invocationConfig refactor.
117114 invocationConfig: Schema.optional(InvocationConfig),
118115 }) {}
119116
@@ -137,7 +134,16 @@ const encodeInvocationConfig = Schema.encodeSync(InvocationConfig); · 17 unmodified lines
137134 const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig);
138135
139136 const encodeOAuth2 = Schema.encodeSync(OAuth2Auth);
140−const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);
137+
138+// Tolerant decode for the stored `oauth2` column: the schema was widened
139+// (flow, tokenUrl, authorizationUrl, scopes, ...) after rows were first
140+// written as thin `{kind, connectionId}` pointers. Legacy rows must not
141+// crash the source list — they're surfaced with no stored OAuth config,
142+// and the user can re-sign-in from the source detail page to repopulate
143+// the new fields.
144+const decodeOAuth2Option = Schema.decodeUnknownOption(OAuth2Auth);
145+const decodeOAuth2 = (value: unknown): OAuth2Auth | undefined =>
146+ Option.getOrUndefined(decodeOAuth2Option(value));
141147
142148 const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession);
143149 const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession);
@@ -239,8 +245,21 @@ export const makeDefaultOpenapiStore = ({ · 95 unmodified lines
239245 ? undefined
240246 : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw);
241247 const headers = decodeHeaders(row.headers);
248+ // Strip a legacy `oauth2` blob from invocation_config before decoding.
249+ // The schema inside InvocationConfig embeds OAuth2Auth, which was
250+ // widened after some rows were written; the tolerant per-field decode
251+ // we already have for `row.oauth2` doesn't reach the nested copy, so
252+ // we drop it here if it fails to decode against the current schema.
253+ // The legit copy lives on `config.oauth2`; the user re-signs-in from
254+ // the source detail page to repopulate both.
255+ const rawInvocation = asJsonObject(row.invocation_config);
256+ const rawNestedOAuth2 = rawInvocation.oauth2;
257+ const nestedOAuth2Valid =
258+ rawNestedOAuth2 != null && decodeOAuth2(rawNestedOAuth2) !== undefined;
242259 const invocationConfig = decodeInvocationConfig(
243− asJsonObject(row.invocation_config),
260+ nestedOAuth2Valid
261+ ? rawInvocation
262+ : { ...rawInvocation, oauth2: undefined },
244263 );
245264 return {
246265 namespace: row.id as string,
packages/plugins/openapi/src/sdk/types.ts+64−38
@@ -113,33 +113,58 @@ export const HeaderValue = Schema.Union( · 112 unmodified lines
113113 export type HeaderValue = typeof HeaderValue.Type;
114114
115115 // ---------------------------------------------------------------------------
116−// OAuth2 auth — applied as Authorization: Bearer <token> at invocation time.
117−// Tokens are stored as secrets; the bearer value is resolved (and refreshed)
118−// on every request via withRefreshedAccessToken.
119−// ---------------------------------------------------------------------------
116+// OAuth2 auth — points at the Connection that owns live tokens, and also
117+// carries enough API-level config to kick off a fresh sign-in from the
118+// source detail UI without needing the connection to still exist.
119+//
120+// Split of responsibilities:
121+// - The Source owns: the OAuth config (tokenUrl, authorizationUrl,
122+// client credential secret ids, scopes, flow, securitySchemeName).
123+// Values are a property of the target API, identical for every user
124+// signing into this source. Source-owned = reconnect works even if
125+// the connection row has been removed.
126+// - The Connection owns: live access/refresh tokens, token expiry,
127+// provider state the refresh path reads from. The connection's
128+// `providerState` caches the refresh-relevant bits of the config
129+// so the refresh loop never reaches back into source storage.
130+//
131+// This is a deliberate small duplication (scopes + tokenUrl +
132+// clientIdSecretId + clientSecretSecretId appear on both). The values
133+// are static per source so the two copies can't drift.
134+// ---------------------------------------------------------------------------
135+
136+export const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");
137+export type OAuth2Flow = typeof OAuth2Flow.Type;
120138
121139 export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({
122140 kind: Schema.Literal("oauth2"),
123− /** Key into `components.securitySchemes` this auth came from. */
141+ /** Id of the Connection that owns this sign-in. Points at the core
142+ * `connection` table; resolve via `ctx.connections.get(id)` or
143+ * `ctx.connections.accessToken(id)`. Updated when the user signs in
144+ * again from the source detail UI (a fresh connection is minted and
145+ * this pointer is rewritten). */
146+ connectionId: Schema.String,
147+ /** Key into `components.securitySchemes` this auth came from. Kept here
148+ * so a spec with multiple OAuth2 schemes can wire each one to its own
149+ * connection. */
124150 securitySchemeName: Schema.String,
125− /**
126− * Which flow produced this auth. `clientCredentials` sources have no
127− * refresh token — the invoker re-exchanges via client_credentials when
128− * the access token is near expiry.
129− */
130− flow: Schema.Literal("authorizationCode", "clientCredentials"),
131− /** Token endpoint (from the flow) — used for refresh / re-exchange. */
151+ /** OAuth2 grant type used for this source. Determines which flow the
152+ * sign-in button runs (authorizationCode opens a browser popup;
153+ * clientCredentials is server-to-server). */
154+ flow: OAuth2Flow,
155+ /** Absolute token endpoint URL. */
132156 tokenUrl: Schema.String,
157+ /** Absolute authorization endpoint URL. Only used for authorizationCode
158+ * flows; clientCredentials has no user consent step. */
159+ authorizationUrl: Schema.NullOr(Schema.String),
160+ /** Secret id holding the OAuth client_id. */
133161 clientIdSecretId: Schema.String,
162+ /** Secret id holding the OAuth client_secret. Optional for public
163+ * clients (PKCE-only authorizationCode). */
134164 clientSecretSecretId: Schema.NullOr(Schema.String),
135− accessTokenSecretId: Schema.String,
136− refreshTokenSecretId: Schema.NullOr(Schema.String),
137− tokenType: Schema.String,
138− /** Epoch ms when the access token expires; null if the server did not declare an expiry. */
139− expiresAt: Schema.NullOr(Schema.Number),
140− /** Scope string as returned by the token endpoint. */
141− scope: Schema.NullOr(Schema.String),
142− /** Scopes this auth was granted (for display + refresh). */
165+ /** OAuth scopes requested on sign-in. Stored as a static list so the
166+ * sign-in button can re-request the same capabilities without having
167+ * to re-derive them from the OpenAPI spec. */
143168 scopes: Schema.Array(Schema.String),
144169 }) {}
145170
@@ -171,38 +196,39 @@ export class InvocationConfig extends Schema.Class<InvocationConfig>("Invocation · 25 unmodified lines
171196 }) {}
172197
173198 // ---------------------------------------------------------------------------
174−// Pending OAuth session — persisted between startOAuth and completeOAuth
199+// Pending OAuth session — persisted between startOAuth and completeOAuth.
200+// All the fields the exchange needs (token endpoint, client credential
201+// secret ids, redirect URL, PKCE verifier) plus the pre-decided Connection
202+// / secret ids the SDK stamps when the user returns.
175203 // ---------------------------------------------------------------------------
176204
177205 export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>(
178206 "OpenApiOAuthSession",
179207 )({
180− /** Display name used for the stored token secret labels. */
208+ /** Display name used for the resulting Connection's identity label. */
181209 displayName: Schema.String,
182210 securitySchemeName: Schema.String,
183− /** For now only authorizationCode is supported end-to-end; clientCredentials is follow-up work. */
211+ /** Only authorizationCode reaches this session type. client_credentials
212+ * has no user-interactive step so it creates the Connection inline in
213+ * `startOAuth` without persisting a session. */
184214 flow: Schema.Literal("authorizationCode"),
185215 tokenUrl: Schema.String,
216+ /** Absolute authorization endpoint — persisted so completeOAuth can
217+ * stamp it onto the resulting `OAuth2Auth` for future sign-ins. */
218+ authorizationUrl: Schema.String,
186219 redirectUrl: Schema.String,
187220 clientIdSecretId: Schema.String,
188221 clientSecretSecretId: Schema.NullOr(Schema.String),
189− /**
190− * Executor scope id where the minted access/refresh token secrets will
191− * land when `completeOAuth` runs. Typically the innermost (per-user)
192− * scope. Persisted in the session so the callback that completes the
193− * flow writes tokens to the same tenancy the caller intended at
194− * `startOAuth` time.
195− */
222+ /** Executor scope that will own the resulting Connection (and its
223+ * backing secret rows). Typically the innermost (per-user) scope. */
196224 tokenScope: Schema.String,
197− /**
198− * Pre-decided secret ids for the minted access + refresh tokens. The
199− * caller names these so the source's `OAuth2Auth` can reference the
200− * same ids regardless of which scope actually owns the value —
201− * `ctx.secrets.get` resolves them via fallthrough (innermost first),
202− * so per-user tokens shadow org-level fallbacks on the same source.
203− */
225+ /** Pre-decided Connection id stamped at `completeOAuth` time. */
226+ connectionId: Schema.String,
227+ /** Pre-decided secret ids for the Connection's access + refresh
228+ * tokens. Fixed at session creation so a retried callback lands
229+ * on the same ids. */
204230 accessTokenSecretId: Schema.String,
205− refreshTokenSecretId: Schema.NullOr(Schema.String),
231+ refreshTokenSecretId: Schema.String,
206232 scopes: Schema.Array(Schema.String),
207233 codeVerifier: Schema.String,
208234 }) {}
packages/react/src/api/atoms.tsx+12−0
@@ -64,6 +64,13 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 63 unmodified lines
6464 reactivityKeys: [ReactivityKey.secrets],
6565 });
6666
67+export const connectionsAtom = (scopeId: ScopeId) =>
68+ ExecutorApiClient.query("connections", "list", {
69+ path: { scopeId },
70+ timeToLive: "30 seconds",
71+ reactivityKeys: [ReactivityKey.connections],
72+ });
73+
6774 // ---------------------------------------------------------------------------
6875 // Mutation atoms — reactivityKeys must be passed at call site (effect-atom
6976 // does not accept them at definition time). See `reactivity-keys.tsx` for the
@@ -76,6 +83,11 @@ export const resolveSecret = ExecutorApiClient.mutation("secrets", "resolve"); · 6 unmodified lines
7683
7784 export const removeSecret = ExecutorApiClient.mutation("secrets", "remove");
7885
86+export const removeConnection = ExecutorApiClient.mutation(
87+ "connections",
88+ "remove",
89+);
90+
7991 export const removeSource = ExecutorApiClient.mutation("sources", "remove");
8092
8193 export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
packages/react/src/api/reactivity-keys.tsx+10−0
@@ -21,6 +21,7 @@ export const ReactivityKey = { · 20 unmodified lines
2121 sources: "sources",
2222 tools: "tools",
2323 secrets: "secrets",
24+ connections: "connections",
2425 scope: "scope",
2526 // cloud-only resources
2627 orgMembers: "org:members",
@@ -35,6 +36,15 @@ export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as c · 8 unmodified lines
3536 /** Mutations that mint or revoke secrets. */
3637 export const secretWriteKeys = [ReactivityKey.secrets] as const;
3738
39+/** Mutations that create or remove connections. Touches `secrets` too
40+ * because connection-owned secret rows are filtered out of the secrets
41+ * list — signing out should unhide them (in the future) or remove
42+ * them, and either way the secrets page needs to re-query. */
43+export const connectionWriteKeys = [
44+ ReactivityKey.connections,
45+ ReactivityKey.secrets,
46+] as const;
47+
3848 /** Mutations that change scope membership/info. */
3949 export const scopeWriteKeys = [ReactivityKey.scope] as const;
4050
packages/react/src/pages/connections.tsx+185−0
@@ -0,0 +1,185 @@
1+import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
2+import { ConnectionId } from "@executor/sdk";
3+
4+import { connectionsAtom, removeConnection } from "../api/atoms";
5+import { connectionWriteKeys } from "../api/reactivity-keys";
6+import { useScope } from "../hooks/use-scope";
7+import { Badge } from "../components/badge";
8+import { Button } from "../components/button";
9+import {
10+ CardStack,
11+ CardStackContent,
12+ CardStackEntry,
13+ CardStackEntryActions,
14+ CardStackEntryContent,
15+ CardStackEntryDescription,
16+ CardStackEntryTitle,
17+ CardStackHeader,
18+} from "../components/card-stack";
19+import {
20+ DropdownMenu,
21+ DropdownMenuContent,
22+ DropdownMenuItem,
23+ DropdownMenuTrigger,
24+} from "../components/dropdown-menu";
25+
26+// ---------------------------------------------------------------------------
27+// Provider display
28+// ---------------------------------------------------------------------------
29+
30+// Friendly labels for the internal provider keys minted by plugins.
31+// Falls through to the raw key so new providers still render something.
32+const providerDisplayNames: Record<string, string> = {
33+ "mcp:oauth2": "MCP",
34+ "openapi:oauth2": "OpenAPI",
35+ "google-discovery:oauth2": "Google",
36+};
37+
38+const displayProvider = (provider: string): string =>
39+ providerDisplayNames[provider] ?? provider;
40+
41+// ---------------------------------------------------------------------------
42+// Connection row
43+// ---------------------------------------------------------------------------
44+
45+function ConnectionRow(props: {
46+ connection: {
47+ id: string;
48+ provider: string;
49+ identityLabel: string | null;
50+ kind: "user" | "app";
51+ };
52+ onRemove: () => void;
53+}) {
54+ const { connection } = props;
55+ const displayLabel =
56+ connection.identityLabel && connection.identityLabel.length > 0
57+ ? connection.identityLabel
58+ : connection.id;
59+
60+ return (
61+ <CardStackEntry>
62+ <CardStackEntryContent>
63+ <CardStackEntryTitle className="flex items-center gap-2">
64+ <span className="truncate">{displayLabel}</span>
65+ </CardStackEntryTitle>
66+ <CardStackEntryDescription className="text-xs text-muted-foreground">
67+ {displayProvider(connection.provider)}
68+ </CardStackEntryDescription>
69+ </CardStackEntryContent>
70+ <CardStackEntryActions>
71+ <Badge variant="outline">{connection.kind}</Badge>
72+ <DropdownMenu>
73+ <DropdownMenuTrigger asChild>
74+ <Button
75+ variant="ghost"
76+ size="icon"
77+ className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"
78+ >
79+ <svg viewBox="0 0 16 16" className="size-3">
80+ <circle cx="8" cy="3" r="1.2" fill="currentColor" />
81+ <circle cx="8" cy="8" r="1.2" fill="currentColor" />
82+ <circle cx="8" cy="13" r="1.2" fill="currentColor" />
83+ </svg>
84+ </Button>
85+ </DropdownMenuTrigger>
86+ <DropdownMenuContent align="end" className="w-40">
87+ <DropdownMenuItem
88+ className="text-destructive focus:text-destructive text-sm"
89+ onClick={props.onRemove}
90+ >
91+ Remove
92+ </DropdownMenuItem>
93+ </DropdownMenuContent>
94+ </DropdownMenu>
95+ </CardStackEntryActions>
96+ </CardStackEntry>
97+ );
98+}
99+
100+// ---------------------------------------------------------------------------
101+// Page
102+// ---------------------------------------------------------------------------
103+
104+export function ConnectionsPage() {
105+ const scopeId = useScope();
106+ const connections = useAtomValue(connectionsAtom(scopeId));
107+ const doRemove = useAtomSet(removeConnection, { mode: "promise" });
108+
109+ const handleRemove = async (connectionId: string) => {
110+ try {
111+ await doRemove({
112+ path: { scopeId, connectionId: ConnectionId.make(connectionId) },
113+ reactivityKeys: connectionWriteKeys,
114+ });
115+ } catch {
116+ // TODO: toast
117+ }
118+ };
119+
120+ return (
121+ <div className="min-h-0 flex-1 overflow-y-auto">
122+ <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14">
123+ <div className="flex items-end justify-between mb-10">
124+ <div>
125+ <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none">
126+ Connections
127+ </h1>
128+ <p className="mt-2 text-sm text-muted-foreground leading-relaxed">
129+ Signed-in accounts your sources use to call their APIs.
130+ Remove a connection to revoke access and drop its tokens.
131+ </p>
132+ </div>
133+ </div>
134+
135+ {Result.match(connections, {
136+ onInitial: () => (
137+ <div className="flex items-center gap-2 py-8">
138+ <div className="size-1.5 rounded-full bg-muted-foreground/30 animate-pulse" />
139+ <p className="text-sm text-muted-foreground">
140+ Loading connections…
141+ </p>
142+ </div>
143+ ),
144+ onFailure: () => (
145+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
146+ <p className="text-sm text-destructive">
147+ Failed to load connections
148+ </p>
149+ </div>
150+ ),
151+ onSuccess: ({ value }) => (
152+ <CardStack>
153+ <CardStackHeader>Connections</CardStackHeader>
154+ <CardStackContent>
155+ {value.length === 0 ? (
156+ <CardStackEntry>
157+ <CardStackEntryContent>
158+ <CardStackEntryDescription>
159+ No signed-in accounts yet. Add an OAuth source and
160+ its sign-in will appear here.
161+ </CardStackEntryDescription>
162+ </CardStackEntryContent>
163+ </CardStackEntry>
164+ ) : (
165+ value.map((c) => (
166+ <ConnectionRow
167+ key={c.id}
168+ connection={{
169+ id: c.id,
170+ provider: c.provider,
171+ identityLabel: c.identityLabel,
172+ kind: c.kind,
173+ }}
174+ onRemove={() => handleRemove(c.id)}
175+ />
176+ ))
177+ )}
178+ </CardStackContent>
179+ </CardStack>
180+ ),
181+ })}
182+ </div>
183+ </div>
184+ );
185+}
packages/react/src/pages/source-detail.tsx+6−0
@@ -127,6 +127,12 @@ export function SourceDetailPage(props: { · 126 unmodified lines
127127 </div>
128128
129129 <div className="flex shrink-0 items-center gap-2">
130+ {editPlugin?.signIn && !editing && (
131+ <Suspense fallback={null}>
132+ <editPlugin.signIn sourceId={namespace} />
133+ </Suspense>
134+ )}
135+
130136 {canEdit && editPlugin && !editing && (
131137 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
132138 Edit
packages/react/src/plugins/source-plugin.tsx+13−0
@@ -74,6 +74,19 @@ export interface SourcePlugin { · 73 unmodified lines
7474 readonly sourceId: string;
7575 }>;
7676
77+ /**
78+ * Optional top-bar action for signing in / reconnecting to the source.
79+ * Rendered by the shell next to Edit when present. Plugins that don't
80+ * support interactive sign-in (no OAuth etc.) simply omit this.
81+ *
82+ * Deliberately separate from `edit` — edit is source config, sign-in is
83+ * the user-level connection lifecycle. A source can be valid-to-edit
84+ * while its sign-in is expired / missing, and vice versa.
85+ */
86+ readonly signIn?: ComponentType<{
87+ readonly sourceId: string;
88+ }>;
89+
7790 /** Curated presets shown on the sources page for quick-add */
7891 readonly presets?: readonly SourcePreset[];
7992 }