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 @@
1CREATE 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
17ALTER TABLE "secret" ADD COLUMN "owned_by_connection_id" text;--> statement-breakpoint
18CREATE INDEX "connection_scope_id_idx" ON "connection" USING btree ("scope_id");--> statement-breakpoint
19CREATE INDEX "connection_provider_idx" ON "connection" USING btree ("provider");--> statement-breakpoint
20CREATE 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
22 "when": 1776709954401,22 "when": 1776709954401,
23 "tag": "0002_fat_white_tiger",23 "tag": "0002_fat_white_tiger",
24 "breakpoints": true24 "breakpoints": true
25 },
26 {
27 "idx": 3,
28 "version": "7",
29 "when": 1776728656793,
30 "tag": "0003_add_connections",
31 "breakpoints": true
25 }32 }
26 ]33 ]
27}34}
apps/cloud/src/api/layers.ts+7−1
@@ -1,7 +1,7 @@
1import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform";1import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3 3
4import { CoreExecutorApi } from "@executor/api";4import { CoreExecutorApi, InternalError, observabilityMiddleware } from "@executor/api";
5import { CoreHandlers } from "@executor/api/server";5import { CoreHandlers } from "@executor/api/server";
6import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";6import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
7import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";7import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
@@ -19,6 +19,7 @@ import { DbService } from "../services/db"; · 11 unmodified lines
19import { TelemetryLive } from "../services/telemetry";19import { TelemetryLive } from "../services/telemetry";
20import { OrgHttpApi } from "../org/compose";20import { OrgHttpApi } from "../org/compose";
21import { OrgHandlers } from "../org/handlers";21import { OrgHandlers } from "../org/handlers";
22import { ErrorCaptureLive } from "../observability";
22 23
23import { CoreSharedServices } from "./core-shared-services";24import { CoreSharedServices } from "./core-shared-services";
24 25
@@ -27,8 +28,11 @@ export { CoreSharedServices }; · 2 unmodified lines
27const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)28const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
28 .add(McpGroup)29 .add(McpGroup)
29 .add(GraphqlGroup)30 .add(GraphqlGroup)
31 .addError(InternalError)
30 .middleware(OrgAuth);32 .middleware(OrgAuth);
31 33
34const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
35
32const DbLive = DbService.Live;36const DbLive = DbService.Live;
33const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));37const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
34 38
@@ -50,8 +54,10 @@ export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( · 15 unmodified lines
50 McpHandlers,54 McpHandlers,
51 GraphqlHandlers,55 GraphqlHandlers,
52 OrgAuthLive,56 OrgAuthLive,
57 ObservabilityLive,
53 ),58 ),
54 ),59 ),
60 Layer.provide(ErrorCaptureLive),
55);61);
56 62
57const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(63const 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
15 15
16import { ErrorCapture } from "@executor/api";16import { ErrorCapture } from "@executor/api";
17 17
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.
23const MAX_CONSOLE_CAUSE_CHARS = 4_000;
24
25const 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
18export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(30export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
19 ErrorCapture,31 ErrorCapture,
20 ErrorCapture.of({32 ErrorCapture.of({
21 captureException: (cause) =>33 captureException: (cause) =>
22 Effect.sync(() => {34 Effect.sync(() => {
23 const squashed = Cause.squash(cause);35 const squashed = Cause.squash(cause);
24 // Mirror to console so dev terminals see the defect; Sentry gets the36 const pretty = Cause.pretty(cause);
25 // full structured cause for production correlation.37 console.error("[api] unhandled cause:", truncate(pretty));
26 console.error("[api] unhandled cause:", Cause.pretty(cause));
27 const eventId = Sentry.captureException(squashed, (scope) => {38 const eventId = Sentry.captureException(squashed, (scope) => {
28 scope.setExtra("cause", Cause.pretty(cause));39 scope.setExtra("cause", pretty);
29 return scope;40 return scope;
30 });41 });
31 return eventId ?? "";42 return eventId ?? "";
apps/cloud/src/routeTree.gen.ts+21−0
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
12import { Route as ToolsRouteImport } from './routes/tools'12import { Route as ToolsRouteImport } from './routes/tools'
13import { Route as SecretsRouteImport } from './routes/secrets'13import { Route as SecretsRouteImport } from './routes/secrets'
14import { Route as OrgRouteImport } from './routes/org'14import { Route as OrgRouteImport } from './routes/org'
15import { Route as ConnectionsRouteImport } from './routes/connections'
15import { Route as BillingRouteImport } from './routes/billing'16import { Route as BillingRouteImport } from './routes/billing'
16import { Route as IndexRouteImport } from './routes/index'17import { Route as IndexRouteImport } from './routes/index'
17import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'18import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
@@ -33,6 +34,11 @@ const OrgRoute = OrgRouteImport.update({ · 15 unmodified lines
33 path: '/org',34 path: '/org',
34 getParentRoute: () => rootRouteImport,35 getParentRoute: () => rootRouteImport,
35} as any)36} as any)
37const ConnectionsRoute = ConnectionsRouteImport.update({
38 id: '/connections',
39 path: '/connections',
40 getParentRoute: () => rootRouteImport,
41} as any)
36const BillingRoute = BillingRouteImport.update({42const BillingRoute = BillingRouteImport.update({
37 id: '/billing',43 id: '/billing',
38 path: '/billing',44 path: '/billing',
@@ -62,6 +68,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ · 23 unmodified lines
62export interface FileRoutesByFullPath {68export interface FileRoutesByFullPath {
63 '/': typeof IndexRoute69 '/': typeof IndexRoute
64 '/billing': typeof BillingRoute70 '/billing': typeof BillingRoute
71 '/connections': typeof ConnectionsRoute
65 '/org': typeof OrgRoute72 '/org': typeof OrgRoute
66 '/secrets': typeof SecretsRoute73 '/secrets': typeof SecretsRoute
67 '/tools': typeof ToolsRoute74 '/tools': typeof ToolsRoute
@@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
72export interface FileRoutesByTo {79export interface FileRoutesByTo {
73 '/': typeof IndexRoute80 '/': typeof IndexRoute
74 '/billing': typeof BillingRoute81 '/billing': typeof BillingRoute
82 '/connections': typeof ConnectionsRoute
75 '/org': typeof OrgRoute83 '/org': typeof OrgRoute
76 '/secrets': typeof SecretsRoute84 '/secrets': typeof SecretsRoute
77 '/tools': typeof ToolsRoute85 '/tools': typeof ToolsRoute
@@ -83,6 +91,7 @@ export interface FileRoutesById { · 5 unmodified lines
83 __root__: typeof rootRouteImport91 __root__: typeof rootRouteImport
84 '/': typeof IndexRoute92 '/': typeof IndexRoute
85 '/billing': typeof BillingRoute93 '/billing': typeof BillingRoute
94 '/connections': typeof ConnectionsRoute
86 '/org': typeof OrgRoute95 '/org': typeof OrgRoute
87 '/secrets': typeof SecretsRoute96 '/secrets': typeof SecretsRoute
88 '/tools': typeof ToolsRoute97 '/tools': typeof ToolsRoute
@@ -95,6 +104,7 @@ export interface FileRouteTypes { · 6 unmodified lines
95 fullPaths:104 fullPaths:
96 | '/'105 | '/'
97 | '/billing'106 | '/billing'
107 | '/connections'
98 | '/org'108 | '/org'
99 | '/secrets'109 | '/secrets'
100 | '/tools'110 | '/tools'
@@ -105,6 +115,7 @@ export interface FileRouteTypes { · 4 unmodified lines
105 to:115 to:
106 | '/'116 | '/'
107 | '/billing'117 | '/billing'
118 | '/connections'
108 | '/org'119 | '/org'
109 | '/secrets'120 | '/secrets'
110 | '/tools'121 | '/tools'
@@ -115,6 +126,7 @@ export interface FileRouteTypes { · 4 unmodified lines
115 | '__root__'126 | '__root__'
116 | '/'127 | '/'
117 | '/billing'128 | '/billing'
129 | '/connections'
118 | '/org'130 | '/org'
119 | '/secrets'131 | '/secrets'
120 | '/tools'132 | '/tools'
@@ -126,6 +138,7 @@ export interface FileRouteTypes { · 5 unmodified lines
126export interface RootRouteChildren {138export interface RootRouteChildren {
127 IndexRoute: typeof IndexRoute139 IndexRoute: typeof IndexRoute
128 BillingRoute: typeof BillingRoute140 BillingRoute: typeof BillingRoute
141 ConnectionsRoute: typeof ConnectionsRoute
129 OrgRoute: typeof OrgRoute142 OrgRoute: typeof OrgRoute
130 SecretsRoute: typeof SecretsRoute143 SecretsRoute: typeof SecretsRoute
131 ToolsRoute: typeof ToolsRoute144 ToolsRoute: typeof ToolsRoute
@@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { · 25 unmodified lines
157 preLoaderRoute: typeof OrgRouteImport170 preLoaderRoute: typeof OrgRouteImport
158 parentRoute: typeof rootRouteImport171 parentRoute: typeof rootRouteImport
159 }172 }
173 '/connections': {
174 id: '/connections'
175 path: '/connections'
176 fullPath: '/connections'
177 preLoaderRoute: typeof ConnectionsRouteImport
178 parentRoute: typeof rootRouteImport
179 }
160 '/billing': {180 '/billing': {
161 id: '/billing'181 id: '/billing'
162 path: '/billing'182 path: '/billing'
@@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { · 35 unmodified lines
198const rootRouteChildren: RootRouteChildren = {218const rootRouteChildren: RootRouteChildren = {
199 IndexRoute: IndexRoute,219 IndexRoute: IndexRoute,
200 BillingRoute: BillingRoute,220 BillingRoute: BillingRoute,
221 ConnectionsRoute: ConnectionsRoute,
201 OrgRoute: OrgRoute,222 OrgRoute: OrgRoute,
202 SecretsRoute: SecretsRoute,223 SecretsRoute: SecretsRoute,
203 ToolsRoute: ToolsRoute,224 ToolsRoute: ToolsRoute,
apps/cloud/src/routes/connections.tsx+6−0
@@ -0,0 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { ConnectionsPage } from "@executor/react/pages/connections";
3
4export 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
56 scope_id: text('scope_id').notNull(),56 scope_id: text('scope_id').notNull(),
57 name: text('name').notNull(),57 name: text('name').notNull(),
58 provider: text('provider').notNull(),58 provider: text('provider').notNull(),
59 owned_by_connection_id: text('owned_by_connection_id'),
59 created_at: timestamp('created_at').notNull()60 created_at: timestamp('created_at').notNull()
60}, (table) => [61}, (table) => [
61 primaryKey({ columns: [table.scope_id, table.id] }),62 primaryKey({ columns: [table.scope_id, table.id] }),
62 index("secret_scope_id_idx").on(table.scope_id),63 index("secret_scope_id_idx").on(table.scope_id),
63 index("secret_provider_idx").on(table.provider),64 index("secret_provider_idx").on(table.provider),
65 index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id),
66]);
67
68export 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),
64]);85]);
65 86
66export const openapi_source = pgTable("openapi_source", {87export const openapi_source = pgTable("openapi_source", {
apps/cloud/src/services/mcp-oauth.node.test.ts+16−56
@@ -15,16 +15,16 @@ · 14 unmodified lines
15// Two scenarios:15// Two scenarios:
16//16//
17// 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts17// 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts
18// tokens landed in the invoker's secret store under the stable18// the response carries the Connection id the exchange minted and
19// `mcp_<ns>_*` ids and that source-level OAuth state (clientInfo,19// that source-level OAuth state (clientInfo, AS URL) is echoed back
20// AS URL) is echoed back on the response.20// so subsequent users can skip DCR.
21//21//
22// 2. Two users, same source: second user's startOAuth passes the22// 2. Two users, same source: second user's startOAuth passes the
23// clientInformation the first user's completeOAuth returned. The23// clientInformation the first user's completeOAuth returned. The
24// DCR endpoint counter on the fake server must NOT tick — proof24// DCR endpoint counter on the fake server must NOT tick — proof
25// the DCR-hoist code path re-uses the existing client. Then both25// the DCR-hoist code path re-uses the existing client. Both users
26// users' tokens must resolve independently at their own scope26// end up with their own Connection (same id, different scope) via
27// despite sharing the same secret id.27// the SDK's innermost-wins shadowing.
28// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
29 29
30import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";30import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
@@ -33,7 +33,7 @@ import type { AddressInfo } from "node:net"; · 2 unmodified lines
33import { createHash, randomBytes } from "node:crypto";33import { createHash, randomBytes } from "node:crypto";
34 34
35import { Effect } from "effect";35import { Effect } from "effect";
36import { ScopeId, SecretId } from "@executor/sdk";36import { ScopeId } from "@executor/sdk";
37 37
38import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";38import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
39 39
@@ -282,8 +282,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 242 unmodified lines
282 const userId = `user_${crypto.randomUUID()}`;282 const userId = `user_${crypto.randomUUID()}`;
283 const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId));283 const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId));
284 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;284 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
285 const accessTokenSecretId = `mcp_${namespace}_access_token`;285 const connectionId = `mcp-oauth2-${namespace}`;
286 const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
287 const redirectUrl = "http://test.local/api/mcp/oauth/callback";286 const redirectUrl = "http://test.local/api/mcp/oauth/callback";
288 287
289 const started = yield* asUser(userId, orgId, (client) =>288 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
292 payload: {291 payload: {
293 endpoint: `${fake.url}/mcp`,292 endpoint: `${fake.url}/mcp`,
294 redirectUrl,293 redirectUrl,
295 accessTokenSecretId,294 connectionId,
296 refreshTokenSecretId,
297 },295 },
298 }),296 }),
299 );297 );
@@ -310,34 +308,12 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 10 unmodified lines
310 payload: { state, code },308 payload: { state, code },
311 }),309 }),
312 );310 );
313 expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);311 expect(completed.connectionId).toBe(connectionId);
314 expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
315 expect(completed.tokenType).toBe("Bearer");312 expect(completed.tokenType).toBe("Bearer");
316 // DCR hoist: the source-level state the UI will persist on the313 // DCR hoist: the source-level state the UI will persist on the
317 // source's auth config, so the next user skips DCR entirely.314 // source's auth config, so the next user skips DCR entirely.
318 expect(completed.clientInformation).not.toBeNull();315 expect(completed.clientInformation).not.toBeNull();
319 expect(completed.authorizationServerUrl).not.toBeNull();316 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_/);
341 }),317 }),
342 30_000,318 30_000,
343 );319 );
@@ -352,8 +328,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 8 unmodified lines
352 const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId));328 const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId));
353 const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId));329 const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId));
354 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;330 const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
355 const accessTokenSecretId = `mcp_${namespace}_access_token`;331 const connectionId = `mcp-oauth2-${namespace}`;
356 const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
357 const endpoint = `${fake.url}/mcp`;332 const endpoint = `${fake.url}/mcp`;
358 const redirectUrl = "http://test.local/api/mcp/oauth/callback";333 const redirectUrl = "http://test.local/api/mcp/oauth/callback";
359 334
@@ -366,8 +341,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines
366 payload: {341 payload: {
367 endpoint,342 endpoint,
368 redirectUrl,343 redirectUrl,
369 accessTokenSecretId,344 connectionId,
370 refreshTokenSecretId,
371 },345 },
372 }),346 }),
373 );347 );
@@ -380,6 +354,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines
380 payload: { state: redirA.state, code: redirA.code },354 payload: { state: redirA.state, code: redirA.code },
381 }),355 }),
382 );356 );
357 expect(completedA.connectionId).toBe(connectionId);
383 expect(completedA.clientInformation).not.toBeNull();358 expect(completedA.clientInformation).not.toBeNull();
384 expect(fake.registrations()).toBe(regsBefore + 1);359 expect(fake.registrations()).toBe(regsBefore + 1);
385 360
@@ -390,8 +365,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 4 unmodified lines
390 payload: {365 payload: {
391 endpoint,366 endpoint,
392 redirectUrl,367 redirectUrl,
393 accessTokenSecretId,368 connectionId,
394 refreshTokenSecretId,
395 clientInformation: completedA.clientInformation,369 clientInformation: completedA.clientInformation,
396 authorizationServerUrl: completedA.authorizationServerUrl,370 authorizationServerUrl: completedA.authorizationServerUrl,
397 resourceMetadataUrl: completedA.resourceMetadataUrl,371 resourceMetadataUrl: completedA.resourceMetadataUrl,
@@ -401,28 +375,14 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 3 unmodified lines
401 const redirB = yield* Effect.promise(() =>375 const redirB = yield* Effect.promise(() =>
402 followAuthorize(startedB.authorizationUrl),376 followAuthorize(startedB.authorizationUrl),
403 );377 );
404 yield* asUser(userB, orgId, (client) =>378 const completedB = yield* asUser(userB, orgId, (client) =>
405 client.mcp.completeOAuth({379 client.mcp.completeOAuth({
406 path: { scopeId: scopeB },380 path: { scopeId: scopeB },
407 payload: { state: redirB.state, code: redirB.code },381 payload: { state: redirB.state, code: redirB.code },
408 }),382 }),
409 );383 );
384 expect(completedB.connectionId).toBe(connectionId);
410 expect(fake.registrations()).toBe(regsBefore + 1);385 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_/);
426 }),386 }),
427 30_000,387 30_000,
428 );388 );
apps/cloud/src/web/shell.tsx+2−0
@@ -360,6 +360,7 @@ function UserFooter() { · 359 unmodified lines
360function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {360function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {
361 const isHome = props.pathname === "/";361 const isHome = props.pathname === "/";
362 const isSecrets = props.pathname === "/secrets";362 const isSecrets = props.pathname === "/secrets";
363 const isConnections = props.pathname === "/connections";
363 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");364 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
364 const isOrg = props.pathname === "/org";365 const isOrg = props.pathname === "/org";
365 366
@@ -375,6 +376,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 9 unmodified lines
375 376
376 <nav className="flex flex-1 flex-col overflow-y-auto p-2">377 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
377 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />378 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
379 <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} />
378 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />380 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
379 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />381 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />
380 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />382 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
apps/local/drizzle/0002_lively_sue_storm.sql+20−0
@@ -0,0 +1,20 @@
1CREATE 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
17CREATE INDEX `connection_scope_id_idx` ON `connection` (`scope_id`);--> statement-breakpoint
18CREATE INDEX `connection_provider_idx` ON `connection` (`provider`);--> statement-breakpoint
19ALTER TABLE `secret` ADD `owned_by_connection_id` text;--> statement-breakpoint
20CREATE 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
15 "when": 1776680432025,15 "when": 1776680432025,
16 "tag": "0001_sour_catseye",16 "tag": "0001_sour_catseye",
17 "breakpoints": true17 "breakpoints": true
18 },
19 {
20 "idx": 2,
21 "version": "6",
22 "when": 1776732062873,
23 "tag": "0002_lively_sue_storm",
24 "breakpoints": true
18 }25 }
19 ]26 ]
20}27}
apps/local/src/server/executor-schema.ts+21−0
@@ -56,11 +56,32 @@ export const secret = sqliteTable("secret", { · 55 unmodified lines
56 scope_id: text('scope_id').notNull(),56 scope_id: text('scope_id').notNull(),
57 name: text('name').notNull(),57 name: text('name').notNull(),
58 provider: text('provider').notNull(),58 provider: text('provider').notNull(),
59 owned_by_connection_id: text('owned_by_connection_id'),
59 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()60 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()
60}, (table) => [61}, (table) => [
61 primaryKey({ columns: [table.scope_id, table.id] }),62 primaryKey({ columns: [table.scope_id, table.id] }),
62 index("secret_scope_id_idx").on(table.scope_id),63 index("secret_scope_id_idx").on(table.scope_id),
63 index("secret_provider_idx").on(table.provider),64 index("secret_provider_idx").on(table.provider),
65 index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id),
66]);
67
68export 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),
64]);85]);
65 86
66export const openapi_source = sqliteTable("openapi_source", {87export 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
55import {55import {
56 Scope,56 Scope,
57 ScopeId,57 ScopeId,
58 SecretId,
59 collectSchemas,58 collectSchemas,
60 createExecutor,59 createExecutor,
61} from "@executor/sdk";60} from "@executor/sdk";
@@ -382,14 +381,13 @@ const followAuthorize = async ( · 320 unmodified lines
382// ---------------------------------------------------------------------------381// ---------------------------------------------------------------------------
383 382
384describe("local mcp oauth (real OAuth + MCP server)", () => {383describe("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 () => {
386 const clientLayer = FetchHttpClient.layer.pipe(385 const clientLayer = FetchHttpClient.layer.pipe(
387 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)),386 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)),
388 );387 );
389 388
390 const namespace = `ns_${randomBytes(4).toString("hex")}`;389 const namespace = `ns_${randomBytes(4).toString("hex")}`;
391 const accessTokenSecretId = `mcp_${namespace}_access_token`;390 const connectionId = `mcp-oauth2-${namespace}`;
392 const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
393 const redirectUrl = "http://local.test/api/mcp/oauth/callback";391 const redirectUrl = "http://local.test/api/mcp/oauth/callback";
394 const scopeId = ScopeId.make(harness.scopeId);392 const scopeId = ScopeId.make(harness.scopeId);
395 393
@@ -410,8 +408,7 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 14 unmodified lines
410 payload: {408 payload: {
411 endpoint: `${fake.url}/mcp`,409 endpoint: `${fake.url}/mcp`,
412 redirectUrl,410 redirectUrl,
413 accessTokenSecretId,411 connectionId,
414 refreshTokenSecretId,
415 },412 },
416 }),413 }),
417 ),414 ),
@@ -429,30 +426,9 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 11 unmodified lines
429 }),426 }),
430 ),427 ),
431 );428 );
432 expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);429 expect(completed.connectionId).toBe(connectionId);
433 expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
434 expect(completed.tokenType).toBe("Bearer");430 expect(completed.tokenType).toBe("Bearer");
435 expect(completed.clientInformation).not.toBeNull();431 expect(completed.clientInformation).not.toBeNull();
436 expect(completed.authorizationServerUrl).not.toBeNull();432 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_/);
457 }, 30_000);433 }, 30_000);
458});434});
bun.lock+1−0
@@ -614,6 +614,7 @@ · 613 unmodified lines
614 "@effect/platform": "catalog:",614 "@effect/platform": "catalog:",
615 "@effect/platform-node": "catalog:",615 "@effect/platform-node": "catalog:",
616 "@executor/config": "workspace:*",616 "@executor/config": "workspace:*",
617 "@executor/plugin-oauth2": "workspace:*",
617 "@executor/sdk": "workspace:*",618 "@executor/sdk": "workspace:*",
618 "@modelcontextprotocol/sdk": "^1.29.0",619 "@modelcontextprotocol/sdk": "^1.29.0",
619 "effect": "catalog:",620 "effect": "catalog:",
packages/core/api/src/api.ts+2−0
@@ -4,6 +4,7 @@ import type { HttpApiGroup } from "@effect/platform"; · 3 unmodified lines
4import { ToolsApi } from "./tools/api";4import { ToolsApi } from "./tools/api";
5import { SourcesApi } from "./sources/api";5import { SourcesApi } from "./sources/api";
6import { SecretsApi } from "./secrets/api";6import { SecretsApi } from "./secrets/api";
7import { ConnectionsApi } from "./connections/api";
7import { ExecutionsApi } from "./executions/api";8import { ExecutionsApi } from "./executions/api";
8import { ScopeApi } from "./scope/api";9import { ScopeApi } from "./scope/api";
9 10
@@ -11,6 +12,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 1 unmodified line
11 .add(ToolsApi)12 .add(ToolsApi)
12 .add(SourcesApi)13 .add(SourcesApi)
13 .add(SecretsApi)14 .add(SecretsApi)
15 .add(ConnectionsApi)
14 .add(ExecutionsApi)16 .add(ExecutionsApi)
15 .add(ScopeApi)17 .add(ScopeApi)
16 .annotateContext(18 .annotateContext(
packages/core/api/src/connections/api.ts+57−0
@@ -0,0 +1,57 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3
4import {
5 ConnectionId,
6 ConnectionKind,
7 ScopeId,
8 SecretId,
9} from "@executor/sdk";
10
11import { InternalError } from "../observability";
12
13// ---------------------------------------------------------------------------
14// Params
15// ---------------------------------------------------------------------------
16
17const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
18const connectionIdParam = HttpApiSchema.param("connectionId", ConnectionId);
19
20// ---------------------------------------------------------------------------
21// Response schemas
22// ---------------------------------------------------------------------------
23
24const 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
42export 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 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Effect } from "effect";
3
4import { capture } from "@executor/api";
5import type { ConnectionRef } from "@executor/sdk";
6
7import { ExecutorApi } from "../api";
8import { ExecutorService } from "../services";
9
10const 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
24export 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
3import { ToolsHandlers } from "./tools";3import { ToolsHandlers } from "./tools";
4import { SourcesHandlers } from "./sources";4import { SourcesHandlers } from "./sources";
5import { SecretsHandlers } from "./secrets";5import { SecretsHandlers } from "./secrets";
6import { ConnectionsHandlers } from "./connections";
6import { ScopeHandlers } from "./scope";7import { ScopeHandlers } from "./scope";
7import { ExecutionsHandlers } from "./executions";8import { ExecutionsHandlers } from "./executions";
8 9
9export { ToolsHandlers } from "./tools";10export { ToolsHandlers } from "./tools";
10export { SourcesHandlers } from "./sources";11export { SourcesHandlers } from "./sources";
11export { SecretsHandlers } from "./secrets";12export { SecretsHandlers } from "./secrets";
13export { ConnectionsHandlers } from "./connections";
12export { ScopeHandlers } from "./scope";14export { ScopeHandlers } from "./scope";
13export { ExecutionsHandlers } from "./executions";15export { ExecutionsHandlers } from "./executions";
14 16
@@ -16,6 +18,7 @@ export const CoreHandlers = Layer.mergeAll( · 1 unmodified line
16 ToolsHandlers,18 ToolsHandlers,
17 SourcesHandlers,19 SourcesHandlers,
18 SecretsHandlers,20 SecretsHandlers,
21 ConnectionsHandlers,
19 ScopeHandlers,22 ScopeHandlers,
20 ExecutionsHandlers,23 ExecutionsHandlers,
21);24);
packages/core/api/src/index.ts+1−0
@@ -2,6 +2,7 @@ export { ExecutorApi, CoreExecutorApi, addGroup } from "./api"; · 1 unmodified line
2export { ToolsApi } from "./tools/api";2export { ToolsApi } from "./tools/api";
3export { SourcesApi } from "./sources/api";3export { SourcesApi } from "./sources/api";
4export { SecretsApi } from "./secrets/api";4export { SecretsApi } from "./secrets/api";
5export { ConnectionsApi } from "./connections/api";
5export { ExecutionsApi } from "./executions/api";6export { ExecutionsApi } from "./executions/api";
6export { ScopeApi } from "./scope/api";7export { ScopeApi } from "./scope/api";
7export {8export {
packages/core/api/src/secrets/api.ts+12−2
@@ -1,6 +1,12 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";3import {
4 ScopeId,
5 SecretId,
6 SecretNotFoundError,
7 SecretOwnedByConnectionError,
8 SecretResolutionError,
9} from "@executor/sdk";
4 10
5import { InternalError } from "../observability";11import { InternalError } from "../observability";
6 12
@@ -48,6 +54,9 @@ const SecretNotFound = SecretNotFoundError.annotations(HttpApiSchema.annotations · 41 unmodified lines
48const SecretResolution = SecretResolutionError.annotations(54const SecretResolution = SecretResolutionError.annotations(
49 HttpApiSchema.annotations({ status: 500 }),55 HttpApiSchema.annotations({ status: 500 }),
50);56);
57const SecretOwnedByConnection = SecretOwnedByConnectionError.annotations(
58 HttpApiSchema.annotations({ status: 409 }),
59);
51 60
52// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
53// Group62// Group
@@ -79,6 +88,7 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 25 unmodified lines
79 .add(88 .add(
80 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`89 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`
81 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))90 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
82 .addError(SecretNotFound),91 .addError(SecretNotFound)
92 .addError(SecretOwnedByConnection),
83 )93 )
84 .addError(InternalError) {}94 .addError(InternalError) {}
packages/core/config/src/schema.ts+4−3
@@ -59,9 +59,10 @@ export const McpAuthConfig = Schema.Union( · 58 unmodified lines
59 }),59 }),
60 Schema.Struct({60 Schema.Struct({
61 kind: Schema.Literal("oauth2"),61 kind: Schema.Literal("oauth2"),
62 accessTokenSecret: Schema.String,62 /** Stable id of the SDK Connection holding access + refresh token
63 refreshTokenSecret: Schema.optional(Schema.NullOr(Schema.String)),63 * material. Scope shadowing means the same id resolves per-user
64 tokenType: Schema.optional(Schema.String),64 * via the executor's innermost-wins lookup. */
65 connectionId: Schema.String,
65 }),66 }),
66);67);
67export type McpAuthConfig = typeof McpAuthConfig.Type;68export 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 @@
1import { Data, Effect, Schema } from "effect";
2
3import { 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. */
18export const ConnectionProviderState = Schema.Record({
19 key: Schema.String,
20 value: Schema.Unknown,
21});
22export type ConnectionProviderState = typeof ConnectionProviderState.Type;
23
24export const ConnectionKind = Schema.Literal("user", "app");
25export 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
33export 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
63export 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
73export 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
98export 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
113export 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
137export 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
157export 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
171export 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
84 // provider key). Actual values never touch this table — they live in84 // provider key). Actual values never touch this table — they live in
85 // the secret provider (keychain, 1password, file, etc.) and are85 // the secret provider (keychain, 1password, file, etc.) and are
86 // resolved on demand via `ctx.secrets.get(id)`.86 // 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.
87 secret: {95 secret: {
88 fields: {96 fields: {
89 id: { type: "string", required: true },97 id: { type: "string", required: true },
90 scope_id: { type: "string", required: true, index: true },98 scope_id: { type: "string", required: true, index: true },
91 name: { type: "string", required: true },99 name: { type: "string", required: true },
92 provider: { type: "string", required: true, index: true },100 provider: { type: "string", required: true, index: true },
101 owned_by_connection_id: {
102 type: "string",
103 required: false,
104 index: true,
105 },
93 created_at: { type: "date", required: true },106 created_at: { type: "date", required: true },
94 },107 },
95 },108 },
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 },
96} as const satisfies DBSchema;155} as const satisfies DBSchema;
97 156
98export type CoreSchema = typeof coreSchema;157export type CoreSchema = typeof coreSchema;
@@ -116,6 +175,11 @@ export type DefinitionRow = InferDBFieldsOutput< · 17 unmodified lines
116export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> &175export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> &
117 Record<string, unknown>;176 Record<string, unknown>;
118 177
178export type ConnectionRow = InferDBFieldsOutput<
179 CoreSchema["connection"]["fields"]
180> &
181 Record<string, unknown>;
182
119// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
120// Tool annotations — default-policy metadata the executor consults184// Tool annotations — default-policy metadata the executor consults
121// before invocation. Returned by `plugin.resolveAnnotations` (dynamic185// before invocation. Returned by `plugin.resolveAnnotations` (dynamic
packages/core/sdk/src/errors.ts+43−2
@@ -1,6 +1,6 @@
1import { Data, Schema } from "effect";1import { Data, Schema } from "effect";
2 2
3import { ToolId, SecretId } from "./ids";3import { ConnectionId, ToolId, SecretId } from "./ids";
4 4
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6// Tool lifecycle6// Tool lifecycle
@@ -74,6 +74,43 @@ export class SecretResolutionError extends Schema.TaggedError<SecretResolutionEr · 67 unmodified lines
74 },74 },
75) {}75) {}
76 76
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. */
81export class SecretOwnedByConnectionError extends Schema.TaggedError<SecretOwnedByConnectionError>()(
82 "SecretOwnedByConnectionError",
83 {
84 secretId: SecretId,
85 connectionId: ConnectionId,
86 },
87) {}
88
89// ---------------------------------------------------------------------------
90// Connections
91// ---------------------------------------------------------------------------
92
93export class ConnectionNotFoundError extends Schema.TaggedError<ConnectionNotFoundError>()(
94 "ConnectionNotFoundError",
95 { connectionId: ConnectionId },
96) {}
97
98export class ConnectionProviderNotRegisteredError extends Schema.TaggedError<ConnectionProviderNotRegisteredError>()(
99 "ConnectionProviderNotRegisteredError",
100 {
101 provider: Schema.String,
102 connectionId: Schema.optional(ConnectionId),
103 },
104) {}
105
106export class ConnectionRefreshNotSupportedError extends Schema.TaggedError<ConnectionRefreshNotSupportedError>()(
107 "ConnectionRefreshNotSupportedError",
108 {
109 connectionId: ConnectionId,
110 provider: Schema.String,
111 },
112) {}
113
77// ---------------------------------------------------------------------------114// ---------------------------------------------------------------------------
78// Union type for convenience in signatures.115// Union type for convenience in signatures.
79// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
@@ -86,4 +123,8 @@ export type ExecutorError = · 6 unmodified lines
86 | SourceNotFoundError123 | SourceNotFoundError
87 | SourceRemovalNotAllowedError124 | SourceRemovalNotAllowedError
88 | SecretNotFoundError125 | 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
13 pluginBlobStore,13 pluginBlobStore,
14 type BlobStore,14 type BlobStore,
15} from "./blob";15} from "./blob";
16import {
17 ConnectionProviderState,
18 ConnectionRef,
19 ConnectionRefreshError,
20 type ConnectionProvider,
21 type CreateConnectionInput,
22 type UpdateConnectionTokensInput,
23} from "./connections";
16import {24import {
17 coreSchema,25 coreSchema,
26 type ConnectionRow,
18 type CoreSchema,27 type CoreSchema,
19 type DefinitionsInput,28 type DefinitionsInput,
20 type SourceInput,29 type SourceInput,
@@ -30,13 +39,17 @@ import { · 9 unmodified lines
30 type ElicitationRequest,39 type ElicitationRequest,
31} from "./elicitation";40} from "./elicitation";
32import {41import {
42 ConnectionNotFoundError,
43 ConnectionProviderNotRegisteredError,
44 ConnectionRefreshNotSupportedError,
33 NoHandlerError,45 NoHandlerError,
34 PluginNotLoadedError,46 PluginNotLoadedError,
47 SecretOwnedByConnectionError,
35 SourceRemovalNotAllowedError,48 SourceRemovalNotAllowedError,
36 ToolInvocationError,49 ToolInvocationError,
37 ToolNotFoundError,50 ToolNotFoundError,
38} from "./errors";51} from "./errors";
39import { ScopeId, SecretId, ToolId } from "./ids";52import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids";
40import type {53import type {
41 AnyPlugin,54 AnyPlugin,
42 Elicit,55 Elicit,
@@ -168,11 +181,51 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 125 unmodified lines
168 readonly set: (181 readonly set: (
169 input: SetSecretInput,182 input: SetSecretInput,
170 ) => Effect.Effect<SecretRef, StorageFailure>;183 ) => 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>;
172 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;190 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
173 readonly providers: () => Effect.Effect<readonly string[]>;191 readonly providers: () => Effect.Effect<readonly string[]>;
174 };192 };
175 193
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
176 readonly close: () => Effect.Effect<void, StorageFailure>;229 readonly close: () => Effect.Effect<void, StorageFailure>;
177} & PluginExtensions<TPlugins>;230} & PluginExtensions<TPlugins>;
178 231
@@ -547,6 +600,9 @@ export const createExecutor = < · 368 unmodified lines
547 const runtimes = new Map<string, PluginRuntime>();600 const runtimes = new Map<string, PluginRuntime>();
548 // Secret providers keyed by `provider.key`.601 // Secret providers keyed by `provider.key`.
549 const secretProviders = new Map<string, SecretProvider>();602 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>();
550 const extensions: Record<string, object> = {};606 const extensions: Record<string, object> = {};
551 607
552 // ------------------------------------------------------------------608 // ------------------------------------------------------------------
@@ -743,7 +799,9 @@ export const createExecutor = < · 190 unmodified lines
743 });799 });
744 });800 });
745 801
746 const secretsRemove = (id: string): Effect.Effect<void, StorageFailure> =>802 const secretsRemove = (
803 id: string,
804 ): Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure> =>
747 Effect.gen(function* () {805 Effect.gen(function* () {
748 // Remove is shadowing-aware: drop only the innermost-scope row.806 // Remove is shadowing-aware: drop only the innermost-scope row.
749 // Removing a user-scope override on a secret that also has an807 // Removing a user-scope override on a secret that also has an
@@ -757,6 +815,18 @@ export const createExecutor = < · 7 unmodified lines
757 where: [{ field: "id", value: id }],815 where: [{ field: "id", value: id }],
758 });816 });
759 const target = findInnermost(rows);817 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 }
760 const targetScope = (target?.scope_id as string | undefined) ??830 const targetScope = (target?.scope_id as string | undefined) ??
761 scopeIds[0]!;831 scopeIds[0]!;
762 832
@@ -808,8 +878,18 @@ export const createExecutor = < · 45 unmodified lines
808 878
809 // Core routing rows first. Adapter returns rows from every879 // Core routing rows first. Adapter returns rows from every
810 // scope in the stack; resolve collisions using the caller's880 // scope in the stack; resolve collisions using the caller's
811 // precedence order (innermost first).881 // precedence order (innermost first). Rows owned by a
812 const rows = yield* core.findMany({ model: "secret" });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);
813 const precedence = new Map<string, number>();893 const precedence = new Map<string, number>();
814 scopeIds.forEach((id, index) => precedence.set(id, index));894 scopeIds.forEach((id, index) => precedence.set(id, index));
815 const pick = (row: typeof rows[number]) => {895 const pick = (row: typeof rows[number]) => {
@@ -859,6 +939,7 @@ export const createExecutor = < · 43 unmodified lines
859 for (const { key, entries } of lists) {939 for (const { key, entries } of lists) {
860 for (const entry of entries) {940 for (const entry of entries) {
861 if (byId.has(entry.id)) continue; // core row wins941 if (byId.has(entry.id)) continue; // core row wins
942 if (connectionOwnedIds.has(entry.id)) continue; // hidden by connection
862 byId.set(943 byId.set(
863 entry.id,944 entry.id,
864 new SecretRef({945 new SecretRef({
@@ -887,6 +968,525 @@ export const createExecutor = < · 22 unmodified lines
887 }));968 }));
888 });969 });
889 970
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
890 // ------------------------------------------------------------------1490 // ------------------------------------------------------------------
891 // Plugin wiring — build ctx, run extension, populate static pools,1491 // Plugin wiring — build ctx, run extension, populate static pools,
892 // register secret providers. No adapter reads here.1492 // register secret providers. No adapter reads here.
@@ -996,6 +1596,16 @@ export const createExecutor = < · 103 unmodified lines
996 set: (input) => secretsSet(input),1596 set: (input) => secretsSet(input),
997 remove: (id) => secretsRemove(id),1597 remove: (id) => secretsRemove(id),
998 },1598 },
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 },
999 // Open one real tx boundary and route every nested write inside1609 // Open one real tx boundary and route every nested write inside
1000 // `effect` through that same handle via the activeAdapterRef —1610 // `effect` through that same handle via the activeAdapterRef —
1001 // see buildAdapterRouter above. Caller-typed errors (`E`)1611 // see buildAdapterRouter above. Caller-typed errors (`E`)
@@ -1070,6 +1680,24 @@ export const createExecutor = < · 68 unmodified lines
1070 secretProviders.set(provider.key, provider);1680 secretProviders.set(provider.key, provider);
1071 }1681 }
1072 }1682 }
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 }
1073 }1701 }
1074 1702
1075 // ------------------------------------------------------------------1703 // ------------------------------------------------------------------
@@ -1647,6 +2275,20 @@ export const createExecutor = < · 571 unmodified lines
1647 () => Array.from(secretProviders.keys()) as readonly string[],2275 () => Array.from(secretProviders.keys()) as readonly string[],
1648 ),2276 ),
1649 },2277 },
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 },
1650 close,2292 close,
1651 };2293 };
1652 2294
packages/core/sdk/src/ids.ts+3−0
@@ -11,3 +11,6 @@ export type SecretId = typeof SecretId.Type; · 10 unmodified lines
11 11
12export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId"));12export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId"));
13export type PolicyId = typeof PolicyId.Type;13export type PolicyId = typeof PolicyId.Type;
14
15export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId"));
16export 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
23export { StorageError, UniqueViolationError } from "@executor/storage-core";23export { StorageError, UniqueViolationError } from "@executor/storage-core";
24 24
25// IDs (branded)25// IDs (branded)
26export { ScopeId, ToolId, SecretId, PolicyId } from "./ids";26export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids";
27 27
28// Scope28// Scope
29export { Scope } from "./scope";29export { Scope } from "./scope";
@@ -38,6 +38,10 @@ export { · 8 unmodified lines
38 PluginNotLoadedError,38 PluginNotLoadedError,
39 SecretNotFoundError,39 SecretNotFoundError,
40 SecretResolutionError,40 SecretResolutionError,
41 SecretOwnedByConnectionError,
42 ConnectionNotFoundError,
43 ConnectionProviderNotRegisteredError,
44 ConnectionRefreshNotSupportedError,
41 type ExecutorError,45 type ExecutorError,
42} from "./errors";46} from "./errors";
43 47
@@ -60,6 +64,7 @@ export { · 16 unmodified lines
60 type ToolRow,64 type ToolRow,
61 type DefinitionRow,65 type DefinitionRow,
62 type SecretRow,66 type SecretRow,
67 type ConnectionRow,
63 type DefinitionsInput,68 type DefinitionsInput,
64 type ToolAnnotations,69 type ToolAnnotations,
65} from "./core-schema";70} from "./core-schema";
@@ -71,6 +76,20 @@ export { · 5 unmodified lines
71 type SecretProvider,76 type SecretProvider,
72} from "./secrets";77} from "./secrets";
73 78
79// Connections
80export {
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
74// Elicitation93// Elicitation
75export {94export {
76 FormElicitation,95 FormElicitation,
packages/core/sdk/src/plugin.ts+76−1
@@ -7,6 +7,13 @@ import type { · 6 unmodified lines
7} from "@executor/storage-core";7} from "@executor/storage-core";
8 8
9import type { PluginBlobStore } from "./blob";9import type { PluginBlobStore } from "./blob";
10import type {
11 ConnectionProvider,
12 ConnectionRef,
13 ConnectionRefreshError,
14 CreateConnectionInput,
15 UpdateConnectionTokensInput,
16} from "./connections";
10import type {17import type {
11 DefinitionsInput,18 DefinitionsInput,
12 SourceInput,19 SourceInput,
@@ -20,6 +27,12 @@ import type { · 7 unmodified lines
20 ElicitationRequest,27 ElicitationRequest,
21 ElicitationResponse,28 ElicitationResponse,
22} from "./elicitation";29} from "./elicitation";
30import type {
31 ConnectionNotFoundError,
32 ConnectionProviderNotRegisteredError,
33 ConnectionRefreshNotSupportedError,
34 SecretOwnedByConnectionError,
35} from "./errors";
23import type { Scope } from "./scope";36import type { Scope } from "./scope";
24import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";37import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";
25 38
@@ -119,6 +132,9 @@ export interface PluginCtx<TStore = unknown> { · 93 unmodified lines
119 readonly get: (132 readonly get: (
120 id: string,133 id: string,
121 ) => Effect.Effect<string | null, StorageFailure>;134 ) => 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. */
122 readonly list: () => Effect.Effect<138 readonly list: () => Effect.Effect<
123 readonly { readonly id: string; readonly name: string; readonly provider: string }[],139 readonly { readonly id: string; readonly name: string; readonly provider: string }[],
124 StorageFailure140 StorageFailure
@@ -132,7 +148,54 @@ export interface PluginCtx<TStore = unknown> { · 7 unmodified lines
132 readonly set: (148 readonly set: (
133 input: SetSecretInput,149 input: SetSecretInput,
134 ) => Effect.Effect<SecretRef, StorageFailure>;150 ) => 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 >;
136 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;199 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
137 };200 };
138 201
@@ -331,6 +394,18 @@ export interface PluginSpec< · 192 unmodified lines
331 ctx: PluginCtx<TStore>,394 ctx: PluginCtx<TStore>,
332 ) => Effect.Effect<readonly SecretProvider[]>);395 ) => Effect.Effect<readonly SecretProvider[]>);
333 396
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
334 readonly close?: () => Effect.Effect<void, unknown>;409 readonly close?: () => Effect.Effect<void, unknown>;
335}410}
336 411
packages/core/storage-drizzle/src/adapter.ts+24−3
@@ -260,19 +260,40 @@ const isUniqueViolation = (cause: unknown): boolean => { · 259 unmodified lines
260 return false;260 return false;
261};261};
262 262
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.
268const 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
263const classifyError = (283const classifyError = (
264 op: string,284 op: string,
265 model: string | undefined,285 model: string | undefined,
266 cause: unknown,286 cause: unknown,
267): StorageFailure => {287): StorageFailure => {
268 if (isUniqueViolation(cause)) {288 const driverCause = unwrapDriverCause(cause);
289 if (isUniqueViolation(driverCause)) {
269 return model !== undefined290 return model !== undefined
270 ? new UniqueViolationError({ model })291 ? new UniqueViolationError({ model })
271 : new UniqueViolationError({});292 : new UniqueViolationError({});
272 }293 }
273 return new StorageError({294 return new StorageError({
274 message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`,295 message: `[storage-drizzle] ${op} failed: ${driverCause instanceof Error ? driverCause.message : String(driverCause)}`,
275 cause,296 cause: driverCause,
276 });297 });
277};298};
278 299
packages/plugins/google-discovery/src/api/group.ts+22−10
@@ -21,13 +21,9 @@ const AuthPayload = Schema.Union( · 20 unmodified lines
21 }),21 }),
22 Schema.Struct({22 Schema.Struct({
23 kind: Schema.Literal("oauth2"),23 kind: Schema.Literal("oauth2"),
24 connectionId: Schema.String,
24 clientIdSecretId: Schema.String,25 clientIdSecretId: Schema.String,
25 clientSecretSecretId: Schema.NullOr(Schema.String),26 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),
31 scopes: Schema.Array(Schema.String),27 scopes: Schema.Array(Schema.String),
32 }),28 }),
33);29);
@@ -65,6 +61,15 @@ const AddSourceResponse = Schema.Struct({ · 31 unmodified lines
65 namespace: Schema.String,61 namespace: Schema.String,
66});62});
67 63
64const UpdateSourcePayload = Schema.Struct({
65 name: Schema.optional(Schema.String),
66 auth: Schema.optional(AuthPayload),
67});
68
69const UpdateSourceResponse = Schema.Struct({
70 updated: Schema.Boolean,
71});
72
68const StartOAuthPayload = Schema.Struct({73const StartOAuthPayload = Schema.Struct({
69 name: Schema.String,74 name: Schema.String,
70 discoveryUrl: Schema.String,75 discoveryUrl: Schema.String,
@@ -72,6 +77,8 @@ const StartOAuthPayload = Schema.Struct({ · 1 unmodified line
72 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),77 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),
73 redirectUrl: Schema.String,78 redirectUrl: Schema.String,
74 scopes: Schema.optional(Schema.Array(Schema.String)),79 scopes: Schema.optional(Schema.Array(Schema.String)),
80 /** Optional executor scope that will own the resulting Connection. */
81 tokenScope: Schema.optional(Schema.String),
75});82});
76 83
77const StartOAuthResponse = Schema.Struct({84const StartOAuthResponse = Schema.Struct({
@@ -88,13 +95,11 @@ const CompleteOAuthPayload = Schema.Struct({ · 10 unmodified lines
88 95
89const CompleteOAuthResponse = Schema.Struct({96const CompleteOAuthResponse = Schema.Struct({
90 kind: Schema.Literal("oauth2"),97 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,
91 clientIdSecretId: Schema.String,101 clientIdSecretId: Schema.String,
92 clientSecretSecretId: Schema.NullOr(Schema.String),102 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),
98 scopes: Schema.Array(Schema.String),103 scopes: Schema.Array(Schema.String),
99});104});
100 105
@@ -138,6 +143,13 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 37 unmodified lines
138 .setPayload(AddSourcePayload)143 .setPayload(AddSourcePayload)
139 .addSuccess(AddSourceResponse),144 .addSuccess(AddSourceResponse),
140 )145 )
146 .add(
147 HttpApiEndpoint.patch(
148 "updateSource",
149 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
150 .setPayload(UpdateSourcePayload)
151 .addSuccess(UpdateSourceResponse),
152 )
141 .add(153 .add(
142 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`154 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
143 .setPayload(StartOAuthPayload)155 .setPayload(StartOAuthPayload)
packages/plugins/google-discovery/src/api/handlers.test.ts+1−0
@@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = { · 25 unmodified lines
26 startOAuth: () => unused,26 startOAuth: () => unused,
27 completeOAuth: () => Effect.die(new Error("Not implemented")),27 completeOAuth: () => Effect.die(new Error("Not implemented")),
28 getSource: (_namespace: string, _scope: string) => Effect.succeed(null),28 getSource: (_namespace: string, _scope: string) => Effect.succeed(null),
29 updateSource: () => unused,
29};30};
30 31
31const Api = addGroup(GoogleDiscoveryGroup);32const 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
84 clientSecretSecretId: payload.clientSecretSecretId,84 clientSecretSecretId: payload.clientSecretSecretId,
85 redirectUrl: payload.redirectUrl,85 redirectUrl: payload.redirectUrl,
86 scopes: payload.scopes,86 scopes: payload.scopes,
87 tokenScope: payload.tokenScope,
87 });88 });
88 })),89 })),
89 )90 )
@@ -103,6 +104,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines
103 return yield* ext.getSource(path.namespace, path.scopeId);104 return yield* ext.getSource(path.namespace, path.scopeId);
104 })),105 })),
105 )106 )
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 )
106 .handle("oauthCallback", ({ urlParams }) =>117 .handle("oauthCallback", ({ urlParams }) =>
107 capture(Effect.gen(function* () {118 capture(Effect.gen(function* () {
108 const ext = yield* GoogleDiscoveryExtensionService;119 const ext = yield* GoogleDiscoveryExtensionService;
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+11−13
@@ -368,13 +368,9 @@ type ProbeResult = { · 367 unmodified lines
368 368
369type OAuthAuth = {369type OAuthAuth = {
370 kind: "oauth2";370 kind: "oauth2";
371 connectionId: string;
371 clientIdSecretId: string;372 clientIdSecretId: string;
372 clientSecretSecretId: string | null;373 clientSecretSecretId: string | null;
373 accessTokenSecretId: string;
374 refreshTokenSecretId: string | null;
375 tokenType: string;
376 expiresAt: number | null;
377 scope: string | null;
378 scopes: string[];374 scopes: string[];
379};375};
380 376
@@ -522,13 +518,9 @@ export default function AddGoogleDiscoverySource(props: { · 141 unmodified lines
522 if (result.ok) {518 if (result.ok) {
523 setOauthAuth({519 setOauthAuth({
524 kind: "oauth2",520 kind: "oauth2",
521 connectionId: result.connectionId,
525 clientIdSecretId: result.clientIdSecretId,522 clientIdSecretId: result.clientIdSecretId,
526 clientSecretSecretId: result.clientSecretSecretId,523 clientSecretSecretId: result.clientSecretSecretId,
527 accessTokenSecretId: result.accessTokenSecretId,
528 refreshTokenSecretId: result.refreshTokenSecretId,
529 tokenType: result.tokenType,
530 expiresAt: result.expiresAt,
531 scope: result.scope,
532 scopes: [...result.scopes],524 scopes: [...result.scopes],
533 });525 });
534 setError(null);526 setError(null);
@@ -573,8 +565,14 @@ export default function AddGoogleDiscoverySource(props: { · 38 unmodified lines
573 discoveryUrl: discoveryUrl.trim(),565 discoveryUrl: discoveryUrl.trim(),
574 namespace: slugifyNamespace(identity.namespace) || undefined,566 namespace: slugifyNamespace(identity.namespace) || undefined,
575 auth:567 auth:
576 authKind === "oauth2"568 authKind === "oauth2" && oauthAuth
577 ? (oauthAuth ?? { kind: "none" as const })569 ? {
570 kind: "oauth2" as const,
571 connectionId: oauthAuth.connectionId,
572 clientIdSecretId: oauthAuth.clientIdSecretId,
573 clientSecretSecretId: oauthAuth.clientSecretSecretId,
574 scopes: oauthAuth.scopes,
575 }
578 : { kind: "none" as const },576 : { kind: "none" as const },
579 },577 },
580 reactivityKeys: [...sourceWriteKeys],578 reactivityKeys: [...sourceWriteKeys],
@@ -790,7 +788,7 @@ export default function AddGoogleDiscoverySource(props: { · 209 unmodified lines
790 </Collapsible>788 </Collapsible>
791 {oauthAuth && (789 {oauthAuth && (
792 <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">790 <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.
794 </div>792 </div>
795 )}793 )}
796 </div>794 </div>
packages/plugins/google-discovery/src/react/GoogleDiscoverySignInButton.tsx+159−0
@@ -0,0 +1,159 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3
4import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5import { useScope } from "@executor/react/api/scope-context";
6import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7import { connectionsAtom } from "@executor/react/api/atoms";
8import { Button } from "@executor/react/components/button";
9
10import {
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
27const CALLBACK_PATH = "/api/google-discovery/oauth/callback";
28const POPUP_NAME = "google-discovery-oauth";
29const CHANNEL_NAME = "executor:google-discovery-oauth-result";
30
31type GoogleOAuthPopupPayload = {
32 kind: "oauth2";
33 connectionId: string;
34 clientIdSecretId: string;
35 clientSecretSecretId: string | null;
36 scopes: readonly string[];
37};
38
39export 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
17 "googleDiscovery",17 "googleDiscovery",
18 "addSource",18 "addSource",
19);19);
20export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
21 "googleDiscovery",
22 "updateSource",
23);
20export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(24export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
21 "googleDiscovery",25 "googleDiscovery",
22 "startOAuth",26 "startOAuth",
packages/plugins/google-discovery/src/react/source-plugin.ts+1−0
@@ -8,5 +8,6 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 7 unmodified lines
8 add: lazy(() => import("./AddGoogleDiscoverySource")),8 add: lazy(() => import("./AddGoogleDiscoverySource")),
9 edit: lazy(() => import("./EditGoogleDiscoverySource")),9 edit: lazy(() => import("./EditGoogleDiscoverySource")),
10 summary: lazy(() => import("./GoogleDiscoverySourceSummary")),10 summary: lazy(() => import("./GoogleDiscoverySourceSummary")),
11 signIn: lazy(() => import("./GoogleDiscoverySignInButton")),
11 presets: googleDiscoveryPresets,12 presets: googleDiscoveryPresets,
12};13};
packages/plugins/google-discovery/src/sdk/binding-store.ts+38−0
@@ -154,6 +154,14 @@ export interface GoogleDiscoveryStore { · 153 unmodified lines
154 readonly putSource: (154 readonly putSource: (
155 source: GoogleDiscoveryStoredSource,155 source: GoogleDiscoveryStoredSource,
156 ) => Effect.Effect<void, StorageFailure>;156 ) => 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>;
157 readonly removeSource: (165 readonly removeSource: (
158 sourceId: string,166 sourceId: string,
159 scope: string,167 scope: string,
@@ -288,6 +296,36 @@ export const makeGoogleDiscoveryStore = ( · 128 unmodified lines
288 });296 });
289 }),297 }),
290 298
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
291 removeSource: (sourceId, scope) =>329 removeSource: (sourceId, scope) =>
292 db330 db
293 .delete({331 .delete({
packages/plugins/google-discovery/src/sdk/invoke.ts+17−113
@@ -1,15 +1,7 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
3 3
4import {
5 withRefreshedAccessToken,
6 type OAuth2SecretsIO,
7} from "@executor/plugin-oauth2";
8
9import type { PluginCtx, StorageFailure } from "@executor/sdk";4import type { PluginCtx, StorageFailure } from "@executor/sdk";
10import { SetSecretInput } from "@executor/sdk";
11
12import { GOOGLE_TOKEN_URL } from "./oauth";
13 5
14import {6import {
15 GoogleDiscoveryInvocationError,7 GoogleDiscoveryInvocationError,
@@ -18,8 +10,8 @@ import { · 2 unmodified lines
18import type { GoogleDiscoveryStore } from "./binding-store";10import type { GoogleDiscoveryStore } from "./binding-store";
19import {11import {
20 GoogleDiscoveryInvocationResult,12 GoogleDiscoveryInvocationResult,
21 GoogleDiscoveryStoredSourceData,
22 type GoogleDiscoveryParameter,13 type GoogleDiscoveryParameter,
14 type GoogleDiscoveryStoredSourceData,
23} from "./types";15} from "./types";
24 16
25const SAFE_METHODS = new Set(["get", "head", "options"]);17const SAFE_METHODS = new Set(["get", "head", "options"]);
@@ -35,36 +27,6 @@ export const annotationsForOperation = ( · 9 unmodified lines
35 };27 };
36};28};
37 29
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
44const 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
68// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
69// Path / query parameter helpers (unchanged from the old invoker)31// Path / query parameter helpers (unchanged from the old invoker)
70// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
@@ -113,65 +75,6 @@ const isJsonContentType = (contentType: string | null | undefined): boolean => { · 42 unmodified lines
113 );75 );
114};76};
115 77
116// ---------------------------------------------------------------------------
117// Resolve (and lazily refresh) an OAuth2 access token for a stored source.
118// ---------------------------------------------------------------------------
119
120const 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
175// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
176// HTTP request builder / executor79// HTTP request builder / executor
177// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
@@ -280,10 +183,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 102 unmodified lines
280export const invokeGoogleDiscoveryTool = (input: {183export const invokeGoogleDiscoveryTool = (input: {
281 ctx: PluginCtx<GoogleDiscoveryStore>;184 ctx: PluginCtx<GoogleDiscoveryStore>;
282 toolId: string;185 toolId: string;
283 /** Resolved owning scope of the tool row (innermost-wins from the186 /** Resolved owning scope of the tool row. */
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. */
287 toolScope: string;187 toolScope: string;
288 args: unknown;188 args: unknown;
289 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;189 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
@@ -314,18 +214,22 @@ export const invokeGoogleDiscoveryTool = (input: { · 24 unmodified lines
314 }214 }
315 const source = stored.config;215 const source = stored.config;
316 216
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
327 const authHeader =217 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;
329 233
330 const layer = input.httpClientLayer ?? FetchHttpClient.layer;234 const layer = input.httpClientLayer ?? FetchHttpClient.layer;
331 235
packages/plugins/google-discovery/src/sdk/plugin.test.ts+42−23
@@ -7,12 +7,15 @@ import { Effect } from "effect"; · 6 unmodified lines
7import { vi } from "vitest";7import { vi } from "vitest";
8 8
9import {9import {
10 ConnectionId,
11 CreateConnectionInput,
10 createExecutor,12 createExecutor,
11 makeTestConfig,13 makeTestConfig,
12 Scope,14 Scope,
13 ScopeId,15 ScopeId,
14 SecretId,16 SecretId,
15 SetSecretInput,17 SetSecretInput,
18 TokenMaterial,
16 type InvokeOptions,19 type InvokeOptions,
17} from "@executor/sdk";20} from "@executor/sdk";
18 21
@@ -300,13 +303,22 @@ describe("Google Discovery plugin", () => { · 281 unmodified lines
300 });303 });
301 304
302 expect(auth.kind).toBe("oauth2");305 expect(auth.kind).toBe("oauth2");
303 expect(auth.clientIdSecretId).toBe("google-client-id");306 expect(auth.connectionId).toMatch(/^google-discovery-oauth2-/);
304 expect(auth.refreshTokenSecretId).not.toBeNull();
305 307
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 );
307 expect(accessToken).toBe("access-token-value");313 expect(accessToken).toBe("access-token-value");
308 const refreshToken = yield* executor.secrets.get(auth.refreshTokenSecretId!);314
309 expect(refreshToken).toBe("refresh-token-value");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`);
310 } finally {322 } finally {
311 fetchMock.mockRestore();323 fetchMock.mockRestore();
312 yield* executor.close();324 yield* executor.close();
@@ -328,20 +340,31 @@ describe("Google Discovery plugin", () => { · 15 unmodified lines
328 );340 );
329 341
330 try {342 try {
331 yield* executor.secrets.set(343 // A connection wraps the access token (+ optional refresh) and
332 new SetSecretInput({344 // the invoke path resolves via ctx.connections.accessToken.
333 id: SecretId.make("drive-access-token"),345 const connectionId = ConnectionId.make(
334 scope: "test-scope" as SetSecretInput["scope"],346 "google-discovery-oauth2-test",
335 name: "Drive Access Token",
336 value: "secret-token",
337 }),
338 );347 );
339 yield* executor.secrets.set(348 yield* executor.connections.create(
340 new SetSecretInput({349 new CreateConnectionInput({
341 id: SecretId.make("drive-client-id"),350 id: connectionId,
342 scope: "test-scope" as SetSecretInput["scope"],351 scope: ScopeId.make("test-scope"),
343 name: "Drive Client ID",352 provider: "google-discovery:oauth2",
344 value: "client-123",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 },
345 }),368 }),
346 );369 );
347 370
@@ -352,13 +375,9 @@ describe("Google Discovery plugin", () => { · 4 unmodified lines
352 namespace: "drive",375 namespace: "drive",
353 auth: {376 auth: {
354 kind: "oauth2",377 kind: "oauth2",
378 connectionId,
355 clientIdSecretId: "drive-client-id",379 clientIdSecretId: "drive-client-id",
356 clientSecretSecretId: null,380 clientSecretSecretId: null,
357 accessTokenSecretId: "drive-access-token",
358 refreshTokenSecretId: null,
359 tokenType: "Bearer",
360 expiresAt: null,
361 scope: null,
362 scopes: ["https://www.googleapis.com/auth/drive.readonly"],381 scopes: ["https://www.googleapis.com/auth/drive.readonly"],
363 },382 },
364 });383 });
packages/plugins/google-discovery/src/sdk/plugin.ts+281−134
@@ -1,13 +1,25 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2 2
3import { Effect, Option } from "effect";3import { Effect, Option, Schema } from "effect";
4 4
5import { storeOAuthTokens } from "@executor/plugin-oauth2";5import {
6 OAuth2Error,
7 refreshAccessToken,
8 type OAuth2TokenResponse,
9} from "@executor/plugin-oauth2";
6 10
7import {11import {
8 definePlugin,12 ConnectionId,
9 SetSecretInput,13 ConnectionRefreshError,
14 CreateConnectionInput,
15 ScopeId,
16 SecretId,
10 SourceDetectionResult,17 SourceDetectionResult,
18 TokenMaterial,
19 definePlugin,
20 type ConnectionProvider,
21 type ConnectionRefreshInput,
22 type ConnectionRefreshResult,
11 type PluginCtx,23 type PluginCtx,
12 type StorageFailure,24 type StorageFailure,
13 type ToolAnnotations,25 type ToolAnnotations,
@@ -30,6 +42,7 @@ import { · 16 unmodified lines
30 buildGoogleAuthorizationUrl,42 buildGoogleAuthorizationUrl,
31 createPkceCodeVerifier,43 createPkceCodeVerifier,
32 exchangeAuthorizationCode,44 exchangeAuthorizationCode,
45 GOOGLE_TOKEN_URL,
33} from "./oauth";46} from "./oauth";
34import type {47import type {
35 GoogleDiscoveryAuth,48 GoogleDiscoveryAuth,
@@ -41,7 +54,7 @@ import type { · 5 unmodified lines
41import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";54import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
42 55
43// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
44// Public input / output shapes (unchanged from the old plugin)57// Public input / output shapes
45// ---------------------------------------------------------------------------58// ---------------------------------------------------------------------------
46 59
47export interface GoogleDiscoveryProbeOperation {60export interface GoogleDiscoveryProbeOperation {
@@ -69,6 +82,13 @@ export interface GoogleDiscoveryAddSourceInput { · 21 unmodified lines
69 readonly auth: GoogleDiscoveryAuth;82 readonly auth: GoogleDiscoveryAuth;
70}83}
71 84
85export 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
72export interface GoogleDiscoveryOAuthStartInput {92export interface GoogleDiscoveryOAuthStartInput {
73 readonly name: string;93 readonly name: string;
74 readonly discoveryUrl: string;94 readonly discoveryUrl: string;
@@ -76,6 +96,9 @@ export interface GoogleDiscoveryOAuthStartInput { · 1 unmodified line
76 readonly clientSecretSecretId?: string | null;96 readonly clientSecretSecretId?: string | null;
77 readonly redirectUrl: string;97 readonly redirectUrl: string;
78 readonly scopes?: readonly string[];98 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;
79}102}
80 103
81export interface GoogleDiscoveryOAuthStartResponse {104export interface GoogleDiscoveryOAuthStartResponse {
@@ -90,28 +113,23 @@ export interface GoogleDiscoveryOAuthCompleteInput { · 8 unmodified lines
90 readonly error?: string;113 readonly error?: string;
91}114}
92 115
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 */
93export interface GoogleDiscoveryOAuthAuthResult {123export interface GoogleDiscoveryOAuthAuthResult {
94 readonly kind: "oauth2";124 readonly kind: "oauth2";
125 readonly connectionId: string;
95 readonly clientIdSecretId: string;126 readonly clientIdSecretId: string;
96 readonly clientSecretSecretId: string | null;127 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;
102 readonly scopes: readonly string[];128 readonly scopes: readonly string[];
103}129}
104 130
105/**131/**
106 * Errors any Google Discovery extension method may surface. The first132 * Errors any Google Discovery extension method may surface.
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.
115 */133 */
116export type GoogleDiscoveryExtensionFailure =134export type GoogleDiscoveryExtensionFailure =
117 | GoogleDiscoveryParseError135 | GoogleDiscoveryParseError
@@ -155,6 +173,11 @@ export interface GoogleDiscoveryPluginExtension { · 37 unmodified lines
155 namespace: string,173 namespace: string,
156 scope: string,174 scope: string,
157 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;175 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
176 readonly updateSource: (
177 namespace: string,
178 scope: string,
179 input: GoogleDiscoveryUpdateSourceInput,
180 ) => Effect.Effect<void, StorageFailure>;
158}181}
159 182
160// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
@@ -220,6 +243,29 @@ const deriveNamespace = (input: { name: string; service: string; version: string · 59 unmodified lines
220 input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,243 input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
221 ) || `google_${input.service}`;244 ) || `google_${input.service}`;
222 245
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
252const GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY = "google-discovery:oauth2" as const;
253
254const OAuth2ProviderState = Schema.Struct({
255 clientIdSecretId: Schema.String,
256 clientSecretSecretId: Schema.NullOr(Schema.String),
257 scopes: Schema.Array(Schema.String),
258});
259type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
260
261const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
262const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
263
264const toProviderStateRecord = (
265 state: OAuth2ProviderState,
266): Record<string, unknown> =>
267 encodeProviderState(state) as unknown as Record<string, unknown>;
268
223// ---------------------------------------------------------------------------269// ---------------------------------------------------------------------------
224// Register a parsed manifest against the executor core + plugin storage.270// Register a parsed manifest against the executor core + plugin storage.
225// Runs inside a transaction.271// Runs inside a transaction.
@@ -233,13 +279,9 @@ const registerManifest = ( · 7 unmodified lines
233 sourceData: GoogleDiscoveryStoredSourceData,279 sourceData: GoogleDiscoveryStoredSourceData,
234) =>280) =>
235 Effect.gen(function* () {281 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.
239 yield* ctx.storage.removeBindingsBySource(namespace, scope);282 yield* ctx.storage.removeBindingsBySource(namespace, scope);
240 yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore);283 yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore);
241 284
242 // 2. Register the source + tool rows in core.
243 yield* ctx.core.sources.register({285 yield* ctx.core.sources.register({
244 id: namespace,286 id: namespace,
245 scope,287 scope,
@@ -257,7 +299,6 @@ const registerManifest = ( · 11 unmodified lines
257 })),299 })),
258 });300 });
259 301
260 // 3. Register shared $defs, if any.
261 if (Object.keys(manifest.schemaDefinitions).length > 0) {302 if (Object.keys(manifest.schemaDefinitions).length > 0) {
262 yield* ctx.core.definitions.register({303 yield* ctx.core.definitions.register({
263 sourceId: namespace,304 sourceId: namespace,
@@ -266,8 +307,6 @@ const registerManifest = ( · 2 unmodified lines
266 });307 });
267 }308 }
268 309
269 // 4. Write per-tool bindings to plugin storage (keyed by the same
270 // ${source_id}.${name} tool id the executor synthesizes).
271 yield* Effect.forEach(310 yield* Effect.forEach(
272 manifest.methods,311 manifest.methods,
273 (method) =>312 (method) =>
@@ -280,7 +319,6 @@ const registerManifest = ( · 6 unmodified lines
280 { discard: true },319 { discard: true },
281 );320 );
282 321
283 // 5. Write the source config blob.
284 yield* ctx.storage.putSource({322 yield* ctx.storage.putSource({
285 namespace,323 namespace,
286 scope,324 scope,
@@ -291,32 +329,6 @@ const registerManifest = ( · 4 unmodified lines
291 return manifest.methods.length;329 return manifest.methods.length;
292 });330 });
293 331
294// ---------------------------------------------------------------------------
295// Mint a fresh secret via ctx.secrets.set and return {id} for storeOAuthTokens.
296// ---------------------------------------------------------------------------
297
298const 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
320// ---------------------------------------------------------------------------332// ---------------------------------------------------------------------------
321// Plugin333// Plugin
322// ---------------------------------------------------------------------------334// ---------------------------------------------------------------------------
@@ -418,6 +430,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 95 unmodified lines
418 }430 }
419 const sessionId = randomUUID();431 const sessionId = randomUUID();
420 const codeVerifier = createPkceCodeVerifier();432 const codeVerifier = createPkceCodeVerifier();
433 const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
434 const connectionId = `google-discovery-oauth2-${randomUUID()}`;
421 yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {435 yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {
422 discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),436 discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
423 name: input.name,437 name: input.name,
@@ -426,6 +440,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 2 unmodified lines
426 redirectUrl: input.redirectUrl,440 redirectUrl: input.redirectUrl,
427 scopes,441 scopes,
428 codeVerifier,442 codeVerifier,
443 tokenScope,
444 connectionId,
429 });445 });
430 return {446 return {
431 sessionId,447 sessionId,
@@ -441,102 +457,138 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 9 unmodified lines
441 }),457 }),
442 458
443 completeOAuth: (input) =>459 completeOAuth: (input) =>
444 Effect.gen(function* () {460 ctx.transaction(
445 const session = yield* ctx.storage.getOAuthSession(input.state);461 Effect.gen(function* () {
446 if (!session) {462 const session = yield* ctx.storage.getOAuthSession(input.state);
447 return yield* new GoogleDiscoveryOAuthError({463 if (!session) {
448 message: "OAuth session not found or has expired",464 return yield* new GoogleDiscoveryOAuthError({
449 });465 message: "OAuth session not found or has expired",
450 }466 });
451 yield* ctx.storage.deleteOAuthSession(input.state);467 }
452 468 yield* ctx.storage.deleteOAuthSession(input.state);
453 if (input.error) {469
454 return yield* new GoogleDiscoveryOAuthError({ message: input.error });470 if (input.error) {
455 }471 return yield* new GoogleDiscoveryOAuthError({ message: input.error });
456 if (!input.code) {472 }
457 return yield* new GoogleDiscoveryOAuthError({473 if (!input.code) {
458 message: "OAuth callback did not include an authorization code",474 return yield* new GoogleDiscoveryOAuthError({
459 });475 message: "OAuth callback did not include an authorization code",
460 }476 });
477 }
461 478
462 const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId);479 const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId);
463 if (clientIdValue === null) {480 if (clientIdValue === null) {
464 return yield* new GoogleDiscoveryOAuthError({481 return yield* new GoogleDiscoveryOAuthError({
465 message: `OAuth client ID secret not found: ${session.clientIdSecretId}`,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,
466 });507 });
467 }
468 508
469 const clientSecretValue =509 const expiresAt =
470 session.clientSecretSecretId === null510 typeof tokenResponse.expires_in === "number"
471 ? null511 ? Date.now() + tokenResponse.expires_in * 1000
472 : yield* ctx.secrets.get(session.clientSecretSecretId).pipe(512 : null;
473 Effect.flatMap((v) =>513
474 v === null514 const providerState: OAuth2ProviderState = {
475 ? Effect.fail(515 clientIdSecretId: session.clientIdSecretId,
476 new GoogleDiscoveryOAuthError({516 clientSecretSecretId: session.clientSecretSecretId,
477 message: `OAuth client secret not found: ${session.clientSecretSecretId}`,517 scopes: [...session.scopes],
478 }),518 };
479 )519
480 : Effect.succeed(v),520 yield* ctx.connections
481 ),521 .create(
482 );522 new CreateConnectionInput({
483 523 id: ConnectionId.make(session.connectionId),
484 const tokenResponse = yield* exchangeAuthorizationCode({524 scope: ScopeId.make(session.tokenScope),
485 clientId: clientIdValue,525 provider: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY,
486 clientSecret: clientSecretValue,526 kind: "user",
487 redirectUrl: session.redirectUrl,527 identityLabel: session.name,
488 codeVerifier: session.codeVerifier,528 accessToken: new TokenMaterial({
489 code: input.code,529 secretId: SecretId.make(`${session.connectionId}.access_token`),
490 });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 ),
491 572
492 const stored = yield* storeOAuthTokens({573 getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope),
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 );
502 574
503 return {575 updateSource: (namespace, scope, input) =>
504 kind: "oauth2" as const,576 ctx.storage.updateSourceMeta(namespace, scope, {
505 clientIdSecretId: session.clientIdSecretId,577 name: input.name?.trim() || undefined,
506 clientSecretSecretId: session.clientSecretSecretId,578 auth: input.auth,
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 };
514 }),579 }),
515
516 getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope),
517 } satisfies GoogleDiscoveryPluginExtension),580 } satisfies GoogleDiscoveryPluginExtension),
518 581
519 invokeTool: ({ ctx, toolRow, args }) =>582 invokeTool: ({ ctx, toolRow, args }) =>
520 invokeGoogleDiscoveryTool({583 invokeGoogleDiscoveryTool({
521 ctx: ctx as PluginCtx<GoogleDiscoveryStore>,584 ctx: ctx as PluginCtx<GoogleDiscoveryStore>,
522 toolId: toolRow.id,585 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.
528 toolScope: toolRow.scope_id as string,586 toolScope: toolRow.scope_id as string,
529 args,587 args,
530 }),588 }),
531 589
532 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>590 resolveAnnotations: ({ ctx, sourceId, toolRows }) =>
533 Effect.gen(function* () {591 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.
540 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;592 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
541 const scopes = new Set<string>();593 const scopes = new Set<string>();
542 for (const row of toolRows) scopes.add(row.scope_id as string);594 for (const row of toolRows) scopes.add(row.scope_id as string);
@@ -605,9 +657,6 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 62 unmodified lines
605 refreshSource: ({ ctx, sourceId, scope }) =>657 refreshSource: ({ ctx, sourceId, scope }) =>
606 Effect.gen(function* () {658 Effect.gen(function* () {
607 const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;659 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.
611 const existing = yield* typedCtx.storage.getSource(sourceId, scope);660 const existing = yield* typedCtx.storage.getSource(sourceId, scope);
612 if (!existing) return;661 if (!existing) return;
613 const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl);662 const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl);
@@ -621,4 +670,102 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 7 unmodified lines
621 });670 });
622 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);671 yield* registerManifest(typedCtx, sourceId, scope, manifest, next);
623 }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))),672 }).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 ],
624}));771}));
packages/plugins/google-discovery/src/sdk/types.ts+23−5
@@ -60,19 +60,31 @@ export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifes · 59 unmodified lines
60 methods: Schema.Array(GoogleDiscoveryManifestMethod),60 methods: Schema.Array(GoogleDiscoveryManifestMethod),
61}) {}61}) {}
62 62
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
63export const GoogleDiscoveryAuth = Schema.Union(74export const GoogleDiscoveryAuth = Schema.Union(
64 Schema.Struct({75 Schema.Struct({
65 kind: Schema.Literal("none"),76 kind: Schema.Literal("none"),
66 }),77 }),
67 Schema.Struct({78 Schema.Struct({
68 kind: Schema.Literal("oauth2"),79 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. */
69 clientIdSecretId: Schema.String,84 clientIdSecretId: Schema.String,
85 /** Secret id holding the OAuth client_secret. Null for public clients. */
70 clientSecretSecretId: Schema.NullOr(Schema.String),86 clientSecretSecretId: Schema.NullOr(Schema.String),
71 accessTokenSecretId: Schema.String,87 /** Scopes requested on sign-in. */
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),
76 scopes: Schema.Array(Schema.String),88 scopes: Schema.Array(Schema.String),
77 }),89 }),
78);90);
@@ -113,5 +125,11 @@ export const GoogleDiscoveryOAuthSession = Schema.Struct({ · 34 unmodified lines
113 redirectUrl: Schema.String,125 redirectUrl: Schema.String,
114 scopes: Schema.Array(Schema.String),126 scopes: Schema.Array(Schema.String),
115 codeVerifier: Schema.String,127 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,
116});134});
117export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type;135export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type;
packages/plugins/mcp/package.json+1−0
@@ -50,6 +50,7 @@ · 49 unmodified lines
50 "@effect/platform": "catalog:",50 "@effect/platform": "catalog:",
51 "@effect/platform-node": "catalog:",51 "@effect/platform-node": "catalog:",
52 "@executor/config": "workspace:*",52 "@executor/config": "workspace:*",
53 "@executor/plugin-oauth2": "workspace:*",
53 "@executor/sdk": "workspace:*",54 "@executor/sdk": "workspace:*",
54 "@modelcontextprotocol/sdk": "^1.29.0",55 "@modelcontextprotocol/sdk": "^1.29.0",
55 "effect": "catalog:"56 "effect": "catalog:"
packages/plugins/mcp/src/api/group.ts+16−12
@@ -36,14 +36,10 @@ const AuthPayload = Schema.Union( · 35 unmodified lines
36 }),36 }),
37 Schema.Struct({37 Schema.Struct({
38 kind: Schema.Literal("oauth2"),38 kind: Schema.Literal("oauth2"),
39 accessTokenSecretId: Schema.String,39 /** Stable id of the SDK Connection minted by `completeOAuth`. The
40 refreshTokenSecretId: Schema.NullOr(Schema.String),40 * backing access/refresh secrets live on the connection row; the
41 tokenType: Schema.optional(Schema.String),41 * source only needs this pointer. */
42 expiresAt: Schema.NullOr(Schema.Number),42 connectionId: Schema.String,
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)),
47 }),43 }),
48);44);
49 45
@@ -113,8 +109,12 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
113 endpoint: Schema.String,109 endpoint: Schema.String,
114 redirectUrl: Schema.String,110 redirectUrl: Schema.String,
115 queryParams: Schema.optional(Schema.NullOr(StringMap)),111 queryParams: Schema.optional(Schema.NullOr(StringMap)),
116 accessTokenSecretId: Schema.String,112 /** Pre-decided SDK connection id the exchange will mint. Caller
117 refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),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,
118 /** Source-level OAuth state captured by a previous user's flow. When118 /** Source-level OAuth state captured by a previous user's flow. When
119 * passed, DCR is skipped — the same client_id is re-used so the119 * passed, DCR is skipped — the same client_id is re-used so the
120 * source's auth config stays stable across users. */120 * source's auth config stays stable across users. */
@@ -161,11 +161,15 @@ const StartOAuthResponse = Schema.Struct({ · 40 unmodified lines
161});161});
162 162
163const CompleteOAuthResponse = Schema.Struct({163const CompleteOAuthResponse = Schema.Struct({
164 accessTokenSecretId: Schema.String,164 /** Id of the SDK Connection minted by the exchange. The UI stores it
165 refreshTokenSecretId: Schema.NullOr(Schema.String),165 * on the source's auth config as `{kind: "oauth2", connectionId}`. */
166 connectionId: Schema.String,
166 tokenType: Schema.String,167 tokenType: Schema.String,
167 expiresAt: Schema.NullOr(Schema.Number),168 expiresAt: Schema.NullOr(Schema.Number),
168 scope: Schema.NullOr(Schema.String),169 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. */
169 clientInformation: Schema.NullOr(JsonObject),173 clientInformation: Schema.NullOr(JsonObject),
170 authorizationServerUrl: Schema.NullOr(Schema.String),174 authorizationServerUrl: Schema.NullOr(Schema.String),
171 resourceMetadataUrl: Schema.NullOr(Schema.String),175 resourceMetadataUrl: Schema.NullOr(Schema.String),
packages/plugins/mcp/src/api/handlers.ts+10−16
@@ -34,11 +34,13 @@ type OAuthPopupResult = · 33 unmodified lines
34 type: "executor:oauth-result";34 type: "executor:oauth-result";
35 ok: true;35 ok: true;
36 sessionId: string;36 sessionId: string;
37 accessTokenSecretId: string;37 connectionId: string;
38 refreshTokenSecretId: string | null;
39 tokenType: string;38 tokenType: string;
40 expiresAt: number | null;39 expiresAt: number | null;
41 scope: string | null;40 scope: string | null;
41 clientInformation: Record<string, unknown> | null;
42 authorizationServerUrl: string | null;
43 resourceMetadataUrl: string | null;
42 }44 }
43 | {45 | {
44 type: "executor:oauth-result";46 type: "executor:oauth-result";
@@ -130,15 +132,6 @@ const toSourceConfig = ( · 85 unmodified lines
130 auth?: { kind: string } & Record<string, unknown>;132 auth?: { kind: string } & Record<string, unknown>;
131 };133 };
132 134
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
142 return {135 return {
143 transport: "remote",136 transport: "remote",
144 scope,137 scope,
@@ -148,7 +141,7 @@ const toSourceConfig = ( · 3 unmodified lines
148 queryParams: p.queryParams,141 queryParams: p.queryParams,
149 headers: p.headers,142 headers: p.headers,
150 namespace: p.namespace,143 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,
152 };145 };
153};146};
154 147
@@ -204,8 +197,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 49 unmodified lines
204 endpoint: payload.endpoint,197 endpoint: payload.endpoint,
205 redirectUrl: payload.redirectUrl,198 redirectUrl: payload.redirectUrl,
206 queryParams: payload.queryParams,199 queryParams: payload.queryParams,
207 accessTokenSecretId: payload.accessTokenSecretId,200 connectionId: payload.connectionId,
208 refreshTokenSecretId: payload.refreshTokenSecretId,
209 clientInformation: payload.clientInformation,201 clientInformation: payload.clientInformation,
210 authorizationServerUrl: payload.authorizationServerUrl,202 authorizationServerUrl: payload.authorizationServerUrl,
211 resourceMetadataUrl: payload.resourceMetadataUrl,203 resourceMetadataUrl: payload.resourceMetadataUrl,
@@ -259,11 +251,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 47 unmodified lines
259 type: "executor:oauth-result",251 type: "executor:oauth-result",
260 ok: true,252 ok: true,
261 sessionId: urlParams.state,253 sessionId: urlParams.state,
262 accessTokenSecretId: c.accessTokenSecretId,254 connectionId: c.connectionId,
263 refreshTokenSecretId: c.refreshTokenSecretId,
264 tokenType: c.tokenType,255 tokenType: c.tokenType,
265 expiresAt: c.expiresAt,256 expiresAt: c.expiresAt,
266 scope: c.scope,257 scope: c.scope,
258 clientInformation: c.clientInformation,
259 authorizationServerUrl: c.authorizationServerUrl,
260 resourceMetadataUrl: c.resourceMetadataUrl,
267 }),261 }),
268 onFailure: (cause) =>262 onFailure: (cause) =>
269 Effect.succeed<OAuthPopupResult>({263 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
49 return mcpPresets.find((p) => p.id === id);49 return mcpPresets.find((p) => p.id === id);
50}50}
51 51
52// Stable secret ids for an MCP source's OAuth access/refresh tokens.52// Stable SDK connection id for an MCP source's OAuth sign-in. The same
53// The same ids are passed into `startOAuth` AND stored on the source's53// id is passed into `startOAuth` AND stored on the source's auth
54// auth config, so at invoke time the per-user scope (which actually54// config, so at invoke time the per-user scope (which owns the user's
55// holds the value) resolves via shadowing over the source-level id.55// own secrets) resolves via shadowing over the source-level pointer.
56// Keyed by namespace so reconnecting overwrites the previous token56// Keyed by namespace so reconnecting mints a new connection that
57// rather than leaking orphaned secret rows.57// shadows the previous one at the same scope.
58const mcpOAuthTokenSecretIds = (58const mcpOAuthConnectionId = (namespaceSlug: string): string =>
59 namespaceSlug: string,59 `mcp-oauth2-${namespaceSlug || "default"}`;
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};
67 60
68// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
69// State machine (remote flow)62// State machine (remote flow)
70// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
71 64
72type OAuthTokens = {65type OAuthTokens = {
73 accessTokenSecretId: string;66 /** Id of the SDK Connection minted by the exchange. The UI stores it
74 refreshTokenSecretId: string | null;67 * on the source's auth config as `{kind: "oauth2", connectionId}`. */
68 connectionId: string;
75 tokenType: string;69 tokenType: string;
76 expiresAt: number | null;70 expiresAt: number | null;
77 scope: string | null;71 scope: string | null;
@@ -445,14 +439,13 @@ export default function AddMcpSource(props: { · 367 unmodified lines
445 slugifyNamespace(remoteIdentity.namespace) ||439 slugifyNamespace(remoteIdentity.namespace) ||
446 slugifyNamespace(probe?.namespace ?? "") ||440 slugifyNamespace(probe?.namespace ?? "") ||
447 "mcp";441 "mcp";
448 const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug);442 const connectionId = mcpOAuthConnectionId(namespaceSlug);
449 const result = await doStartOAuth({443 const result = await doStartOAuth({
450 path: { scopeId },444 path: { scopeId },
451 payload: {445 payload: {
452 endpoint: state.url.trim(),446 endpoint: state.url.trim(),
453 redirectUrl,447 redirectUrl,
454 accessTokenSecretId: tokenIds.accessTokenSecretId,448 connectionId,
455 refreshTokenSecretId: tokenIds.refreshTokenSecretId,
456 },449 },
457 });450 });
458 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });451 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
@@ -464,8 +457,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines
464 dispatch({457 dispatch({
465 type: "oauth-ok",458 type: "oauth-ok",
466 tokens: {459 tokens: {
467 accessTokenSecretId: data.accessTokenSecretId,460 connectionId: data.connectionId,
468 refreshTokenSecretId: data.refreshTokenSecretId,
469 tokenType: data.tokenType,461 tokenType: data.tokenType,
470 expiresAt: data.expiresAt,462 expiresAt: data.expiresAt,
471 scope: data.scope,463 scope: data.scope,
@@ -512,14 +504,7 @@ export default function AddMcpSource(props: { · 40 unmodified lines
512 : remoteAuthMode === "oauth2" && tokens504 : remoteAuthMode === "oauth2" && tokens
513 ? {505 ? {
514 kind: "oauth2" as const,506 kind: "oauth2" as const,
515 accessTokenSecretId: tokens.accessTokenSecretId,507 connectionId: tokens.connectionId,
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,
523 }508 }
524 : { kind: "none" as const };509 : { kind: "none" as const };
525 const headers = Object.fromEntries(510 const headers = Object.fromEntries(
packages/plugins/mcp/src/react/McpSignInButton.tsx+153−0
@@ -0,0 +1,153 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3
4import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5import { useScope } from "@executor/react/api/scope-context";
6import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7import { connectionsAtom } from "@executor/react/api/atoms";
8import { Button } from "@executor/react/components/button";
9import { slugifyNamespace } from "@executor/react/plugins/source-identity";
10
11import { 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
23const CALLBACK_PATH = "/api/mcp/oauth/callback";
24const POPUP_NAME = "mcp-oauth";
25const CHANNEL_NAME = "executor:mcp-oauth-result";
26
27type 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
37const mcpOAuthConnectionId = (namespaceSlug: string): string =>
38 `mcp-oauth2-${namespaceSlug || "default"}`;
39
40export 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
5const LazyAddMcpSource = lazy(() => import("./AddMcpSource"));5const LazyAddMcpSource = lazy(() => import("./AddMcpSource"));
6const LazyEditMcpSource = lazy(() => import("./EditMcpSource"));6const LazyEditMcpSource = lazy(() => import("./EditMcpSource"));
7const LazySummary = lazy(() => import("./McpSourceSummary"));7const LazySummary = lazy(() => import("./McpSourceSummary"));
8const LazyMcpSignInButton = lazy(() => import("./McpSignInButton"));
8 9
9type AddProps = ComponentProps<SourcePlugin["add"]>;10type AddProps = ComponentProps<SourcePlugin["add"]>;
10 11
@@ -40,6 +41,7 @@ export const createMcpSourcePlugin = ( · 29 unmodified lines
40 add: AddWithFlag,41 add: AddWithFlag,
41 edit: LazyEditMcpSource,42 edit: LazyEditMcpSource,
42 summary: LazySummary,43 summary: LazySummary,
44 signIn: LazyMcpSignInButton,
43 presets,45 presets,
44 };46 };
45};47};
packages/plugins/mcp/src/sdk/oauth.ts+10−10
@@ -38,18 +38,18 @@ export const McpOAuthSession = Schema.Struct({ · 37 unmodified lines
38 redirectUrl: Schema.String,38 redirectUrl: Schema.String,
39 codeVerifier: Schema.String,39 codeVerifier: Schema.String,
40 /**40 /**
41 * Executor scope id where the minted access/refresh tokens will land.41 * Executor scope id where the minted Connection (and its owned
42 * Pinned at `startOAuth` time so token writes target the same scope42 * access/refresh secrets) will land. Pinned at `startOAuth` time so
43 * regardless of who's currently invoking. For per-user OAuth this is43 * token writes target the same scope regardless of who's currently
44 * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it44 * invoking. For per-user OAuth this is the innermost
45 * can be the org scope.45 * (`ctx.scopes[0]`) scope; for org-shared installs it can be the org
46 * scope.
46 */47 */
47 tokenScope: Schema.String,48 tokenScope: Schema.String,
48 /** Stable secret ids the minted tokens are written to. Stored once on49 /** Stable id of the SDK Connection the exchange will mint. Stored
49 * the source's auth config so per-user scope shadowing resolves to50 * once on the source's auth config so per-user scope shadowing
50 * the calling user's tokens at invoke time. */51 * resolves to the calling user's connection at invoke time. */
51 accessTokenSecretId: Schema.String,52 connectionId: Schema.String,
52 refreshTokenSecretId: Schema.NullOr(Schema.String),
53});53});
54export type McpOAuthSession = typeof McpOAuthSession.Type;54export type McpOAuthSession = typeof McpOAuthSession.Type;
55 55
packages/plugins/mcp/src/sdk/plugin.ts+326−195
@@ -1,20 +1,28 @@
1import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";1import { Duration, Effect, Exit, Schema, Scope, ScopedCache } from "effect";
2 2
3import type {3import {
4 OAuthClientProvider,4 refreshAuthorization,
5 OAuthDiscoveryState,5 type OAuthClientProvider,
6 type OAuthDiscoveryState,
6} from "@modelcontextprotocol/sdk/client/auth.js";7} from "@modelcontextprotocol/sdk/client/auth.js";
7import type {8import type {
8 OAuthClientInformationMixed,9 OAuthClientInformationMixed,
10 OAuthMetadata,
9 OAuthTokens,11 OAuthTokens,
10} from "@modelcontextprotocol/sdk/shared/auth.js";12} from "@modelcontextprotocol/sdk/shared/auth.js";
11 13
12import {14import {
13 definePlugin,15 ConnectionId,
16 ConnectionRefreshError,
17 CreateConnectionInput,
14 ScopeId,18 ScopeId,
15 SecretId,19 SecretId,
16 SetSecretInput,
17 SourceDetectionResult,20 SourceDetectionResult,
21 TokenMaterial,
22 definePlugin,
23 type ConnectionProvider,
24 type ConnectionRefreshInput,
25 type ConnectionRefreshResult,
18 type PluginCtx,26 type PluginCtx,
19 type StorageFailure,27 type StorageFailure,
20} from "@executor/sdk";28} from "@executor/sdk";
@@ -42,7 +50,12 @@ import { · 21 unmodified lines
42 type McpToolManifestEntry,50 type McpToolManifestEntry,
43} from "./manifest";51} from "./manifest";
44import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth";52import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth";
45import { McpToolBinding, type McpConnectionAuth, type McpStoredSourceData } from "./types";53import {
54 McpToolBinding,
55 McpJsonObject,
56 type McpConnectionAuth,
57 type McpStoredSourceData,
58} from "./types";
46 59
47import {60import {
48 SECRET_REF_PREFIX,61 SECRET_REF_PREFIX,
@@ -97,20 +110,20 @@ export interface McpOAuthStartInput { · 48 unmodified lines
97 readonly redirectUrl: string;110 readonly redirectUrl: string;
98 readonly queryParams?: Record<string, string> | null;111 readonly queryParams?: Record<string, string> | null;
99 /**112 /**
100 * Executor scope id where the minted access/refresh tokens will land.113 * Executor scope id where the minted Connection will land. Defaults
101 * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack114 * to `ctx.scopes[0].id` (innermost) — for a per-user stack
102 * `[user, org]` that pins tokens to the user scope so the source's115 * `[user, org]` that pins the connection to the user scope so the
103 * stored `accessTokenSecretId` resolves per-user via shadowing.116 * source's stored `connectionId` resolves per-user via shadowing.
104 */117 */
105 readonly tokenScope?: string;118 readonly tokenScope?: string;
106 /**119 /**
107 * Pre-decided secret ids for the minted tokens. Mint deterministically120 * Pre-decided SDK connection id the exchange will mint. Stable per
108 * (e.g. `mcp_${namespace}_access_token`) so the source's stored121 * source (typically `mcp-oauth2-${namespace}`) so multiple users
109 * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get`122 * signing in against the same MCP source all write to the same id at
110 * resolves per-user via scope fall-through.123 * their own scope — `ctx.connections.accessToken(id)` then resolves
124 * innermost-first.
111 */125 */
112 readonly accessTokenSecretId: string;126 readonly connectionId: string;
113 readonly refreshTokenSecretId?: string | null;
114 /**127 /**
115 * Source-level OAuth state captured by a previous user's flow. Pass128 * Source-level OAuth state captured by a previous user's flow. Pass
116 * the values stored on the source's auth config to skip Dynamic129 * the values stored on the source's auth config to skip Dynamic
@@ -134,14 +147,16 @@ export interface McpOAuthCompleteInput { · 17 unmodified lines
134}147}
135 148
136export interface McpOAuthCompleteResponse {149export interface McpOAuthCompleteResponse {
137 readonly accessTokenSecretId: string;150 /** Id of the SDK Connection the exchange minted. The caller stores it
138 readonly refreshTokenSecretId: string | null;151 * on the source's `oauth2` auth field and resolves tokens via
152 * `ctx.connections`. */
153 readonly connectionId: string;
139 readonly tokenType: string;154 readonly tokenType: string;
140 readonly expiresAt: number | null;155 readonly expiresAt: number | null;
141 readonly scope: string | null;156 readonly scope: string | null;
142 /** DCR client + discovery URLs captured during the flow. The UI157 /** DCR client + discovery URLs captured during the flow. The UI
143 * stores them on the source's auth config so refreshes don't158 * stores them on the source's auth config so subsequent users can
144 * re-register or re-discover. */159 * skip DCR and re-discovery. */
145 readonly clientInformation: Record<string, unknown> | null;160 readonly clientInformation: Record<string, unknown> | null;
146 readonly authorizationServerUrl: string | null;161 readonly authorizationServerUrl: string | null;
147 readonly resourceMetadataUrl: string | null;162 readonly resourceMetadataUrl: string | null;
@@ -208,31 +223,20 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 60 unmodified lines
208interface OAuthProviderInputs {223interface OAuthProviderInputs {
209 readonly accessToken: string;224 readonly accessToken: string;
210 readonly tokenType: string;225 readonly tokenType: string;
211 readonly refreshToken?: string;226 /** Source-level state — same for every user. Surfaced to the MCP
212 readonly expiresAt?: number | null;227 * transport so internal DCR/discovery lookups find a populated
213 readonly scope?: string | null;228 * provider, but never used to mint tokens: refresh is owned by the
214 /** Source-level state — same for every user. */229 * SDK via `ctx.connections.accessToken`. */
215 readonly clientInformation?: OAuthClientInformationMixed | null;230 readonly clientInformation?: OAuthClientInformationMixed | null;
216 readonly authorizationServerUrl?: string | null;231 readonly authorizationServerUrl?: string | null;
217 readonly resourceMetadataUrl?: string | null;232 readonly resourceMetadataUrl?: string | null;
218 readonly endpoint: string;233 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;
225}234}
226 235
227const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {236const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
228 let currentTokens: OAuthTokens = {237 const currentTokens: OAuthTokens = {
229 access_token: inputs.accessToken,238 access_token: inputs.accessToken,
230 token_type: inputs.tokenType,239 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 } : {}),
236 };240 };
237 let clientInformation: OAuthClientInformationMixed | undefined =241 let clientInformation: OAuthClientInformationMixed | undefined =
238 inputs.clientInformation ?? undefined;242 inputs.clientInformation ?? undefined;
@@ -263,10 +267,13 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 24 unmodified lines
263 clientInformation = ci;267 clientInformation = ci;
264 },268 },
265 tokens: () => currentTokens,269 tokens: () => currentTokens,
266 saveTokens: async (t) => {270 // No-op: token refresh is driven by the SDK through
267 currentTokens = t;271 // `ctx.connections.accessToken`, and the access token resolved at
268 if (inputs.onRefresh) await inputs.onRefresh(t);272 // `resolveConnectorInput` time is the only one this provider ever
269 },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,
270 redirectToAuthorization: async () => {277 redirectToAuthorization: async () => {
271 throw new Error("MCP OAuth re-authorization required");278 throw new Error("MCP OAuth re-authorization required");
272 },279 },
@@ -281,6 +288,38 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 8 unmodified lines
281 };288 };
282};289};
283 290
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
302const MCP_OAUTH2_PROVIDER_KEY = "mcp:oauth2" as const;
303
304const 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});
313type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
314
315const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
316const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
317
318const toProviderStateRecord = (
319 state: OAuth2ProviderState,
320): Record<string, unknown> =>
321 encodeProviderState(state) as unknown as Record<string, unknown>;
322
284const remoteConnectionError = (message: string) =>323const remoteConnectionError = (message: string) =>
285 new McpConnectionError({ transport: "remote", message });324 new McpConnectionError({ transport: "remote", message });
286 325
@@ -331,66 +370,47 @@ const resolveConnectorInput = ( · 44 unmodified lines
331 }370 }
332 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val;371 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val;
333 } else if (auth.kind === "oauth2") {372 } else if (auth.kind === "oauth2") {
334 const accessToken = yield* ctx.secrets.get(auth.accessTokenSecretId);373 // `accessToken(id)` handles refresh internally — by the time we
335 if (accessToken === null) {374 // hand the value to the MCP transport it's guaranteed fresh. The
336 return yield* Effect.fail(375 // connection row carries the DCR client + discovery metadata in
337 remoteConnectionError("Failed to resolve OAuth access token"),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 ),
338 );391 );
339 }392
340 const refreshToken = auth.refreshTokenSecretId393 const connection = yield* ctx.connections.get(auth.connectionId);
341 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined394 const providerState: OAuth2ProviderState | null =
342 : undefined;395 connection?.providerState
343 // Capture the resolved owning scope of these secrets so refreshed396 ? yield* Effect.sync(() => {
344 // tokens land back at the same per-user scope. `ctx.secrets.get`397 try {
345 // walks the executor scope stack innermost-first, so the existing398 return decodeProviderState(connection.providerState);
346 // value lives at whichever scope shadowed the source-level id —399 } catch {
347 // we mirror that with `ctx.scopes[0]!.id`, matching the scope400 return null;
348 // chosen at startOAuth time.401 }
349 const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string);402 })
350 const accessSecretId = auth.accessTokenSecretId;403 : null;
351 const refreshSecretId = auth.refreshTokenSecretId;404
352 authProvider = makeOAuthProvider({405 authProvider = makeOAuthProvider({
353 accessToken,406 accessToken,
354 tokenType: auth.tokenType ?? "Bearer",407 tokenType: providerState?.tokenType ?? "Bearer",
355 refreshToken,408 clientInformation:
356 expiresAt: auth.expiresAt,409 (providerState?.clientInformation as OAuthClientInformationMixed | undefined) ??
357 scope: auth.scope,410 null,
358 clientInformation: auth.clientInformation as411 authorizationServerUrl: providerState?.authorizationServerUrl ?? null,
359 | OAuthClientInformationMixed412 resourceMetadataUrl: providerState?.resourceMetadataUrl ?? null,
360 | null
361 | undefined,
362 authorizationServerUrl: auth.authorizationServerUrl,
363 resourceMetadataUrl: auth.resourceMetadataUrl,
364 endpoint: sd.endpoint,413 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 },
394 });414 });
395 }415 }
396 416
@@ -488,11 +508,7 @@ const authToConfig = (auth: McpConnectionAuth | undefined): McpAuthConfig | unde · 91 unmodified lines
488 }508 }
489 return {509 return {
490 kind: "oauth2",510 kind: "oauth2",
491 accessTokenSecret: secretRef(auth.accessTokenSecretId),511 connectionId: auth.connectionId,
492 refreshTokenSecret: auth.refreshTokenSecretId
493 ? secretRef(auth.refreshTokenSecretId)
494 : null,
495 tokenType: auth.tokenType,
496 };512 };
497};513};
498 514
@@ -889,8 +905,7 @@ export const mcpPlugin = definePlugin( · 390 unmodified lines
889 authorizationServerMetadata: started.authorizationServerMetadata,905 authorizationServerMetadata: started.authorizationServerMetadata,
890 clientInformation: started.clientInformation,906 clientInformation: started.clientInformation,
891 tokenScope,907 tokenScope,
892 accessTokenSecretId: input.accessTokenSecretId,908 connectionId: input.connectionId,
893 refreshTokenSecretId: input.refreshTokenSecretId ?? null,
894 })909 })
895 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));910 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
896 911
@@ -901,104 +916,139 @@ export const mcpPlugin = definePlugin( · 4 unmodified lines
901 }).pipe(Effect.withSpan("mcp.plugin.start_oauth"));916 }).pipe(Effect.withSpan("mcp.plugin.start_oauth"));
902 917
903 const completeOAuth = (input: McpOAuthCompleteInput) =>918 const completeOAuth = (input: McpOAuthCompleteInput) =>
904 Effect.gen(function* () {919 ctx
905 if (input.error) {920 .transaction(
906 return yield* Effect.fail(921 Effect.gen(function* () {
907 mcpOAuthError(`OAuth error: ${input.error}`),922 if (input.error) {
908 );923 return yield* Effect.fail(
909 }924 mcpOAuthError(`OAuth error: ${input.error}`),
910 if (!input.code) {925 );
911 return yield* Effect.fail(926 }
912 mcpOAuthError("Missing OAuth authorization code"),927 if (!input.code) {
913 );928 return yield* Effect.fail(
914 }929 mcpOAuthError("Missing OAuth authorization code"),
915 930 );
916 const session = yield* ctx.storage.getOAuthSession(input.state);931 }
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 );
932 932
933 // Token storage is fully driven by the session: scope and933 const session = yield* ctx.storage.getOAuthSession(input.state);
934 // secret ids were chosen at startOAuth time and pinned to934 if (!session) {
935 // the row. That keeps shadowing deterministic — every935 return yield* Effect.fail(
936 // user's OAuth flow on the same source writes to the same936 mcpOAuthError(`OAuth session not found: ${input.state}`),
937 // ids, so the source's stored OAuth2 auth resolves937 );
938 // per-user via scope fall-through.938 }
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 );
955 939
956 let refreshTokenSecretId: string | null = null;940 const exchanged = yield* exchangeMcpOAuthCode({
957 if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {941 session,
958 const refreshId = session.refreshTokenSecretId;942 code: input.code,
959 yield* ctx.secrets943 }).pipe(
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(
969 Effect.mapError((e) =>944 Effect.mapError((e) =>
970 mcpOAuthError(945 mcpOAuthError(`OAuth exchange failed: ${e.message}`),
971 `Failed to store refresh token: ${String(e)}`,
972 ),
973 ),946 ),
947 Effect.withSpan("mcp.plugin.oauth.exchange_code"),
974 );948 );
975 refreshTokenSecretId = refreshId;
976 }
977
978 yield* ctx.storage.deleteOAuthSession(input.state);
979 949
980 const expiresAt =950 yield* ctx.storage.deleteOAuthSession(input.state);
981 typeof exchanged.tokens.expires_in === "number"951
982 ? Date.now() + exchanged.tokens.expires_in * 1000952 const tokenType = exchanged.tokens.token_type ?? "Bearer";
983 : null;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 );
984 1034
985 return {1035 return {
986 accessTokenSecretId: accessSecretId,1036 connectionId: session.connectionId,
987 refreshTokenSecretId,1037 tokenType,
988 tokenType: exchanged.tokens.token_type ?? "Bearer",1038 expiresAt,
989 expiresAt,1039 scope: exchanged.tokens.scope ?? null,
990 scope: exchanged.tokens.scope ?? null,1040 // Echo the source-level OAuth state captured during this
991 // Echo the source-level OAuth state captured during this1041 // flow. The UI persists it on the source's auth config
992 // flow. The UI persists it on the source's auth config so1042 // so subsequent users can skip DCR + re-discovery.
993 // refreshes (and any future user's OAuth) re-use the same1043 clientInformation: exchanged.clientInformation as
994 // DCR client + skip discovery.1044 | Record<string, unknown>
995 clientInformation: exchanged.clientInformation as1045 | null,
996 | Record<string, unknown>1046 authorizationServerUrl: exchanged.authorizationServerUrl,
997 | null,1047 resourceMetadataUrl: exchanged.resourceMetadataUrl,
998 authorizationServerUrl: exchanged.authorizationServerUrl,1048 };
999 resourceMetadataUrl: exchanged.resourceMetadataUrl,1049 }),
1000 };1050 )
1001 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));1051 .pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
1002 1052
1003 const updateSource = (1053 const updateSource = (
1004 namespace: string,1054 namespace: string,
@@ -1205,6 +1255,87 @@ export const mcpPlugin = definePlugin( · 200 unmodified lines
1205 1255
1206 refreshSource: () => Effect.void,1256 refreshSource: () => Effect.void,
1207 1257
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
1208 close: () =>1339 close: () =>
1209 Effect.gen(function* () {1340 Effect.gen(function* () {
1210 const runtime = runtimeRef.current;1341 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
13 13
14// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
15// Connection auth (only applies to remote sources)15// 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.
16// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
17 23
18/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */24/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
19const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });25const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
26export { JsonObject as McpJsonObject };
20 27
21export const McpConnectionAuth = Schema.Union(28export const McpConnectionAuth = Schema.Union(
22 Schema.Struct({ kind: Schema.Literal("none") }),29 Schema.Struct({ kind: Schema.Literal("none") }),
@@ -28,32 +35,7 @@ export const McpConnectionAuth = Schema.Union( · 5 unmodified lines
28 }),35 }),
29 Schema.Struct({36 Schema.Struct({
30 kind: Schema.Literal("oauth2"),37 kind: Schema.Literal("oauth2"),
31 accessTokenSecretId: Schema.String,38 connectionId: 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 }),
57 }),39 }),
58);40);
59export type McpConnectionAuth = typeof McpConnectionAuth.Type;41export type McpConnectionAuth = typeof McpConnectionAuth.Type;
packages/plugins/oauth2/src/index.ts+0−14
@@ -399,17 +399,3 @@ export const shouldRefreshToken = (input: { · 398 unmodified lines
399 return input.expiresAt <= now + skew;399 return input.expiresAt <= now + skew;
400};400};
401 401
402// ---------------------------------------------------------------------------
403// Re-exports from sibling modules
404// ---------------------------------------------------------------------------
405
406export {
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
8import { afterEach, describe, expect, it, vi } from "vitest";
9import { Effect, Exit } from "effect";
10
11import {
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
24const 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
55const originalFetch = globalThis.fetch;
56
57const 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
70const jsonResponse = (status: number, body: unknown): Response =>
71 new Response(JSON.stringify(body), {
72 status,
73 headers: { "content-type": "application/json" },
74 });
75
76afterEach(() => {
77 globalThis.fetch = originalFetch;
78});
79
80// ---------------------------------------------------------------------------
81// storeOAuthTokens
82// ---------------------------------------------------------------------------
83
84describe("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
165describe("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
10import { Effect } from "effect";
11
12import { OAuth2Error, type OAuth2TokenResponse } from "./index";
13import {
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
24export 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
34const 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. */
47export 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
55export 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 */
79export 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. */
121export 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. */
132export type RefreshedAuthSnapshot = {
133 readonly tokenType: string;
134 readonly expiresAt: number | null;
135 readonly scope: string | null;
136};
137
138export 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 */
166export 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
40 name: Schema.optional(Schema.String),40 name: Schema.optional(Schema.String),
41 baseUrl: Schema.optional(Schema.String),41 baseUrl: Schema.optional(Schema.String),
42 headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),42 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),
43});46});
44 47
45const UpdateSourceResponse = Schema.Struct({48const UpdateSourceResponse = Schema.Struct({
@@ -60,10 +63,9 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines
60// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
61 64
62// Shared identity fields for both OAuth2 flows. `tokenScope` names which65// Shared identity fields for both OAuth2 flows. `tokenScope` names which
63// executor scope will own the minted tokens (typically the per-user scope).66// executor scope will own the resulting Connection (typically the per-user
64// The token secret ids are pre-decided so the source's stored `OAuth2Auth`67// scope). Token secret ids are minted by the SDK from the Connection id —
65// can reference the same ids across every user — per-user values shadow68// callers never pick them.
66// org-level fallbacks via secret fall-through on read.
67const StartOAuthIdentityFields = {69const StartOAuthIdentityFields = {
68 displayName: Schema.String,70 displayName: Schema.String,
69 securitySchemeName: Schema.String,71 securitySchemeName: Schema.String,
@@ -71,7 +73,6 @@ const StartOAuthIdentityFields = { · 1 unmodified line
71 clientIdSecretId: Schema.String,73 clientIdSecretId: Schema.String,
72 scopes: Schema.Array(Schema.String),74 scopes: Schema.Array(Schema.String),
73 tokenScope: Schema.optional(ScopeId),75 tokenScope: Schema.optional(ScopeId),
74 accessTokenSecretId: Schema.String,
75} as const;76} as const;
76 77
77const StartOAuthPayload = Schema.Union(78const StartOAuthPayload = Schema.Union(
@@ -81,7 +82,6 @@ const StartOAuthPayload = Schema.Union( · 3 unmodified lines
81 authorizationUrl: Schema.String,82 authorizationUrl: Schema.String,
82 redirectUrl: Schema.String,83 redirectUrl: Schema.String,
83 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),84 clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),
84 refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),
85 }),85 }),
86 // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The86 // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The
87 // plugin exchanges tokens inline and returns a completed auth. The87 // 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
91 name: payload.name,91 name: payload.name,
92 baseUrl: payload.baseUrl,92 baseUrl: payload.baseUrl,
93 headers: payload.headers as Record<string, HeaderValue> | undefined,93 headers: payload.headers as Record<string, HeaderValue> | undefined,
94 oauth2: payload.oauth2,
94 } as OpenApiUpdateSourceInput);95 } as OpenApiUpdateSourceInput);
95 return { updated: true };96 return { updated: true };
96 })),97 })),
@@ -112,7 +113,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines
112 clientSecretSecretId: payload.clientSecretSecretId,113 clientSecretSecretId: payload.clientSecretSecretId,
113 scopes: [...payload.scopes],114 scopes: [...payload.scopes],
114 tokenScope,115 tokenScope,
115 accessTokenSecretId: payload.accessTokenSecretId,
116 });116 });
117 }117 }
118 return yield* ext.startOAuth({118 return yield* ext.startOAuth({
@@ -126,8 +126,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
126 clientSecretSecretId: payload.clientSecretSecretId ?? null,126 clientSecretSecretId: payload.clientSecretSecretId ?? null,
127 scopes: [...payload.scopes],127 scopes: [...payload.scopes],
128 tokenScope,128 tokenScope,
129 accessTokenSecretId: payload.accessTokenSecretId,
130 refreshTokenSecretId: payload.refreshTokenSecretId ?? null,
131 });129 });
132 })),130 })),
133 )131 )
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
61export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";61export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
62export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/oauth/callback";62export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/oauth/callback";
63 63
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.
72const 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
84const substituteUrlVariables = (url: string, values: Record<string, string>): string => {64const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
85 let out = url;65 let out = url;
86 for (const [name, value] of Object.entries(values)) {66 for (const [name, value] of Object.entries(values)) {
@@ -402,15 +382,6 @@ export default function AddOpenApiSource(props: { · 315 unmodified lines
402 const displayName =382 const displayName =
403 identity.name.trim() || selectedOAuth2Preset.securitySchemeName;383 identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
404 384
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
414 const tokenUrl = resolveOAuthUrl(385 const tokenUrl = resolveOAuthUrl(
415 selectedOAuth2Preset.tokenUrl,386 selectedOAuth2Preset.tokenUrl,
416 resolvedBaseUrl,387 resolvedBaseUrl,
@@ -435,7 +406,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
435 clientIdSecretId: oauth2ClientIdSecretId,406 clientIdSecretId: oauth2ClientIdSecretId,
436 clientSecretSecretId: oauth2ClientSecretSecretId,407 clientSecretSecretId: oauth2ClientSecretSecretId,
437 scopes: [...oauth2SelectedScopes],408 scopes: [...oauth2SelectedScopes],
438 accessTokenSecretId: tokenIds.accessTokenSecretId,
439 },409 },
440 });410 });
441 setStartingOAuth(false);411 setStartingOAuth(false);
@@ -465,8 +435,6 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
465 clientIdSecretId: oauth2ClientIdSecretId,435 clientIdSecretId: oauth2ClientIdSecretId,
466 clientSecretSecretId: oauth2ClientSecretSecretId,436 clientSecretSecretId: oauth2ClientSecretSecretId,
467 scopes: [...oauth2SelectedScopes],437 scopes: [...oauth2SelectedScopes],
468 accessTokenSecretId: tokenIds.accessTokenSecretId,
469 refreshTokenSecretId: tokenIds.refreshTokenSecretId,
470 },438 },
471 });439 });
472 440
@@ -484,23 +452,17 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
484 oauthCleanup.current = null;452 oauthCleanup.current = null;
485 setStartingOAuth(false);453 setStartingOAuth(false);
486 if (result.ok) {454 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.
490 setOauth2Auth(455 setOauth2Auth(
491 new OAuth2Auth({456 new OAuth2Auth({
492 kind: "oauth2",457 kind: "oauth2",
458 connectionId: result.connectionId,
493 securitySchemeName: result.securitySchemeName,459 securitySchemeName: result.securitySchemeName,
494 flow: result.flow,460 flow: result.flow,
495 tokenUrl: result.tokenUrl,461 tokenUrl: result.tokenUrl,
462 authorizationUrl: result.authorizationUrl,
496 clientIdSecretId: result.clientIdSecretId,463 clientIdSecretId: result.clientIdSecretId,
497 clientSecretSecretId: result.clientSecretSecretId,464 clientSecretSecretId: result.clientSecretSecretId,
498 accessTokenSecretId: result.accessTokenSecretId,465 scopes: result.scopes,
499 refreshTokenSecretId: result.refreshTokenSecretId,
500 tokenType: result.tokenType,
501 expiresAt: result.expiresAt,
502 scope: result.scope,
503 scopes: [...result.scopes],
504 }),466 }),
505 );467 );
506 setOauth2Error(null);468 setOauth2Error(null);
@@ -535,7 +497,6 @@ export default function AddOpenApiSource(props: { · 28 unmodified lines
535 doStartOAuth,497 doStartOAuth,
536 scopeId,498 scopeId,
537 identity.name,499 identity.name,
538 identity.namespace,
539 ]);500 ]);
540 501
541 const handleCancelOAuth2 = useCallback(() => {502 const handleCancelOAuth2 = useCallback(() => {
@@ -974,8 +935,8 @@ export default function AddOpenApiSource(props: { · 432 unmodified lines
974 {oauth2Auth ? (935 {oauth2Auth ? (
975 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">936 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
976 <div className="text-[11px] text-green-700 dark:text-green-400">937 <div className="text-[11px] text-green-700 dark:text-green-400">
977 Connected · {oauth2Auth.scopes.length} scope938 Connected · {oauth2SelectedScopes.size} scope
978 {oauth2Auth.scopes.length === 1 ? "" : "s"} granted939 {oauth2SelectedScopes.size === 1 ? "" : "s"} granted
979 </div>940 </div>
980 <Button941 <Button
981 variant="ghost"942 variant="ghost"
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+5−380
@@ -1,30 +1,16 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useState } from "react";
2import {2import {
3 useAtomValue,3 useAtomValue,
4 useAtomSet,4 useAtomSet,
5 useAtomRefresh,
6 Result,5 Result,
7} from "@effect-atom/atom-react";6} from "@effect-atom/atom-react";
8import { Option } from "effect";
9
10import { SecretId } from "@executor/sdk";
11import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
12 7
13import {8import {
14 openApiSourceAtom,9 openApiSourceAtom,
15 previewOpenApiSpec,
16 startOpenApiOAuth,
17 updateOpenApiSource,10 updateOpenApiSource,
18} from "./atoms";11} from "./atoms";
19import { useScope } from "@executor/react/api/scope-context";12import { useScope } from "@executor/react/api/scope-context";
20import {13import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
21 secretStatusAtom,
22 removeSecret,
23} from "@executor/react/api/atoms";
24import {
25 secretWriteKeys,
26 sourceWriteKeys,
27} from "@executor/react/api/reactivity-keys";
28import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";14import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
29import {15import {
30 headerValueToState,16 headerValueToState,
@@ -45,357 +31,12 @@ import { · 14 unmodified lines
45import { FieldLabel } from "@executor/react/components/field";31import { FieldLabel } from "@executor/react/components/field";
46import { Input } from "@executor/react/components/input";32import { Input } from "@executor/react/components/input";
47import { Badge } from "@executor/react/components/badge";33import { Badge } from "@executor/react/components/badge";
48import { Spinner } from "@executor/react/components/spinner";
49import {
50 OPENAPI_OAUTH_CALLBACK_PATH,
51 OPENAPI_OAUTH_CHANNEL,
52 OPENAPI_OAUTH_POPUP_NAME,
53 resolveOAuthUrl,
54} from "./AddOpenApiSource";
55import type { SpecPreview, OAuth2Preset } from "../sdk/preview";
56import { OAuth2Auth } from "../sdk/types";
57import type { StoredSourceSchemaType } from "../sdk/store";34import type { StoredSourceSchemaType } from "../sdk/store";
58 35
59// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
60// Connections — one row per OAuth2Auth on the source37// Edit form — source config only. Sign-in / reconnect lives on the source
61// ---------------------------------------------------------------------------38// detail top bar (see source-plugin.signIn) and connection lifecycle lives
62 39// on the Connections page.
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 */
69function 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
330function 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
399// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
400 41
401function EditForm(props: {42function EditForm(props: {
@@ -423,13 +64,6 @@ function EditForm(props: { · 21 unmodified lines
423 64
424 const identityDirty = identity.name.trim() !== props.initial.name.trim();65 const identityDirty = identity.name.trim() !== props.initial.name.trim();
425 66
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
433 const handleHeadersChange = (next: HeaderState[]) => {67 const handleHeadersChange = (next: HeaderState[]) => {
434 setHeaders(next);68 setHeaders(next);
435 setDirty(true);69 setDirty(true);
@@ -503,15 +137,6 @@ function EditForm(props: { · 67 unmodified lines
503 />137 />
504 </section>138 </section>
505 139
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
515 {error && (140 {error && (
516 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">141 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
517 <p className="text-sm text-destructive">{error}</p>142 <p className="text-sm text-destructive">{error}</p>
packages/plugins/openapi/src/react/OpenApiSignInButton.tsx+213−0
@@ -0,0 +1,213 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3
4import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5import { useScope } from "@executor/react/api/scope-context";
6import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
7import { connectionsAtom } from "@executor/react/api/atoms";
8import { Button } from "@executor/react/components/button";
9
10import {
11 openApiSourceAtom,
12 startOpenApiOAuth,
13 updateOpenApiSource,
14} from "./atoms";
15import {
16 OPENAPI_OAUTH_CALLBACK_PATH,
17 OPENAPI_OAUTH_CHANNEL,
18 OPENAPI_OAUTH_POPUP_NAME,
19} from "./AddOpenApiSource";
20import { 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
32export 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 @@
1import { Result, useAtomValue } from "@effect-atom/atom-react";1import { Result, useAtomValue } from "@effect-atom/atom-react";
2 2
3import { SecretId } from "@executor/sdk";
4import { useScope } from "@executor/react/api/scope-context";
5import { secretStatusAtom } from "@executor/react/api/atoms";
6import { Badge } from "@executor/react/components/badge";3import { Badge } from "@executor/react/components/badge";
7import { Skeleton } from "@executor/react/components/skeleton";
8 4
9import { openApiSourceAtom } from "./atoms";5import { openApiSourceAtom } from "./atoms";
6import { useScope } from "@executor/react/api/scope-context";
10 7
11function ConnectedBadge(props: { accessTokenSecretId: string }) {8function ConnectedBadge() {
12 const scopeId = useScope();9 return (
13 const status = useAtomValue(10 <Badge
14 secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)),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>
15 );16 );
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 });
38}17}
39 18
40// The entry row already renders name + id + kind, so this summary19// 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
51 : undefined;30 : undefined;
52 31
53 if (!oauth2) return null;32 if (!oauth2) return null;
54 return <ConnectedBadge accessTokenSecretId={oauth2.accessTokenSecretId} />;33 return <ConnectedBadge />;
55}34}
packages/plugins/openapi/src/react/source-plugin.ts+1−0
@@ -8,5 +8,6 @@ export const openApiSourcePlugin: SourcePlugin = { · 7 unmodified lines
8 add: lazy(() => import("./AddOpenApiSource")),8 add: lazy(() => import("./AddOpenApiSource")),
9 edit: lazy(() => import("./EditOpenApiSource")),9 edit: lazy(() => import("./EditOpenApiSource")),
10 summary: lazy(() => import("./OpenApiSourceSummary")),10 summary: lazy(() => import("./OpenApiSourceSummary")),
11 signIn: lazy(() => import("./OpenApiSignInButton")),
11 presets: openApiPresets,12 presets: openApiPresets,
12};13};
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
3//3//
4// Models the production scenario: an org-level admin uploads the shared4// Models the production scenario: an org-level admin uploads the shared
5// client credentials, each member of the org runs their own OAuth flow,5// 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 the6// and each member's access token lives on a per-user Connection. The
7// org fallback provides client id/secret and the user scope provides7// Connections primitive owns every secret — they're filtered out of the
8// the access token the invoker actually injects.8// user-facing `secrets.list()` automatically.
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.
26// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
27 10
28import { afterEach } from "vitest";11import { afterEach } from "vitest";
@@ -53,7 +36,6 @@ import { · 24 unmodified lines
53} from "@executor/sdk";36} from "@executor/sdk";
54import { makeMemoryAdapter } from "@executor/storage-core/testing/memory";37import { makeMemoryAdapter } from "@executor/storage-core/testing/memory";
55 38
56import { OAuth2Auth } from "./types";
57import { openApiPlugin } from "./plugin";39import { openApiPlugin } from "./plugin";
58 40
59const autoApprove: InvokeOptions = { onElicitation: "accept-all" };41const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -101,7 +83,7 @@ const TestLayer = HttpApiBuilder.serve().pipe( · 41 unmodified lines
101const originalFetch = globalThis.fetch;83const originalFetch = globalThis.fetch;
102 84
103const mockTokenFetch = (tokenByCode: Record<string, string>) => {85const mockTokenFetch = (tokenByCode: Record<string, string>) => {
104 globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {86 globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
105 const bodyText =87 const bodyText =
106 init?.body instanceof URLSearchParams88 init?.body instanceof URLSearchParams
107 ? init.body.toString()89 ? init.body.toString()
@@ -117,9 +99,6 @@ const mockTokenFetch = (tokenByCode: Record<string, string>) => { · 9 unmodified lines
117 { status: 400, headers: { "content-type": "application/json" } },99 { status: 400, headers: { "content-type": "application/json" } },
118 );100 );
119 }101 }
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.
123 return new Response(102 return new Response(
124 JSON.stringify({103 JSON.stringify({
125 access_token: token,104 access_token: token,
@@ -141,18 +120,11 @@ afterEach(() => { · 15 unmodified lines
141 120
142layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => {121layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => {
143 it.effect(122 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",
145 () =>124 () =>
146 Effect.gen(function* () {125 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.
154 const secretStore = new Map<string, string>();126 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}`;
156 const memoryProvider: SecretProvider = {128 const memoryProvider: SecretProvider = {
157 key: "memory",129 key: "memory",
158 writable: true,130 writable: true,
@@ -171,7 +143,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines
171 secretProviders: [memoryProvider],143 secretProviders: [memoryProvider],
172 }));144 }));
173 145
174 // Route OpenAPI tool invocations through the Effect test server.
175 const httpClient = yield* HttpClient.HttpClient;146 const httpClient = yield* HttpClient.HttpClient;
176 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);147 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
177 const plugins = [148 const plugins = [
@@ -179,8 +150,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 1 unmodified line
179 memorySecretsPlugin(),150 memorySecretsPlugin(),
180 ] as const;151 ] as const;
181 152
182 // One adapter + blob store behind all three executors — exactly
183 // what a multi-tenant deployment looks like in production.
184 const schema = collectSchemas(plugins);153 const schema = collectSchemas(plugins);
185 const adapter = makeMemoryAdapter({ schema });154 const adapter = makeMemoryAdapter({ schema });
186 const blobs = makeInMemoryBlobStore();155 const blobs = makeInMemoryBlobStore();
@@ -242,56 +211,15 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 55 unmodified lines
242 );211 );
243 212
244 // -------------------------------------------------------------213 // -------------------------------------------------------------
245 // 2. Each user adds the petstore source at their own scope,214 // 2. Each user runs startOAuth + completeOAuth to mint a
246 // pre-populating OAuth2Auth that points at:215 // per-user Connection.
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.
288 // -------------------------------------------------------------216 // -------------------------------------------------------------
289 mockTokenFetch({217 mockTokenFetch({
290 "code-alice": "alice-token",218 "code-alice": "alice-token",
291 "code-bob": "bob-token",219 "code-bob": "bob-token",
292 });220 });
293 221
294 const startInputFor = (user: "alice" | "bob", scope: ScopeId) => ({222 const startInputFor = (user: string, scope: ScopeId) => ({
295 displayName: `Petstore (${user})`,223 displayName: `Petstore (${user})`,
296 securitySchemeName: "oauth2",224 securitySchemeName: "oauth2",
297 flow: "authorizationCode" as const,225 flow: "authorizationCode" as const,
@@ -302,8 +230,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 4 unmodified lines
302 clientSecretSecretId: "petstore_client_secret",230 clientSecretSecretId: "petstore_client_secret",
303 scopes: ["read"],231 scopes: ["read"],
304 tokenScope: scope as unknown as string,232 tokenScope: scope as unknown as string,
305 accessTokenSecretId: `petstore_access_token_${user}`,
306 refreshTokenSecretId: `petstore_refresh_token_${user}`,
307 });233 });
308 234
309 const aliceStart = yield* aliceExec.openapi.startOAuth(235 const aliceStart = yield* aliceExec.openapi.startOAuth(
@@ -319,18 +245,38 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 9 unmodified lines
319 throw new Error("expected authorizationCode flow for bob");245 throw new Error("expected authorizationCode flow for bob");
320 }246 }
321 247
322 yield* aliceExec.openapi.completeOAuth({248 const aliceAuth = yield* aliceExec.openapi.completeOAuth({
323 state: aliceStart.sessionId,249 state: aliceStart.sessionId,
324 code: "code-alice",250 code: "code-alice",
325 });251 });
326 yield* bobExec.openapi.completeOAuth({252 const bobAuth = yield* bobExec.openapi.completeOAuth({
327 state: bobStart.sessionId,253 state: bobStart.sessionId,
328 code: "code-bob",254 code: "code-bob",
329 });255 });
330 256
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
331 // -------------------------------------------------------------277 // -------------------------------------------------------------
332 // 4. Invoke the tool through each executor. The injected278 // 4. Invoke through each exec — Authorization must carry that
333 // Authorization header must carry THAT user's token.279 // user's token.
334 // -------------------------------------------------------------280 // -------------------------------------------------------------
335 const aliceResult = (yield* aliceExec.tools.invoke(281 const aliceResult = (yield* aliceExec.tools.invoke(
336 "petstore.items.echoHeaders",282 "petstore.items.echoHeaders",
@@ -349,51 +295,49 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines
349 expect(bobResult.data?.authorization).toBe("Bearer bob-token");295 expect(bobResult.data?.authorization).toBe("Bearer bob-token");
350 296
351 // -------------------------------------------------------------297 // -------------------------------------------------------------
352 // 5. Scope isolation on `secrets.list()`:298 // 5. Each user's Connection is scoped to them; admin sees none.
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.
357 // -------------------------------------------------------------299 // -------------------------------------------------------------
358 const aliceIds = new Set(300 const aliceConnections = yield* aliceExec.connections.list();
359 (yield* aliceExec.secrets.list()).map((s) => s.id as unknown as string),301 const aliceConn = aliceConnections.find(
302 (c) => c.id === aliceAuth.connectionId,
360 );303 );
361 expect(aliceIds).toContain("petstore_client_id");304 expect(aliceConn?.scopeId as unknown as string).toBe("user-alice");
362 expect(aliceIds).toContain("petstore_client_secret");305 expect(aliceConn?.kind).toBe("user");
363 expect(aliceIds).toContain("petstore_access_token_alice");306
364 expect(aliceIds).toContain("petstore_refresh_token_alice");307 const bobConnections = yield* bobExec.connections.list();
365 expect(aliceIds).not.toContain("petstore_access_token_bob");308 const bobConn = bobConnections.find(
366 expect(aliceIds).not.toContain("petstore_refresh_token_bob");309 (c) => c.id === bobAuth.connectionId,
367
368 const bobIds = new Set(
369 (yield* bobExec.secrets.list()).map((s) => s.id as unknown as string),
370 );310 );
371 expect(bobIds).toContain("petstore_client_id");311 expect(bobConn?.scopeId as unknown as string).toBe("user-bob");
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");
375 312
376 const adminIds = new Set(313 const adminConnectionIds = new Set(
377 (yield* adminExec.secrets.list()).map((s) => s.id as unknown as string),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,
378 );321 );
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");
383 322
384 // -------------------------------------------------------------323 // -------------------------------------------------------------
385 // 6. Secret-row scope attribution: alice's tokens are pinned to324 // 6. Connection-owned secrets are filtered from secrets.list().
386 // her scope, not smuggled in under the org fallback.325 // Alice only sees the org client creds; her access / refresh
326 // tokens are hidden behind the Connection primitive.
387 // -------------------------------------------------------------327 // -------------------------------------------------------------
388 const aliceRows = yield* aliceExec.secrets.list();328 const aliceSecretIds = new Set(
389 const aliceAccess = aliceRows.find(329 (yield* aliceExec.secrets.list()).map(
390 (r) => (r.id as unknown as string) === "petstore_access_token_alice",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`,
391 );337 );
392 expect(aliceAccess?.scopeId as unknown as string).toBe("user-alice");338 expect(aliceSecretIds).not.toContain(
393 const aliceClient = aliceRows.find(339 `${aliceAuth.connectionId}.refresh_token`,
394 (r) => (r.id as unknown as string) === "petstore_client_id",
395 );340 );
396 expect(aliceClient?.scopeId as unknown as string).toBe("org");
397 }),341 }),
398 );342 );
399});343});
packages/plugins/openapi/src/sdk/plugin.ts+270−271
@@ -5,21 +5,27 @@ import { FetchHttpClient, HttpClient } from "@effect/platform"; · 4 unmodified lines
5import type { Layer } from "effect";5import type { Layer } from "effect";
6 6
7import {7import {
8 OAuth2Error,
8 buildAuthorizationUrl,9 buildAuthorizationUrl,
9 createPkceCodeVerifier,10 createPkceCodeVerifier,
10 exchangeAuthorizationCode,11 exchangeAuthorizationCode,
11 exchangeClientCredentials,12 exchangeClientCredentials,
12 shouldRefreshToken,13 refreshAccessToken,
13 withRefreshedAccessToken,
14 type OAuth2TokenResponse,14 type OAuth2TokenResponse,
15} from "@executor/plugin-oauth2";15} from "@executor/plugin-oauth2";
16 16
17import {17import {
18 ConnectionId,
19 ConnectionRefreshError,
20 CreateConnectionInput,
18 ScopeId,21 ScopeId,
19 SecretId,22 SecretId,
20 SetSecretInput,
21 SourceDetectionResult,23 SourceDetectionResult,
24 TokenMaterial,
22 definePlugin,25 definePlugin,
26 type ConnectionProvider,
27 type ConnectionRefreshInput,
28 type ConnectionRefreshResult,
23 type PluginCtx,29 type PluginCtx,
24 type StorageFailure,30 type StorageFailure,
25 type ToolAnnotations,31 type ToolAnnotations,
@@ -90,27 +96,26 @@ export interface OpenApiUpdateSourceInput { · 64 unmodified lines
90 readonly name?: string;96 readonly name?: string;
91 readonly baseUrl?: string;97 readonly baseUrl?: string;
92 readonly headers?: Record<string, HeaderValue>;98 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;
93}102}
94 103
95// ---------------------------------------------------------------------------104// ---------------------------------------------------------------------------
96// OAuth2 onboarding inputs / outputs105// 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.
97// ---------------------------------------------------------------------------109// ---------------------------------------------------------------------------
98 110
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 */
107interface StartOAuthIdentity {111interface StartOAuthIdentity {
108 readonly displayName: string;112 readonly displayName: string;
109 readonly securitySchemeName: string;113 readonly securitySchemeName: string;
110 readonly clientIdSecretId: string;114 readonly clientIdSecretId: string;
111 readonly scopes: readonly string[];115 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`. */
112 readonly tokenScope?: string;118 readonly tokenScope?: string;
113 readonly accessTokenSecretId: string;
114}119}
115 120
116export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {121export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {
@@ -119,16 +124,13 @@ export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { · 2 unmodified lines
119 readonly tokenUrl: string;124 readonly tokenUrl: string;
120 readonly redirectUrl: string;125 readonly redirectUrl: string;
121 readonly clientSecretSecretId?: string | null;126 readonly clientSecretSecretId?: string | null;
122 readonly refreshTokenSecretId?: string | null;
123}127}
124 128
125/**129/**
126 * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges130 * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges
127 * tokens inline and writes the access token at131 * tokens inline, creates the Connection, and returns a completed
128 * `accessTokenSecretId` + `tokenScope`, returning a completed132 * `OAuth2Auth` pointing at it. No `authorizationUrl`, no session row,
129 * `OAuth2Auth`. No `authorizationUrl`, no session row, no133 * no `completeOAuth`.
130 * `completeOAuth`. Re-exchange happens at invoke time when the token
131 * is near expiry.
132 */134 */
133export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity {135export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity {
134 readonly flow: "clientCredentials";136 readonly flow: "clientCredentials";
@@ -280,89 +282,35 @@ const descriptionFor = (def: ToolDefinition): string => { · 145 unmodified lines
280};282};
281 283
282// ---------------------------------------------------------------------------284// ---------------------------------------------------------------------------
283// clientCredentials access-token resolver.285// Connection `provider_state` shape for openapi-oauth2.
284//286//
285// RFC 6749 §4.4.3 forbids refresh tokens for this grant, so the only way to287// Every field needed to re-hit the token endpoint on refresh lives here,
286// recover from an expired access token is a fresh client_credentials288// so the SDK's `ctx.connections.accessToken(id)` can drive both grant
287// exchange. We re-exchange proactively (when `shouldRefreshToken` fires289// types without the plugin keeping its own refresh state. The flow
288// within skew) and persist the new token at the same id+scope the source's290// literal chooses between `refresh_token` (authorizationCode) and
289// OAuth2Auth already references, keeping per-user shadowing intact.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.
290// ---------------------------------------------------------------------------294// ---------------------------------------------------------------------------
291 295
292type ResolveClientCredentialsArgs = {296const OPENAPI_OAUTH2_PROVIDER_KEY = "openapi:oauth2" as const;
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};
305 297
306const resolveClientCredentialsAccessToken = (args: ResolveClientCredentialsArgs) =>298const OAuth2ProviderState = Schema.Struct({
307 Effect.gen(function* () {299 flow: Schema.Literal("authorizationCode", "clientCredentials"),
308 const { auth } = args;300 tokenUrl: Schema.String,
309 const needsRefresh = shouldRefreshToken({ expiresAt: auth.expiresAt });301 clientIdSecretId: Schema.String,
310 302 clientSecretSecretId: Schema.NullOr(Schema.String),
311 if (!needsRefresh) {303 scopes: Schema.Array(Schema.String),
312 const current = yield* args.secretsGet(auth.accessTokenSecretId);304});
313 if (current !== null) return current;305type OAuth2ProviderState = typeof OAuth2ProviderState.Type;
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 });
349 306
350 const expiresAt =307const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);
351 typeof tokenResponse.expires_in === "number"308const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState);
352 ? Date.now() + tokenResponse.expires_in * 1000309
353 : null;310const toProviderStateRecord = (
354 311 state: OAuth2ProviderState,
355 yield* args.updateSourceMeta(312): Record<string, unknown> =>
356 new OAuth2Auth({313 encodeProviderState(state) as unknown as Record<string, unknown>;
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 });
366 314
367// ---------------------------------------------------------------------------315// ---------------------------------------------------------------------------
368// Plugin factory316// Plugin factory
@@ -540,28 +488,6 @@ export const openApiPlugin = definePlugin( · 171 unmodified lines
540 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),488 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
541 489
542 extension: (ctx) => {490 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
565 const addSpecInternal = (config: OpenApiSpecConfig) =>491 const addSpecInternal = (config: OpenApiSpecConfig) =>
566 Effect.gen(function* () {492 Effect.gen(function* () {
567 // Resolve URL → text and parse BEFORE opening a transaction.493 // Resolve URL → text and parse BEFORE opening a transaction.
@@ -619,6 +545,7 @@ export const openApiPlugin = definePlugin( · 51 unmodified lines
619 name: input.name?.trim() || undefined,545 name: input.name?.trim() || undefined,
620 baseUrl: input.baseUrl,546 baseUrl: input.baseUrl,
621 headers: input.headers,547 headers: input.headers,
548 oauth2: input.oauth2,
622 }),549 }),
623 550
624 startOAuth: (input) =>551 startOAuth: (input) =>
@@ -663,35 +590,61 @@ export const openApiPlugin = definePlugin( · 38 unmodified lines
663 ),590 ),
664 );591 );
665 592
666 const accessRef = yield* writeOAuthSecret({593 const connectionId = `openapi-oauth2-${randomUUID()}`;
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
677 const expiresAt =594 const expiresAt =
678 typeof tokenResponse.expires_in === "number"595 typeof tokenResponse.expires_in === "number"
679 ? Date.now() + tokenResponse.expires_in * 1000596 ? Date.now() + tokenResponse.expires_in * 1000
680 : null;597 : null;
681 598
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
682 const auth = new OAuth2Auth({639 const auth = new OAuth2Auth({
683 kind: "oauth2",640 kind: "oauth2",
641 connectionId,
684 securitySchemeName: input.securitySchemeName,642 securitySchemeName: input.securitySchemeName,
685 flow: "clientCredentials",643 flow: "clientCredentials",
686 tokenUrl: input.tokenUrl,644 tokenUrl: input.tokenUrl,
645 authorizationUrl: null,
687 clientIdSecretId: input.clientIdSecretId,646 clientIdSecretId: input.clientIdSecretId,
688 clientSecretSecretId: input.clientSecretSecretId,647 clientSecretSecretId: input.clientSecretSecretId ?? null,
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,
695 scopes: scopesArray,648 scopes: scopesArray,
696 });649 });
697 650
@@ -705,6 +658,7 @@ export const openApiPlugin = definePlugin( · 7 unmodified lines
705 // authorizationCode path.658 // authorizationCode path.
706 const sessionId = randomUUID();659 const sessionId = randomUUID();
707 const codeVerifier = createPkceCodeVerifier();660 const codeVerifier = createPkceCodeVerifier();
661 const connectionId = `openapi-oauth2-${randomUUID()}`;
708 662
709 yield* ctx.storage663 yield* ctx.storage
710 .putOAuthSession(664 .putOAuthSession(
@@ -714,12 +668,14 @@ export const openApiPlugin = definePlugin( · 3 unmodified lines
714 securitySchemeName: input.securitySchemeName,668 securitySchemeName: input.securitySchemeName,
715 flow: input.flow,669 flow: input.flow,
716 tokenUrl: input.tokenUrl,670 tokenUrl: input.tokenUrl,
671 authorizationUrl: input.authorizationUrl,
717 redirectUrl: input.redirectUrl,672 redirectUrl: input.redirectUrl,
718 clientIdSecretId: input.clientIdSecretId,673 clientIdSecretId: input.clientIdSecretId,
719 clientSecretSecretId: input.clientSecretSecretId ?? null,674 clientSecretSecretId: input.clientSecretSecretId ?? null,
720 tokenScope,675 tokenScope,
721 accessTokenSecretId: input.accessTokenSecretId,676 connectionId,
722 refreshTokenSecretId: input.refreshTokenSecretId ?? null,677 accessTokenSecretId: `${connectionId}.access_token`,
678 refreshTokenSecretId: `${connectionId}.refresh_token`,
723 scopes: scopesArray,679 scopes: scopesArray,
724 codeVerifier,680 codeVerifier,
725 }),681 }),
@@ -800,57 +756,65 @@ export const openApiPlugin = definePlugin( · 74 unmodified lines
800 ),756 ),
801 );757 );
802 758
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
837 const expiresAt =759 const expiresAt =
838 typeof tokenResponse.expires_in === "number"760 typeof tokenResponse.expires_in === "number"
839 ? Date.now() + tokenResponse.expires_in * 1000761 ? Date.now() + tokenResponse.expires_in * 1000
840 : null;762 : null;
841 763
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
842 return new OAuth2Auth({809 return new OAuth2Auth({
843 kind: "oauth2",810 kind: "oauth2",
811 connectionId: session.connectionId,
844 securitySchemeName: session.securitySchemeName,812 securitySchemeName: session.securitySchemeName,
845 flow: session.flow,813 flow: "authorizationCode",
846 tokenUrl: session.tokenUrl,814 tokenUrl: session.tokenUrl,
815 authorizationUrl: session.authorizationUrl,
847 clientIdSecretId: session.clientIdSecretId,816 clientIdSecretId: session.clientIdSecretId,
848 clientSecretSecretId: session.clientSecretSecretId,817 clientSecretSecretId: session.clientSecretSecretId,
849 accessTokenSecretId: accessRef.id,
850 refreshTokenSecretId: refreshSecretId,
851 tokenType: tokenResponse.token_type ?? "Bearer",
852 expiresAt,
853 scope: tokenResponse.scope ?? null,
854 scopes: [...session.scopes],818 scopes: [...session.scopes],
855 });819 });
856 }),820 }),
@@ -946,101 +910,27 @@ export const openApiPlugin = definePlugin( · 89 unmodified lines
946 { get: ctx.secrets.get },910 { get: ctx.secrets.get },
947 );911 );
948 912
949 // If the source has OAuth2 auth, resolve/refresh access token and913 // If the source has OAuth2 auth, resolve a guaranteed-fresh
950 // inject Authorization header (wins over a manually-set one).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.
951 if (Option.isSome(config.oauth2)) {917 if (Option.isSome(config.oauth2)) {
952 const auth = config.oauth2.value;918 const auth = config.oauth2.value;
953 const accessToken =919 const accessToken = yield* ctx.connections
954 auth.flow === "clientCredentials"920 .accessToken(auth.connectionId)
955 ? yield* resolveClientCredentialsAccessToken({921 .pipe(
956 auth,922 Effect.mapError(
957 source,923 (err) =>
958 scopeId: ctx.scopes[0]!.id as string,924 new Error(
959 secretsGet: (id) =>925 `OAuth connection resolution failed: ${
960 ctx.secrets926 "message" in err
961 .get(id)927 ? (err as { message: string }).message
962 .pipe(Effect.mapError((err) => new Error(err.message))),928 : String(err)
963 secretsSet: (args) =>929 }`,
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),
1002 ),930 ),
1003 ),931 ),
1004 setValue: ({ secretId, value, name }) =>932 );
1005 // Refresh writes land at the innermost scope of the933 resolvedHeaders["Authorization"] = `Bearer ${accessToken}`;
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}`;
1044 }934 }
1045 935
1046 const result = yield* invokeWithLayer(936 const result = yield* invokeWithLayer(
@@ -1129,6 +1019,115 @@ export const openApiPlugin = definePlugin( · 82 unmodified lines
1129 namespace,1019 namespace,
1130 });1020 });
1131 }),1021 }),
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 ],
1132 };1131 };
1133 },1132 },
1134);1133);
packages/plugins/openapi/src/sdk/store.ts+24−5
@@ -111,9 +111,6 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 110 unmodified lines
111 // securitySchemeName, with the secret ids that back its tokens.111 // securitySchemeName, with the secret ids that back its tokens.
112 oauth2: Schema.optional(OAuth2Auth),112 oauth2: Schema.optional(OAuth2Auth),
113 }),113 }),
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.
117 invocationConfig: Schema.optional(InvocationConfig),114 invocationConfig: Schema.optional(InvocationConfig),
118}) {}115}) {}
119 116
@@ -137,7 +134,16 @@ const encodeInvocationConfig = Schema.encodeSync(InvocationConfig); · 17 unmodified lines
137const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig);134const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig);
138 135
139const encodeOAuth2 = Schema.encodeSync(OAuth2Auth);136const encodeOAuth2 = Schema.encodeSync(OAuth2Auth);
140const 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.
144const decodeOAuth2Option = Schema.decodeUnknownOption(OAuth2Auth);
145const decodeOAuth2 = (value: unknown): OAuth2Auth | undefined =>
146 Option.getOrUndefined(decodeOAuth2Option(value));
141 147
142const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession);148const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession);
143const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession);149const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession);
@@ -239,8 +245,21 @@ export const makeDefaultOpenapiStore = ({ · 95 unmodified lines
239 ? undefined245 ? undefined
240 : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw);246 : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw);
241 const headers = decodeHeaders(row.headers);247 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;
242 const invocationConfig = decodeInvocationConfig(259 const invocationConfig = decodeInvocationConfig(
243 asJsonObject(row.invocation_config),260 nestedOAuth2Valid
261 ? rawInvocation
262 : { ...rawInvocation, oauth2: undefined },
244 );263 );
245 return {264 return {
246 namespace: row.id as string,265 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
113export type HeaderValue = typeof HeaderValue.Type;113export type HeaderValue = typeof HeaderValue.Type;
114 114
115// ---------------------------------------------------------------------------115// ---------------------------------------------------------------------------
116// OAuth2 auth — applied as Authorization: Bearer <token> at invocation time.116// OAuth2 auth — points at the Connection that owns live tokens, and also
117// Tokens are stored as secrets; the bearer value is resolved (and refreshed)117// carries enough API-level config to kick off a fresh sign-in from the
118// on every request via withRefreshedAccessToken.118// source detail UI without needing the connection to still exist.
119// ---------------------------------------------------------------------------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
136export const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");
137export type OAuth2Flow = typeof OAuth2Flow.Type;
120 138
121export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({139export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({
122 kind: Schema.Literal("oauth2"),140 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. */
124 securitySchemeName: Schema.String,150 securitySchemeName: Schema.String,
125 /**151 /** OAuth2 grant type used for this source. Determines which flow the
126 * Which flow produced this auth. `clientCredentials` sources have no152 * sign-in button runs (authorizationCode opens a browser popup;
127 * refresh token — the invoker re-exchanges via client_credentials when153 * clientCredentials is server-to-server). */
128 * the access token is near expiry.154 flow: OAuth2Flow,
129 */155 /** Absolute token endpoint URL. */
130 flow: Schema.Literal("authorizationCode", "clientCredentials"),
131 /** Token endpoint (from the flow) — used for refresh / re-exchange. */
132 tokenUrl: Schema.String,156 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. */
133 clientIdSecretId: Schema.String,161 clientIdSecretId: Schema.String,
162 /** Secret id holding the OAuth client_secret. Optional for public
163 * clients (PKCE-only authorizationCode). */
134 clientSecretSecretId: Schema.NullOr(Schema.String),164 clientSecretSecretId: Schema.NullOr(Schema.String),
135 accessTokenSecretId: Schema.String,165 /** OAuth scopes requested on sign-in. Stored as a static list so the
136 refreshTokenSecretId: Schema.NullOr(Schema.String),166 * sign-in button can re-request the same capabilities without having
137 tokenType: Schema.String,167 * to re-derive them from the OpenAPI spec. */
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). */
143 scopes: Schema.Array(Schema.String),168 scopes: Schema.Array(Schema.String),
144}) {}169}) {}
145 170
@@ -171,38 +196,39 @@ export class InvocationConfig extends Schema.Class<InvocationConfig>("Invocation · 25 unmodified lines
171}) {}196}) {}
172 197
173// ---------------------------------------------------------------------------198// ---------------------------------------------------------------------------
174// Pending OAuth session — persisted between startOAuth and completeOAuth199// 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.
175// ---------------------------------------------------------------------------203// ---------------------------------------------------------------------------
176 204
177export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>(205export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>(
178 "OpenApiOAuthSession",206 "OpenApiOAuthSession",
179)({207)({
180 /** Display name used for the stored token secret labels. */208 /** Display name used for the resulting Connection's identity label. */
181 displayName: Schema.String,209 displayName: Schema.String,
182 securitySchemeName: Schema.String,210 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. */
184 flow: Schema.Literal("authorizationCode"),214 flow: Schema.Literal("authorizationCode"),
185 tokenUrl: Schema.String,215 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,
186 redirectUrl: Schema.String,219 redirectUrl: Schema.String,
187 clientIdSecretId: Schema.String,220 clientIdSecretId: Schema.String,
188 clientSecretSecretId: Schema.NullOr(Schema.String),221 clientSecretSecretId: Schema.NullOr(Schema.String),
189 /**222 /** Executor scope that will own the resulting Connection (and its
190 * Executor scope id where the minted access/refresh token secrets will223 * backing secret rows). Typically the innermost (per-user) scope. */
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 */
196 tokenScope: Schema.String,224 tokenScope: Schema.String,
197 /**225 /** Pre-decided Connection id stamped at `completeOAuth` time. */
198 * Pre-decided secret ids for the minted access + refresh tokens. The226 connectionId: Schema.String,
199 * caller names these so the source's `OAuth2Auth` can reference the227 /** Pre-decided secret ids for the Connection's access + refresh
200 * same ids regardless of which scope actually owns the value —228 * tokens. Fixed at session creation so a retried callback lands
201 * `ctx.secrets.get` resolves them via fallthrough (innermost first),229 * on the same ids. */
202 * so per-user tokens shadow org-level fallbacks on the same source.
203 */
204 accessTokenSecretId: Schema.String,230 accessTokenSecretId: Schema.String,
205 refreshTokenSecretId: Schema.NullOr(Schema.String),231 refreshTokenSecretId: Schema.String,
206 scopes: Schema.Array(Schema.String),232 scopes: Schema.Array(Schema.String),
207 codeVerifier: Schema.String,233 codeVerifier: Schema.String,
208}) {}234}) {}
packages/react/src/api/atoms.tsx+12−0
@@ -64,6 +64,13 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 63 unmodified lines
64 reactivityKeys: [ReactivityKey.secrets],64 reactivityKeys: [ReactivityKey.secrets],
65 });65 });
66 66
67export const connectionsAtom = (scopeId: ScopeId) =>
68 ExecutorApiClient.query("connections", "list", {
69 path: { scopeId },
70 timeToLive: "30 seconds",
71 reactivityKeys: [ReactivityKey.connections],
72 });
73
67// ---------------------------------------------------------------------------74// ---------------------------------------------------------------------------
68// Mutation atoms — reactivityKeys must be passed at call site (effect-atom75// Mutation atoms — reactivityKeys must be passed at call site (effect-atom
69// does not accept them at definition time). See `reactivity-keys.tsx` for the76// 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
76 83
77export const removeSecret = ExecutorApiClient.mutation("secrets", "remove");84export const removeSecret = ExecutorApiClient.mutation("secrets", "remove");
78 85
86export const removeConnection = ExecutorApiClient.mutation(
87 "connections",
88 "remove",
89);
90
79export const removeSource = ExecutorApiClient.mutation("sources", "remove");91export const removeSource = ExecutorApiClient.mutation("sources", "remove");
80 92
81export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");93export 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
21 sources: "sources",21 sources: "sources",
22 tools: "tools",22 tools: "tools",
23 secrets: "secrets",23 secrets: "secrets",
24 connections: "connections",
24 scope: "scope",25 scope: "scope",
25 // cloud-only resources26 // cloud-only resources
26 orgMembers: "org:members",27 orgMembers: "org:members",
@@ -35,6 +36,15 @@ export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as c · 8 unmodified lines
35/** Mutations that mint or revoke secrets. */36/** Mutations that mint or revoke secrets. */
36export const secretWriteKeys = [ReactivityKey.secrets] as const;37export const secretWriteKeys = [ReactivityKey.secrets] as const;
37 38
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. */
43export const connectionWriteKeys = [
44 ReactivityKey.connections,
45 ReactivityKey.secrets,
46] as const;
47
38/** Mutations that change scope membership/info. */48/** Mutations that change scope membership/info. */
39export const scopeWriteKeys = [ReactivityKey.scope] as const;49export const scopeWriteKeys = [ReactivityKey.scope] as const;
40 50
packages/react/src/pages/connections.tsx+185−0
@@ -0,0 +1,185 @@
1import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
2import { ConnectionId } from "@executor/sdk";
3
4import { connectionsAtom, removeConnection } from "../api/atoms";
5import { connectionWriteKeys } from "../api/reactivity-keys";
6import { useScope } from "../hooks/use-scope";
7import { Badge } from "../components/badge";
8import { Button } from "../components/button";
9import {
10 CardStack,
11 CardStackContent,
12 CardStackEntry,
13 CardStackEntryActions,
14 CardStackEntryContent,
15 CardStackEntryDescription,
16 CardStackEntryTitle,
17 CardStackHeader,
18} from "../components/card-stack";
19import {
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.
32const providerDisplayNames: Record<string, string> = {
33 "mcp:oauth2": "MCP",
34 "openapi:oauth2": "OpenAPI",
35 "google-discovery:oauth2": "Google",
36};
37
38const displayProvider = (provider: string): string =>
39 providerDisplayNames[provider] ?? provider;
40
41// ---------------------------------------------------------------------------
42// Connection row
43// ---------------------------------------------------------------------------
44
45function 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
104export 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
127 </div>127 </div>
128 128
129 <div className="flex shrink-0 items-center gap-2">129 <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
130 {canEdit && editPlugin && !editing && (136 {canEdit && editPlugin && !editing && (
131 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>137 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
132 Edit138 Edit
packages/react/src/plugins/source-plugin.tsx+13−0
@@ -74,6 +74,19 @@ export interface SourcePlugin { · 73 unmodified lines
74 readonly sourceId: string;74 readonly sourceId: string;
75 }>;75 }>;
76 76
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
77 /** Curated presets shown on the sources page for quick-add */90 /** Curated presets shown on the sources page for quick-add */
78 readonly presets?: readonly SourcePreset[];91 readonly presets?: readonly SourcePreset[];
79}92}