History

Refactor sources into recipes and actor credentials

Repair migrated source recipes on bootstrap Add source recipe regression coverage

Rhys Sullivan committed 8 months ago

Commit e0b3e171 parent f41d31434 files · +3954 −363Browse files

Unified · Split

34 files
  1. packages/control-plane/drizzle/20260310120000_source_recipes_and_actor_credentials/migration.sql +491 −0
  2. packages/control-plane/src/api/oauth/http.ts +2 −1
  3. packages/control-plane/src/api/sources/http.ts +4 −1
  4. packages/control-plane/src/persistence/control-plane-rows.test.ts +184 −28
  5. packages/control-plane/src/persistence/index.ts +18 −3
  6. packages/control-plane/src/persistence/post-migrations.test.ts +269 −0
  7. packages/control-plane/src/persistence/post-migrations.ts +321 −0
  8. packages/control-plane/src/persistence/repos/credentials-repo.ts +160 −10
  9. packages/control-plane/src/persistence/repos/index.ts +5 −1
  10. packages/control-plane/src/persistence/repos/organizations-repo.ts +89 −3
  11. packages/control-plane/src/persistence/repos/secret-materials-repo.ts +3 −7
  12. packages/control-plane/src/persistence/repos/source-auth-sessions-repo.ts +35 −25
  13. packages/control-plane/src/persistence/repos/source-oauth-clients-repo.ts +79 −0
  14. packages/control-plane/src/persistence/repos/source-recipe-documents-repo.ts +77 −0
  15. packages/control-plane/src/persistence/repos/source-recipe-operations-repo.ts +77 −0
  16. packages/control-plane/src/persistence/repos/source-recipe-revisions-repo.ts +111 −0
  17. packages/control-plane/src/persistence/repos/source-recipes-repo.ts +67 −0
  18. packages/control-plane/src/persistence/repos/sources-repo.ts +38 −1
  19. packages/control-plane/src/persistence/repos/workspaces-repo.ts +89 −3
  20. packages/control-plane/src/persistence/schema.ts +223 −21
  21. packages/control-plane/src/runtime/source-auth-service.test.ts +18 −22
  22. packages/control-plane/src/runtime/source-auth-service.ts +186 −75
  23. packages/control-plane/src/runtime/source-definitions.test.ts +435 −0
  24. packages/control-plane/src/runtime/source-definitions.ts +214 −54
  25. packages/control-plane/src/runtime/source-store.test.ts +347 −18
  26. packages/control-plane/src/runtime/source-store.ts +187 −66
  27. packages/control-plane/src/schema/enums.ts +22 −0
  28. packages/control-plane/src/schema/ids.ts +10 −0
  29. packages/control-plane/src/schema/index.ts +2 −0
  30. packages/control-plane/src/schema/models/credential.ts +4 −19
  31. packages/control-plane/src/schema/models/source-auth-session.ts +7 −4
  32. packages/control-plane/src/schema/models/source-oauth-client.ts +23 −0
  33. packages/control-plane/src/schema/models/source-recipe.ts +140 −0
  34. packages/control-plane/src/schema/models/source.ts +17 −1
packages/control-plane/drizzle/20260310120000_source_recipes_and_actor_credentials/migration.sql+491−0
@@ -0,0 +1,491 @@
1+ALTER TABLE "sources" ADD COLUMN "recipe_id" text;--> statement-breakpoint
2+ALTER TABLE "sources" ADD COLUMN "recipe_revision_id" text;--> statement-breakpoint
3+ALTER TABLE "sources" ADD COLUMN "binding_config_json" text;--> statement-breakpoint
4+
5+CREATE TABLE "source_recipes" (
6+ "id" text PRIMARY KEY NOT NULL,
7+ "kind" text NOT NULL,
8+ "importer_kind" text NOT NULL,
9+ "provider_key" text NOT NULL,
10+ "name" text NOT NULL,
11+ "summary" text,
12+ "visibility" text NOT NULL,
13+ "latest_revision_id" text NOT NULL,
14+ "created_at" bigint NOT NULL,
15+ "updated_at" bigint NOT NULL,
16+ CONSTRAINT "source_recipes_kind_check" CHECK ("kind" in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')),
17+ CONSTRAINT "source_recipes_importer_kind_check" CHECK ("importer_kind" in (
18+ 'openapi',
19+ 'google_discovery',
20+ 'postman_collection',
21+ 'snippet_bundle',
22+ 'graphql_introspection',
23+ 'mcp_manifest',
24+ 'internal_manifest'
25+ )),
26+ CONSTRAINT "source_recipes_visibility_check" CHECK ("visibility" in ('private', 'workspace', 'organization', 'public'))
27+);--> statement-breakpoint
28+
29+CREATE TABLE "source_recipe_revisions" (
30+ "id" text PRIMARY KEY NOT NULL,
31+ "recipe_id" text NOT NULL,
32+ "revision_number" bigint NOT NULL,
33+ "source_config_json" text NOT NULL,
34+ "manifest_json" text,
35+ "manifest_hash" text,
36+ "created_at" bigint NOT NULL,
37+ "updated_at" bigint NOT NULL
38+);--> statement-breakpoint
39+
40+CREATE TABLE "source_recipe_documents" (
41+ "id" text PRIMARY KEY NOT NULL,
42+ "recipe_revision_id" text NOT NULL,
43+ "document_kind" text NOT NULL,
44+ "document_key" text NOT NULL,
45+ "content_text" text NOT NULL,
46+ "content_hash" text NOT NULL,
47+ "fetched_at" bigint,
48+ "created_at" bigint NOT NULL,
49+ "updated_at" bigint NOT NULL,
50+ CONSTRAINT "source_recipe_documents_kind_check" CHECK ("document_kind" in (
51+ 'google_discovery',
52+ 'openapi',
53+ 'postman_collection',
54+ 'postman_environment',
55+ 'graphql_introspection',
56+ 'mcp_manifest'
57+ ))
58+);--> statement-breakpoint
59+
60+CREATE TABLE "source_recipe_operations" (
61+ "id" text PRIMARY KEY NOT NULL,
62+ "recipe_revision_id" text NOT NULL,
63+ "operation_key" text NOT NULL,
64+ "transport_kind" text NOT NULL,
65+ "tool_id" text NOT NULL,
66+ "title" text,
67+ "description" text,
68+ "operation_kind" text NOT NULL,
69+ "search_text" text NOT NULL,
70+ "input_schema_json" text,
71+ "output_schema_json" text,
72+ "provider_kind" text NOT NULL,
73+ "provider_data_json" text,
74+ "mcp_tool_name" text,
75+ "openapi_method" text,
76+ "openapi_path_template" text,
77+ "openapi_operation_hash" text,
78+ "openapi_raw_tool_id" text,
79+ "openapi_operation_id" text,
80+ "openapi_tags_json" text,
81+ "openapi_request_body_required" boolean,
82+ "graphql_operation_type" text,
83+ "graphql_operation_name" text,
84+ "created_at" bigint NOT NULL,
85+ "updated_at" bigint NOT NULL,
86+ CONSTRAINT "source_recipe_operations_transport_kind_check" CHECK ("transport_kind" in ('http', 'graphql', 'mcp', 'internal')),
87+ CONSTRAINT "source_recipe_operations_kind_check" CHECK ("operation_kind" in ('read', 'write', 'delete', 'unknown')),
88+ CONSTRAINT "source_recipe_operations_provider_kind_check" CHECK ("provider_kind" in ('mcp', 'openapi', 'graphql', 'internal'))
89+);--> statement-breakpoint
90+
91+CREATE TABLE "workspace_source_oauth_clients" (
92+ "id" text PRIMARY KEY NOT NULL,
93+ "workspace_id" text NOT NULL,
94+ "source_id" text NOT NULL,
95+ "provider_key" text NOT NULL,
96+ "client_id" text NOT NULL,
97+ "client_secret_provider_id" text,
98+ "client_secret_handle" text,
99+ "client_metadata_json" text,
100+ "created_at" bigint NOT NULL,
101+ "updated_at" bigint NOT NULL
102+);--> statement-breakpoint
103+
104+UPDATE "sources"
105+SET
106+ "recipe_id" = 'src_recipe_' || md5("workspace_id" || ':' || "source_id"),
107+ "recipe_revision_id" = 'src_recipe_rev_' || md5("workspace_id" || ':' || "source_id"),
108+ "binding_config_json" = null;--> statement-breakpoint
109+
110+INSERT INTO "source_recipes" (
111+ "id",
112+ "kind",
113+ "importer_kind",
114+ "provider_key",
115+ "name",
116+ "summary",
117+ "visibility",
118+ "latest_revision_id",
119+ "created_at",
120+ "updated_at"
121+)
122+SELECT
123+ "s"."recipe_id",
124+ CASE
125+ WHEN "s"."kind" = 'openapi' THEN 'http_recipe'
126+ WHEN "s"."kind" = 'graphql' THEN 'graphql_recipe'
127+ WHEN "s"."kind" = 'mcp' THEN 'mcp_recipe'
128+ ELSE 'internal_recipe'
129+ END,
130+ CASE
131+ WHEN "s"."kind" = 'openapi' THEN 'openapi'
132+ WHEN "s"."kind" = 'graphql' THEN 'graphql_introspection'
133+ WHEN "s"."kind" = 'mcp' THEN 'mcp_manifest'
134+ ELSE 'internal_manifest'
135+ END,
136+ CASE
137+ WHEN "s"."kind" = 'openapi' THEN 'generic_http'
138+ WHEN "s"."kind" = 'graphql' THEN 'generic_graphql'
139+ WHEN "s"."kind" = 'mcp' THEN 'generic_mcp'
140+ ELSE 'generic_internal'
141+ END,
142+ "s"."name",
143+ null,
144+ 'workspace',
145+ "s"."recipe_revision_id",
146+ "s"."created_at",
147+ "s"."updated_at"
148+FROM "sources" AS "s";--> statement-breakpoint
149+
150+INSERT INTO "source_recipe_revisions" (
151+ "id",
152+ "recipe_id",
153+ "revision_number",
154+ "source_config_json",
155+ "manifest_json",
156+ "manifest_hash",
157+ "created_at",
158+ "updated_at"
159+)
160+SELECT
161+ "s"."recipe_revision_id",
162+ "s"."recipe_id",
163+ 1,
164+ CASE
165+ WHEN "s"."kind" = 'mcp' THEN jsonb_build_object(
166+ 'kind', 'mcp',
167+ 'endpoint', "s"."endpoint",
168+ 'transport', "s"."transport",
169+ 'queryParams', COALESCE("s"."query_params_json"::jsonb, 'null'::jsonb),
170+ 'headers', COALESCE("s"."headers_json"::jsonb, 'null'::jsonb)
171+ )::text
172+ WHEN "s"."kind" = 'openapi' THEN jsonb_build_object(
173+ 'kind', 'openapi',
174+ 'endpoint', "s"."endpoint",
175+ 'specUrl', COALESCE("s"."spec_url", "s"."endpoint"),
176+ 'defaultHeaders', COALESCE("s"."default_headers_json"::jsonb, 'null'::jsonb)
177+ )::text
178+ WHEN "s"."kind" = 'graphql' THEN jsonb_build_object(
179+ 'kind', 'graphql',
180+ 'endpoint', "s"."endpoint",
181+ 'defaultHeaders', COALESCE("s"."default_headers_json"::jsonb, 'null'::jsonb)
182+ )::text
183+ ELSE jsonb_build_object(
184+ 'kind', 'internal',
185+ 'endpoint', "s"."endpoint"
186+ )::text
187+ END,
188+ null,
189+ "s"."source_hash",
190+ "s"."created_at",
191+ "s"."updated_at"
192+FROM "sources" AS "s";--> statement-breakpoint
193+
194+INSERT INTO "source_recipe_documents" (
195+ "id",
196+ "recipe_revision_id",
197+ "document_kind",
198+ "document_key",
199+ "content_text",
200+ "content_hash",
201+ "fetched_at",
202+ "created_at",
203+ "updated_at"
204+)
205+SELECT
206+ 'src_recipe_doc_' || md5("s"."workspace_id" || ':' || "s"."source_id" || ':primary'),
207+ "s"."recipe_revision_id",
208+ CASE
209+ WHEN "s"."kind" = 'openapi' THEN 'openapi'
210+ WHEN "s"."kind" = 'graphql' THEN 'graphql_introspection'
211+ ELSE 'mcp_manifest'
212+ END,
213+ CASE
214+ WHEN "s"."kind" = 'openapi' THEN COALESCE("s"."spec_url", "s"."endpoint")
215+ ELSE "s"."endpoint"
216+ END,
217+ "s"."source_document_text",
218+ md5("s"."source_document_text"),
219+ "s"."updated_at",
220+ "s"."created_at",
221+ "s"."updated_at"
222+FROM "sources" AS "s"
223+WHERE "s"."source_document_text" IS NOT NULL;--> statement-breakpoint
224+
225+INSERT INTO "source_recipe_operations" (
226+ "id",
227+ "recipe_revision_id",
228+ "operation_key",
229+ "transport_kind",
230+ "tool_id",
231+ "title",
232+ "description",
233+ "operation_kind",
234+ "search_text",
235+ "input_schema_json",
236+ "output_schema_json",
237+ "provider_kind",
238+ "provider_data_json",
239+ "mcp_tool_name",
240+ "openapi_method",
241+ "openapi_path_template",
242+ "openapi_operation_hash",
243+ "openapi_raw_tool_id",
244+ "openapi_operation_id",
245+ "openapi_tags_json",
246+ "openapi_request_body_required",
247+ "graphql_operation_type",
248+ "graphql_operation_name",
249+ "created_at",
250+ "updated_at"
251+)
252+SELECT
253+ 'src_recipe_op_' || md5("ta"."workspace_id" || ':' || "ta"."source_id" || ':' || "ta"."path"),
254+ "s"."recipe_revision_id",
255+ "ta"."tool_id",
256+ CASE
257+ WHEN "ta"."provider_kind" = 'openapi' THEN 'http'
258+ ELSE 'mcp'
259+ END,
260+ "ta"."tool_id",
261+ "ta"."title",
262+ "ta"."description",
263+ CASE
264+ WHEN "ta"."provider_kind" = 'openapi' AND upper(COALESCE("ta"."openapi_method", '')) IN ('GET', 'HEAD') THEN 'read'
265+ WHEN "ta"."provider_kind" = 'openapi' AND upper(COALESCE("ta"."openapi_method", '')) = 'DELETE' THEN 'delete'
266+ WHEN "ta"."provider_kind" = 'openapi' THEN 'write'
267+ ELSE 'unknown'
268+ END,
269+ "ta"."search_text",
270+ "ta"."input_schema_json",
271+ "ta"."output_schema_json",
272+ "ta"."provider_kind",
273+ CASE
274+ WHEN "ta"."provider_kind" = 'mcp' THEN jsonb_build_object(
275+ 'kind', 'mcp',
276+ 'toolId', "ta"."tool_id",
277+ 'toolName', COALESCE("ta"."mcp_tool_name", "ta"."title", "ta"."path"),
278+ 'description', "ta"."description"
279+ )::text
280+ ELSE jsonb_build_object(
281+ 'kind', 'openapi',
282+ 'toolId', "ta"."tool_id",
283+ 'rawToolId', "ta"."openapi_raw_tool_id",
284+ 'operationId', "ta"."openapi_operation_id",
285+ 'tags', COALESCE("ta"."openapi_tags_json"::jsonb, '[]'::jsonb),
286+ 'method', "ta"."openapi_method",
287+ 'path', "ta"."openapi_path_template",
288+ 'operationHash', "ta"."openapi_operation_hash"
289+ )::text
290+ END,
291+ "ta"."mcp_tool_name",
292+ "ta"."openapi_method",
293+ "ta"."openapi_path_template",
294+ "ta"."openapi_operation_hash",
295+ "ta"."openapi_raw_tool_id",
296+ "ta"."openapi_operation_id",
297+ "ta"."openapi_tags_json",
298+ "ta"."openapi_request_body_required",
299+ null,
300+ null,
301+ "ta"."created_at",
302+ "ta"."updated_at"
303+FROM "tool_artifacts" AS "ta"
304+JOIN "sources" AS "s"
305+ ON "s"."workspace_id" = "ta"."workspace_id"
306+ AND "s"."source_id" = "ta"."source_id";--> statement-breakpoint
307+
308+WITH "mcp_manifests" AS (
309+ SELECT
310+ "s"."workspace_id",
311+ "s"."source_id",
312+ "s"."recipe_revision_id",
313+ jsonb_build_object(
314+ 'version', 1,
315+ 'tools', COALESCE(
316+ jsonb_agg(
317+ jsonb_build_object(
318+ 'toolId', "ta"."tool_id",
319+ 'toolName', COALESCE("ta"."mcp_tool_name", "ta"."title", "ta"."path"),
320+ 'description', "ta"."description",
321+ 'inputSchemaJson', "ta"."input_schema_json",
322+ 'outputSchemaJson', "ta"."output_schema_json"
323+ )
324+ ORDER BY "ta"."path"
325+ ) FILTER (WHERE "ta"."provider_kind" = 'mcp'),
326+ '[]'::jsonb
327+ )
328+ )::text AS "manifest_json"
329+ FROM "sources" AS "s"
330+ LEFT JOIN "tool_artifacts" AS "ta"
331+ ON "ta"."workspace_id" = "s"."workspace_id"
332+ AND "ta"."source_id" = "s"."source_id"
333+ WHERE "s"."kind" = 'mcp'
334+ GROUP BY "s"."workspace_id", "s"."source_id", "s"."recipe_revision_id"
335+)
336+UPDATE "source_recipe_revisions" AS "rev"
337+SET
338+ "manifest_json" = "mcp"."manifest_json",
339+ "manifest_hash" = md5("mcp"."manifest_json")
340+FROM "mcp_manifests" AS "mcp"
341+WHERE "rev"."id" = "mcp"."recipe_revision_id";--> statement-breakpoint
342+
343+INSERT INTO "source_recipe_documents" (
344+ "id",
345+ "recipe_revision_id",
346+ "document_kind",
347+ "document_key",
348+ "content_text",
349+ "content_hash",
350+ "fetched_at",
351+ "created_at",
352+ "updated_at"
353+)
354+SELECT
355+ 'src_recipe_doc_' || md5("s"."workspace_id" || ':' || "s"."source_id" || ':mcp_manifest'),
356+ "s"."recipe_revision_id",
357+ 'mcp_manifest',
358+ "s"."endpoint",
359+ "rev"."manifest_json",
360+ md5("rev"."manifest_json"),
361+ "s"."updated_at",
362+ "s"."created_at",
363+ "s"."updated_at"
364+FROM "sources" AS "s"
365+JOIN "source_recipe_revisions" AS "rev"
366+ ON "rev"."id" = "s"."recipe_revision_id"
367+WHERE "s"."kind" = 'mcp'
368+ AND "rev"."manifest_json" IS NOT NULL
369+ AND NOT EXISTS (
370+ SELECT 1
371+ FROM "source_recipe_documents" AS "doc"
372+ WHERE "doc"."recipe_revision_id" = "s"."recipe_revision_id"
373+ AND "doc"."document_kind" = 'mcp_manifest'
374+ AND "doc"."document_key" = "s"."endpoint"
375+ );--> statement-breakpoint
376+
377+ALTER TABLE "sources" ALTER COLUMN "recipe_id" SET NOT NULL;--> statement-breakpoint
378+ALTER TABLE "sources" ALTER COLUMN "recipe_revision_id" SET NOT NULL;--> statement-breakpoint
379+CREATE INDEX "sources_workspace_recipe_idx" ON "sources" ("workspace_id","recipe_id","updated_at","source_id");--> statement-breakpoint
380+
381+CREATE TABLE "workspace_source_credentials" (
382+ "id" text PRIMARY KEY NOT NULL,
383+ "workspace_id" text NOT NULL,
384+ "source_id" text NOT NULL,
385+ "actor_account_id" text,
386+ "auth_kind" text NOT NULL,
387+ "auth_header_name" text NOT NULL,
388+ "auth_prefix" text NOT NULL,
389+ "token_provider_id" text NOT NULL,
390+ "token_handle" text NOT NULL,
391+ "refresh_token_provider_id" text,
392+ "refresh_token_handle" text,
393+ "created_at" bigint NOT NULL,
394+ "updated_at" bigint NOT NULL,
395+ CONSTRAINT "credentials_auth_kind_check" CHECK ("auth_kind" in ('bearer', 'oauth2'))
396+);--> statement-breakpoint
397+
398+INSERT INTO "workspace_source_credentials" (
399+ "id",
400+ "workspace_id",
401+ "source_id",
402+ "actor_account_id",
403+ "auth_kind",
404+ "auth_header_name",
405+ "auth_prefix",
406+ "token_provider_id",
407+ "token_handle",
408+ "refresh_token_provider_id",
409+ "refresh_token_handle",
410+ "created_at",
411+ "updated_at"
412+)
413+SELECT
414+ CASE
415+ WHEN count(*) OVER (PARTITION BY "c"."id") = 1 THEN "c"."id"
416+ ELSE "c"."id" || ':' || "scb"."source_id"
417+ END,
418+ "c"."workspace_id",
419+ "scb"."source_id",
420+ null,
421+ "c"."auth_kind",
422+ "c"."auth_header_name",
423+ "c"."auth_prefix",
424+ "c"."token_provider_id",
425+ "c"."token_handle",
426+ "c"."refresh_token_provider_id",
427+ "c"."refresh_token_handle",
428+ "c"."created_at",
429+ "c"."updated_at"
430+FROM "credentials" AS "c"
431+JOIN "source_credential_bindings" AS "scb"
432+ ON "scb"."workspace_id" = "c"."workspace_id"
433+ AND "scb"."credential_id" = "c"."id";--> statement-breakpoint
434+
435+DROP TABLE "credentials";--> statement-breakpoint
436+DROP TABLE "source_credential_bindings";--> statement-breakpoint
437+CREATE INDEX "credentials_workspace_idx" ON "workspace_source_credentials" ("workspace_id","updated_at","id");--> statement-breakpoint
438+CREATE UNIQUE INDEX "credentials_workspace_source_actor_idx" ON "workspace_source_credentials" ("workspace_id","source_id","actor_account_id");--> statement-breakpoint
439+CREATE INDEX "credentials_workspace_source_idx" ON "workspace_source_credentials" ("workspace_id","source_id","updated_at","id");--> statement-breakpoint
440+
441+ALTER TABLE "source_auth_sessions" ADD COLUMN "actor_account_id" text;--> statement-breakpoint
442+ALTER TABLE "source_auth_sessions" ADD COLUMN "provider_kind" text;--> statement-breakpoint
443+ALTER TABLE "source_auth_sessions" ADD COLUMN "session_data_json" text;--> statement-breakpoint
444+
445+UPDATE "source_auth_sessions"
446+SET
447+ "provider_kind" = 'mcp_oauth',
448+ "session_data_json" = jsonb_build_object(
449+ 'kind', 'mcp_oauth',
450+ 'endpoint', "endpoint",
451+ 'redirectUri', "redirect_uri",
452+ 'scope', "scope",
453+ 'resourceMetadataUrl', "resource_metadata_url",
454+ 'authorizationServerUrl', "authorization_server_url",
455+ 'resourceMetadataJson', "resource_metadata_json",
456+ 'authorizationServerMetadataJson', "authorization_server_metadata_json",
457+ 'clientInformationJson', "client_information_json",
458+ 'codeVerifier', "code_verifier",
459+ 'authorizationUrl', "authorization_url"
460+ )::text;--> statement-breakpoint
461+
462+ALTER TABLE "source_auth_sessions" ALTER COLUMN "provider_kind" SET NOT NULL;--> statement-breakpoint
463+ALTER TABLE "source_auth_sessions" ALTER COLUMN "session_data_json" SET NOT NULL;--> statement-breakpoint
464+DROP INDEX IF EXISTS "source_auth_sessions_pending_idx";--> statement-breakpoint
465+ALTER TABLE "source_auth_sessions" DROP CONSTRAINT "source_auth_sessions_strategy_check";--> statement-breakpoint
466+ALTER TABLE "source_auth_sessions" DROP COLUMN "strategy";--> statement-breakpoint
467+ALTER TABLE "source_auth_sessions" DROP COLUMN "endpoint";--> statement-breakpoint
468+ALTER TABLE "source_auth_sessions" DROP COLUMN "redirect_uri";--> statement-breakpoint
469+ALTER TABLE "source_auth_sessions" DROP COLUMN "scope";--> statement-breakpoint
470+ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_url";--> statement-breakpoint
471+ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_url";--> statement-breakpoint
472+ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_json";--> statement-breakpoint
473+ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_metadata_json";--> statement-breakpoint
474+ALTER TABLE "source_auth_sessions" DROP COLUMN "client_information_json";--> statement-breakpoint
475+ALTER TABLE "source_auth_sessions" DROP COLUMN "code_verifier";--> statement-breakpoint
476+ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_url";--> statement-breakpoint
477+ALTER TABLE "source_auth_sessions" ADD CONSTRAINT "source_auth_sessions_provider_kind_check" CHECK ("provider_kind" in ('mcp_oauth', 'oauth2_pkce'));--> statement-breakpoint
478+CREATE INDEX "source_auth_sessions_pending_idx" ON "source_auth_sessions" ("workspace_id","source_id","actor_account_id","status","updated_at","id");--> statement-breakpoint
479+
480+CREATE INDEX "source_recipes_provider_updated_idx" ON "source_recipes" ("provider_key","updated_at","id");--> statement-breakpoint
481+CREATE INDEX "source_recipes_visibility_updated_idx" ON "source_recipes" ("visibility","updated_at","id");--> statement-breakpoint
482+CREATE UNIQUE INDEX "source_recipe_revisions_recipe_revision_idx" ON "source_recipe_revisions" ("recipe_id","revision_number");--> statement-breakpoint
483+CREATE UNIQUE INDEX "source_recipe_revisions_recipe_manifest_idx" ON "source_recipe_revisions" ("recipe_id","manifest_hash");--> statement-breakpoint
484+CREATE INDEX "source_recipe_revisions_recipe_created_idx" ON "source_recipe_revisions" ("recipe_id","created_at","id");--> statement-breakpoint
485+CREATE UNIQUE INDEX "source_recipe_documents_revision_kind_key_idx" ON "source_recipe_documents" ("recipe_revision_id","document_kind","document_key");--> statement-breakpoint
486+CREATE INDEX "source_recipe_documents_revision_created_idx" ON "source_recipe_documents" ("recipe_revision_id","created_at","id");--> statement-breakpoint
487+CREATE UNIQUE INDEX "source_recipe_operations_revision_operation_key_idx" ON "source_recipe_operations" ("recipe_revision_id","operation_key");--> statement-breakpoint
488+CREATE INDEX "source_recipe_operations_revision_tool_idx" ON "source_recipe_operations" ("recipe_revision_id","tool_id","updated_at","id");--> statement-breakpoint
489+CREATE INDEX "source_recipe_operations_search_text_idx" ON "source_recipe_operations" USING gin (to_tsvector('simple', "search_text"));--> statement-breakpoint
490+CREATE UNIQUE INDEX "workspace_source_oauth_clients_workspace_source_provider_idx" ON "workspace_source_oauth_clients" ("workspace_id","source_id","provider_key");--> statement-breakpoint
491+CREATE INDEX "workspace_source_oauth_clients_workspace_idx" ON "workspace_source_oauth_clients" ("workspace_id","updated_at","source_id");
packages/control-plane/src/api/oauth/http.ts+2−1
@@ -241,13 +241,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 240 unmodified lines
241241 (handlers) =>
242242 handlers
243243 .handle("startSourceAuth", ({ path, payload }) =>
244− withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, () =>
244+ withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) =>
245245 withPolicy(requireWriteSources(path.workspaceId))(
246246 Effect.gen(function* () {
247247 const request = yield* HttpServerRequest.HttpServerRequest;
248248 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
249249 return yield* sourceAuthService.startSourceOAuthSession({
250250 workspaceId: path.workspaceId,
251+ actorAccountId: actor.principal.accountId,
251252 baseUrl: resolveRequestOrigin(request),
252253 displayName: payload.name,
253254 provider: {
packages/control-plane/src/api/sources/http.ts+4−1
@@ -675,7 +675,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 674 unmodified lines
675675 ),
676676 )
677677 .handle("connect", ({ path, payload }) =>
678− withWorkspaceRequestActor("sources.connect", path.workspaceId, () =>
678+ withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) =>
679679 withPolicy(requireWriteSources(path.workspaceId))(
680680 Effect.gen(function* () {
681681 const request = yield* HttpServerRequest.HttpServerRequest;
@@ -685,6 +685,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines
685685 if (payload.kind === "mcp") {
686686 return yield* sourceAuthService.connectMcpSource({
687687 workspaceId: path.workspaceId,
688+ actorAccountId: actor.principal.accountId,
688689 endpoint: payload.endpoint,
689690 name: payload.name,
690691 namespace: payload.namespace,
@@ -699,6 +700,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 8 unmodified lines
699700 return yield* sourceAuthService.addExecutorSource(
700701 {
701702 workspaceId: path.workspaceId,
703+ actorAccountId: actor.principal.accountId,
702704 executionId: null,
703705 interactionId: null,
704706 kind: "openapi",
@@ -715,6 +717,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 10 unmodified lines
715717 return yield* sourceAuthService.addExecutorSource(
716718 {
717719 workspaceId: path.workspaceId,
720+ actorAccountId: actor.principal.accountId,
718721 executionId: null,
719722 interactionId: null,
720723 kind: "graphql",
packages/control-plane/src/persistence/control-plane-rows.test.ts+184−28
@@ -9,17 +9,22 @@ import { · 8 unmodified lines
99 SecretMaterialIdSchema,
1010 SourceAuthSessionIdSchema,
1111 SourceIdSchema,
12+ SourceRecipeIdSchema,
13+ SourceRecipeRevisionIdSchema,
1214 WorkspaceIdSchema,
1315 } from "#schema";
1416 import * as Effect from "effect/Effect";
1517 import * as Option from "effect/Option";
18+import * as Scope from "effect/Scope";
1619
1720 import {
1821 createSqlControlPlanePersistence,
1922 type SqlControlPlanePersistence,
2023 } from "./index";
24+import { drizzleSchema } from "./schema";
2125
22−const makePersistence = Effect.acquireRelease(
26+const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
27+ Effect.acquireRelease(
2328 createSqlControlPlanePersistence({
2429 localDataDir: ":memory:",
2530 }),
@@ -28,20 +33,19 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines
2833 try: () => persistence.close(),
2934 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
3035 }).pipe(Effect.orDie),
31−);
36+ );
3237
33−const seedWorkspaceCredentialState = (input: {
38+const seedWorkspaceSourceState = (input: {
3439 persistence: SqlControlPlanePersistence;
3540 accountId: ReturnType<typeof AccountIdSchema.make>;
3641 organizationId: ReturnType<typeof OrganizationIdSchema.make>;
3742 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
3843 sourceId: ReturnType<typeof SourceIdSchema.make>;
39−}) =>
44+}): Effect.Effect<void, unknown, never> =>
4045 Effect.gen(function* () {
4146 const now = Date.now();
42− const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
43− const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);
44− const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
47+ const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
48+ const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`);
4549
4650 yield* input.persistence.rows.organizations.insert({
4751 id: input.organizationId,
@@ -63,6 +67,8 @@ const seedWorkspaceCredentialState = (input: { · 15 unmodified lines
6367 yield* input.persistence.rows.sources.insert({
6468 id: input.sourceId,
6569 workspaceId: input.workspaceId,
70+ recipeId,
71+ recipeRevisionId,
6672 name: "Github",
6773 kind: "openapi",
6874 endpoint: "https://api.github.com",
@@ -70,6 +76,7 @@ const seedWorkspaceCredentialState = (input: { · 1 unmodified line
7076 enabled: true,
7177 namespace: "github",
7278 transport: null,
79+ bindingConfigJson: null,
7380 queryParamsJson: null,
7481 headersJson: null,
7582 specUrl: "https://api.github.com/openapi.json",
@@ -80,6 +87,25 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines
8087 createdAt: now,
8188 updatedAt: now,
8289 });
90+ });
91+
92+const seedWorkspaceCredentialState = (input: {
93+ persistence: SqlControlPlanePersistence;
94+ accountId: ReturnType<typeof AccountIdSchema.make>;
95+ organizationId: ReturnType<typeof OrganizationIdSchema.make>;
96+ workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
97+ sourceId: ReturnType<typeof SourceIdSchema.make>;
98+}): Effect.Effect<{
99+ tokenId: ReturnType<typeof SecretMaterialIdSchema.make>;
100+ refreshId: ReturnType<typeof SecretMaterialIdSchema.make>;
101+}, unknown, never> =>
102+ Effect.gen(function* () {
103+ const now = Date.now();
104+ const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
105+ const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);
106+ const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
107+
108+ yield* seedWorkspaceSourceState(input);
83109 yield* input.persistence.rows.secretMaterials.upsert({
84110 id: tokenId,
85111 name: null,
@@ -99,6 +125,8 @@ const seedWorkspaceCredentialState = (input: { · 13 unmodified lines
99125 yield* input.persistence.rows.credentials.upsert({
100126 id: credentialId,
101127 workspaceId: input.workspaceId,
128+ sourceId: input.sourceId,
129+ actorAccountId: input.accountId,
102130 authKind: "oauth2",
103131 authHeaderName: "Authorization",
104132 authPrefix: "Bearer ",
@@ -109,33 +137,29 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines
109137 createdAt: now,
110138 updatedAt: now,
111139 });
112− yield* input.persistence.rows.sourceCredentialBindings.upsert({
113− id: `src_cred_bind_${input.workspaceId}`,
114− workspaceId: input.workspaceId,
115− sourceId: input.sourceId,
116− credentialId,
117− createdAt: now,
118− updatedAt: now,
119− });
120140 yield* input.persistence.rows.sourceAuthSessions.upsert({
121141 id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`),
122142 workspaceId: input.workspaceId,
123143 sourceId: input.sourceId,
144+ actorAccountId: input.accountId,
124145 executionId: null,
125146 interactionId: null,
126− strategy: "oauth2_authorization_code",
147+ providerKind: "mcp_oauth",
127148 status: "pending",
128− endpoint: "https://api.github.com",
129149 state: `state_${input.workspaceId}`,
130− redirectUri: "http://127.0.0.1/callback",
131− scope: null,
132− resourceMetadataUrl: null,
133− authorizationServerUrl: null,
134− resourceMetadataJson: null,
135− authorizationServerMetadataJson: null,
136− clientInformationJson: null,
137− codeVerifier: "verifier",
138− authorizationUrl: "https://example.com/auth",
150+ sessionDataJson: JSON.stringify({
151+ kind: "mcp_oauth",
152+ endpoint: "https://api.github.com",
153+ redirectUri: "http://127.0.0.1/callback",
154+ scope: null,
155+ resourceMetadataUrl: null,
156+ authorizationServerUrl: null,
157+ resourceMetadataJson: null,
158+ authorizationServerMetadataJson: null,
159+ clientInformationJson: null,
160+ codeVerifier: "verifier",
161+ authorizationUrl: "https://example.com/auth",
162+ }),
139163 errorText: null,
140164 completedAt: null,
141165 createdAt: now,
@@ -176,6 +200,8 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines
176200 yield* persistence.rows.sources.insert({
177201 id: SourceIdSchema.make("src_1"),
178202 workspaceId,
203+ recipeId: SourceRecipeIdSchema.make("src_recipe_1"),
204+ recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),
179205 name: "Github",
180206 kind: "openapi",
181207 endpoint: "https://api.github.com",
@@ -183,6 +209,7 @@ describe("control-plane-persistence-drizzle", () => { · 1 unmodified line
183209 enabled: true,
184210 namespace: "github",
185211 transport: null,
212+ bindingConfigJson: null,
186213 queryParamsJson: null,
187214 headersJson: null,
188215 specUrl: "https://api.github.com/openapi.json",
@@ -271,6 +298,137 @@ describe("control-plane-persistence-drizzle", () => { · 82 unmodified lines
271298 }),
272299 );
273300
301+ it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () =>
302+ Effect.gen(function* () {
303+ const persistence = yield* makePersistence;
304+ const accountId = AccountIdSchema.make("acc_credentials");
305+ const organizationId = OrganizationIdSchema.make("org_credentials");
306+ const workspaceId = WorkspaceIdSchema.make("ws_credentials");
307+ const sourceId = SourceIdSchema.make("src_credentials");
308+ const actorCredentialId = CredentialIdSchema.make("cred_actor_credentials");
309+ const nullCredentialA = CredentialIdSchema.make("cred_null_credentials_a");
310+ const nullCredentialB = CredentialIdSchema.make("cred_null_credentials_b");
311+ const now = Date.now();
312+
313+ yield* seedWorkspaceSourceState({
314+ persistence,
315+ accountId,
316+ organizationId,
317+ workspaceId,
318+ sourceId,
319+ });
320+
321+ yield* Effect.tryPromise(async () => {
322+ await persistence.db.insert(drizzleSchema.credentialsTable).values([
323+ {
324+ id: nullCredentialA,
325+ workspaceId,
326+ sourceId,
327+ actorAccountId: null,
328+ authKind: "bearer",
329+ authHeaderName: "Authorization",
330+ authPrefix: "Bearer ",
331+ tokenProviderId: "postgres",
332+ tokenHandle: "sec_null_a",
333+ refreshTokenProviderId: null,
334+ refreshTokenHandle: null,
335+ createdAt: now,
336+ updatedAt: now,
337+ },
338+ {
339+ id: nullCredentialB,
340+ workspaceId,
341+ sourceId,
342+ actorAccountId: null,
343+ authKind: "bearer",
344+ authHeaderName: "Authorization",
345+ authPrefix: "Bearer ",
346+ tokenProviderId: "postgres",
347+ tokenHandle: "sec_null_b",
348+ refreshTokenProviderId: null,
349+ refreshTokenHandle: null,
350+ createdAt: now + 1,
351+ updatedAt: now + 1,
352+ },
353+ ]);
354+ }).pipe(Effect.orDie);
355+
356+ yield* persistence.rows.credentials.upsert({
357+ id: CredentialIdSchema.make("cred_null_credentials_replacement"),
358+ workspaceId,
359+ sourceId,
360+ actorAccountId: null,
361+ authKind: "oauth2",
362+ authHeaderName: "X-Auth",
363+ authPrefix: "Token ",
364+ tokenProviderId: "postgres",
365+ tokenHandle: "sec_null_replacement",
366+ refreshTokenProviderId: "postgres",
367+ refreshTokenHandle: "sec_null_refresh",
368+ createdAt: now + 2,
369+ updatedAt: now + 2,
370+ });
371+
372+ yield* persistence.rows.credentials.upsert({
373+ id: actorCredentialId,
374+ workspaceId,
375+ sourceId,
376+ actorAccountId: accountId,
377+ authKind: "bearer",
378+ authHeaderName: "Authorization",
379+ authPrefix: "Bearer ",
380+ tokenProviderId: "postgres",
381+ tokenHandle: "sec_actor",
382+ refreshTokenProviderId: null,
383+ refreshTokenHandle: null,
384+ createdAt: now + 3,
385+ updatedAt: now + 3,
386+ });
387+
388+ const allCredentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({
389+ workspaceId,
390+ sourceId,
391+ });
392+ expect(allCredentials).toHaveLength(2);
393+ const nullActorCredentials = allCredentials.filter((credential) => credential.actorAccountId === null);
394+ expect(nullActorCredentials).toHaveLength(1);
395+ expect(nullActorCredentials[0]?.id).toBe(nullCredentialA);
396+ expect(nullActorCredentials[0]?.authKind).toBe("oauth2");
397+ expect(nullActorCredentials[0]?.authHeaderName).toBe("X-Auth");
398+ expect(nullActorCredentials[0]?.tokenHandle).toBe("sec_null_replacement");
399+ expect(allCredentials.map((credential) => credential.id).sort()).toEqual(
400+ [actorCredentialId, nullCredentialA].sort(),
401+ );
402+
403+ const forActor = yield* persistence.rows.credentials.listByWorkspaceSourceAndActor({
404+ workspaceId,
405+ sourceId,
406+ actorAccountId: accountId,
407+ });
408+ expect(forActor).toHaveLength(2);
409+ expect(new Set(forActor.map((credential) => credential.id))).toEqual(
410+ new Set([actorCredentialId, nullCredentialA]),
411+ );
412+
413+ const nullActorOnly = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({
414+ workspaceId,
415+ sourceId,
416+ actorAccountId: null,
417+ });
418+ assertTrue(Option.isSome(nullActorOnly));
419+ if (Option.isSome(nullActorOnly)) {
420+ expect(nullActorOnly.value.id).toBe(nullCredentialA);
421+ }
422+
423+ const missingActor = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({
424+ workspaceId,
425+ sourceId,
426+ actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
427+ });
428+ expect(Option.isNone(missingActor)).toBe(true);
429+ }),
430+ );
431+
274432 it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () =>
275433 Effect.gen(function* () {
276434 const persistence = yield* makePersistence;
@@ -291,7 +449,6 @@ describe("control-plane-persistence-drizzle", () => { · 14 unmodified lines
291449 expect(removed).toBe(true);
292450 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
293451 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
294− expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
295452 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
296453 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
297454 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
@@ -318,7 +475,6 @@ describe("control-plane-persistence-drizzle", () => { · 20 unmodified lines
318475 expect(removed).toBe(true);
319476 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
320477 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
321− expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
322478 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
323479 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
324480 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
packages/control-plane/src/persistence/index.ts+18−3
@@ -15,7 +15,11 @@ import { · 14 unmodified lines
1515 createPoliciesRepo,
1616 createSecretMaterialsRepo,
1717 createSourceAuthSessionsRepo,
18− createSourceCredentialBindingsRepo,
18+ createSourceOauthClientsRepo,
19+ createSourceRecipeDocumentsRepo,
20+ createSourceRecipeOperationsRepo,
21+ createSourceRecipeRevisionsRepo,
22+ createSourceRecipesRepo,
1923 createSourcesRepo,
2024 createToolArtifactsRepo,
2125 createWorkspacesRepo,
@@ -29,6 +33,7 @@ import { · 7 unmodified lines
2933 type SqlBackend,
3034 type SqlRuntime,
3135 } from "./sql-runtime";
36+import { runPostMigrationRepairs } from "./post-migrations";
3237
3338 export { tableNames, type DrizzleTables } from "./schema";
3439 export {
@@ -52,9 +57,13 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 17 unmodified lines
5257 organizationMemberships: createOrganizationMembershipsRepo(client, tables),
5358 workspaces: createWorkspacesRepo(client, tables),
5459 sources: createSourcesRepo(client, tables),
60+ sourceRecipes: createSourceRecipesRepo(client, tables),
61+ sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),
62+ sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables),
63+ sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
5564 credentials: createCredentialsRepo(client, tables),
65+ sourceOauthClients: createSourceOauthClientsRepo(client, tables),
5666 toolArtifacts: createToolArtifactsRepo(client, tables),
57− sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables),
5867 secretMaterials: createSecretMaterialsRepo(client, tables),
5968 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
6069 policies: createPoliciesRepo(client, tables),
@@ -127,14 +136,20 @@ export const createSqlControlPlanePersistence = ( · 66 unmodified lines
127136 backend: runtime.backend,
128137 db: runtime.db,
129138 });
139+ const rows = createRows(client);
130140
131141 return {
132142 backend: runtime.backend,
133143 db: runtime.db,
134− rows: createRows(client),
144+ rows,
135145 close: () => runtime.close(),
136146 } satisfies SqlControlPlanePersistence;
137147 }),
148+ Effect.flatMap((persistence) =>
149+ runPostMigrationRepairs(persistence.rows).pipe(
150+ Effect.mapError(toBootstrapError),
151+ Effect.map(() => persistence),
152+ )),
138153 Effect.catchAll((error) =>
139154 closeRuntimeEffect(runtime).pipe(
140155 Effect.zipRight(Effect.fail(error)),
packages/control-plane/src/persistence/post-migrations.test.ts+269−0
@@ -0,0 +1,269 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ AccountIdSchema,
4+ OrganizationIdSchema,
5+ SourceIdSchema,
6+ SourceRecipeIdSchema,
7+ SourceRecipeRevisionIdSchema,
8+ WorkspaceIdSchema,
9+} from "#schema";
10+import * as Effect from "effect/Effect";
11+import * as Option from "effect/Option";
12+import * as Scope from "effect/Scope";
13+import {
14+ buildSchema,
15+ getIntrospectionQuery,
16+ graphqlSync,
17+} from "graphql";
18+
19+import {
20+ createSqlControlPlanePersistence,
21+ type SqlControlPlanePersistence,
22+} from "./index";
23+import { runPostMigrationRepairs } from "./post-migrations";
24+
25+const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
26+ Effect.acquireRelease(
27+ createSqlControlPlanePersistence({
28+ localDataDir: ":memory:",
29+ }),
30+ (persistence) =>
31+ Effect.tryPromise({
32+ try: () => persistence.close(),
33+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
34+ }).pipe(Effect.orDie),
35+ );
36+
37+const seedMigratedSourceRecipe = (input: {
38+ persistence: SqlControlPlanePersistence;
39+ kind: "openapi" | "graphql";
40+ workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
41+ sourceId: ReturnType<typeof SourceIdSchema.make>;
42+ documentText: string;
43+}): Effect.Effect<{
44+ recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
45+}, unknown, never> =>
46+ Effect.gen(function* () {
47+ const now = Date.now();
48+ const accountId = AccountIdSchema.make(`acc_${input.sourceId}`);
49+ const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`);
50+ const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
51+ const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`);
52+
53+ yield* input.persistence.rows.organizations.insert({
54+ id: organizationId,
55+ slug: `org-${input.sourceId}`,
56+ name: `Org ${input.sourceId}`,
57+ status: "active",
58+ createdByAccountId: accountId,
59+ createdAt: now,
60+ updatedAt: now,
61+ });
62+ yield* input.persistence.rows.workspaces.insert({
63+ id: input.workspaceId,
64+ organizationId,
65+ name: `Workspace ${input.sourceId}`,
66+ createdByAccountId: accountId,
67+ createdAt: now,
68+ updatedAt: now,
69+ });
70+ yield* input.persistence.rows.sourceRecipes.upsert({
71+ id: recipeId,
72+ kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe",
73+ importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",
74+ providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql",
75+ name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
76+ summary: null,
77+ visibility: "workspace",
78+ latestRevisionId: recipeRevisionId,
79+ createdAt: now,
80+ updatedAt: now,
81+ });
82+ yield* input.persistence.rows.sourceRecipeRevisions.upsert({
83+ id: recipeRevisionId,
84+ recipeId,
85+ revisionNumber: 1,
86+ sourceConfigJson: JSON.stringify(
87+ input.kind === "openapi"
88+ ? {
89+ kind: "openapi",
90+ endpoint: "https://api.example.com",
91+ specUrl: "https://api.example.com/openapi.json",
92+ defaultHeaders: null,
93+ }
94+ : {
95+ kind: "graphql",
96+ endpoint: "https://api.example.com/graphql",
97+ defaultHeaders: null,
98+ },
99+ ),
100+ manifestJson: null,
101+ manifestHash: null,
102+ createdAt: now,
103+ updatedAt: now,
104+ });
105+ yield* input.persistence.rows.sources.insert({
106+ id: input.sourceId,
107+ workspaceId: input.workspaceId,
108+ recipeId,
109+ recipeRevisionId,
110+ name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
111+ kind: input.kind,
112+ endpoint:
113+ input.kind === "openapi"
114+ ? "https://api.example.com"
115+ : "https://api.example.com/graphql",
116+ status: "connected",
117+ enabled: true,
118+ namespace: input.kind === "openapi" ? "github" : "graphql",
119+ transport: null,
120+ bindingConfigJson: null,
121+ queryParamsJson: null,
122+ headersJson: null,
123+ specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null,
124+ defaultHeadersJson: null,
125+ sourceHash: null,
126+ sourceDocumentText: null,
127+ lastError: null,
128+ createdAt: now,
129+ updatedAt: now,
130+ });
131+ yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
132+ recipeRevisionId,
133+ documents: [{
134+ id: `src_recipe_doc_${input.sourceId}`,
135+ recipeRevisionId,
136+ documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",
137+ documentKey:
138+ input.kind === "openapi"
139+ ? "https://api.example.com/openapi.json"
140+ : "https://api.example.com/graphql",
141+ contentText: input.documentText,
142+ contentHash: `hash_${input.sourceId}`,
143+ fetchedAt: now,
144+ createdAt: now,
145+ updatedAt: now,
146+ }],
147+ });
148+
149+ return { recipeRevisionId };
150+ });
151+
152+describe("post-migrations", () => {
153+ it.scoped("repairs migrated OpenAPI recipes from stored documents", () =>
154+ Effect.gen(function* () {
155+ const persistence = yield* makePersistence;
156+ const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi");
157+ const sourceId = SourceIdSchema.make("src_post_migration_openapi");
158+
159+ const openApiDocument = JSON.stringify({
160+ openapi: "3.0.3",
161+ info: {
162+ title: "GitHub",
163+ version: "1.0.0",
164+ },
165+ paths: {
166+ "/repos/{owner}/{repo}": {
167+ get: {
168+ operationId: "repos.getRepo",
169+ parameters: [
170+ {
171+ name: "owner",
172+ in: "path",
173+ required: true,
174+ schema: { type: "string" },
175+ },
176+ {
177+ name: "repo",
178+ in: "path",
179+ required: true,
180+ schema: { type: "string" },
181+ },
182+ ],
183+ responses: {
184+ 200: {
185+ description: "ok",
186+ content: {
187+ "application/json": {
188+ schema: {
189+ type: "object",
190+ properties: {
191+ full_name: { type: "string" },
192+ },
193+ required: ["full_name"],
194+ },
195+ },
196+ },
197+ },
198+ },
199+ },
200+ },
201+ },
202+ });
203+
204+ const { recipeRevisionId } = yield* seedMigratedSourceRecipe({
205+ persistence,
206+ kind: "openapi",
207+ workspaceId,
208+ sourceId,
209+ documentText: openApiDocument,
210+ });
211+
212+ yield* runPostMigrationRepairs(persistence.rows);
213+
214+ const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
215+ expect(Option.isSome(revision)).toBe(true);
216+ expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
217+ expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)
218+ .toBeGreaterThan(0);
219+ }),
220+ );
221+
222+ it.scoped("repairs migrated GraphQL recipes from stored documents", () =>
223+ Effect.gen(function* () {
224+ const persistence = yield* makePersistence;
225+ const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql");
226+ const sourceId = SourceIdSchema.make("src_post_migration_graphql");
227+ const schema = buildSchema(`
228+ type Query {
229+ viewer: User!
230+ }
231+
232+ type Mutation {
233+ createIssue(title: String!): Issue!
234+ }
235+
236+ type User {
237+ login: String!
238+ }
239+
240+ type Issue {
241+ id: ID!
242+ title: String!
243+ }
244+ `);
245+ const graphqlDocument = JSON.stringify(
246+ graphqlSync({
247+ schema,
248+ source: getIntrospectionQuery(),
249+ }),
250+ );
251+
252+ const { recipeRevisionId } = yield* seedMigratedSourceRecipe({
253+ persistence,
254+ kind: "graphql",
255+ workspaceId,
256+ sourceId,
257+ documentText: graphqlDocument,
258+ });
259+
260+ yield* runPostMigrationRepairs(persistence.rows);
261+
262+ const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
263+ expect(Option.isSome(revision)).toBe(true);
264+ expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
265+ expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)
266+ .toBeGreaterThan(0);
267+ }),
268+ );
269+});
packages/control-plane/src/persistence/post-migrations.ts+321−0
@@ -0,0 +1,321 @@
1+import { randomUUID } from "node:crypto";
2+
3+import {
4+ buildOpenApiToolPresentation,
5+ compileOpenApiToolDefinitions,
6+ extractOpenApiManifest,
7+} from "@executor/codemode-openapi";
8+import type {
9+ StoredSourceRecipeDocumentRecord,
10+ StoredSourceRecipeOperationRecord,
11+ StoredSourceRecipeRevisionRecord,
12+ StoredSourceRecord,
13+} from "#schema";
14+import * as Effect from "effect/Effect";
15+
16+import {
17+ buildGraphqlToolPresentation,
18+ compileGraphqlToolDefinitions,
19+ extractGraphqlManifest,
20+} from "../runtime/graphql-tools";
21+import type { SqlControlPlaneRows } from "./index";
22+
23+const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
24+ parts
25+ .flatMap((part) => (part ? [part.trim()] : []))
26+ .filter(Boolean)
27+ .join(" ")
28+ .toLowerCase();
29+
30+const toOpenApiRecipeOperationRecord = (input: {
31+ recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
32+ definition: ReturnType<typeof compileOpenApiToolDefinitions>[number];
33+ manifest: Parameters<typeof buildOpenApiToolPresentation>[0]["manifest"];
34+ now: number;
35+}): StoredSourceRecipeOperationRecord => {
36+ const presentation = buildOpenApiToolPresentation({
37+ manifest: input.manifest,
38+ definition: input.definition,
39+ });
40+ const method = input.definition.method.toUpperCase();
41+
42+ return {
43+ id: `src_recipe_op_${randomUUID()}`,
44+ recipeRevisionId: input.recipeRevisionId,
45+ operationKey: input.definition.toolId,
46+ transportKind: "http",
47+ toolId: input.definition.toolId,
48+ title: input.definition.name,
49+ description: input.definition.description,
50+ operationKind:
51+ method === "GET" || method === "HEAD"
52+ ? "read"
53+ : method === "DELETE"
54+ ? "delete"
55+ : "write",
56+ searchText: normalizeSearchText(
57+ input.definition.toolId,
58+ input.definition.name,
59+ input.definition.description,
60+ input.definition.rawToolId,
61+ input.definition.operationId ?? undefined,
62+ input.definition.method,
63+ input.definition.path,
64+ input.definition.group,
65+ input.definition.leaf,
66+ input.definition.tags.join(" "),
67+ ),
68+ inputSchemaJson: presentation.inputSchemaJson ?? null,
69+ outputSchemaJson: presentation.outputSchemaJson ?? null,
70+ providerKind: "openapi",
71+ providerDataJson: presentation.providerDataJson,
72+ mcpToolName: null,
73+ openApiMethod: input.definition.method,
74+ openApiPathTemplate: input.definition.path,
75+ openApiOperationHash: input.definition.operationHash,
76+ openApiRawToolId: input.definition.rawToolId,
77+ openApiOperationId: input.definition.operationId ?? null,
78+ openApiTagsJson: JSON.stringify(input.definition.tags),
79+ openApiRequestBodyRequired: input.definition.invocation.requestBody?.required ?? null,
80+ graphqlOperationType: null,
81+ graphqlOperationName: null,
82+ createdAt: input.now,
83+ updatedAt: input.now,
84+ };
85+};
86+
87+const toGraphqlRecipeOperationRecord = (input: {
88+ recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
89+ definition: ReturnType<typeof compileGraphqlToolDefinitions>[number];
90+ manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
91+ now: number;
92+}): StoredSourceRecipeOperationRecord => {
93+ const presentation = buildGraphqlToolPresentation({
94+ manifest: input.manifest,
95+ definition: input.definition,
96+ });
97+
98+ return {
99+ id: `src_recipe_op_${randomUUID()}`,
100+ recipeRevisionId: input.recipeRevisionId,
101+ operationKey: input.definition.toolId,
102+ transportKind: "graphql",
103+ toolId: input.definition.toolId,
104+ title: input.definition.name,
105+ description: input.definition.description,
106+ operationKind:
107+ input.definition.operationType === "query"
108+ ? "read"
109+ : input.definition.operationType === "mutation"
110+ ? "write"
111+ : "unknown",
112+ searchText: normalizeSearchText(
113+ input.definition.toolId,
114+ input.definition.name,
115+ input.definition.description,
116+ input.definition.rawToolId,
117+ input.definition.group,
118+ input.definition.leaf,
119+ input.definition.fieldName,
120+ input.definition.operationType,
121+ input.definition.operationName,
122+ input.definition.searchTerms.join(" "),
123+ ),
124+ inputSchemaJson: presentation.inputSchemaJson ?? null,
125+ outputSchemaJson: presentation.outputSchemaJson ?? null,
126+ providerKind: "graphql",
127+ providerDataJson: presentation.providerDataJson,
128+ mcpToolName: null,
129+ openApiMethod: null,
130+ openApiPathTemplate: null,
131+ openApiOperationHash: null,
132+ openApiRawToolId: null,
133+ openApiOperationId: null,
134+ openApiTagsJson: null,
135+ openApiRequestBodyRequired: null,
136+ graphqlOperationType: input.definition.operationType,
137+ graphqlOperationName: input.definition.operationName,
138+ createdAt: input.now,
139+ updatedAt: input.now,
140+ };
141+};
142+
143+const primaryRecipeDocument = (input: {
144+ sourceRecord: StoredSourceRecord;
145+ documents: readonly StoredSourceRecipeDocumentRecord[];
146+}): StoredSourceRecipeDocumentRecord | null => {
147+ const documentKind =
148+ input.sourceRecord.kind === "openapi"
149+ ? "openapi"
150+ : input.sourceRecord.kind === "graphql"
151+ ? "graphql_introspection"
152+ : null;
153+
154+ if (documentKind === null) {
155+ return null;
156+ }
157+
158+ return input.documents.find((document) => document.documentKind === documentKind) ?? null;
159+};
160+
161+const repairOpenApiRecipeRevision = (input: {
162+ rows: SqlControlPlaneRows;
163+ sourceRecord: StoredSourceRecord;
164+ revision: StoredSourceRecipeRevisionRecord;
165+ document: StoredSourceRecipeDocumentRecord;
166+}): Effect.Effect<void, Error, never> =>
167+ Effect.gen(function* () {
168+ const manifest = yield* extractOpenApiManifest(
169+ input.sourceRecord.name,
170+ input.document.contentText,
171+ );
172+ const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
173+ const definitions = compileOpenApiToolDefinitions(manifest);
174+
175+ yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
176+ manifestJson: JSON.stringify(manifest),
177+ manifestHash: manifest.sourceHash,
178+ updatedAt: now,
179+ });
180+ yield* input.rows.sourceRecipeOperations.replaceForRevision({
181+ recipeRevisionId: input.revision.id,
182+ operations: definitions.map((definition) =>
183+ toOpenApiRecipeOperationRecord({
184+ recipeRevisionId: input.revision.id,
185+ definition,
186+ manifest,
187+ now,
188+ })
189+ ),
190+ });
191+
192+ if (input.sourceRecord.sourceHash !== manifest.sourceHash) {
193+ yield* input.rows.sources.update(
194+ input.sourceRecord.workspaceId,
195+ input.sourceRecord.id,
196+ {
197+ sourceHash: manifest.sourceHash,
198+ updatedAt: now,
199+ },
200+ );
201+ }
202+ });
203+
204+const repairGraphqlRecipeRevision = (input: {
205+ rows: SqlControlPlaneRows;
206+ sourceRecord: StoredSourceRecord;
207+ revision: StoredSourceRecipeRevisionRecord;
208+ document: StoredSourceRecipeDocumentRecord;
209+}): Effect.Effect<void, Error, never> =>
210+ Effect.gen(function* () {
211+ const manifest = yield* extractGraphqlManifest(
212+ input.sourceRecord.name,
213+ input.document.contentText,
214+ );
215+ const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
216+ const definitions = compileGraphqlToolDefinitions(manifest);
217+
218+ yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
219+ manifestJson: JSON.stringify(manifest),
220+ manifestHash: manifest.sourceHash,
221+ updatedAt: now,
222+ });
223+ yield* input.rows.sourceRecipeOperations.replaceForRevision({
224+ recipeRevisionId: input.revision.id,
225+ operations: definitions.map((definition) =>
226+ toGraphqlRecipeOperationRecord({
227+ recipeRevisionId: input.revision.id,
228+ definition,
229+ manifest,
230+ now,
231+ })
232+ ),
233+ });
234+
235+ if (input.sourceRecord.sourceHash !== manifest.sourceHash) {
236+ yield* input.rows.sources.update(
237+ input.sourceRecord.workspaceId,
238+ input.sourceRecord.id,
239+ {
240+ sourceHash: manifest.sourceHash,
241+ updatedAt: now,
242+ },
243+ );
244+ }
245+ });
246+
247+// Repair pre-recipe OpenAPI/GraphQL sources that were migrated with raw documents
248+// but without the canonical manifest/operation rows required by the new runtime.
249+export const runPostMigrationRepairs = (
250+ rows: SqlControlPlaneRows,
251+): Effect.Effect<void, Error, never> =>
252+ Effect.gen(function* () {
253+ const sourceRecords = yield* rows.sources.listAll();
254+ if (sourceRecords.length === 0) {
255+ return;
256+ }
257+
258+ const revisionIds = [...new Set(sourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
259+ const revisions = yield* rows.sourceRecipeRevisions.listByIds(revisionIds);
260+ const documents = yield* rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
261+ const operations = yield* rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
262+
263+ const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
264+ const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
265+ for (const document of documents) {
266+ const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
267+ existing.push(document);
268+ documentsByRevisionId.set(document.recipeRevisionId, existing);
269+ }
270+
271+ const operationCountsByRevisionId = new Map<string, number>();
272+ for (const operation of operations) {
273+ operationCountsByRevisionId.set(
274+ operation.recipeRevisionId,
275+ (operationCountsByRevisionId.get(operation.recipeRevisionId) ?? 0) + 1,
276+ );
277+ }
278+
279+ yield* Effect.forEach(sourceRecords, (sourceRecord) =>
280+ Effect.gen(function* () {
281+ if (sourceRecord.kind !== "openapi" && sourceRecord.kind !== "graphql") {
282+ return;
283+ }
284+
285+ const revision = revisionById.get(sourceRecord.recipeRevisionId);
286+ if (!revision) {
287+ return;
288+ }
289+
290+ const existingOperationCount = operationCountsByRevisionId.get(revision.id) ?? 0;
291+ if (revision.manifestJson !== null && existingOperationCount > 0) {
292+ return;
293+ }
294+
295+ const sourceDocuments = documentsByRevisionId.get(revision.id) ?? [];
296+ const document = primaryRecipeDocument({
297+ sourceRecord,
298+ documents: sourceDocuments,
299+ });
300+ if (!document) {
301+ return;
302+ }
303+
304+ if (sourceRecord.kind === "openapi") {
305+ yield* repairOpenApiRecipeRevision({
306+ rows,
307+ sourceRecord,
308+ revision,
309+ document,
310+ });
311+ return;
312+ }
313+
314+ yield* repairGraphqlRecipeRevision({
315+ rows,
316+ sourceRecord,
317+ revision,
318+ document,
319+ });
320+ }), { discard: true });
321+ });
packages/control-plane/src/persistence/repos/credentials-repo.ts+160−10
@@ -4,14 +4,26 @@ import { · 3 unmodified lines
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { asc, eq } from "drizzle-orm";
7+import { and, asc, eq, inArray, isNull, or } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
11−import { firstOption, withoutCreatedAt } from "./shared";
11+import { firstOption } from "./shared";
1212
1313 const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);
1414
15+const credentialUpdateSet = (credential: Credential) => {
16+ const {
17+ id: _id,
18+ workspaceId: _workspaceId,
19+ sourceId: _sourceId,
20+ actorAccountId: _actorAccountId,
21+ createdAt: _createdAt,
22+ ...patch
23+ } = credential;
24+ return patch;
25+};
26+
1527 export const createCredentialsRepo = (
1628 client: DrizzleClient,
1729 tables: DrizzleTables,
@@ -30,12 +42,78 @@ export const createCredentialsRepo = ( · 12 unmodified lines
3042 return rows.map((row) => decodeCredential(row));
3143 }),
3244
33− getById: (id: Credential["id"]) =>
34− client.use("rows.credentials.get_by_id", async (db) => {
45+ listByWorkspaceAndSourceId: (input: {
46+ workspaceId: Credential["workspaceId"];
47+ sourceId: Credential["sourceId"];
48+ }) =>
49+ client.use("rows.credentials.list_by_workspace_source", async (db) => {
3550 const rows = await db
3651 .select()
3752 .from(tables.credentialsTable)
38− .where(eq(tables.credentialsTable.id, id))
53+ .where(
54+ and(
55+ eq(tables.credentialsTable.workspaceId, input.workspaceId),
56+ eq(tables.credentialsTable.sourceId, input.sourceId),
57+ ),
58+ )
59+ .orderBy(
60+ asc(tables.credentialsTable.updatedAt),
61+ asc(tables.credentialsTable.id),
62+ );
63+
64+ return rows.map((row) => decodeCredential(row));
65+ }),
66+
67+ listByWorkspaceSourceAndActor: (input: {
68+ workspaceId: Credential["workspaceId"];
69+ sourceId: Credential["sourceId"];
70+ actorAccountId: Credential["actorAccountId"];
71+ }) =>
72+ client.use("rows.credentials.list_by_workspace_source_actor", async (db) => {
73+ const rows = await db
74+ .select()
75+ .from(tables.credentialsTable)
76+ .where(
77+ and(
78+ eq(tables.credentialsTable.workspaceId, input.workspaceId),
79+ eq(tables.credentialsTable.sourceId, input.sourceId),
80+ or(
81+ input.actorAccountId === null
82+ ? isNull(tables.credentialsTable.actorAccountId)
83+ : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
84+ input.actorAccountId === null
85+ ? undefined
86+ : isNull(tables.credentialsTable.actorAccountId),
87+ ),
88+ ),
89+ )
90+ .orderBy(
91+ asc(tables.credentialsTable.actorAccountId),
92+ asc(tables.credentialsTable.updatedAt),
93+ asc(tables.credentialsTable.id),
94+ );
95+
96+ return rows.map((row) => decodeCredential(row));
97+ }),
98+
99+ getByWorkspaceSourceAndActor: (input: {
100+ workspaceId: Credential["workspaceId"];
101+ sourceId: Credential["sourceId"];
102+ actorAccountId: Credential["actorAccountId"];
103+ }) =>
104+ client.use("rows.credentials.get_by_workspace_source_actor", async (db) => {
105+ const rows = await db
106+ .select()
107+ .from(tables.credentialsTable)
108+ .where(
109+ and(
110+ eq(tables.credentialsTable.workspaceId, input.workspaceId),
111+ eq(tables.credentialsTable.sourceId, input.sourceId),
112+ input.actorAccountId === null
113+ ? isNull(tables.credentialsTable.actorAccountId)
114+ : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
115+ ),
116+ )
39117 .limit(1);
40118
41119 const row = firstOption(rows);
@@ -46,24 +124,96 @@ export const createCredentialsRepo = ( · 4 unmodified lines
46124
47125 upsert: (credential: Credential) =>
48126 client.use("rows.credentials.upsert", async (db) => {
127+ if (credential.actorAccountId === null) {
128+ const existingRows = await db
129+ .select({
130+ id: tables.credentialsTable.id,
131+ })
132+ .from(tables.credentialsTable)
133+ .where(
134+ and(
135+ eq(tables.credentialsTable.workspaceId, credential.workspaceId),
136+ eq(tables.credentialsTable.sourceId, credential.sourceId),
137+ isNull(tables.credentialsTable.actorAccountId),
138+ ),
139+ )
140+ .orderBy(
141+ asc(tables.credentialsTable.updatedAt),
142+ asc(tables.credentialsTable.id),
143+ );
144+
145+ const existing = firstOption(existingRows);
146+ if (Option.isSome(existing)) {
147+ await db
148+ .update(tables.credentialsTable)
149+ .set(credentialUpdateSet(credential))
150+ .where(eq(tables.credentialsTable.id, existing.value.id));
151+
152+ const duplicateIds = existingRows
153+ .slice(1)
154+ .map((row) => row.id);
155+ if (duplicateIds.length > 0) {
156+ await db
157+ .delete(tables.credentialsTable)
158+ .where(inArray(tables.credentialsTable.id, duplicateIds));
159+ }
160+
161+ return;
162+ }
163+ }
164+
49165 await db
50166 .insert(tables.credentialsTable)
51167 .values(credential)
52168 .onConflictDoUpdate({
53− target: [tables.credentialsTable.id],
169+ target: [
170+ tables.credentialsTable.workspaceId,
171+ tables.credentialsTable.sourceId,
172+ tables.credentialsTable.actorAccountId,
173+ ],
54174 set: {
55− ...withoutCreatedAt(credential),
175+ ...credentialUpdateSet(credential),
56176 },
57177 });
58178 }),
59179
60− removeById: (id: Credential["id"]) =>
61− client.use("rows.credentials.remove", async (db) => {
180+ removeByWorkspaceSourceAndActor: (input: {
181+ workspaceId: Credential["workspaceId"];
182+ sourceId: Credential["sourceId"];
183+ actorAccountId: Credential["actorAccountId"];
184+ }) =>
185+ client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => {
62186 const deleted = await db
63187 .delete(tables.credentialsTable)
64− .where(eq(tables.credentialsTable.id, id))
188+ .where(
189+ and(
190+ eq(tables.credentialsTable.workspaceId, input.workspaceId),
191+ eq(tables.credentialsTable.sourceId, input.sourceId),
192+ input.actorAccountId === null
193+ ? isNull(tables.credentialsTable.actorAccountId)
194+ : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
195+ ),
196+ )
65197 .returning();
66198
67199 return deleted.length > 0;
68200 }),
201+
202+ removeByWorkspaceAndSourceId: (input: {
203+ workspaceId: Credential["workspaceId"];
204+ sourceId: Credential["sourceId"];
205+ }) =>
206+ client.use("rows.credentials.remove_by_workspace_source", async (db) => {
207+ const deleted = await db
208+ .delete(tables.credentialsTable)
209+ .where(
210+ and(
211+ eq(tables.credentialsTable.workspaceId, input.workspaceId),
212+ eq(tables.credentialsTable.sourceId, input.sourceId),
213+ ),
214+ )
215+ .returning();
216+
217+ return deleted.length;
218+ }),
69219 });
packages/control-plane/src/persistence/repos/index.ts+5−1
@@ -3,9 +3,13 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines
33 export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
44 export { createWorkspacesRepo } from "./workspaces-repo";
55 export { createSourcesRepo } from "./sources-repo";
6+export { createSourceRecipesRepo } from "./source-recipes-repo";
7+export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
8+export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo";
9+export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
610 export { createCredentialsRepo } from "./credentials-repo";
11+export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
712 export { createToolArtifactsRepo } from "./tool-artifacts-repo";
8−export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo";
913 export { createSecretMaterialsRepo } from "./secret-materials-repo";
1014 export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
1115 export { createPoliciesRepo } from "./policies-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+89−3
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
66 } from "#schema";
77 import * as Option from "effect/Option";
88 import { Schema } from "effect";
9−import { asc, eq, inArray } from "drizzle-orm";
9+import { and, asc, eq, inArray, or } from "drizzle-orm";
1010
1111 import type { DrizzleClient } from "../client";
1212 import type { DrizzleTables } from "../schema";
@@ -136,6 +136,13 @@ export const createOrganizationsRepo = ( · 123 unmodified lines
136136 .select({ id: tables.executionsTable.id })
137137 .from(tables.executionsTable)
138138 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
139+ const sourceRows = await tx
140+ .select({
141+ sourceId: tables.sourcesTable.sourceId,
142+ recipeId: tables.sourcesTable.recipeId,
143+ })
144+ .from(tables.sourcesTable)
145+ .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
139146 const credentials = await tx
140147 .select({
141148 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -147,6 +154,28 @@ export const createOrganizationsRepo = ( · 5 unmodified lines
147154 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
148155
149156 const executionIds = executionRows.map((execution) => execution.id);
157+ const sourceIds = sourceRows.map((source) => source.sourceId);
158+ const recipeIds = sourceRows.map((source) => source.recipeId);
159+ const recipeRevisionRows = recipeIds.length > 0
160+ ? await tx
161+ .select({ id: tables.sourceRecipeRevisionsTable.id })
162+ .from(tables.sourceRecipeRevisionsTable)
163+ .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds))
164+ : [];
165+ const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id);
166+ const toolArtifactPaths = sourceIds.length > 0
167+ ? (
168+ await tx
169+ .select({ path: tables.toolArtifactsTable.path })
170+ .from(tables.toolArtifactsTable)
171+ .where(
172+ and(
173+ inArray(tables.toolArtifactsTable.workspaceId, workspaceIds),
174+ inArray(tables.toolArtifactsTable.sourceId, sourceIds),
175+ ),
176+ )
177+ ).map((row) => row.path)
178+ : [];
150179 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
151180
152181 if (executionIds.length > 0) {
@@ -157,6 +186,39 @@ export const createOrganizationsRepo = ( · 4 unmodified lines
157186 );
158187 }
159188
189+ if (toolArtifactPaths.length > 0) {
190+ await tx
191+ .delete(tables.toolArtifactParametersTable)
192+ .where(
193+ and(
194+ inArray(tables.toolArtifactParametersTable.workspaceId, workspaceIds),
195+ or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))),
196+ ),
197+ );
198+
199+ await tx
200+ .delete(tables.toolArtifactRequestBodyContentTypesTable)
201+ .where(
202+ and(
203+ inArray(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceIds),
204+ or(
205+ ...toolArtifactPaths.map((path) =>
206+ eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
207+ ),
208+ ),
209+ ),
210+ );
211+
212+ await tx
213+ .delete(tables.toolArtifactRefHintKeysTable)
214+ .where(
215+ and(
216+ inArray(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceIds),
217+ or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))),
218+ ),
219+ );
220+ }
221+
160222 await tx
161223 .delete(tables.executionsTable)
162224 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
@@ -166,17 +228,41 @@ export const createOrganizationsRepo = ( · 3 unmodified lines
166228 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));
167229
168230 await tx
169− .delete(tables.sourceCredentialBindingsTable)
170− .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds));
231+ .delete(tables.workspaceSourceOauthClientsTable)
232+ .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));
171233
172234 await tx
173235 .delete(tables.credentialsTable)
174236 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
175237
238+ await tx
239+ .delete(tables.toolArtifactsTable)
240+ .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds));
241+
176242 await tx
177243 .delete(tables.sourcesTable)
178244 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
179245
246+ if (recipeRevisionIds.length > 0) {
247+ await tx
248+ .delete(tables.sourceRecipeDocumentsTable)
249+ .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));
250+
251+ await tx
252+ .delete(tables.sourceRecipeOperationsTable)
253+ .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds));
254+ }
255+
256+ if (recipeIds.length > 0) {
257+ await tx
258+ .delete(tables.sourceRecipeRevisionsTable)
259+ .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds));
260+
261+ await tx
262+ .delete(tables.sourceRecipesTable)
263+ .where(inArray(tables.sourceRecipesTable.id, recipeIds));
264+ }
265+
180266 await tx
181267 .delete(tables.policiesTable)
182268 .where(inArray(tables.policiesTable.workspaceId, workspaceIds));
packages/control-plane/src/persistence/repos/secret-materials-repo.ts+3−7
@@ -105,19 +105,15 @@ export const createSecretMaterialsRepo = ( · 104 unmodified lines
105105 tokenProviderId: tables.credentialsTable.tokenProviderId,
106106 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,
107107 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
108− sourceId: tables.sourceCredentialBindingsTable.sourceId,
108+ sourceId: tables.credentialsTable.sourceId,
109109 sourceName: tables.sourcesTable.name,
110110 })
111111 .from(tables.credentialsTable)
112− .innerJoin(
113− tables.sourceCredentialBindingsTable,
114− eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId),
115− )
116112 .innerJoin(
117113 tables.sourcesTable,
118114 and(
119− eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId),
120− eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId),
115+ eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId),
116+ eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId),
121117 ),
122118 )
123119 .where(
packages/control-plane/src/persistence/repos/source-auth-sessions-repo.ts+35−25
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { and, asc, eq } from "drizzle-orm";
7+import { and, asc, eq, isNull } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
@@ -60,29 +60,39 @@ export const createSourceAuthSessionsRepo = ( · 49 unmodified lines
6060 : Option.none<SourceAuthSession>();
6161 }),
6262
63− getPendingByWorkspaceAndSourceId: (
64− workspaceId: SourceAuthSession["workspaceId"],
65− sourceId: SourceAuthSession["sourceId"],
66− ) =>
67− client.use("rows.source_auth_sessions.get_pending_by_workspace_and_source_id", async (db) => {
68− const rows = await db
69− .select()
70− .from(tables.sourceAuthSessionsTable)
71− .where(
72− and(
73− eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId),
74− eq(tables.sourceAuthSessionsTable.sourceId, sourceId),
75− eq(tables.sourceAuthSessionsTable.status, "pending"),
76− ),
77− )
78− .orderBy(asc(tables.sourceAuthSessionsTable.updatedAt), asc(tables.sourceAuthSessionsTable.id))
79− .limit(1);
80−
81− const row = firstOption(rows);
82− return Option.isSome(row)
83− ? Option.some(decodeSourceAuthSession(row.value))
84− : Option.none<SourceAuthSession>();
85− }),
63+ getPendingByWorkspaceSourceAndActor: (input: {
64+ workspaceId: SourceAuthSession["workspaceId"];
65+ sourceId: SourceAuthSession["sourceId"];
66+ actorAccountId: SourceAuthSession["actorAccountId"];
67+ }) =>
68+ client.use(
69+ "rows.source_auth_sessions.get_pending_by_workspace_source_actor",
70+ async (db) => {
71+ const rows = await db
72+ .select()
73+ .from(tables.sourceAuthSessionsTable)
74+ .where(
75+ and(
76+ eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId),
77+ eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId),
78+ input.actorAccountId === null
79+ ? isNull(tables.sourceAuthSessionsTable.actorAccountId)
80+ : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId),
81+ eq(tables.sourceAuthSessionsTable.status, "pending"),
82+ ),
83+ )
84+ .orderBy(
85+ asc(tables.sourceAuthSessionsTable.updatedAt),
86+ asc(tables.sourceAuthSessionsTable.id),
87+ )
88+ .limit(1);
89+
90+ const row = firstOption(rows);
91+ return Option.isSome(row)
92+ ? Option.some(decodeSourceAuthSession(row.value))
93+ : Option.none<SourceAuthSession>();
94+ },
95+ ),
8696
8797 insert: (session: SourceAuthSession) =>
8898 client.use("rows.source_auth_sessions.insert", async (db) => {
@@ -123,7 +133,7 @@ export const createSourceAuthSessionsRepo = ( · 34 unmodified lines
123133 workspaceId: SourceAuthSession["workspaceId"],
124134 sourceId: SourceAuthSession["sourceId"],
125135 ) =>
126− client.use("rows.source_auth_sessions.remove_by_workspace_and_source_id", async (db) => {
136+ client.use("rows.source_auth_sessions.remove_by_workspace_source", async (db) => {
127137 const deleted = await db
128138 .delete(tables.sourceAuthSessionsTable)
129139 .where(
packages/control-plane/src/persistence/repos/source-oauth-clients-repo.ts+79−0
@@ -0,0 +1,79 @@
1+import {
2+ type WorkspaceSourceOauthClient,
3+ WorkspaceSourceOauthClientSchema,
4+} from "#schema";
5+import * as Option from "effect/Option";
6+import { Schema } from "effect";
7+import { and, eq } from "drizzle-orm";
8+
9+import type { DrizzleClient } from "../client";
10+import type { DrizzleTables } from "../schema";
11+import { firstOption, withoutCreatedAt } from "./shared";
12+
13+const decodeWorkspaceSourceOauthClient = Schema.decodeUnknownSync(
14+ WorkspaceSourceOauthClientSchema,
15+);
16+
17+export const createSourceOauthClientsRepo = (
18+ client: DrizzleClient,
19+ tables: DrizzleTables,
20+) => ({
21+ getByWorkspaceSourceAndProvider: (input: {
22+ workspaceId: WorkspaceSourceOauthClient["workspaceId"];
23+ sourceId: WorkspaceSourceOauthClient["sourceId"];
24+ providerKey: string;
25+ }) =>
26+ client.use("rows.source_oauth_clients.get_by_workspace_source_provider", async (db) => {
27+ const rows = await db
28+ .select()
29+ .from(tables.workspaceSourceOauthClientsTable)
30+ .where(
31+ and(
32+ eq(tables.workspaceSourceOauthClientsTable.workspaceId, input.workspaceId),
33+ eq(tables.workspaceSourceOauthClientsTable.sourceId, input.sourceId),
34+ eq(tables.workspaceSourceOauthClientsTable.providerKey, input.providerKey),
35+ ),
36+ )
37+ .limit(1);
38+
39+ const row = firstOption(rows);
40+ return Option.isSome(row)
41+ ? Option.some(decodeWorkspaceSourceOauthClient(row.value))
42+ : Option.none<WorkspaceSourceOauthClient>();
43+ }),
44+
45+ upsert: (oauthClient: WorkspaceSourceOauthClient) =>
46+ client.use("rows.source_oauth_clients.upsert", async (db) => {
47+ await db
48+ .insert(tables.workspaceSourceOauthClientsTable)
49+ .values(oauthClient)
50+ .onConflictDoUpdate({
51+ target: [
52+ tables.workspaceSourceOauthClientsTable.workspaceId,
53+ tables.workspaceSourceOauthClientsTable.sourceId,
54+ tables.workspaceSourceOauthClientsTable.providerKey,
55+ ],
56+ set: {
57+ ...withoutCreatedAt(oauthClient),
58+ },
59+ });
60+ }),
61+
62+ removeByWorkspaceAndSourceId: (input: {
63+ workspaceId: WorkspaceSourceOauthClient["workspaceId"];
64+ sourceId: WorkspaceSourceOauthClient["sourceId"];
65+ }) =>
66+ client.use("rows.source_oauth_clients.remove_by_workspace_source", async (db) => {
67+ const deleted = await db
68+ .delete(tables.workspaceSourceOauthClientsTable)
69+ .where(
70+ and(
71+ eq(tables.workspaceSourceOauthClientsTable.workspaceId, input.workspaceId),
72+ eq(tables.workspaceSourceOauthClientsTable.sourceId, input.sourceId),
73+ ),
74+ )
75+ .returning();
76+
77+ return deleted.length;
78+ }),
79+});
packages/control-plane/src/persistence/repos/source-recipe-documents-repo.ts+77−0
@@ -0,0 +1,77 @@
1+import {
2+ type StoredSourceRecipeDocumentRecord,
3+ StoredSourceRecipeDocumentRecordSchema,
4+} from "#schema";
5+import { Schema } from "effect";
6+import { asc, eq, inArray } from "drizzle-orm";
7+
8+import type { DrizzleClient } from "../client";
9+import type { DrizzleTables } from "../schema";
10+
11+const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync(
12+ StoredSourceRecipeDocumentRecordSchema,
13+);
14+
15+export const createSourceRecipeDocumentsRepo = (
16+ client: DrizzleClient,
17+ tables: DrizzleTables,
18+) => ({
19+ listByRevisionId: (recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"]) =>
20+ client.use("rows.source_recipe_documents.list_by_revision", async (db) => {
21+ const rows = await db
22+ .select()
23+ .from(tables.sourceRecipeDocumentsTable)
24+ .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionId))
25+ .orderBy(
26+ asc(tables.sourceRecipeDocumentsTable.documentKind),
27+ asc(tables.sourceRecipeDocumentsTable.documentKey),
28+ );
29+
30+ return rows.map((row) => decodeStoredSourceRecipeDocumentRecord(row));
31+ }),
32+
33+ listByRevisionIds: (
34+ recipeRevisionIds: readonly StoredSourceRecipeDocumentRecord["recipeRevisionId"][],
35+ ) =>
36+ client.use("rows.source_recipe_documents.list_by_revisions", async (db) => {
37+ if (recipeRevisionIds.length === 0) {
38+ return [] as StoredSourceRecipeDocumentRecord[];
39+ }
40+
41+ const rows = await db
42+ .select()
43+ .from(tables.sourceRecipeDocumentsTable)
44+ .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, [...recipeRevisionIds]))
45+ .orderBy(
46+ asc(tables.sourceRecipeDocumentsTable.recipeRevisionId),
47+ asc(tables.sourceRecipeDocumentsTable.documentKind),
48+ asc(tables.sourceRecipeDocumentsTable.documentKey),
49+ );
50+
51+ return rows.map((row) => decodeStoredSourceRecipeDocumentRecord(row));
52+ }),
53+
54+ replaceForRevision: (input: {
55+ recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"];
56+ documents: readonly StoredSourceRecipeDocumentRecord[];
57+ }) =>
58+ client.useTx("rows.source_recipe_documents.replace_for_revision", async (tx) => {
59+ await tx
60+ .delete(tables.sourceRecipeDocumentsTable)
61+ .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId));
62+
63+ if (input.documents.length > 0) {
64+ await tx.insert(tables.sourceRecipeDocumentsTable).values([...input.documents]);
65+ }
66+ }),
67+
68+ removeByRevisionId: (recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"]) =>
69+ client.use("rows.source_recipe_documents.remove_by_revision", async (db) => {
70+ const deleted = await db
71+ .delete(tables.sourceRecipeDocumentsTable)
72+ .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionId))
73+ .returning();
74+
75+ return deleted.length;
76+ }),
77+});
packages/control-plane/src/persistence/repos/source-recipe-operations-repo.ts+77−0
@@ -0,0 +1,77 @@
1+import {
2+ type StoredSourceRecipeOperationRecord,
3+ StoredSourceRecipeOperationRecordSchema,
4+} from "#schema";
5+import { Schema } from "effect";
6+import { asc, eq, inArray } from "drizzle-orm";
7+
8+import type { DrizzleClient } from "../client";
9+import type { DrizzleTables } from "../schema";
10+
11+const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync(
12+ StoredSourceRecipeOperationRecordSchema,
13+);
14+
15+export const createSourceRecipeOperationsRepo = (
16+ client: DrizzleClient,
17+ tables: DrizzleTables,
18+) => ({
19+ listByRevisionId: (recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"]) =>
20+ client.use("rows.source_recipe_operations.list_by_revision", async (db) => {
21+ const rows = await db
22+ .select()
23+ .from(tables.sourceRecipeOperationsTable)
24+ .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionId))
25+ .orderBy(
26+ asc(tables.sourceRecipeOperationsTable.toolId),
27+ asc(tables.sourceRecipeOperationsTable.id),
28+ );
29+
30+ return rows.map((row) => decodeStoredSourceRecipeOperationRecord(row));
31+ }),
32+
33+ listByRevisionIds: (
34+ recipeRevisionIds: readonly StoredSourceRecipeOperationRecord["recipeRevisionId"][],
35+ ) =>
36+ client.use("rows.source_recipe_operations.list_by_revisions", async (db) => {
37+ if (recipeRevisionIds.length === 0) {
38+ return [] as StoredSourceRecipeOperationRecord[];
39+ }
40+
41+ const rows = await db
42+ .select()
43+ .from(tables.sourceRecipeOperationsTable)
44+ .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, [...recipeRevisionIds]))
45+ .orderBy(
46+ asc(tables.sourceRecipeOperationsTable.recipeRevisionId),
47+ asc(tables.sourceRecipeOperationsTable.toolId),
48+ asc(tables.sourceRecipeOperationsTable.id),
49+ );
50+
51+ return rows.map((row) => decodeStoredSourceRecipeOperationRecord(row));
52+ }),
53+
54+ replaceForRevision: (input: {
55+ recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"];
56+ operations: readonly StoredSourceRecipeOperationRecord[];
57+ }) =>
58+ client.useTx("rows.source_recipe_operations.replace_for_revision", async (tx) => {
59+ await tx
60+ .delete(tables.sourceRecipeOperationsTable)
61+ .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId));
62+
63+ if (input.operations.length > 0) {
64+ await tx.insert(tables.sourceRecipeOperationsTable).values([...input.operations]);
65+ }
66+ }),
67+
68+ removeByRevisionId: (recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"]) =>
69+ client.use("rows.source_recipe_operations.remove_by_revision", async (db) => {
70+ const deleted = await db
71+ .delete(tables.sourceRecipeOperationsTable)
72+ .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionId))
73+ .returning();
74+
75+ return deleted.length;
76+ }),
77+});
packages/control-plane/src/persistence/repos/source-recipe-revisions-repo.ts+111−0
@@ -0,0 +1,111 @@
1+import {
2+ type StoredSourceRecipeRevisionRecord,
3+ StoredSourceRecipeRevisionRecordSchema,
4+} from "#schema";
5+import * as Option from "effect/Option";
6+import { Schema } from "effect";
7+import { asc, eq, inArray } from "drizzle-orm";
8+
9+import type { DrizzleClient } from "../client";
10+import type { DrizzleTables } from "../schema";
11+import { firstOption, withoutCreatedAt } from "./shared";
12+
13+const decodeStoredSourceRecipeRevisionRecord = Schema.decodeUnknownSync(
14+ StoredSourceRecipeRevisionRecordSchema,
15+);
16+
17+type Mutable<T> = { -readonly [K in keyof T]: T[K] };
18+type RecipeRevisionPatch = Partial<
19+ Omit<Mutable<StoredSourceRecipeRevisionRecord>, "id" | "recipeId" | "revisionNumber" | "createdAt">
20+>;
21+
22+export const createSourceRecipeRevisionsRepo = (
23+ client: DrizzleClient,
24+ tables: DrizzleTables,
25+) => ({
26+ listByRecipeId: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) =>
27+ client.use("rows.source_recipe_revisions.list_by_recipe", async (db) => {
28+ const rows = await db
29+ .select()
30+ .from(tables.sourceRecipeRevisionsTable)
31+ .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId))
32+ .orderBy(
33+ asc(tables.sourceRecipeRevisionsTable.revisionNumber),
34+ asc(tables.sourceRecipeRevisionsTable.createdAt),
35+ );
36+
37+ return rows.map((row) => decodeStoredSourceRecipeRevisionRecord(row));
38+ }),
39+
40+ listByIds: (ids: readonly StoredSourceRecipeRevisionRecord["id"][]) =>
41+ client.use("rows.source_recipe_revisions.list_by_ids", async (db) => {
42+ if (ids.length === 0) {
43+ return [] as StoredSourceRecipeRevisionRecord[];
44+ }
45+
46+ const rows = await db
47+ .select()
48+ .from(tables.sourceRecipeRevisionsTable)
49+ .where(inArray(tables.sourceRecipeRevisionsTable.id, [...ids]))
50+ .orderBy(
51+ asc(tables.sourceRecipeRevisionsTable.recipeId),
52+ asc(tables.sourceRecipeRevisionsTable.revisionNumber),
53+ );
54+
55+ return rows.map((row) => decodeStoredSourceRecipeRevisionRecord(row));
56+ }),
57+
58+ getById: (id: StoredSourceRecipeRevisionRecord["id"]) =>
59+ client.use("rows.source_recipe_revisions.get_by_id", async (db) => {
60+ const rows = await db
61+ .select()
62+ .from(tables.sourceRecipeRevisionsTable)
63+ .where(eq(tables.sourceRecipeRevisionsTable.id, id))
64+ .limit(1);
65+
66+ const row = firstOption(rows);
67+ return Option.isSome(row)
68+ ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value))
69+ : Option.none<StoredSourceRecipeRevisionRecord>();
70+ }),
71+
72+ upsert: (revision: StoredSourceRecipeRevisionRecord) =>
73+ client.use("rows.source_recipe_revisions.upsert", async (db) => {
74+ await db
75+ .insert(tables.sourceRecipeRevisionsTable)
76+ .values(revision)
77+ .onConflictDoUpdate({
78+ target: [tables.sourceRecipeRevisionsTable.id],
79+ set: {
80+ ...withoutCreatedAt(revision),
81+ },
82+ });
83+ }),
84+
85+ update: (
86+ id: StoredSourceRecipeRevisionRecord["id"],
87+ patch: RecipeRevisionPatch,
88+ ) =>
89+ client.use("rows.source_recipe_revisions.update", async (db) => {
90+ const rows = await db
91+ .update(tables.sourceRecipeRevisionsTable)
92+ .set(patch)
93+ .where(eq(tables.sourceRecipeRevisionsTable.id, id))
94+ .returning();
95+
96+ const row = firstOption(rows);
97+ return Option.isSome(row)
98+ ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value))
99+ : Option.none<StoredSourceRecipeRevisionRecord>();
100+ }),
101+
102+ removeByRecipeId: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) =>
103+ client.use("rows.source_recipe_revisions.remove_by_recipe", async (db) => {
104+ const deleted = await db
105+ .delete(tables.sourceRecipeRevisionsTable)
106+ .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId))
107+ .returning();
108+
109+ return deleted.length;
110+ }),
111+});
packages/control-plane/src/persistence/repos/source-recipes-repo.ts+67−0
@@ -0,0 +1,67 @@
1+import {
2+ type StoredSourceRecipeRecord,
3+ StoredSourceRecipeRecordSchema,
4+} from "#schema";
5+import * as Option from "effect/Option";
6+import { Schema } from "effect";
7+import { asc, eq } from "drizzle-orm";
8+
9+import type { DrizzleClient } from "../client";
10+import type { DrizzleTables } from "../schema";
11+import { firstOption, withoutCreatedAt } from "./shared";
12+
13+const decodeStoredSourceRecipeRecord = Schema.decodeUnknownSync(
14+ StoredSourceRecipeRecordSchema,
15+);
16+
17+export const createSourceRecipesRepo = (
18+ client: DrizzleClient,
19+ tables: DrizzleTables,
20+) => ({
21+ listByUpdatedAt: () =>
22+ client.use("rows.source_recipes.list", async (db) => {
23+ const rows = await db
24+ .select()
25+ .from(tables.sourceRecipesTable)
26+ .orderBy(asc(tables.sourceRecipesTable.updatedAt), asc(tables.sourceRecipesTable.id));
27+
28+ return rows.map((row) => decodeStoredSourceRecipeRecord(row));
29+ }),
30+
31+ getById: (id: StoredSourceRecipeRecord["id"]) =>
32+ client.use("rows.source_recipes.get_by_id", async (db) => {
33+ const rows = await db
34+ .select()
35+ .from(tables.sourceRecipesTable)
36+ .where(eq(tables.sourceRecipesTable.id, id))
37+ .limit(1);
38+
39+ const row = firstOption(rows);
40+ return Option.isSome(row)
41+ ? Option.some(decodeStoredSourceRecipeRecord(row.value))
42+ : Option.none<StoredSourceRecipeRecord>();
43+ }),
44+
45+ upsert: (recipe: StoredSourceRecipeRecord) =>
46+ client.use("rows.source_recipes.upsert", async (db) => {
47+ await db
48+ .insert(tables.sourceRecipesTable)
49+ .values(recipe)
50+ .onConflictDoUpdate({
51+ target: [tables.sourceRecipesTable.id],
52+ set: {
53+ ...withoutCreatedAt(recipe),
54+ },
55+ });
56+ }),
57+
58+ removeById: (id: StoredSourceRecipeRecord["id"]) =>
59+ client.use("rows.source_recipes.remove", async (db) => {
60+ const deleted = await db
61+ .delete(tables.sourceRecipesTable)
62+ .where(eq(tables.sourceRecipesTable.id, id))
63+ .returning();
64+
65+ return deleted.length > 0;
66+ }),
67+});
packages/control-plane/src/persistence/repos/sources-repo.ts+38−1
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { and, asc, eq, or } from "drizzle-orm";
7+import { and, asc, count, eq, or } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
@@ -18,12 +18,15 @@ const toSourceUpdateSet = ( · 7 unmodified lines
1818 ): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => {
1919 const set: Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> = {};
2020
21+ if (patch.recipeId !== undefined) set.recipeId = patch.recipeId;
22+ if (patch.recipeRevisionId !== undefined) set.recipeRevisionId = patch.recipeRevisionId;
2123 if (patch.name !== undefined) set.name = patch.name;
2224 if (patch.kind !== undefined) set.kind = patch.kind;
2325 if (patch.endpoint !== undefined) set.endpoint = patch.endpoint;
2426 if (patch.status !== undefined) set.status = patch.status;
2527 if (patch.enabled !== undefined) set.enabled = patch.enabled;
2628 if (patch.namespace !== undefined) set.namespace = patch.namespace;
29+ if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson;
2730 if (patch.transport !== undefined) set.transport = patch.transport;
2831 if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson;
2932 if (patch.headersJson !== undefined) set.headersJson = patch.headersJson;
@@ -43,6 +46,20 @@ export const createSourcesRepo = ( · 13 unmodified lines
4346 client: DrizzleClient,
4447 tables: DrizzleTables,
4548 ) => ({
49+ listAll: () =>
50+ client.use("rows.sources.list_all", async (db) => {
51+ const rows = await db
52+ .select()
53+ .from(tables.sourcesTable)
54+ .orderBy(
55+ asc(tables.sourcesTable.workspaceId),
56+ asc(tables.sourcesTable.updatedAt),
57+ asc(tables.sourcesTable.sourceId),
58+ );
59+
60+ return rows.map((row) => decodeStoredSourceRecord(row));
61+ }),
62+
4663 listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) =>
4764 client.use("rows.sources.list_by_workspace", async (db) => {
4865 const rows = await db
@@ -76,6 +93,26 @@ export const createSourcesRepo = ( · 27 unmodified lines
7693 : Option.none<StoredSourceRecord>();
7794 }),
7895
96+ countByRecipeId: (recipeId: StoredSourceRecord["recipeId"]) =>
97+ client.use("rows.sources.count_by_recipe", async (db) => {
98+ const rows = await db
99+ .select({ value: count() })
100+ .from(tables.sourcesTable)
101+ .where(eq(tables.sourcesTable.recipeId, recipeId));
102+
103+ return Number(rows[0]?.value ?? 0);
104+ }),
105+
106+ countByRecipeRevisionId: (recipeRevisionId: StoredSourceRecord["recipeRevisionId"]) =>
107+ client.use("rows.sources.count_by_recipe_revision", async (db) => {
108+ const rows = await db
109+ .select({ value: count() })
110+ .from(tables.sourcesTable)
111+ .where(eq(tables.sourcesTable.recipeRevisionId, recipeRevisionId));
112+
113+ return Number(rows[0]?.value ?? 0);
114+ }),
115+
79116 insert: (source: StoredSourceRecord) =>
80117 client.use("rows.sources.insert", async (db) => {
81118 await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source));
packages/control-plane/src/persistence/repos/workspaces-repo.ts+89−3
@@ -1,7 +1,7 @@
11 import { type Workspace, WorkspaceSchema } from "#schema";
22 import * as Option from "effect/Option";
33 import { Schema } from "effect";
4−import { and, asc, eq, inArray } from "drizzle-orm";
4+import { and, asc, eq, inArray, or } from "drizzle-orm";
55
66 import type { DrizzleClient } from "../client";
77 import type { DrizzleTables } from "../schema";
@@ -69,7 +69,16 @@ export const createWorkspacesRepo = ( · 61 unmodified lines
6969 .select({ id: tables.executionsTable.id })
7070 .from(tables.executionsTable)
7171 .where(eq(tables.executionsTable.workspaceId, workspaceId));
72+ const sourceRows = await tx
73+ .select({
74+ sourceId: tables.sourcesTable.sourceId,
75+ recipeId: tables.sourcesTable.recipeId,
76+ })
77+ .from(tables.sourcesTable)
78+ .where(eq(tables.sourcesTable.workspaceId, workspaceId));
7279 const executionIds = executionRows.map((execution) => execution.id);
80+ const sourceIds = sourceRows.map((source) => source.sourceId);
81+ const recipeIds = sourceRows.map((source) => source.recipeId);
7382 const credentials = await tx
7483 .select({
7584 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -79,6 +88,26 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
7988 })
8089 .from(tables.credentialsTable)
8190 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
91+ const recipeRevisionRows = recipeIds.length > 0
92+ ? await tx
93+ .select({ id: tables.sourceRecipeRevisionsTable.id })
94+ .from(tables.sourceRecipeRevisionsTable)
95+ .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds))
96+ : [];
97+ const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id);
98+ const toolArtifactPaths = sourceIds.length > 0
99+ ? (
100+ await tx
101+ .select({ path: tables.toolArtifactsTable.path })
102+ .from(tables.toolArtifactsTable)
103+ .where(
104+ and(
105+ eq(tables.toolArtifactsTable.workspaceId, workspaceId),
106+ inArray(tables.toolArtifactsTable.sourceId, sourceIds),
107+ ),
108+ )
109+ ).map((row) => row.path)
110+ : [];
82111 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
83112
84113 if (executionIds.length > 0) {
@@ -87,6 +116,39 @@ export const createWorkspacesRepo = ( · 2 unmodified lines
87116 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));
88117 }
89118
119+ if (toolArtifactPaths.length > 0) {
120+ await tx
121+ .delete(tables.toolArtifactParametersTable)
122+ .where(
123+ and(
124+ eq(tables.toolArtifactParametersTable.workspaceId, workspaceId),
125+ or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))),
126+ ),
127+ );
128+
129+ await tx
130+ .delete(tables.toolArtifactRequestBodyContentTypesTable)
131+ .where(
132+ and(
133+ eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId),
134+ or(
135+ ...toolArtifactPaths.map((path) =>
136+ eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
137+ ),
138+ ),
139+ ),
140+ );
141+
142+ await tx
143+ .delete(tables.toolArtifactRefHintKeysTable)
144+ .where(
145+ and(
146+ eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId),
147+ or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))),
148+ ),
149+ );
150+ }
151+
90152 await tx
91153 .delete(tables.executionsTable)
92154 .where(eq(tables.executionsTable.workspaceId, workspaceId));
@@ -96,17 +158,41 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
96158 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));
97159
98160 await tx
99− .delete(tables.sourceCredentialBindingsTable)
100− .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId));
161+ .delete(tables.workspaceSourceOauthClientsTable)
162+ .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));
101163
102164 await tx
103165 .delete(tables.credentialsTable)
104166 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
105167
168+ await tx
169+ .delete(tables.toolArtifactsTable)
170+ .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId));
171+
106172 await tx
107173 .delete(tables.sourcesTable)
108174 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
109175
176+ if (recipeRevisionIds.length > 0) {
177+ await tx
178+ .delete(tables.sourceRecipeDocumentsTable)
179+ .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));
180+
181+ await tx
182+ .delete(tables.sourceRecipeOperationsTable)
183+ .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds));
184+ }
185+
186+ if (recipeIds.length > 0) {
187+ await tx
188+ .delete(tables.sourceRecipeRevisionsTable)
189+ .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds));
190+
191+ await tx
192+ .delete(tables.sourceRecipesTable)
193+ .where(inArray(tables.sourceRecipesTable.id, recipeIds));
194+ }
195+
110196 await tx
111197 .delete(tables.policiesTable)
112198 .where(
packages/control-plane/src/persistence/schema.ts+223−21
@@ -16,12 +16,16 @@ export const tableNames = { · 15 unmodified lines
1616 organizationMemberships: "organization_memberships",
1717 workspaces: "workspaces",
1818 sources: "sources",
19+ sourceRecipes: "source_recipes",
20+ sourceRecipeRevisions: "source_recipe_revisions",
21+ sourceRecipeDocuments: "source_recipe_documents",
22+ sourceRecipeOperations: "source_recipe_operations",
1923 toolArtifacts: "tool_artifacts",
2024 toolArtifactParameters: "tool_artifact_parameters",
2125 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
2226 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
23− credentials: "credentials",
24− sourceCredentialBindings: "source_credential_bindings",
27+ credentials: "workspace_source_credentials",
28+ workspaceSourceOauthClients: "workspace_source_oauth_clients",
2529 secretMaterials: "secret_materials",
2630 sourceAuthSessions: "source_auth_sessions",
2731 policies: "policies",
@@ -133,12 +137,15 @@ export const sourcesTable = pgTable( · 105 unmodified lines
133137 {
134138 workspaceId: text("workspace_id").notNull(),
135139 sourceId: text("source_id").notNull(),
140+ recipeId: text("recipe_id").notNull(),
141+ recipeRevisionId: text("recipe_revision_id").notNull(),
136142 name: text("name").notNull(),
137143 kind: text("kind").notNull(),
138144 endpoint: text("endpoint").notNull(),
139145 status: text("status").notNull(),
140146 enabled: boolean("enabled").notNull(),
141147 namespace: text("namespace"),
148+ bindingConfigJson: text("binding_config_json"),
142149 transport: text("transport"),
143150 queryParamsJson: text("query_params_json"),
144151 headersJson: text("headers_json"),
@@ -159,6 +166,12 @@ export const sourcesTable = pgTable( · 14 unmodified lines
159166 table.updatedAt,
160167 table.sourceId,
161168 ),
169+ index("sources_workspace_recipe_idx").on(
170+ table.workspaceId,
171+ table.recipeId,
172+ table.updatedAt,
173+ table.sourceId,
174+ ),
162175 uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name),
163176 check(
164177 "sources_kind_check",
@@ -175,11 +188,187 @@ export const sourcesTable = pgTable( · 10 unmodified lines
175188 ],
176189 );
177190
191+export const sourceRecipesTable = pgTable(
192+ tableNames.sourceRecipes,
193+ {
194+ id: text("id").notNull().primaryKey(),
195+ kind: text("kind").notNull(),
196+ importerKind: text("importer_kind").notNull(),
197+ providerKey: text("provider_key").notNull(),
198+ name: text("name").notNull(),
199+ summary: text("summary"),
200+ visibility: text("visibility").notNull(),
201+ latestRevisionId: text("latest_revision_id").notNull(),
202+ createdAt: bigint("created_at", { mode: "number" }).notNull(),
203+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
204+ },
205+ (table) => [
206+ index("source_recipes_provider_updated_idx").on(
207+ table.providerKey,
208+ table.updatedAt,
209+ table.id,
210+ ),
211+ index("source_recipes_visibility_updated_idx").on(
212+ table.visibility,
213+ table.updatedAt,
214+ table.id,
215+ ),
216+ check(
217+ "source_recipes_kind_check",
218+ sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`,
219+ ),
220+ check(
221+ "source_recipes_importer_kind_check",
222+ sql`${table.importerKind} in (
223+ 'openapi',
224+ 'google_discovery',
225+ 'postman_collection',
226+ 'snippet_bundle',
227+ 'graphql_introspection',
228+ 'mcp_manifest',
229+ 'internal_manifest'
230+ )`,
231+ ),
232+ check(
233+ "source_recipes_visibility_check",
234+ sql`${table.visibility} in ('private', 'workspace', 'organization', 'public')`,
235+ ),
236+ ],
237+);
238+
239+export const sourceRecipeRevisionsTable = pgTable(
240+ tableNames.sourceRecipeRevisions,
241+ {
242+ id: text("id").notNull().primaryKey(),
243+ recipeId: text("recipe_id").notNull(),
244+ revisionNumber: bigint("revision_number", { mode: "number" }).notNull(),
245+ sourceConfigJson: text("source_config_json").notNull(),
246+ manifestJson: text("manifest_json"),
247+ manifestHash: text("manifest_hash"),
248+ createdAt: bigint("created_at", { mode: "number" }).notNull(),
249+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
250+ },
251+ (table) => [
252+ uniqueIndex("source_recipe_revisions_recipe_revision_idx").on(
253+ table.recipeId,
254+ table.revisionNumber,
255+ ),
256+ uniqueIndex("source_recipe_revisions_recipe_manifest_idx").on(
257+ table.recipeId,
258+ table.manifestHash,
259+ ),
260+ index("source_recipe_revisions_recipe_created_idx").on(
261+ table.recipeId,
262+ table.createdAt,
263+ table.id,
264+ ),
265+ ],
266+);
267+
268+export const sourceRecipeDocumentsTable = pgTable(
269+ tableNames.sourceRecipeDocuments,
270+ {
271+ id: text("id").notNull().primaryKey(),
272+ recipeRevisionId: text("recipe_revision_id").notNull(),
273+ documentKind: text("document_kind").notNull(),
274+ documentKey: text("document_key").notNull(),
275+ contentText: text("content_text").notNull(),
276+ contentHash: text("content_hash").notNull(),
277+ fetchedAt: bigint("fetched_at", { mode: "number" }),
278+ createdAt: bigint("created_at", { mode: "number" }).notNull(),
279+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
280+ },
281+ (table) => [
282+ uniqueIndex("source_recipe_documents_revision_kind_key_idx").on(
283+ table.recipeRevisionId,
284+ table.documentKind,
285+ table.documentKey,
286+ ),
287+ index("source_recipe_documents_revision_created_idx").on(
288+ table.recipeRevisionId,
289+ table.createdAt,
290+ table.id,
291+ ),
292+ check(
293+ "source_recipe_documents_kind_check",
294+ sql`${table.documentKind} in (
295+ 'google_discovery',
296+ 'openapi',
297+ 'postman_collection',
298+ 'postman_environment',
299+ 'graphql_introspection',
300+ 'mcp_manifest'
301+ )`,
302+ ),
303+ ],
304+);
305+
306+export const sourceRecipeOperationsTable = pgTable(
307+ tableNames.sourceRecipeOperations,
308+ {
309+ id: text("id").notNull().primaryKey(),
310+ recipeRevisionId: text("recipe_revision_id").notNull(),
311+ operationKey: text("operation_key").notNull(),
312+ transportKind: text("transport_kind").notNull(),
313+ toolId: text("tool_id").notNull(),
314+ title: text("title"),
315+ description: text("description"),
316+ operationKind: text("operation_kind").notNull(),
317+ searchText: text("search_text").notNull(),
318+ inputSchemaJson: text("input_schema_json"),
319+ outputSchemaJson: text("output_schema_json"),
320+ providerKind: text("provider_kind").notNull(),
321+ providerDataJson: text("provider_data_json"),
322+ mcpToolName: text("mcp_tool_name"),
323+ openApiMethod: text("openapi_method"),
324+ openApiPathTemplate: text("openapi_path_template"),
325+ openApiOperationHash: text("openapi_operation_hash"),
326+ openApiRawToolId: text("openapi_raw_tool_id"),
327+ openApiOperationId: text("openapi_operation_id"),
328+ openApiTagsJson: text("openapi_tags_json"),
329+ openApiRequestBodyRequired: boolean("openapi_request_body_required"),
330+ graphqlOperationType: text("graphql_operation_type"),
331+ graphqlOperationName: text("graphql_operation_name"),
332+ createdAt: bigint("created_at", { mode: "number" }).notNull(),
333+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
334+ },
335+ (table) => [
336+ uniqueIndex("source_recipe_operations_revision_operation_key_idx").on(
337+ table.recipeRevisionId,
338+ table.operationKey,
339+ ),
340+ index("source_recipe_operations_revision_tool_idx").on(
341+ table.recipeRevisionId,
342+ table.toolId,
343+ table.updatedAt,
344+ table.id,
345+ ),
346+ index("source_recipe_operations_search_text_idx").using(
347+ "gin",
348+ sql`to_tsvector('simple', ${table.searchText})`,
349+ ),
350+ check(
351+ "source_recipe_operations_transport_kind_check",
352+ sql`${table.transportKind} in ('http', 'graphql', 'mcp', 'internal')`,
353+ ),
354+ check(
355+ "source_recipe_operations_kind_check",
356+ sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`,
357+ ),
358+ check(
359+ "source_recipe_operations_provider_kind_check",
360+ sql`${table.providerKind} in ('mcp', 'openapi', 'graphql', 'internal')`,
361+ ),
362+ ],
363+);
364+
178365 export const credentialsTable = pgTable(
179366 tableNames.credentials,
180367 {
181368 id: text("id").notNull().primaryKey(),
182369 workspaceId: text("workspace_id").notNull(),
370+ sourceId: text("source_id").notNull(),
371+ actorAccountId: text("actor_account_id"),
183372 authKind: text("auth_kind").notNull(),
184373 authHeaderName: text("auth_header_name").notNull(),
185374 authPrefix: text("auth_prefix").notNull(),
@@ -196,6 +385,17 @@ export const credentialsTable = pgTable( · 10 unmodified lines
196385 table.updatedAt,
197386 table.id,
198387 ),
388+ uniqueIndex("credentials_workspace_source_actor_idx").on(
389+ table.workspaceId,
390+ table.sourceId,
391+ table.actorAccountId,
392+ ),
393+ index("credentials_workspace_source_idx").on(
394+ table.workspaceId,
395+ table.sourceId,
396+ table.updatedAt,
397+ table.id,
398+ ),
199399 check(
200400 "credentials_auth_kind_check",
201401 sql`${table.authKind} in ('bearer', 'oauth2')`,
@@ -203,22 +403,27 @@ export const credentialsTable = pgTable( · 1 unmodified line
203403 ],
204404 );
205405
206−export const sourceCredentialBindingsTable = pgTable(
207− tableNames.sourceCredentialBindings,
406+export const workspaceSourceOauthClientsTable = pgTable(
407+ tableNames.workspaceSourceOauthClients,
208408 {
209409 id: text("id").notNull().primaryKey(),
210410 workspaceId: text("workspace_id").notNull(),
211411 sourceId: text("source_id").notNull(),
212− credentialId: text("credential_id").notNull(),
412+ providerKey: text("provider_key").notNull(),
413+ clientId: text("client_id").notNull(),
414+ clientSecretProviderId: text("client_secret_provider_id"),
415+ clientSecretHandle: text("client_secret_handle"),
416+ clientMetadataJson: text("client_metadata_json"),
213417 createdAt: bigint("created_at", { mode: "number" }).notNull(),
214418 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
215419 },
216420 (table) => [
217− uniqueIndex("source_credential_bindings_workspace_source_idx").on(
421+ uniqueIndex("workspace_source_oauth_clients_workspace_source_provider_idx").on(
218422 table.workspaceId,
219423 table.sourceId,
424+ table.providerKey,
220425 ),
221− index("source_credential_bindings_workspace_idx").on(
426+ index("workspace_source_oauth_clients_workspace_idx").on(
222427 table.workspaceId,
223428 table.updatedAt,
224429 table.sourceId,
@@ -379,21 +584,13 @@ export const sourceAuthSessionsTable = pgTable( · 154 unmodified lines
379584 id: text("id").notNull().primaryKey(),
380585 workspaceId: text("workspace_id").notNull(),
381586 sourceId: text("source_id").notNull(),
587+ actorAccountId: text("actor_account_id"),
382588 executionId: text("execution_id"),
383589 interactionId: text("interaction_id"),
384− strategy: text("strategy").notNull(),
590+ providerKind: text("provider_kind").notNull(),
385591 status: text("status").notNull(),
386− endpoint: text("endpoint").notNull(),
387592 state: text("state").notNull(),
388− redirectUri: text("redirect_uri").notNull(),
389− scope: text("scope"),
390− resourceMetadataUrl: text("resource_metadata_url"),
391− authorizationServerUrl: text("authorization_server_url"),
392− resourceMetadataJson: text("resource_metadata_json"),
393− authorizationServerMetadataJson: text("authorization_server_metadata_json"),
394− clientInformationJson: text("client_information_json"),
395− codeVerifier: text("code_verifier"),
396− authorizationUrl: text("authorization_url"),
593+ sessionDataJson: text("session_data_json").notNull(),
397594 errorText: text("error_text"),
398595 completedAt: bigint("completed_at", { mode: "number" }),
399596 createdAt: bigint("created_at", { mode: "number" }).notNull(),
@@ -408,14 +605,15 @@ export const sourceAuthSessionsTable = pgTable( · 8 unmodified lines
408605 index("source_auth_sessions_pending_idx").on(
409606 table.workspaceId,
410607 table.sourceId,
608+ table.actorAccountId,
411609 table.status,
412610 table.updatedAt,
413611 table.id,
414612 ),
415613 uniqueIndex("source_auth_sessions_state_idx").on(table.state),
416614 check(
417− "source_auth_sessions_strategy_check",
418− sql`${table.strategy} in ('oauth2_authorization_code')`,
615+ "source_auth_sessions_provider_kind_check",
616+ sql`${table.providerKind} in ('mcp_oauth', 'oauth2_pkce')`,
419617 ),
420618 check(
421619 "source_auth_sessions_status_check",
@@ -561,12 +759,16 @@ export const drizzleSchema = { · 139 unmodified lines
561759 organizationMembershipsTable,
562760 workspacesTable,
563761 sourcesTable,
762+ sourceRecipesTable,
763+ sourceRecipeRevisionsTable,
764+ sourceRecipeDocumentsTable,
765+ sourceRecipeOperationsTable,
564766 credentialsTable,
767+ workspaceSourceOauthClientsTable,
565768 toolArtifactsTable,
566769 toolArtifactParametersTable,
567770 toolArtifactRequestBodyContentTypesTable,
568771 toolArtifactRefHintKeysTable,
569− sourceCredentialBindingsTable,
570772 secretMaterialsTable,
571773 sourceAuthSessionsTable,
572774 policiesTable,
packages/control-plane/src/runtime/source-auth-service.test.ts+18−22
@@ -29,55 +29,51 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 28 unmodified lines
2929 });
3030
3131 describe("source-auth-service", () => {
32+ const baseSessionDataJson = JSON.stringify({
33+ kind: "mcp_oauth",
34+ endpoint: "https://example.com/resource",
35+ redirectUri: "http://127.0.0.1/callback",
36+ scope: null,
37+ resourceMetadataUrl: "https://example.com/resource",
38+ authorizationServerUrl: "https://example.com/as",
39+ resourceMetadataJson: '{"issuer":"https://example.com"}',
40+ authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
41+ clientInformationJson: '{"client_id":"abc"}',
42+ codeVerifier: "verifier",
43+ authorizationUrl: "https://example.com/auth",
44+ });
45+
3246 it("clears ephemeral OAuth session fields when failing a session", () => {
3347 const patch = createTerminalSourceAuthSessionPatch({
48+ sessionDataJson: baseSessionDataJson,
3449 status: "failed",
3550 now: 123,
3651 errorText: "OAuth authorization failed",
37− resourceMetadataUrl: "https://example.com/resource",
38− authorizationServerUrl: "https://example.com/as",
39− resourceMetadataJson: '{"issuer":"https://example.com"}',
40− authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
4152 });
4253
4354 expect(patch).toMatchObject({
55+ sessionDataJson: baseSessionDataJson,
4456 status: "failed",
4557 errorText: "OAuth authorization failed",
4658 completedAt: 123,
4759 updatedAt: 123,
48− codeVerifier: null,
49− authorizationUrl: null,
50− clientInformationJson: null,
51− resourceMetadataUrl: "https://example.com/resource",
52− authorizationServerUrl: "https://example.com/as",
53− resourceMetadataJson: '{"issuer":"https://example.com"}',
54− authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
5560 });
5661 });
5762
5863 it("clears ephemeral OAuth session fields when completing a session", () => {
5964 const patch = createTerminalSourceAuthSessionPatch({
65+ sessionDataJson: baseSessionDataJson,
6066 status: "completed",
6167 now: 456,
6268 errorText: null,
63− resourceMetadataUrl: "https://example.com/resource",
64− authorizationServerUrl: "https://example.com/as",
65− resourceMetadataJson: '{"issuer":"https://example.com"}',
66− authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
6769 });
6870
6971 expect(patch).toMatchObject({
72+ sessionDataJson: baseSessionDataJson,
7073 status: "completed",
7174 errorText: null,
7275 completedAt: 456,
7376 updatedAt: 456,
74− codeVerifier: null,
75− authorizationUrl: null,
76− clientInformationJson: null,
77− resourceMetadataUrl: "https://example.com/resource",
78− authorizationServerUrl: "https://example.com/as",
79− resourceMetadataJson: '{"issuer":"https://example.com"}',
80− authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
8177 });
8278 });
8379
packages/control-plane/src/runtime/source-auth-service.ts+186−75
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 type SqlControlPlaneRows,
99 } from "#persistence";
1010 import {
11+ AccountId,
1112 ExecutionIdSchema,
1213 type SecretMaterialPurpose,
1314 Source,
@@ -163,27 +164,74 @@ const probeMcpSourceWithoutAuth = ( · 149 unmodified lines
163164 });
164165
165166 export const createTerminalSourceAuthSessionPatch = (input: {
167+ sessionDataJson: string;
166168 status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">;
167169 now: number;
168170 errorText: string | null;
169− resourceMetadataUrl?: string | null;
170− authorizationServerUrl?: string | null;
171− resourceMetadataJson?: string | null;
172− authorizationServerMetadataJson?: string | null;
173171 }) => ({
174172 status: input.status,
175173 errorText: input.errorText,
176174 completedAt: input.now,
177175 updatedAt: input.now,
178− codeVerifier: null,
179− authorizationUrl: null,
180− clientInformationJson: null,
181− resourceMetadataUrl: input.resourceMetadataUrl ?? null,
182− authorizationServerUrl: input.authorizationServerUrl ?? null,
183− resourceMetadataJson: input.resourceMetadataJson ?? null,
184− authorizationServerMetadataJson: input.authorizationServerMetadataJson ?? null,
176+ sessionDataJson: input.sessionDataJson,
185177 }) satisfies Partial<SourceAuthSession>;
186178
179+type McpSourceAuthSessionData = {
180+ kind: "mcp_oauth";
181+ endpoint: string;
182+ redirectUri: string;
183+ scope: string | null;
184+ resourceMetadataUrl: string | null;
185+ authorizationServerUrl: string | null;
186+ resourceMetadataJson: string | null;
187+ authorizationServerMetadataJson: string | null;
188+ clientInformationJson: string | null;
189+ codeVerifier: string | null;
190+ authorizationUrl: string | null;
191+};
192+
193+const encodeMcpSourceAuthSessionData = (
194+ sessionData: McpSourceAuthSessionData,
195+): string => JSON.stringify(sessionData);
196+
197+const decodeMcpSourceAuthSessionData = (
198+ session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">,
199+): McpSourceAuthSessionData => {
200+ if (session.providerKind !== "mcp_oauth") {
201+ throw new Error(`Unsupported source auth provider for session ${session.id}`);
202+ }
203+
204+ let parsed: unknown;
205+ try {
206+ parsed = JSON.parse(session.sessionDataJson) as unknown;
207+ } catch (cause) {
208+ throw new Error(
209+ `Invalid source auth session data for ${session.id}: ${cause instanceof Error ? cause.message : String(cause)}`,
210+ );
211+ }
212+
213+ if (
214+ parsed === null
215+ || typeof parsed !== "object"
216+ || (parsed as { kind?: unknown }).kind !== "mcp_oauth"
217+ ) {
218+ throw new Error(`Invalid source auth session payload for ${session.id}`);
219+ }
220+
221+ return parsed as McpSourceAuthSessionData;
222+};
223+
224+const mergeMcpSourceAuthSessionData = (input: {
225+ session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">;
226+ patch: Partial<McpSourceAuthSessionData>;
227+}): string => {
228+ const existing = decodeMcpSourceAuthSessionData(input.session);
229+ return encodeMcpSourceAuthSessionData({
230+ ...existing,
231+ ...input.patch,
232+ });
233+};
234+
187235
188236 const completeLiveInteraction = (input: {
189237 liveExecutionManager: LiveExecutionManager;
@@ -217,6 +265,7 @@ const completeLiveInteraction = (input: { · 27 unmodified lines
217265 });
218266
219267 const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: {
268+ actorAccountId?: AccountId | null;
220269 status: Source["status"];
221270 lastError?: string | null;
222271 auth?: Source["auth"];
@@ -225,6 +274,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines
225274 const latest = yield* loadSourceById(rows, {
226275 workspaceId: source.workspaceId,
227276 sourceId: source.id,
277+ actorAccountId: input.actorAccountId,
228278 });
229279
230280 return yield* persistSource(rows, {
@@ -233,6 +283,8 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines
233283 lastError: input.lastError ?? null,
234284 auth: input.auth ?? latest.auth,
235285 updatedAt: Date.now(),
286+ }, {
287+ actorAccountId: input.actorAccountId,
236288 });
237289 });
238290
@@ -277,6 +329,7 @@ export type ExecutorAddSourceInput = · 38 unmodified lines
277329 | {
278330 kind?: "mcp";
279331 workspaceId: WorkspaceId;
332+ actorAccountId?: AccountId | null;
280333 executionId: SourceAuthSession["executionId"];
281334 interactionId: SourceAuthSession["interactionId"];
282335 endpoint: string;
@@ -286,6 +339,7 @@ export type ExecutorAddSourceInput = · 3 unmodified lines
286339 | {
287340 kind: "openapi";
288341 workspaceId: WorkspaceId;
342+ actorAccountId?: AccountId | null;
289343 executionId: SourceAuthSession["executionId"];
290344 interactionId: SourceAuthSession["interactionId"];
291345 endpoint: string;
@@ -297,6 +351,7 @@ export type ExecutorAddSourceInput = · 5 unmodified lines
297351 | {
298352 kind: "graphql";
299353 workspaceId: WorkspaceId;
354+ actorAccountId?: AccountId | null;
300355 executionId: SourceAuthSession["executionId"];
301356 interactionId: SourceAuthSession["interactionId"];
302357 endpoint: string;
@@ -307,6 +362,7 @@ export type ExecutorAddSourceInput = · 4 unmodified lines
307362
308363 export type ConnectMcpSourceInput = {
309364 workspaceId: WorkspaceId;
365+ actorAccountId?: AccountId | null;
310366 sourceId?: Source["id"] | null;
311367 endpoint: string;
312368 name?: string | null;
@@ -332,6 +388,7 @@ export type SourceOAuthProviderInput = { · 19 unmodified lines
332388
333389 export type StartSourceOAuthSessionInput = {
334390 workspaceId: WorkspaceId;
391+ actorAccountId?: AccountId | null;
335392 provider: SourceOAuthProviderInput;
336393 baseUrl?: string | null;
337394 displayName?: string | null;
@@ -482,6 +539,7 @@ const connectMcpSourceInternal = (input: { · 144 unmodified lines
482539 getLocalServerBaseUrl?: () => string | undefined;
483540 baseUrl?: string | null;
484541 workspaceId: WorkspaceId;
542+ actorAccountId?: AccountId | null;
485543 sourceId?: Source["id"] | null;
486544 executionId?: SourceAuthSession["executionId"];
487545 interactionId?: SourceAuthSession["interactionId"];
@@ -502,6 +560,7 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines
502560 ? loadSourceById(input.rows, {
503561 workspaceId: input.workspaceId,
504562 sourceId: input.sourceId,
563+ actorAccountId: input.actorAccountId,
505564 }).pipe(
506565 Effect.flatMap((source) =>
507566 source.kind === "mcp"
@@ -509,7 +568,9 @@ const connectMcpSourceInternal = (input: { · 1 unmodified line
509568 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
510569 ),
511570 )
512− : loadSourcesInWorkspace(input.rows, input.workspaceId).pipe(
571+ : loadSourcesInWorkspace(input.rows, input.workspaceId, {
572+ actorAccountId: input.actorAccountId,
573+ }).pipe(
513574 Effect.map((sources) =>
514575 sources.find(
515576 (source) =>
@@ -569,7 +630,9 @@ const connectMcpSourceInternal = (input: { · 53 unmodified lines
569630 now,
570631 });
571632
572− const persistedDraft = yield* persistSource(input.rows, draftSource);
633+ const persistedDraft = yield* persistSource(input.rows, draftSource, {
634+ actorAccountId: input.actorAccountId,
635+ });
573636 yield* syncSourceToolArtifacts({
574637 rows: input.rows,
575638 source: persistedDraft,
@@ -588,6 +651,7 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines
588651 onRight: (result) =>
589652 Effect.gen(function* () {
590653 const connected = yield* updateSourceStatus(input.rows, persistedDraft, {
654+ actorAccountId: input.actorAccountId,
591655 status: "connected",
592656 lastError: null,
593657 auth: { kind: "none" },
@@ -603,6 +667,7 @@ const connectMcpSourceInternal = (input: { · 9 unmodified lines
603667 return yield* Either.match(indexed, {
604668 onLeft: (error) =>
605669 updateSourceStatus(input.rows, connected, {
670+ actorAccountId: input.actorAccountId,
606671 status: "error",
607672 lastError: error.message,
608673 }).pipe(
@@ -642,6 +707,7 @@ const connectMcpSourceInternal = (input: { · 33 unmodified lines
642707 });
643708
644709 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
710+ actorAccountId: input.actorAccountId,
645711 status: "auth_required",
646712 lastError: null,
647713 });
@@ -651,21 +717,25 @@ const connectMcpSourceInternal = (input: { · 3 unmodified lines
651717 id: sessionId,
652718 workspaceId: input.workspaceId,
653719 sourceId: authRequiredSource.id,
720+ actorAccountId: input.actorAccountId ?? null,
654721 executionId: input.executionId ?? null,
655722 interactionId: input.interactionId ?? null,
656− strategy: "oauth2_authorization_code",
723+ providerKind: "mcp_oauth",
657724 status: "pending",
658− endpoint: normalizedEndpoint,
659725 state,
660− redirectUri: redirectUrl,
661− scope: null,
662− resourceMetadataUrl: oauthStart.resourceMetadataUrl,
663− authorizationServerUrl: oauthStart.authorizationServerUrl,
664− resourceMetadataJson: oauthStart.resourceMetadataJson,
665− authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
666− clientInformationJson: oauthStart.clientInformationJson,
667− codeVerifier: oauthStart.codeVerifier,
668− authorizationUrl: oauthStart.authorizationUrl,
726+ sessionDataJson: encodeMcpSourceAuthSessionData({
727+ kind: "mcp_oauth",
728+ endpoint: normalizedEndpoint,
729+ redirectUri: redirectUrl,
730+ scope: null,
731+ resourceMetadataUrl: oauthStart.resourceMetadataUrl,
732+ authorizationServerUrl: oauthStart.authorizationServerUrl,
733+ resourceMetadataJson: oauthStart.resourceMetadataJson,
734+ authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
735+ clientInformationJson: oauthStart.clientInformationJson,
736+ codeVerifier: oauthStart.codeVerifier,
737+ authorizationUrl: oauthStart.authorizationUrl,
738+ }),
669739 errorText: null,
670740 completedAt: null,
671741 createdAt: sessionNow,
@@ -694,6 +764,9 @@ const addExecutorHttpSource = (input: { · 22 unmodified lines
694764 const existingSources = yield* loadSourcesInWorkspace(
695765 input.rows,
696766 input.sourceInput.workspaceId,
767+ {
768+ actorAccountId: input.sourceInput.actorAccountId,
769+ },
697770 );
698771 const existing = existingSources.find((source) => {
699772 if (source.kind !== input.sourceInput.kind) {
@@ -757,7 +830,9 @@ const addExecutorHttpSource = (input: { · 57 unmodified lines
757830 now,
758831 });
759832
760− const persistedDraft = yield* persistSource(input.rows, draftSource);
833+ const persistedDraft = yield* persistSource(input.rows, draftSource, {
834+ actorAccountId: input.sourceInput.actorAccountId,
835+ });
761836 return {
762837 kind: "credential_required",
763838 source: persistedDraft,
@@ -802,7 +877,9 @@ const addExecutorHttpSource = (input: { · 38 unmodified lines
802877 now,
803878 });
804879
805− const persistedDraft = yield* persistSource(input.rows, draftSource);
880+ const persistedDraft = yield* persistSource(input.rows, draftSource, {
881+ actorAccountId: input.sourceInput.actorAccountId,
882+ });
806883 const synced = yield* Effect.either(
807884 syncSourceToolArtifacts({
808885 rows: input.rows,
@@ -817,6 +894,7 @@ const addExecutorHttpSource = (input: { · 8 unmodified lines
817894 return yield* Either.match(synced, {
818895 onLeft: (error) =>
819896 updateSourceStatus(input.rows, persistedDraft, {
897+ actorAccountId: input.sourceInput.actorAccountId,
820898 status: "error",
821899 lastError: error.message,
822900 }).pipe(
@@ -824,6 +902,7 @@ const addExecutorHttpSource = (input: { · 1 unmodified line
824902 ),
825903 onRight: () =>
826904 updateSourceStatus(input.rows, persistedDraft, {
905+ actorAccountId: input.sourceInput.actorAccountId,
827906 status: "connected",
828907 lastError: null,
829908 }).pipe(
@@ -841,6 +920,7 @@ type RuntimeSourceAuthServiceShape = { · 11 unmodified lines
841920 getSourceById: (input: {
842921 workspaceId: WorkspaceId;
843922 sourceId: Source["id"];
923+ actorAccountId?: AccountId | null;
844924 }) => Effect.Effect<Source, Error, never>;
845925 getLocalServerBaseUrl: () => string | null;
846926 storeSecretMaterial: (input: {
@@ -869,6 +949,7 @@ type RuntimeSourceAuthServiceShape = { · 22 unmodified lines
869949 completeSourceCredentialSetup: (input: {
870950 workspaceId: WorkspaceId;
871951 sourceId: Source["id"];
952+ actorAccountId?: AccountId | null;
872953 state: string;
873954 code?: string | null;
874955 error?: string | null;
@@ -897,10 +978,11 @@ export const createRuntimeSourceAuthService = (input: { · 22 unmodified lines
897978 value,
898979 }),
899980
900− getSourceById: ({ workspaceId, sourceId }) =>
981+ getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>
901982 loadSourceById(input.rows, {
902983 workspaceId,
903984 sourceId,
985+ actorAccountId,
904986 }),
905987
906988 addExecutorSource: (sourceInput, options) =>
@@ -915,6 +997,7 @@ export const createRuntimeSourceAuthService = (input: { · 8 unmodified lines
915997 rows: input.rows,
916998 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
917999 workspaceId: sourceInput.workspaceId,
1000+ actorAccountId: sourceInput.actorAccountId,
9181001 executionId: sourceInput.executionId,
9191002 interactionId: sourceInput.interactionId,
9201003 endpoint: sourceInput.endpoint,
@@ -930,6 +1013,7 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
9301013 rows: input.rows,
9311014 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
9321015 workspaceId: sourceInput.workspaceId,
1016+ actorAccountId: sourceInput.actorAccountId,
9331017 sourceId: sourceInput.sourceId,
9341018 executionId: null,
9351019 interactionId: null,
@@ -972,21 +1056,25 @@ export const createRuntimeSourceAuthService = (input: { · 36 unmodified lines
9721056 id: sessionId,
9731057 workspaceId: oauthInput.workspaceId,
9741058 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),
1059+ actorAccountId: oauthInput.actorAccountId ?? null,
9751060 executionId: null,
9761061 interactionId: null,
977− strategy: "oauth2_authorization_code",
1062+ providerKind: "mcp_oauth",
9781063 status: "pending",
979− endpoint,
9801064 state,
981− redirectUri: redirectUrl,
982− scope: oauthInput.provider.kind,
983− resourceMetadataUrl: oauthStart.resourceMetadataUrl,
984− authorizationServerUrl: oauthStart.authorizationServerUrl,
985− resourceMetadataJson: oauthStart.resourceMetadataJson,
986− authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
987− clientInformationJson: oauthStart.clientInformationJson,
988− codeVerifier: oauthStart.codeVerifier,
989− authorizationUrl: oauthStart.authorizationUrl,
1065+ sessionDataJson: encodeMcpSourceAuthSessionData({
1066+ kind: "mcp_oauth",
1067+ endpoint,
1068+ redirectUri: redirectUrl,
1069+ scope: oauthInput.provider.kind,
1070+ resourceMetadataUrl: oauthStart.resourceMetadataUrl,
1071+ authorizationServerUrl: oauthStart.authorizationServerUrl,
1072+ resourceMetadataJson: oauthStart.resourceMetadataJson,
1073+ authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
1074+ clientInformationJson: oauthStart.clientInformationJson,
1075+ codeVerifier: oauthStart.codeVerifier,
1076+ authorizationUrl: oauthStart.authorizationUrl,
1077+ }),
9901078 errorText: null,
9911079 completedAt: null,
9921080 createdAt: now,
@@ -1012,6 +1100,7 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
10121100 }
10131101
10141102 const session = sessionOption.value;
1103+ const sessionData = decodeMcpSourceAuthSessionData(session);
10151104 if (session.status === "completed") {
10161105 return yield* Effect.fail(new Error(`Source auth session ${session.id} is already completed`));
10171106 }
@@ -1027,13 +1116,10 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
10271116 yield* input.rows.sourceAuthSessions.update(
10281117 session.id,
10291118 createTerminalSourceAuthSessionPatch({
1119+ sessionDataJson: session.sessionDataJson,
10301120 status: "failed",
10311121 now: failedAt,
10321122 errorText: reason,
1033− resourceMetadataUrl: session.resourceMetadataUrl,
1034− authorizationServerUrl: session.authorizationServerUrl,
1035− resourceMetadataJson: session.resourceMetadataJson,
1036− authorizationServerMetadataJson: session.authorizationServerMetadataJson,
10371123 }),
10381124 );
10391125
@@ -1045,31 +1131,31 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
10451131 return yield* Effect.fail(new Error("Missing OAuth authorization code"));
10461132 }
10471133
1048− if (session.codeVerifier === null) {
1134+ if (sessionData.codeVerifier === null) {
10491135 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
10501136 }
10511137
1052− if (session.scope !== null && session.scope !== "mcp") {
1053− return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${session.scope}`));
1138+ if (sessionData.scope !== null && sessionData.scope !== "mcp") {
1139+ return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${sessionData.scope}`));
10541140 }
10551141
10561142 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
10571143 session: {
1058− endpoint: session.endpoint,
1059− redirectUrl: session.redirectUri,
1060− codeVerifier: session.codeVerifier,
1061− resourceMetadataUrl: session.resourceMetadataUrl,
1062− authorizationServerUrl: session.authorizationServerUrl,
1063− resourceMetadataJson: session.resourceMetadataJson,
1064− authorizationServerMetadataJson: session.authorizationServerMetadataJson,
1065− clientInformationJson: session.clientInformationJson,
1144+ endpoint: sessionData.endpoint,
1145+ redirectUrl: sessionData.redirectUri,
1146+ codeVerifier: sessionData.codeVerifier,
1147+ resourceMetadataUrl: sessionData.resourceMetadataUrl,
1148+ authorizationServerUrl: sessionData.authorizationServerUrl,
1149+ resourceMetadataJson: sessionData.resourceMetadataJson,
1150+ authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson,
1151+ clientInformationJson: sessionData.clientInformationJson,
10661152 },
10671153 code: authorizationCode,
10681154 });
10691155
10701156 const oauthSecretName = resolveSourceOAuthSecretName({
10711157 displayName: readSourceOAuthSessionDisplayName(session.state),
1072− endpoint: session.endpoint,
1158+ endpoint: sessionData.endpoint,
10731159 });
10741160 const accessTokenRef = yield* storeSecretMaterial({
10751161 purpose: "oauth_access_token",
@@ -1095,13 +1181,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
10951181 yield* input.rows.sourceAuthSessions.update(
10961182 session.id,
10971183 createTerminalSourceAuthSessionPatch({
1184+ sessionDataJson: mergeMcpSourceAuthSessionData({
1185+ session,
1186+ patch: {
1187+ codeVerifier: null,
1188+ authorizationUrl: null,
1189+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
1190+ authorizationServerUrl: exchanged.authorizationServerUrl,
1191+ resourceMetadataJson: exchanged.resourceMetadataJson,
1192+ authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1193+ },
1194+ }),
10981195 status: "completed",
10991196 now: Date.now(),
11001197 errorText: null,
1101− resourceMetadataUrl: exchanged.resourceMetadataUrl,
1102− authorizationServerUrl: exchanged.authorizationServerUrl,
1103− resourceMetadataJson: exchanged.resourceMetadataJson,
1104− authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
11051198 }),
11061199 );
11071200
@@ -1114,6 +1207,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines
11141207 completeSourceCredentialSetup: ({
11151208 workspaceId,
11161209 sourceId,
1210+ actorAccountId,
11171211 state,
11181212 code,
11191213 error,
@@ -1126,6 +1220,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines
11261220 }
11271221
11281222 const session = sessionOption.value;
1223+ const sessionData = decodeMcpSourceAuthSessionData(session);
11291224 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {
11301225 return yield* Effect.fail(
11311226 new Error(
@@ -1133,10 +1228,19 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line
11331228 ),
11341229 );
11351230 }
1231+ if (
1232+ actorAccountId !== undefined
1233+ && (session.actorAccountId ?? null) !== (actorAccountId ?? null)
1234+ ) {
1235+ return yield* Effect.fail(
1236+ new Error(`Source auth session ${session.id} does not match the active account`),
1237+ );
1238+ }
11361239
11371240 const source = yield* loadSourceById(input.rows, {
11381241 workspaceId: session.workspaceId,
11391242 sourceId: session.sourceId,
1243+ actorAccountId: session.actorAccountId,
11401244 });
11411245
11421246 if (session.status === "completed") {
@@ -1156,16 +1260,14 @@ export const createRuntimeSourceAuthService = (input: { · 13 unmodified lines
11561260 yield* input.rows.sourceAuthSessions.update(
11571261 session.id,
11581262 createTerminalSourceAuthSessionPatch({
1263+ sessionDataJson: session.sessionDataJson,
11591264 status: "failed",
11601265 now: failedAt,
11611266 errorText: reason,
1162− resourceMetadataUrl: session.resourceMetadataUrl,
1163− authorizationServerUrl: session.authorizationServerUrl,
1164− resourceMetadataJson: session.resourceMetadataJson,
1165− authorizationServerMetadataJson: session.authorizationServerMetadataJson,
11661267 }),
11671268 );
11681269 const failedSource = yield* updateSourceStatus(input.rows, source, {
1270+ actorAccountId: session.actorAccountId,
11691271 status: "error",
11701272 lastError: reason,
11711273 });
@@ -1191,20 +1293,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
11911293 return yield* Effect.fail(new Error("Missing OAuth authorization code"));
11921294 }
11931295
1194− if (session.codeVerifier === null) {
1296+ if (sessionData.codeVerifier === null) {
11951297 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
11961298 }
11971299
11981300 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
11991301 session: {
1200− endpoint: session.endpoint,
1201− redirectUrl: session.redirectUri,
1202− codeVerifier: session.codeVerifier,
1203− resourceMetadataUrl: session.resourceMetadataUrl,
1204− authorizationServerUrl: session.authorizationServerUrl,
1205− resourceMetadataJson: session.resourceMetadataJson,
1206− authorizationServerMetadataJson: session.authorizationServerMetadataJson,
1207− clientInformationJson: session.clientInformationJson,
1302+ endpoint: sessionData.endpoint,
1303+ redirectUrl: sessionData.redirectUri,
1304+ codeVerifier: sessionData.codeVerifier,
1305+ resourceMetadataUrl: sessionData.resourceMetadataUrl,
1306+ authorizationServerUrl: sessionData.authorizationServerUrl,
1307+ resourceMetadataJson: sessionData.resourceMetadataJson,
1308+ authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson,
1309+ clientInformationJson: sessionData.clientInformationJson,
12081310 },
12091311 code: authorizationCode,
12101312 });
@@ -1228,6 +1330,7 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines
12281330
12291331 const now = Date.now();
12301332 const connectedSource = yield* updateSourceStatus(input.rows, source, {
1333+ actorAccountId: session.actorAccountId,
12311334 status: "connected",
12321335 lastError: null,
12331336 auth: {
@@ -1248,6 +1351,7 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines
12481351 yield* Either.match(indexed, {
12491352 onLeft: (error) =>
12501353 updateSourceStatus(input.rows, connectedSource, {
1354+ actorAccountId: session.actorAccountId,
12511355 status: "error",
12521356 lastError: error.message,
12531357 }).pipe(
@@ -1259,13 +1363,20 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
12591363 yield* input.rows.sourceAuthSessions.update(
12601364 session.id,
12611365 createTerminalSourceAuthSessionPatch({
1366+ sessionDataJson: mergeMcpSourceAuthSessionData({
1367+ session,
1368+ patch: {
1369+ codeVerifier: null,
1370+ authorizationUrl: null,
1371+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
1372+ authorizationServerUrl: exchanged.authorizationServerUrl,
1373+ resourceMetadataJson: exchanged.resourceMetadataJson,
1374+ authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1375+ },
1376+ }),
12621377 status: "completed",
12631378 now,
12641379 errorText: null,
1265− resourceMetadataUrl: exchanged.resourceMetadataUrl,
1266− authorizationServerUrl: exchanged.authorizationServerUrl,
1267− resourceMetadataJson: exchanged.resourceMetadataJson,
1268− authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
12691380 }),
12701381 );
12711382
packages/control-plane/src/runtime/source-definitions.test.ts+435−0
@@ -0,0 +1,435 @@
1+import { describe, expect, it } from "vitest";
2+import * as Effect from "effect/Effect";
3+
4+import {
5+ CredentialIdSchema,
6+ SourceIdSchema,
7+ WorkspaceIdSchema,
8+ type Source,
9+} from "#schema";
10+
11+import {
12+ createSourceFromPayload,
13+ projectSourceFromStorage,
14+ splitSourceForStorage,
15+ stableSourceRecipeId,
16+ stableSourceRecipeRevisionId,
17+ updateSourceFromPayload,
18+} from "./source-definitions";
19+import { namespaceFromSourceName } from "./tool-artifacts";
20+
21+const makeSource = (overrides: Partial<Source> = {}): Source => ({
22+ id: SourceIdSchema.make("src_source_definitions"),
23+ workspaceId: WorkspaceIdSchema.make("ws_source_definitions"),
24+ name: "GitHub",
25+ kind: "openapi",
26+ endpoint: "https://api.github.com",
27+ status: "connected",
28+ enabled: true,
29+ namespace: "github",
30+ transport: null,
31+ queryParams: null,
32+ headers: null,
33+ specUrl: "https://api.github.com/openapi.json",
34+ defaultHeaders: null,
35+ auth: { kind: "none" },
36+ sourceHash: null,
37+ lastError: null,
38+ createdAt: 1000,
39+ updatedAt: 1000,
40+ ...overrides,
41+});
42+
43+describe("source-definitions", () => {
44+ describe("stable recipe ids", () => {
45+ it("is deterministic across calls and ignores source name/workspace", () => {
46+ const source = makeSource();
47+ const renamed = makeSource({
48+ name: "Renamed GitHub",
49+ });
50+ const differentWorkspace = makeSource({
51+ workspaceId: WorkspaceIdSchema.make("ws_source_definitions_other"),
52+ });
53+
54+ expect(stableSourceRecipeId(source)).toBe(stableSourceRecipeId(source));
55+ expect(stableSourceRecipeRevisionId(source)).toBe(stableSourceRecipeRevisionId(source));
56+ expect(stableSourceRecipeId(renamed)).toBe(stableSourceRecipeId(source));
57+ expect(stableSourceRecipeRevisionId(renamed)).toBe(stableSourceRecipeRevisionId(source));
58+ expect(stableSourceRecipeId(differentWorkspace)).toBe(stableSourceRecipeId(source));
59+ expect(stableSourceRecipeRevisionId(differentWorkspace)).toBe(
60+ stableSourceRecipeRevisionId(source),
61+ );
62+ });
63+
64+ it("changes recipe and revision ids when the source config changes", () => {
65+ const source = makeSource();
66+ const changedEndpoint = makeSource({
67+ endpoint: "https://example.com",
68+ specUrl: "https://example.com/openapi.json",
69+ });
70+
71+ expect(stableSourceRecipeId(changedEndpoint)).not.toBe(stableSourceRecipeId(source));
72+ expect(stableSourceRecipeRevisionId(changedEndpoint)).not.toBe(
73+ stableSourceRecipeRevisionId(source),
74+ );
75+ });
76+ });
77+
78+ describe("payload normalization and validation", () => {
79+ it("defaults created sources to draft/enabled and trims fields", async () => {
80+ const source = await Effect.runPromise(createSourceFromPayload({
81+ workspaceId: WorkspaceIdSchema.make("ws_create_defaults"),
82+ sourceId: SourceIdSchema.make("src_create_defaults"),
83+ payload: {
84+ name: " GitHub ",
85+ kind: "openapi",
86+ endpoint: " https://api.github.com ",
87+ specUrl: " https://api.github.com/openapi.json ",
88+ },
89+ now: 1234,
90+ }));
91+
92+ expect(source.name).toBe("GitHub");
93+ expect(source.endpoint).toBe("https://api.github.com");
94+ expect(source.specUrl).toBe("https://api.github.com/openapi.json");
95+ expect(source.status).toBe("draft");
96+ expect(source.enabled).toBe(true);
97+ expect(source.auth).toEqual({ kind: "none" });
98+ });
99+
100+ it("preserves existing values on partial update and keeps auth when undefined", async () => {
101+ const source = makeSource({
102+ kind: "graphql",
103+ endpoint: "https://example.com/graphql",
104+ specUrl: null,
105+ auth: {
106+ kind: "bearer",
107+ headerName: "Authorization",
108+ prefix: "Bearer ",
109+ token: {
110+ providerId: "postgres",
111+ handle: "sec_token",
112+ },
113+ },
114+ defaultHeaders: { accept: "application/json" },
115+ });
116+
117+ const updated = await Effect.runPromise(updateSourceFromPayload({
118+ source,
119+ payload: {
120+ status: "error",
121+ lastError: "bad gateway",
122+ },
123+ now: 2000,
124+ }));
125+
126+ expect(updated.name).toBe(source.name);
127+ expect(updated.endpoint).toBe(source.endpoint);
128+ expect(updated.queryParams).toEqual(source.queryParams);
129+ expect(updated.defaultHeaders).toEqual(source.defaultHeaders);
130+ expect(updated.auth).toEqual(source.auth);
131+ expect(updated.status).toBe("error");
132+ expect(updated.lastError).toBe("bad gateway");
133+ expect(updated.updatedAt).toBe(2000);
134+ });
135+
136+ it("normalizes oauth2 auth defaults and allows null refresh tokens", async () => {
137+ const source = await Effect.runPromise(createSourceFromPayload({
138+ workspaceId: WorkspaceIdSchema.make("ws_create_oauth_defaults"),
139+ sourceId: SourceIdSchema.make("src_create_oauth_defaults"),
140+ payload: {
141+ name: "GraphQL Demo",
142+ kind: "graphql",
143+ endpoint: "https://example.com/graphql",
144+ auth: {
145+ kind: "oauth2",
146+ headerName: " ",
147+ prefix: undefined,
148+ accessToken: {
149+ providerId: " postgres ",
150+ handle: " sec_access ",
151+ },
152+ refreshToken: null,
153+ } as never,
154+ },
155+ now: 1234,
156+ }));
157+
158+ expect(source.auth).toEqual({
159+ kind: "oauth2",
160+ headerName: "Authorization",
161+ prefix: "Bearer ",
162+ accessToken: {
163+ providerId: "postgres",
164+ handle: "sec_access",
165+ },
166+ refreshToken: null,
167+ });
168+ });
169+
170+ it("rejects invalid bearer and oauth2 secret refs", async () => {
171+ await expect(Effect.runPromise(createSourceFromPayload({
172+ workspaceId: WorkspaceIdSchema.make("ws_invalid_bearer"),
173+ sourceId: SourceIdSchema.make("src_invalid_bearer"),
174+ payload: {
175+ name: "Bad Bearer",
176+ kind: "openapi",
177+ endpoint: "https://example.com",
178+ specUrl: "https://example.com/openapi.json",
179+ auth: {
180+ kind: "bearer",
181+ headerName: "Authorization",
182+ prefix: "Bearer ",
183+ token: {
184+ providerId: " ",
185+ handle: "sec_token",
186+ },
187+ },
188+ },
189+ now: 1234,
190+ }))).rejects.toThrow("Bearer auth requires a token secret ref");
191+
192+ await expect(Effect.runPromise(createSourceFromPayload({
193+ workspaceId: WorkspaceIdSchema.make("ws_invalid_refresh"),
194+ sourceId: SourceIdSchema.make("src_invalid_refresh"),
195+ payload: {
196+ name: "Bad OAuth",
197+ kind: "graphql",
198+ endpoint: "https://example.com/graphql",
199+ auth: {
200+ kind: "oauth2",
201+ headerName: "Authorization",
202+ prefix: "Bearer ",
203+ accessToken: {
204+ providerId: "postgres",
205+ handle: "sec_access",
206+ },
207+ refreshToken: {
208+ providerId: "postgres",
209+ handle: " ",
210+ },
211+ } as never,
212+ },
213+ now: 1234,
214+ }))).rejects.toThrow("OAuth2 refresh token ref must include providerId and handle");
215+ });
216+
217+ it("rejects invalid source kind combinations", async () => {
218+ await expect(Effect.runPromise(createSourceFromPayload({
219+ workspaceId: WorkspaceIdSchema.make("ws_invalid_mcp"),
220+ sourceId: SourceIdSchema.make("src_invalid_mcp"),
221+ payload: {
222+ name: "MCP",
223+ kind: "mcp",
224+ endpoint: "https://example.com/mcp",
225+ specUrl: "https://example.com/openapi.json",
226+ } as never,
227+ now: 1234,
228+ }))).rejects.toThrow("MCP sources cannot define specUrl");
229+
230+ await expect(Effect.runPromise(createSourceFromPayload({
231+ workspaceId: WorkspaceIdSchema.make("ws_invalid_openapi_spec"),
232+ sourceId: SourceIdSchema.make("src_invalid_openapi_spec"),
233+ payload: {
234+ name: "OpenAPI",
235+ kind: "openapi",
236+ endpoint: "https://example.com",
237+ specUrl: " ",
238+ } as never,
239+ now: 1234,
240+ }))).rejects.toThrow("OpenAPI sources require specUrl");
241+
242+ await expect(Effect.runPromise(createSourceFromPayload({
243+ workspaceId: WorkspaceIdSchema.make("ws_invalid_openapi_transport"),
244+ sourceId: SourceIdSchema.make("src_invalid_openapi_transport"),
245+ payload: {
246+ name: "OpenAPI",
247+ kind: "openapi",
248+ endpoint: "https://example.com",
249+ specUrl: "https://example.com/openapi.json",
250+ transport: "sse",
251+ } as never,
252+ now: 1234,
253+ }))).rejects.toThrow("OpenAPI sources cannot define MCP transport settings");
254+
255+ await expect(Effect.runPromise(createSourceFromPayload({
256+ workspaceId: WorkspaceIdSchema.make("ws_invalid_graphql"),
257+ sourceId: SourceIdSchema.make("src_invalid_graphql"),
258+ payload: {
259+ name: "GraphQL",
260+ kind: "graphql",
261+ endpoint: "https://example.com/graphql",
262+ specUrl: "https://example.com/openapi.json",
263+ } as never,
264+ now: 1234,
265+ }))).rejects.toThrow("GraphQL sources cannot define specUrl");
266+
267+ await expect(Effect.runPromise(createSourceFromPayload({
268+ workspaceId: WorkspaceIdSchema.make("ws_invalid_internal"),
269+ sourceId: SourceIdSchema.make("src_invalid_internal"),
270+ payload: {
271+ name: "Internal",
272+ kind: "internal",
273+ endpoint: "internal://executor",
274+ defaultHeaders: {
275+ accept: "application/json",
276+ },
277+ } as never,
278+ now: 1234,
279+ }))).rejects.toThrow("internal sources cannot define OpenAPI settings");
280+ });
281+
282+ it("normalizes new auth during updates", async () => {
283+ const updated = await Effect.runPromise(updateSourceFromPayload({
284+ source: makeSource({
285+ auth: { kind: "none" },
286+ }),
287+ payload: {
288+ auth: {
289+ kind: "bearer",
290+ headerName: " ",
291+ prefix: "Token ",
292+ token: {
293+ providerId: " postgres ",
294+ handle: " sec_token ",
295+ },
296+ } as never,
297+ },
298+ now: 2000,
299+ }));
300+
301+ expect(updated.auth).toEqual({
302+ kind: "bearer",
303+ headerName: "Authorization",
304+ prefix: "Token ",
305+ token: {
306+ providerId: "postgres",
307+ handle: "sec_token",
308+ },
309+ });
310+ });
311+ });
312+
313+ describe("storage roundtrip", () => {
314+ it("roundtrips bearer auth and serialized maps", async () => {
315+ const source = makeSource({
316+ queryParams: { page: "1" },
317+ headers: { "x-api-key": "secret" },
318+ defaultHeaders: { accept: "application/json" },
319+ auth: {
320+ kind: "bearer",
321+ headerName: "Authorization",
322+ prefix: "Bearer ",
323+ token: {
324+ providerId: "postgres",
325+ handle: "sec_bearer",
326+ },
327+ },
328+ });
329+ const recipeId = stableSourceRecipeId(source);
330+ const recipeRevisionId = stableSourceRecipeRevisionId(source);
331+ const existingCredentialId = CredentialIdSchema.make("cred_existing");
332+
333+ const { sourceRecord, credential } = splitSourceForStorage({
334+ source,
335+ recipeId,
336+ recipeRevisionId,
337+ existingCredentialId,
338+ });
339+
340+ expect(credential?.id).toBe(existingCredentialId);
341+ expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams));
342+ expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers));
343+ expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders));
344+
345+ const projected = await Effect.runPromise(projectSourceFromStorage({
346+ sourceRecord,
347+ credential: credential ?? null,
348+ }));
349+
350+ expect(projected).toEqual(source);
351+ });
352+
353+ it("roundtrips oauth2 auth with and without refresh tokens", async () => {
354+ const withRefresh = makeSource({
355+ kind: "graphql",
356+ endpoint: "https://example.com/graphql",
357+ specUrl: null,
358+ defaultHeaders: { accept: "application/json" },
359+ auth: {
360+ kind: "oauth2",
361+ headerName: "Authorization",
362+ prefix: "Bearer ",
363+ accessToken: {
364+ providerId: "postgres",
365+ handle: "sec_access",
366+ },
367+ refreshToken: {
368+ providerId: "postgres",
369+ handle: "sec_refresh",
370+ },
371+ },
372+ });
373+ const withoutRefresh = makeSource({
374+ id: SourceIdSchema.make("src_source_definitions_no_refresh"),
375+ kind: "graphql",
376+ endpoint: "https://example.com/graphql",
377+ specUrl: null,
378+ defaultHeaders: { accept: "application/json" },
379+ auth: {
380+ kind: "oauth2",
381+ headerName: "Authorization",
382+ prefix: "Bearer ",
383+ accessToken: {
384+ providerId: "postgres",
385+ handle: "sec_access",
386+ },
387+ refreshToken: null,
388+ },
389+ });
390+
391+ for (const source of [withRefresh, withoutRefresh]) {
392+ const { sourceRecord, credential } = splitSourceForStorage({
393+ source,
394+ recipeId: stableSourceRecipeId(source),
395+ recipeRevisionId: stableSourceRecipeRevisionId(source),
396+ });
397+ const projected = await Effect.runPromise(projectSourceFromStorage({
398+ sourceRecord,
399+ credential: credential ?? null,
400+ }));
401+
402+ expect(projected).toEqual(source);
403+ }
404+ });
405+
406+ it("stores no credential for auth.kind none and projects back correctly", async () => {
407+ const source = makeSource({
408+ auth: { kind: "none" },
409+ });
410+ const { sourceRecord, credential } = splitSourceForStorage({
411+ source,
412+ recipeId: stableSourceRecipeId(source),
413+ recipeRevisionId: stableSourceRecipeRevisionId(source),
414+ });
415+
416+ expect(credential).toBeNull();
417+
418+ const projected = await Effect.runPromise(projectSourceFromStorage({
419+ sourceRecord,
420+ credential: null,
421+ }));
422+
423+ expect(projected.auth).toEqual({ kind: "none" });
424+ expect(projected).toEqual(source);
425+ });
426+ });
427+
428+ describe("namespaceFromSourceName", () => {
429+ it("normalizes names into namespace-safe dotted segments", () => {
430+ expect(namespaceFromSourceName("My API v2!")).toBe("my.api.v2");
431+ expect(namespaceFromSourceName(" ")).toBe("source");
432+ expect(namespaceFromSourceName("!!!")).toBe("source");
433+ });
434+ });
435+});
packages/control-plane/src/runtime/source-definitions.ts+214−54
@@ -1,18 +1,29 @@
1+import { createHash } from "node:crypto";
2+
13 import type {
24 CreateSourcePayload,
35 UpdateSourcePayload,
46 } from "#api";
57 import type {
8+ AccountId,
69 Credential,
710 Source,
811 SourceAuth,
9− SourceCredentialBinding,
10− SourceId,
12+ SourceRecipeId,
13+ SourceRecipeImporterKind,
14+ SourceRecipeKind,
15+ SourceRecipeRevisionId,
1116 StoredSourceRecord,
17+ StoredSourceRecipeRecord,
18+ StoredSourceRecipeRevisionRecord,
1219 StringMap,
1320 WorkspaceId,
1421 } from "#schema";
15−import { CredentialIdSchema } from "#schema";
22+import {
23+ CredentialIdSchema,
24+ SourceRecipeIdSchema,
25+ SourceRecipeRevisionIdSchema,
26+} from "#schema";
1627 import * as Effect from "effect/Effect";
1728 import * as Schema from "effect/Schema";
1829
@@ -58,6 +69,134 @@ const parseStringMapJson = ( · 39 unmodified lines
5869 ),
5970 );
6071
72+type SourceRecipeSourceConfig =
73+ | {
74+ kind: "mcp";
75+ endpoint: string;
76+ transport: Source["transport"];
77+ queryParams: Source["queryParams"];
78+ headers: Source["headers"];
79+ }
80+ | {
81+ kind: "openapi";
82+ endpoint: string;
83+ specUrl: string;
84+ defaultHeaders: Source["defaultHeaders"];
85+ }
86+ | {
87+ kind: "graphql";
88+ endpoint: string;
89+ defaultHeaders: Source["defaultHeaders"];
90+ }
91+ | {
92+ kind: "internal";
93+ endpoint: string;
94+ };
95+
96+const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => {
97+ if (source.kind === "mcp") {
98+ return {
99+ kind: "mcp",
100+ endpoint: source.endpoint,
101+ transport: source.transport,
102+ queryParams: source.queryParams,
103+ headers: source.headers,
104+ };
105+ }
106+
107+ if (source.kind === "openapi") {
108+ return {
109+ kind: "openapi",
110+ endpoint: source.endpoint,
111+ specUrl: source.specUrl ?? source.endpoint,
112+ defaultHeaders: source.defaultHeaders,
113+ };
114+ }
115+
116+ if (source.kind === "graphql") {
117+ return {
118+ kind: "graphql",
119+ endpoint: source.endpoint,
120+ defaultHeaders: source.defaultHeaders,
121+ };
122+ }
123+
124+ return {
125+ kind: "internal",
126+ endpoint: source.endpoint,
127+ };
128+};
129+
130+const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => {
131+ if (source.kind === "mcp") {
132+ return "mcp_recipe";
133+ }
134+
135+ if (source.kind === "graphql") {
136+ return "graphql_recipe";
137+ }
138+
139+ if (source.kind === "openapi") {
140+ return "http_recipe";
141+ }
142+
143+ return "internal_recipe";
144+};
145+
146+const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => {
147+ if (source.kind === "mcp") {
148+ return "mcp_manifest";
149+ }
150+
151+ if (source.kind === "graphql") {
152+ return "graphql_introspection";
153+ }
154+
155+ if (source.kind === "openapi") {
156+ return "openapi";
157+ }
158+
159+ return "internal_manifest";
160+};
161+
162+const sourceRecipeProviderKeyFromSource = (source: Source): string => {
163+ if (source.kind === "mcp") {
164+ return "generic_mcp";
165+ }
166+
167+ if (source.kind === "graphql") {
168+ return "generic_graphql";
169+ }
170+
171+ if (source.kind === "openapi") {
172+ return "generic_http";
173+ }
174+
175+ return "generic_internal";
176+};
177+
178+const stableHash = (value: string): string =>
179+ createHash("sha256").update(value).digest("hex").slice(0, 24);
180+
181+const sourceRecipeSignature = (source: Source): string =>
182+ JSON.stringify({
183+ recipeKind: sourceRecipeKindFromSource(source),
184+ importerKind: sourceRecipeImporterKindFromSource(source),
185+ providerKey: sourceRecipeProviderKeyFromSource(source),
186+ sourceConfig: sourceConfigFromSource(source),
187+ });
188+
189+export const sourceConfigSignature = (source: Source): string =>
190+ JSON.stringify(sourceConfigFromSource(source));
191+
192+export const stableSourceRecipeId = (source: Source): SourceRecipeId =>
193+ SourceRecipeIdSchema.make(`src_recipe_${stableHash(sourceRecipeSignature(source))}`);
194+
195+export const stableSourceRecipeRevisionId = (
196+ source: Source,
197+): SourceRecipeRevisionId =>
198+ SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${stableHash(sourceConfigSignature(source))}`);
199+
61200 const normalizeAuth = (
62201 auth: SourceAuth | undefined,
63202 ): Effect.Effect<SourceAuth, Error, never> =>
@@ -175,7 +314,7 @@ const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, neve · 111 unmodified lines
175314
176315 export const createSourceFromPayload = (input: {
177316 workspaceId: WorkspaceId;
178− sourceId: SourceId;
317+ sourceId: Source["id"];
179318 payload: CreateSourcePayload;
180319 now: number;
181320 }): Effect.Effect<Source, Error, never> =>
@@ -253,24 +392,65 @@ export const updateSourceFromPayload = (input: { · 71 unmodified lines
253392 });
254393 });
255394
395+export const createSourceRecipeRecord = (input: {
396+ source: Source;
397+ recipeId?: SourceRecipeId | null;
398+ latestRevisionId: SourceRecipeRevisionId;
399+}): StoredSourceRecipeRecord => ({
400+ id: input.recipeId ?? stableSourceRecipeId(input.source),
401+ kind: sourceRecipeKindFromSource(input.source),
402+ importerKind: sourceRecipeImporterKindFromSource(input.source),
403+ providerKey: sourceRecipeProviderKeyFromSource(input.source),
404+ name: input.source.name,
405+ summary: null,
406+ visibility: "workspace",
407+ latestRevisionId: input.latestRevisionId,
408+ createdAt: input.source.createdAt,
409+ updatedAt: input.source.updatedAt,
410+});
411+
412+export const createSourceRecipeRevisionRecord = (input: {
413+ source: Source;
414+ recipeId: SourceRecipeId;
415+ recipeRevisionId?: SourceRecipeRevisionId | null;
416+ revisionNumber: number;
417+ manifestJson?: string | null;
418+ manifestHash?: string | null;
419+}): StoredSourceRecipeRevisionRecord => ({
420+ id:
421+ input.recipeRevisionId
422+ ?? stableSourceRecipeRevisionId(input.source),
423+ recipeId: input.recipeId,
424+ revisionNumber: input.revisionNumber,
425+ sourceConfigJson: sourceConfigSignature(input.source),
426+ manifestJson: input.manifestJson ?? null,
427+ manifestHash: input.manifestHash ?? null,
428+ createdAt: input.source.createdAt,
429+ updatedAt: input.source.updatedAt,
430+});
431+
256432 export const splitSourceForStorage = (input: {
257433 source: Source;
434+ recipeId: SourceRecipeId;
435+ recipeRevisionId: SourceRecipeRevisionId;
436+ actorAccountId?: AccountId | null;
258437 existingCredentialId?: Credential["id"] | null;
259− existingBindingId?: SourceCredentialBinding["id"] | null;
260438 }): {
261439 sourceRecord: StoredSourceRecord;
262440 credential: Credential | null;
263− credentialBinding: SourceCredentialBinding | null;
264441 } => {
265442 const sourceRecord: StoredSourceRecord = {
266443 id: input.source.id,
267444 workspaceId: input.source.workspaceId,
445+ recipeId: input.recipeId,
446+ recipeRevisionId: input.recipeRevisionId,
268447 name: input.source.name,
269448 kind: input.source.kind,
270449 endpoint: input.source.endpoint,
271450 status: input.source.status,
272451 enabled: input.source.enabled,
273452 namespace: input.source.namespace,
453+ bindingConfigJson: null,
274454 transport: input.source.transport,
275455 queryParamsJson: serializeStringMap(input.source.queryParams),
276456 headersJson: serializeStringMap(input.source.headers),
@@ -287,17 +467,17 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines
287467 return {
288468 sourceRecord,
289469 credential: null,
290− credentialBinding: null,
291470 };
292471 }
293472
294473 const credentialId = input.existingCredentialId
295474 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
296− const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`;
297475
298476 const credential: Credential = {
299477 id: credentialId,
300478 workspaceId: input.source.workspaceId,
479+ sourceId: input.source.id,
480+ actorAccountId: input.actorAccountId ?? null,
301481 authKind: input.source.auth.kind,
302482 authHeaderName: input.source.auth.headerName,
303483 authPrefix: input.source.auth.prefix,
@@ -324,20 +504,11 @@ export const splitSourceForStorage = (input: { · 20 unmodified lines
324504 return {
325505 sourceRecord,
326506 credential,
327− credentialBinding: {
328− id: bindingId,
329− workspaceId: input.source.workspaceId,
330− sourceId: input.source.id,
331− credentialId,
332− createdAt: input.source.createdAt,
333− updatedAt: input.source.updatedAt,
334− },
335507 };
336508 };
337509
338510 export const projectSourceFromStorage = (input: {
339511 sourceRecord: StoredSourceRecord;
340− credentialBinding: SourceCredentialBinding | null;
341512 credential: Credential | null;
342513 }): Effect.Effect<Source, Error, never> =>
343514 Effect.gen(function* () {
@@ -355,38 +526,32 @@ export const projectSourceFromStorage = (input: { · 11 unmodified lines
355526 );
356527
357528 let auth: SourceAuth = { kind: "none" };
358− if (input.credentialBinding !== null) {
359− const credential = input.credential;
360− if (credential === null) {
361− return yield* Effect.fail(
362− new Error(`Missing credential ${input.credentialBinding.credentialId} for source ${input.sourceRecord.id}`),
363− );
364− }
365−
366− if (credential.authKind === "bearer") {
529+ if (input.credential !== null) {
530+ if (input.credential.authKind === "bearer") {
367531 auth = {
368532 kind: "bearer",
369− headerName: credential.authHeaderName,
370− prefix: credential.authPrefix,
533+ headerName: input.credential.authHeaderName,
534+ prefix: input.credential.authPrefix,
371535 token: {
372− providerId: credential.tokenProviderId,
373− handle: credential.tokenHandle,
536+ providerId: input.credential.tokenProviderId,
537+ handle: input.credential.tokenHandle,
374538 },
375539 };
376540 } else {
377541 auth = {
378542 kind: "oauth2",
379− headerName: credential.authHeaderName,
380− prefix: credential.authPrefix,
543+ headerName: input.credential.authHeaderName,
544+ prefix: input.credential.authPrefix,
381545 accessToken: {
382− providerId: credential.tokenProviderId,
383− handle: credential.tokenHandle,
546+ providerId: input.credential.tokenProviderId,
547+ handle: input.credential.tokenHandle,
384548 },
385549 refreshToken:
386− credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null
550+ input.credential.refreshTokenProviderId !== null
551+ && input.credential.refreshTokenHandle !== null
387552 ? {
388− providerId: credential.refreshTokenProviderId,
389− handle: credential.refreshTokenHandle,
553+ providerId: input.credential.refreshTokenProviderId,
554+ handle: input.credential.refreshTokenHandle,
390555 }
391556 : null,
392557 };
@@ -421,25 +586,20 @@ export const projectSourceFromStorage = (input: { · 28 unmodified lines
421586
422587 export const projectSourcesFromStorage = (input: {
423588 sourceRecords: ReadonlyArray<StoredSourceRecord>;
424− credentialBindings: ReadonlyArray<SourceCredentialBinding>;
425589 credentials: ReadonlyArray<Credential>;
426590 }): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
427− const bindingsBySourceId = new Map(
428− input.credentialBindings.map((binding) => [binding.sourceId, binding]),
429− );
430− const credentialsById = new Map(
431− input.credentials.map((credential) => [credential.id, credential]),
432− );
591+ const credentialsBySourceId = new Map<string, Credential>();
592+
593+ for (const credential of input.credentials) {
594+ const existing = credentialsBySourceId.get(credential.sourceId);
595+ if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) {
596+ credentialsBySourceId.set(credential.sourceId, credential);
597+ }
598+ }
433599
434− return Effect.forEach(input.sourceRecords, (sourceRecord) => {
435− const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null;
436− return projectSourceFromStorage({
600+ return Effect.forEach(input.sourceRecords, (sourceRecord) =>
601+ projectSourceFromStorage({
437602 sourceRecord,
438− credentialBinding,
439− credential:
440− credentialBinding === null
441− ? null
442− : credentialsById.get(credentialBinding.credentialId) ?? null,
443− });
444− });
603+ credential: credentialsBySourceId.get(sourceRecord.id) ?? null,
604+ }));
445605 };
packages/control-plane/src/runtime/source-store.test.ts+347−18
@@ -11,11 +11,20 @@ import { · 10 unmodified lines
1111 } from "#schema";
1212 import * as Effect from "effect/Effect";
1313 import * as Option from "effect/Option";
14+import * as Scope from "effect/Scope";
1415
15−import { createSqlControlPlanePersistence } from "../persistence";
16+import {
17+ createSqlControlPlanePersistence,
18+ type SqlControlPlanePersistence,
19+} from "../persistence";
20+import {
21+ stableSourceRecipeId,
22+ stableSourceRecipeRevisionId,
23+} from "./source-definitions";
1624 import { persistSource, removeSourceById } from "./source-store";
1725
18−const makePersistence = Effect.acquireRelease(
26+const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
27+ Effect.acquireRelease(
1928 createSqlControlPlanePersistence({
2029 localDataDir: ":memory:",
2130 }),
@@ -24,27 +33,30 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines
2433 try: () => persistence.close(),
2534 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
2635 }).pipe(Effect.orDie),
27−);
36+ );
2837
2938 const makeOpenApiSource = (input: {
3039 workspaceId: Source["workspaceId"];
3140 sourceId: Source["id"];
3241 now: number;
3342 updatedAt?: number;
43+ name?: string;
44+ endpoint?: string;
45+ specUrl?: string;
3446 auth: Source["auth"];
3547 }): Source => ({
3648 id: input.sourceId,
3749 workspaceId: input.workspaceId,
38− name: "GitHub",
50+ name: input.name ?? "GitHub",
3951 kind: "openapi",
40− endpoint: "https://api.github.com",
52+ endpoint: input.endpoint ?? "https://api.github.com",
4153 status: "connected",
4254 enabled: true,
4355 namespace: "github",
4456 transport: null,
4557 queryParams: null,
4658 headers: null,
47− specUrl: "https://example.com/openapi.json",
59+ specUrl: input.specUrl ?? "https://example.com/openapi.json",
4860 defaultHeaders: null,
4961 auth: input.auth,
5062 sourceHash: null,
@@ -122,21 +134,25 @@ describe("source-store", () => { · 71 unmodified lines
122134 id: SourceAuthSessionIdSchema.make("src_auth_source_store"),
123135 workspaceId,
124136 sourceId,
137+ actorAccountId: accountId,
125138 executionId: null,
126139 interactionId: null,
127− strategy: "oauth2_authorization_code",
140+ providerKind: "mcp_oauth",
128141 status: "pending",
129− endpoint: "https://api.github.com",
130142 state: "state_source_store",
131− redirectUri: "http://127.0.0.1/callback",
132− scope: null,
133− resourceMetadataUrl: null,
134− authorizationServerUrl: null,
135− resourceMetadataJson: null,
136− authorizationServerMetadataJson: null,
137− clientInformationJson: null,
138− codeVerifier: "verifier",
139− authorizationUrl: "https://example.com/auth",
143+ sessionDataJson: JSON.stringify({
144+ kind: "mcp_oauth",
145+ endpoint: "https://api.github.com",
146+ redirectUri: "http://127.0.0.1/callback",
147+ scope: null,
148+ resourceMetadataUrl: null,
149+ authorizationServerUrl: null,
150+ resourceMetadataJson: null,
151+ authorizationServerMetadataJson: null,
152+ clientInformationJson: null,
153+ codeVerifier: "verifier",
154+ authorizationUrl: "https://example.com/auth",
155+ }),
140156 errorText: null,
141157 completedAt: null,
142158 createdAt: now,
@@ -177,8 +193,321 @@ describe("source-store", () => { · 34 unmodified lines
177193 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);
178194 expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);
179195 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
180− expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
181196 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
182197 }),
183198 );
199+
200+ it.scoped("creates a fresh actor-scoped credential when a shared credential already exists", () =>
201+ Effect.gen(function* () {
202+ const persistence = yield* makePersistence;
203+ const now = Date.now();
204+ const accountId = AccountIdSchema.make("acc_actor_scoped");
205+ const organizationId = OrganizationIdSchema.make("org_actor_scoped");
206+ const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped");
207+ const sourceId = SourceIdSchema.make("src_actor_scoped");
208+ const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared");
209+
210+ yield* persistence.rows.organizations.insert({
211+ id: organizationId,
212+ slug: "actor-scoped",
213+ name: "Actor Scoped",
214+ status: "active",
215+ createdByAccountId: accountId,
216+ createdAt: now,
217+ updatedAt: now,
218+ });
219+ yield* persistence.rows.workspaces.insert({
220+ id: workspaceId,
221+ organizationId,
222+ name: "Primary",
223+ createdByAccountId: accountId,
224+ createdAt: now,
225+ updatedAt: now,
226+ });
227+ yield* persistence.rows.secretMaterials.upsert({
228+ id: sharedTokenId,
229+ name: null,
230+ purpose: "auth_material",
231+ value: "ghp_shared",
232+ createdAt: now,
233+ updatedAt: now,
234+ });
235+
236+ const source = makeOpenApiSource({
237+ workspaceId,
238+ sourceId,
239+ now,
240+ auth: {
241+ kind: "bearer",
242+ headerName: "Authorization",
243+ prefix: "Bearer ",
244+ token: {
245+ providerId: "postgres",
246+ handle: sharedTokenId,
247+ },
248+ },
249+ });
250+
251+ yield* persistSource(persistence.rows, source);
252+ yield* persistSource(
253+ persistence.rows,
254+ {
255+ ...source,
256+ updatedAt: now + 1,
257+ },
258+ {
259+ actorAccountId: accountId,
260+ },
261+ );
262+
263+ const credentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({
264+ workspaceId,
265+ sourceId,
266+ });
267+ expect(credentials).toHaveLength(2);
268+ expect(credentials.some((credential) => credential.actorAccountId === null)).toBe(true);
269+ expect(credentials.some((credential) => credential.actorAccountId === accountId)).toBe(true);
270+ expect(new Set(credentials.map((credential) => credential.id)).size).toBe(2);
271+ }),
272+ );
273+
274+ it.scoped("cleans up orphaned recipe data when the source config changes", () =>
275+ Effect.gen(function* () {
276+ const persistence = yield* makePersistence;
277+ const now = Date.now();
278+ const accountId = AccountIdSchema.make("acc_recipe_rewrite");
279+ const organizationId = OrganizationIdSchema.make("org_recipe_rewrite");
280+ const workspaceId = WorkspaceIdSchema.make("ws_recipe_rewrite");
281+ const sourceId = SourceIdSchema.make("src_recipe_rewrite");
282+
283+ yield* persistence.rows.organizations.insert({
284+ id: organizationId,
285+ slug: "recipe-rewrite",
286+ name: "Recipe Rewrite",
287+ status: "active",
288+ createdByAccountId: accountId,
289+ createdAt: now,
290+ updatedAt: now,
291+ });
292+ yield* persistence.rows.workspaces.insert({
293+ id: workspaceId,
294+ organizationId,
295+ name: "Primary",
296+ createdByAccountId: accountId,
297+ createdAt: now,
298+ updatedAt: now,
299+ });
300+
301+ const initialSource = makeOpenApiSource({
302+ workspaceId,
303+ sourceId,
304+ now,
305+ auth: { kind: "none" },
306+ });
307+ const initialRecipeId = stableSourceRecipeId(initialSource);
308+ const initialRecipeRevisionId = stableSourceRecipeRevisionId(initialSource);
309+
310+ yield* persistSource(persistence.rows, initialSource);
311+ yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
312+ recipeRevisionId: initialRecipeRevisionId,
313+ documents: [{
314+ id: "src_recipe_doc_recipe_rewrite",
315+ recipeRevisionId: initialRecipeRevisionId,
316+ documentKind: "openapi",
317+ documentKey: initialSource.specUrl ?? initialSource.endpoint,
318+ contentText: "{}",
319+ contentHash: "hash_recipe_rewrite",
320+ fetchedAt: now,
321+ createdAt: now,
322+ updatedAt: now,
323+ }],
324+ });
325+ yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
326+ recipeRevisionId: initialRecipeRevisionId,
327+ operations: [{
328+ id: "src_recipe_op_recipe_rewrite",
329+ recipeRevisionId: initialRecipeRevisionId,
330+ operationKey: "getRepo",
331+ transportKind: "http",
332+ toolId: "getRepo",
333+ title: "Get Repo",
334+ description: "Read a repository",
335+ operationKind: "read",
336+ searchText: "github get repo",
337+ inputSchemaJson: null,
338+ outputSchemaJson: null,
339+ providerKind: "openapi",
340+ providerDataJson: null,
341+ mcpToolName: null,
342+ openApiMethod: "get",
343+ openApiPathTemplate: "/repos/{owner}/{repo}",
344+ openApiOperationHash: "hash_recipe_rewrite",
345+ openApiRawToolId: "repos_getRepo",
346+ openApiOperationId: "repos.getRepo",
347+ openApiTagsJson: JSON.stringify(["repos"]),
348+ openApiRequestBodyRequired: null,
349+ graphqlOperationType: null,
350+ graphqlOperationName: null,
351+ createdAt: now,
352+ updatedAt: now,
353+ }],
354+ });
355+
356+ const updatedSource = makeOpenApiSource({
357+ workspaceId,
358+ sourceId,
359+ now,
360+ updatedAt: now + 1,
361+ endpoint: "https://api.example.com",
362+ specUrl: "https://api.example.com/openapi.json",
363+ auth: { kind: "none" },
364+ });
365+ const nextRecipeId = stableSourceRecipeId(updatedSource);
366+ const nextRecipeRevisionId = stableSourceRecipeRevisionId(updatedSource);
367+
368+ yield* persistSource(persistence.rows, updatedSource);
369+
370+ expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(initialRecipeId))).toBe(true);
371+ expect(
372+ Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(initialRecipeRevisionId)),
373+ ).toBe(true);
374+ expect(
375+ yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(initialRecipeRevisionId),
376+ ).toEqual([]);
377+ expect(
378+ yield* persistence.rows.sourceRecipeOperations.listByRevisionId(initialRecipeRevisionId),
379+ ).toEqual([]);
380+ expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(nextRecipeId))).toBe(true);
381+ expect(
382+ Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(nextRecipeRevisionId)),
383+ ).toBe(true);
384+ }),
385+ );
386+
387+ it.scoped("retains shared recipe data until the last source reference is removed", () =>
388+ Effect.gen(function* () {
389+ const persistence = yield* makePersistence;
390+ const now = Date.now();
391+ const accountId = AccountIdSchema.make("acc_shared_recipe");
392+ const organizationId = OrganizationIdSchema.make("org_shared_recipe");
393+ const workspaceId = WorkspaceIdSchema.make("ws_shared_recipe");
394+ const firstSourceId = SourceIdSchema.make("src_shared_recipe_one");
395+ const secondSourceId = SourceIdSchema.make("src_shared_recipe_two");
396+
397+ yield* persistence.rows.organizations.insert({
398+ id: organizationId,
399+ slug: "shared-recipe",
400+ name: "Shared Recipe",
401+ status: "active",
402+ createdByAccountId: accountId,
403+ createdAt: now,
404+ updatedAt: now,
405+ });
406+ yield* persistence.rows.workspaces.insert({
407+ id: workspaceId,
408+ organizationId,
409+ name: "Primary",
410+ createdByAccountId: accountId,
411+ createdAt: now,
412+ updatedAt: now,
413+ });
414+
415+ const firstSource = makeOpenApiSource({
416+ workspaceId,
417+ sourceId: firstSourceId,
418+ now,
419+ name: "GitHub One",
420+ auth: { kind: "none" },
421+ });
422+ const secondSource = makeOpenApiSource({
423+ workspaceId,
424+ sourceId: secondSourceId,
425+ now,
426+ updatedAt: now + 1,
427+ name: "GitHub Two",
428+ auth: { kind: "none" },
429+ });
430+ const sharedRecipeId = stableSourceRecipeId(firstSource);
431+ const sharedRecipeRevisionId = stableSourceRecipeRevisionId(firstSource);
432+
433+ yield* persistSource(persistence.rows, firstSource);
434+ yield* persistSource(persistence.rows, secondSource);
435+ yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
436+ recipeRevisionId: sharedRecipeRevisionId,
437+ documents: [{
438+ id: "src_recipe_doc_shared_recipe",
439+ recipeRevisionId: sharedRecipeRevisionId,
440+ documentKind: "openapi",
441+ documentKey: firstSource.specUrl ?? firstSource.endpoint,
442+ contentText: "{}",
443+ contentHash: "hash_shared_recipe",
444+ fetchedAt: now,
445+ createdAt: now,
446+ updatedAt: now,
447+ }],
448+ });
449+ yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
450+ recipeRevisionId: sharedRecipeRevisionId,
451+ operations: [{
452+ id: "src_recipe_op_shared_recipe",
453+ recipeRevisionId: sharedRecipeRevisionId,
454+ operationKey: "getRepo",
455+ transportKind: "http",
456+ toolId: "getRepo",
457+ title: "Get Repo",
458+ description: "Read a repository",
459+ operationKind: "read",
460+ searchText: "github get repo",
461+ inputSchemaJson: null,
462+ outputSchemaJson: null,
463+ providerKind: "openapi",
464+ providerDataJson: null,
465+ mcpToolName: null,
466+ openApiMethod: "get",
467+ openApiPathTemplate: "/repos/{owner}/{repo}",
468+ openApiOperationHash: "hash_shared_recipe",
469+ openApiRawToolId: "repos_getRepo",
470+ openApiOperationId: "repos.getRepo",
471+ openApiTagsJson: JSON.stringify(["repos"]),
472+ openApiRequestBodyRequired: null,
473+ graphqlOperationType: null,
474+ graphqlOperationName: null,
475+ createdAt: now,
476+ updatedAt: now,
477+ }],
478+ });
479+
480+ yield* removeSourceById(persistence.rows, {
481+ workspaceId,
482+ sourceId: firstSourceId,
483+ });
484+
485+ expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true);
486+ expect(
487+ Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
488+ ).toBe(true);
489+ expect(
490+ yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
491+ ).toHaveLength(1);
492+ expect(
493+ yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
494+ ).toHaveLength(1);
495+
496+ yield* removeSourceById(persistence.rows, {
497+ workspaceId,
498+ sourceId: secondSourceId,
499+ });
500+
501+ expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true);
502+ expect(
503+ Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
504+ ).toBe(true);
505+ expect(
506+ yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
507+ ).toEqual([]);
508+ expect(
509+ yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
510+ ).toEqual([]);
511+ }),
512+ );
184513 });
packages/control-plane/src/runtime/source-store.ts+187−66
@@ -1,7 +1,10 @@
11 import type {
2+ AccountId,
23 Credential,
34 SecretRef,
45 Source,
6+ SourceRecipeId,
7+ SourceRecipeRevisionId,
58 WorkspaceId,
69 } from "#schema";
710 import { type SqlControlPlaneRows } from "#persistence";
@@ -9,8 +12,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line
912 import * as Option from "effect/Option";
1013
1114 import {
15+ createSourceRecipeRecord,
16+ createSourceRecipeRevisionRecord,
1217 projectSourceFromStorage,
1318 projectSourcesFromStorage,
19+ stableSourceRecipeId,
20+ stableSourceRecipeRevisionId,
1421 splitSourceForStorage,
1522 } from "./source-definitions";
1623 import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
@@ -54,25 +61,57 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 37 unmodified lines
5461 );
5562 });
5663
64+const selectPreferredCredential = (input: {
65+ credentials: ReadonlyArray<Credential>;
66+ actorAccountId?: AccountId | null;
67+}): Credential | null => {
68+ if (input.actorAccountId !== undefined) {
69+ const exact = input.credentials.find((credential) => credential.actorAccountId === input.actorAccountId);
70+ if (exact) {
71+ return exact;
72+ }
73+ }
74+
75+ return input.credentials.find((credential) => credential.actorAccountId === null) ?? null;
76+};
77+
78+const selectExactCredential = (input: {
79+ credentials: ReadonlyArray<Credential>;
80+ actorAccountId?: AccountId | null;
81+}): Credential | null =>
82+ input.credentials.find(
83+ (credential) => credential.actorAccountId === (input.actorAccountId ?? null),
84+ ) ?? null;
85+
5786 export const loadSourcesInWorkspace = (
5887 rows: SqlControlPlaneRows,
5988 workspaceId: WorkspaceId,
89+ options: {
90+ actorAccountId?: AccountId | null;
91+ } = {},
6092 ) =>
6193 Effect.gen(function* () {
6294 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
63− const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
6495 const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId);
96+ const filteredCredentials = sourceRecords.flatMap((sourceRecord) => {
97+ const matches = credentials.filter((credential) => credential.sourceId === sourceRecord.id);
98+ const preferred = selectPreferredCredential({
99+ credentials: matches,
100+ actorAccountId: options.actorAccountId,
101+ });
102+ return preferred ? [preferred] : [];
103+ });
65104
66105 return yield* projectSourcesFromStorage({
67106 sourceRecords,
68− credentialBindings,
69− credentials,
107+ credentials: filteredCredentials,
70108 });
71109 });
72110
73111 export const loadSourceById = (rows: SqlControlPlaneRows, input: {
74112 workspaceId: WorkspaceId;
75113 sourceId: Source["id"];
114+ actorAccountId?: AccountId | null;
76115 }) =>
77116 Effect.gen(function* () {
78117 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
@@ -86,53 +125,79 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 7 unmodified lines
86125 );
87126 }
88127
89− const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
90− input.workspaceId,
91− input.sourceId,
92− );
93− const credential =
94− Option.isSome(credentialBinding)
95− ? yield* rows.credentials.getById(credentialBinding.value.credentialId)
96− : Option.none<Credential>();
128+ const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({
129+ workspaceId: input.workspaceId,
130+ sourceId: input.sourceId,
131+ });
132+ const credential = selectPreferredCredential({
133+ credentials,
134+ actorAccountId: input.actorAccountId,
135+ });
97136
98137 return yield* projectSourceFromStorage({
99138 sourceRecord: sourceRecord.value,
100− credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
101− credential: Option.isSome(credential) ? credential.value : null,
139+ credential,
102140 });
103141 });
104142
105−export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: {
143+const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: {
106144 workspaceId: WorkspaceId;
107145 sourceId: Source["id"];
108146 }) =>
109147 Effect.gen(function* () {
110− const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
111− input.workspaceId,
112− input.sourceId,
113− );
114−
115− if (Option.isNone(existingBinding)) {
116− return false;
117− }
148+ const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
149+ workspaceId: input.workspaceId,
150+ sourceId: input.sourceId,
151+ });
118152
119− const existingCredential = yield* rows.credentials.getById(existingBinding.value.credentialId);
153+ yield* rows.credentials.removeByWorkspaceAndSourceId({
154+ workspaceId: input.workspaceId,
155+ sourceId: input.sourceId,
156+ });
120157
121− yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
122− input.workspaceId,
123− input.sourceId,
158+ yield* Effect.forEach(
159+ existingCredentials,
160+ (credential) =>
161+ cleanupCredentialSecretRefs(rows, {
162+ previous: credential,
163+ next: null,
164+ }),
165+ { discard: true },
124166 );
125167
126− if (Option.isSome(existingCredential)) {
127− yield* rows.credentials.removeById(existingBinding.value.credentialId);
168+ return existingCredentials.length;
169+ });
170+
171+const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {
172+ recipeId: SourceRecipeId;
173+ recipeRevisionId: SourceRecipeRevisionId;
174+}) =>
175+ Effect.gen(function* () {
176+ const revisionReferenceCount = yield* rows.sources.countByRecipeRevisionId(
177+ input.recipeRevisionId,
178+ );
179+ if (revisionReferenceCount === 0) {
180+ yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
181+ yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
128182 }
129183
130− yield* cleanupCredentialSecretRefs(rows, {
131− previous: Option.isSome(existingCredential) ? existingCredential.value : null,
132− next: null,
133− });
184+ const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId);
185+ if (recipeReferenceCount > 0) {
186+ return;
187+ }
134188
135− return true;
189+ const recipeRevisions = yield* rows.sourceRecipeRevisions.listByRecipeId(input.recipeId);
190+ yield* Effect.forEach(
191+ recipeRevisions,
192+ (recipeRevision) =>
193+ Effect.all([
194+ rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),
195+ rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),
196+ ]),
197+ { discard: true },
198+ );
199+ yield* rows.sourceRecipeRevisions.removeByRecipeId(input.recipeId);
200+ yield* rows.sourceRecipes.removeById(input.recipeId);
136201 });
137202
138203 export const removeSourceById = (rows: SqlControlPlaneRows, input: {
@@ -140,34 +205,81 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 1 unmodified line
140205 sourceId: Source["id"];
141206 }) =>
142207 Effect.gen(function* () {
208+ const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
209+ if (Option.isNone(sourceRecord)) {
210+ return false;
211+ }
212+
143213 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
144214 input.workspaceId,
145215 input.sourceId,
146216 );
147− yield* removeCredentialBindingForSource(rows, input);
148− return yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
217+ yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({
218+ workspaceId: input.workspaceId,
219+ sourceId: input.sourceId,
220+ });
221+ yield* removeCredentialsForSource(rows, input);
222+ const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
223+ if (!removed) {
224+ return false;
225+ }
226+
227+ yield* cleanupOrphanedRecipeData(rows, {
228+ recipeId: sourceRecord.value.recipeId,
229+ recipeRevisionId: sourceRecord.value.recipeRevisionId,
230+ });
231+
232+ return true;
149233 });
150234
151−export const persistSource = (rows: SqlControlPlaneRows, source: Source) =>
235+export const persistSource = (
236+ rows: SqlControlPlaneRows,
237+ source: Source,
238+ options: {
239+ actorAccountId?: AccountId | null;
240+ } = {},
241+) =>
152242 Effect.gen(function* () {
153243 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
154− const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
155− source.workspaceId,
156− source.id,
157− );
158− const existingCredential = Option.isSome(existingBinding)
159− ? yield* rows.credentials.getById(existingBinding.value.credentialId)
160− : Option.none<Credential>();
161− const existingCredentialId = Option.isSome(existingBinding)
162− ? existingBinding.value.credentialId
163− : null;
164− const existingBindingId = Option.isSome(existingBinding)
165− ? existingBinding.value.id
166− : null;
167− const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
244+ const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
245+ workspaceId: source.workspaceId,
246+ sourceId: source.id,
247+ });
248+ const existingCredential = selectExactCredential({
249+ credentials: existingCredentials,
250+ actorAccountId: options.actorAccountId,
251+ });
252+
253+ const nextRecipeId = stableSourceRecipeId(source);
254+ const nextRecipeRevisionId = stableSourceRecipeRevisionId(source);
255+ const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);
256+ const nextRevision = createSourceRecipeRevisionRecord({
168257 source,
169− existingCredentialId,
170− existingBindingId,
258+ recipeId: nextRecipeId,
259+ recipeRevisionId: nextRecipeRevisionId,
260+ revisionNumber: Option.isSome(existingTargetRevision)
261+ ? existingTargetRevision.value.revisionNumber
262+ : 1,
263+ manifestJson: Option.isSome(existingTargetRevision)
264+ ? existingTargetRevision.value.manifestJson
265+ : null,
266+ manifestHash: Option.isSome(existingTargetRevision)
267+ ? existingTargetRevision.value.manifestHash
268+ : null,
269+ });
270+
271+ const nextRecipe = createSourceRecipeRecord({
272+ source,
273+ recipeId: nextRecipeId,
274+ latestRevisionId: nextRevision.id,
275+ });
276+
277+ const { sourceRecord, credential } = splitSourceForStorage({
278+ source,
279+ recipeId: nextRecipe.id,
280+ recipeRevisionId: nextRevision.id,
281+ actorAccountId: options.actorAccountId,
282+ existingCredentialId: existingCredential?.id ?? null,
171283 });
172284
173285 if (Option.isNone(existing)) {
@@ -177,30 +289,39 @@ export const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 3 unmodified lines
177289 id: _id,
178290 workspaceId: _workspaceId,
179291 createdAt: _createdAt,
180− sourceDocumentText: _sourceDocumentText,
181292 ...patch
182293 } = sourceRecord;
183294 yield* rows.sources.update(source.workspaceId, source.id, patch);
184295 }
185296
186− if (credential === null || credentialBinding === null) {
187− if (Option.isSome(existingBinding)) {
188− yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
189− source.workspaceId,
190− source.id,
191− );
192−
193− if (Option.isSome(existingCredential)) {
194− yield* rows.credentials.removeById(existingBinding.value.credentialId);
195− }
196− }
297+ yield* rows.sourceRecipes.upsert(nextRecipe);
298+ yield* rows.sourceRecipeRevisions.upsert(nextRevision);
299+
300+ if (
301+ Option.isSome(existing)
302+ && (
303+ existing.value.recipeId !== nextRecipeId
304+ || existing.value.recipeRevisionId !== nextRecipeRevisionId
305+ )
306+ ) {
307+ yield* cleanupOrphanedRecipeData(rows, {
308+ recipeId: existing.value.recipeId,
309+ recipeRevisionId: existing.value.recipeRevisionId,
310+ });
311+ }
312+
313+ if (credential === null) {
314+ yield* rows.credentials.removeByWorkspaceSourceAndActor({
315+ workspaceId: source.workspaceId,
316+ sourceId: source.id,
317+ actorAccountId: options.actorAccountId ?? null,
318+ });
197319 } else {
198320 yield* rows.credentials.upsert(credential);
199− yield* rows.sourceCredentialBindings.upsert(credentialBinding);
200321 }
201322
202323 yield* cleanupCredentialSecretRefs(rows, {
203− previous: Option.isSome(existingCredential) ? existingCredential.value : null,
324+ previous: existingCredential ?? null,
204325 next: credential,
205326 });
206327
packages/control-plane/src/schema/enums.ts+22−0
@@ -20,6 +20,22 @@ export { · 19 unmodified lines
2020 type SourceStatus,
2121 type SourceTransport,
2222 } from "./models/source";
23+export {
24+ SourceRecipeDocumentKindSchema,
25+ SourceRecipeImporterKindSchema,
26+ SourceRecipeKindSchema,
27+ SourceRecipeOperationKindSchema,
28+ SourceRecipeOperationProviderKindSchema,
29+ SourceRecipeTransportKindSchema,
30+ SourceRecipeVisibilitySchema,
31+ type SourceRecipeDocumentKind,
32+ type SourceRecipeImporterKind,
33+ type SourceRecipeKind,
34+ type SourceRecipeOperationKind,
35+ type SourceRecipeOperationProviderKind,
36+ type SourceRecipeTransportKind,
37+ type SourceRecipeVisibility,
38+} from "./models/source-recipe";
2339 export {
2440 SourceAuthInferenceSchema,
2541 SourceDiscoveryAuthKindSchema,
@@ -40,6 +56,12 @@ export { · 14 unmodified lines
4056 CredentialAuthKindSchema,
4157 type CredentialAuthKind,
4258 } from "./models/credential";
59+export {
60+ SourceAuthSessionProviderKindSchema,
61+ SourceAuthSessionStatusSchema,
62+ type SourceAuthSessionProviderKind,
63+ type SourceAuthSessionStatus,
64+} from "./models/source-auth-session";
4365 export {
4466 PolicyApprovalModeSchema,
4567 PolicyEffectSchema,
packages/control-plane/src/schema/ids.ts+10−0
@@ -7,12 +7,19 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 6 unmodified lines
77 );
88 export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
99 export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
10+export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId"));
11+export const SourceRecipeRevisionIdSchema = Schema.String.pipe(
12+ Schema.brand("SourceRecipeRevisionId"),
13+);
1014 export const SourceAuthSessionIdSchema = Schema.String.pipe(
1115 Schema.brand("SourceAuthSessionId"),
1216 );
1317 export const CredentialIdSchema = Schema.String.pipe(
1418 Schema.brand("CredentialId"),
1519 );
20+export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
21+ Schema.brand("WorkspaceSourceOauthClientId"),
22+);
1623 export const SecretMaterialIdSchema = Schema.String.pipe(
1724 Schema.brand("SecretMaterialId"),
1825 );
@@ -28,8 +35,11 @@ export type OrganizationId = typeof OrganizationIdSchema.Type; · 9 unmodified lines
2835 export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;
2936 export type WorkspaceId = typeof WorkspaceIdSchema.Type;
3037 export type SourceId = typeof SourceIdSchema.Type;
38+export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
39+export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
3140 export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
3241 export type CredentialId = typeof CredentialIdSchema.Type;
42+export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
3343 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
3444 export type PolicyId = typeof PolicyIdSchema.Type;
3545 export type InstallationId = typeof InstallationIdSchema.Type;
packages/control-plane/src/schema/index.ts+2−0
@@ -5,7 +5,9 @@ export * from "./ids"; · 4 unmodified lines
55 export * from "./models/auth";
66 export * from "./models/workspace";
77 export * from "./models/source";
8+export * from "./models/source-recipe";
89 export * from "./models/credential";
10+export * from "./models/source-oauth-client";
911 export * from "./models/tool-artifact";
1012 export * from "./models/secret-material";
1113 export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/credential.ts+4−19
@@ -1,12 +1,10 @@
11 import { createSelectSchema } from "drizzle-orm/effect-schema";
22 import { Schema } from "effect";
33
4−import {
5− credentialsTable,
6− sourceCredentialBindingsTable,
7−} from "../../persistence/schema";
4+import { credentialsTable } from "../../persistence/schema";
85 import { TimestampMsSchema } from "../common";
96 import {
7+ AccountIdSchema,
108 CredentialIdSchema,
119 SourceIdSchema,
1210 WorkspaceIdSchema,
@@ -17,6 +15,8 @@ export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); · 4 unmodified lines
1715 const credentialSchemaOverrides = {
1816 id: CredentialIdSchema,
1917 workspaceId: WorkspaceIdSchema,
18+ sourceId: SourceIdSchema,
19+ actorAccountId: Schema.NullOr(AccountIdSchema),
2020 authKind: CredentialAuthKindSchema,
2121 authHeaderName: Schema.String,
2222 authPrefix: Schema.String,
@@ -28,25 +28,10 @@ const credentialSchemaOverrides = { · 5 unmodified lines
2828 updatedAt: TimestampMsSchema,
2929 } as const;
3030
31−const sourceCredentialBindingSchemaOverrides = {
32− id: Schema.String,
33− workspaceId: WorkspaceIdSchema,
34− sourceId: SourceIdSchema,
35− credentialId: CredentialIdSchema,
36− createdAt: TimestampMsSchema,
37− updatedAt: TimestampMsSchema,
38−} as const;
39−
4031 export const CredentialSchema = createSelectSchema(
4132 credentialsTable,
4233 credentialSchemaOverrides,
4334 );
4435
45−export const SourceCredentialBindingSchema = createSelectSchema(
46− sourceCredentialBindingsTable,
47− sourceCredentialBindingSchemaOverrides,
48−);
49−
5036 export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
5137 export type Credential = typeof CredentialSchema.Type;
52−export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type;
packages/control-plane/src/schema/models/source-auth-session.ts+7−4
@@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines
44 import { sourceAuthSessionsTable } from "../../persistence/schema";
55 import { TimestampMsSchema } from "../common";
66 import {
7+ AccountIdSchema,
78 ExecutionIdSchema,
89 ExecutionInteractionIdSchema,
910 SourceAuthSessionIdSchema,
@@ -11,8 +12,9 @@ import { · 1 unmodified line
1112 WorkspaceIdSchema,
1213 } from "../ids";
1314
14−export const SourceAuthSessionStrategySchema = Schema.Literal(
15− "oauth2_authorization_code",
15+export const SourceAuthSessionProviderKindSchema = Schema.Literal(
16+ "mcp_oauth",
17+ "oauth2_pkce",
1618 );
1719
1820 export const SourceAuthSessionStatusSchema = Schema.Literal(
@@ -26,9 +28,10 @@ const sourceAuthSessionSchemaOverrides = { · 7 unmodified lines
2628 id: SourceAuthSessionIdSchema,
2729 workspaceId: WorkspaceIdSchema,
2830 sourceId: SourceIdSchema,
31+ actorAccountId: Schema.NullOr(AccountIdSchema),
2932 executionId: Schema.NullOr(ExecutionIdSchema),
3033 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),
31− strategy: SourceAuthSessionStrategySchema,
34+ providerKind: SourceAuthSessionProviderKindSchema,
3235 status: SourceAuthSessionStatusSchema,
3336 completedAt: Schema.NullOr(TimestampMsSchema),
3437 createdAt: TimestampMsSchema,
@@ -40,6 +43,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 5 unmodified lines
4043 sourceAuthSessionSchemaOverrides,
4144 );
4245
43−export type SourceAuthSessionStrategy = typeof SourceAuthSessionStrategySchema.Type;
46+export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type;
4447 export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;
4548 export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;
packages/control-plane/src/schema/models/source-oauth-client.ts+23−0
@@ -0,0 +1,23 @@
1+import { createSelectSchema } from "drizzle-orm/effect-schema";
2+import { Schema } from "effect";
3+
4+import { workspaceSourceOauthClientsTable } from "../../persistence/schema";
5+import { TimestampMsSchema } from "../common";
6+import {
7+ SourceIdSchema,
8+ WorkspaceIdSchema,
9+ WorkspaceSourceOauthClientIdSchema,
10+} from "../ids";
11+
12+export const WorkspaceSourceOauthClientSchema = createSelectSchema(
13+ workspaceSourceOauthClientsTable,
14+ {
15+ id: WorkspaceSourceOauthClientIdSchema,
16+ workspaceId: WorkspaceIdSchema,
17+ sourceId: SourceIdSchema,
18+ createdAt: TimestampMsSchema,
19+ updatedAt: TimestampMsSchema,
20+ } as const,
21+);
22+
23+export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type;
packages/control-plane/src/schema/models/source-recipe.ts+140−0
@@ -0,0 +1,140 @@
1+import { createSelectSchema } from "drizzle-orm/effect-schema";
2+import { Schema } from "effect";
3+
4+import {
5+ sourceRecipeDocumentsTable,
6+ sourceRecipeOperationsTable,
7+ sourceRecipeRevisionsTable,
8+ sourceRecipesTable,
9+} from "../../persistence/schema";
10+import { TimestampMsSchema } from "../common";
11+import {
12+ SourceRecipeIdSchema,
13+ SourceRecipeRevisionIdSchema,
14+} from "../ids";
15+
16+export const SourceRecipeKindSchema = Schema.Literal(
17+ "http_recipe",
18+ "graphql_recipe",
19+ "mcp_recipe",
20+ "internal_recipe",
21+);
22+
23+export const SourceRecipeImporterKindSchema = Schema.Literal(
24+ "openapi",
25+ "google_discovery",
26+ "postman_collection",
27+ "snippet_bundle",
28+ "graphql_introspection",
29+ "mcp_manifest",
30+ "internal_manifest",
31+);
32+
33+export const SourceRecipeVisibilitySchema = Schema.Literal(
34+ "private",
35+ "workspace",
36+ "organization",
37+ "public",
38+);
39+
40+export const SourceRecipeDocumentKindSchema = Schema.Literal(
41+ "google_discovery",
42+ "openapi",
43+ "postman_collection",
44+ "postman_environment",
45+ "graphql_introspection",
46+ "mcp_manifest",
47+);
48+
49+export const SourceRecipeTransportKindSchema = Schema.Literal(
50+ "http",
51+ "graphql",
52+ "mcp",
53+ "internal",
54+);
55+
56+export const SourceRecipeOperationKindSchema = Schema.Literal(
57+ "read",
58+ "write",
59+ "delete",
60+ "unknown",
61+);
62+
63+export const SourceRecipeOperationProviderKindSchema = Schema.Literal(
64+ "mcp",
65+ "openapi",
66+ "graphql",
67+ "internal",
68+);
69+
70+const recipeRowSchemaOverrides = {
71+ id: SourceRecipeIdSchema,
72+ kind: SourceRecipeKindSchema,
73+ importerKind: SourceRecipeImporterKindSchema,
74+ visibility: SourceRecipeVisibilitySchema,
75+ latestRevisionId: SourceRecipeRevisionIdSchema,
76+ createdAt: TimestampMsSchema,
77+ updatedAt: TimestampMsSchema,
78+} as const;
79+
80+const recipeRevisionRowSchemaOverrides = {
81+ id: SourceRecipeRevisionIdSchema,
82+ recipeId: SourceRecipeIdSchema,
83+ revisionNumber: Schema.Number,
84+ createdAt: TimestampMsSchema,
85+ updatedAt: TimestampMsSchema,
86+} as const;
87+
88+const recipeDocumentRowSchemaOverrides = {
89+ recipeRevisionId: SourceRecipeRevisionIdSchema,
90+ documentKind: SourceRecipeDocumentKindSchema,
91+ fetchedAt: Schema.NullOr(TimestampMsSchema),
92+ createdAt: TimestampMsSchema,
93+ updatedAt: TimestampMsSchema,
94+} as const;
95+
96+const recipeOperationRowSchemaOverrides = {
97+ recipeRevisionId: SourceRecipeRevisionIdSchema,
98+ transportKind: SourceRecipeTransportKindSchema,
99+ operationKind: SourceRecipeOperationKindSchema,
100+ providerKind: SourceRecipeOperationProviderKindSchema,
101+ openApiMethod: Schema.NullOr(
102+ Schema.Literal("get", "put", "post", "delete", "patch", "head", "options", "trace"),
103+ ),
104+ createdAt: TimestampMsSchema,
105+ updatedAt: TimestampMsSchema,
106+} as const;
107+
108+export const StoredSourceRecipeRecordSchema = createSelectSchema(
109+ sourceRecipesTable,
110+ recipeRowSchemaOverrides,
111+);
112+
113+export const StoredSourceRecipeRevisionRecordSchema = createSelectSchema(
114+ sourceRecipeRevisionsTable,
115+ recipeRevisionRowSchemaOverrides,
116+);
117+
118+export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema(
119+ sourceRecipeDocumentsTable,
120+ recipeDocumentRowSchemaOverrides,
121+);
122+
123+export const StoredSourceRecipeOperationRecordSchema = createSelectSchema(
124+ sourceRecipeOperationsTable,
125+ recipeOperationRowSchemaOverrides,
126+);
127+
128+export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type;
129+export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type;
130+export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type;
131+export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type;
132+export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type;
133+export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type;
134+export type SourceRecipeOperationProviderKind =
135+ typeof SourceRecipeOperationProviderKindSchema.Type;
136+export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type;
137+export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type;
138+export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type;
139+export type StoredSourceRecipeOperationRecord =
140+ typeof StoredSourceRecipeOperationRecordSchema.Type;
packages/control-plane/src/schema/models/source.ts+17−1
@@ -5,7 +5,12 @@ import { Schema } from "effect"; · 4 unmodified lines
55
66 import { sourcesTable } from "../../persistence/schema";
77 import { TimestampMsSchema } from "../common";
8−import { SourceIdSchema, WorkspaceIdSchema } from "../ids";
8+import {
9+ SourceIdSchema,
10+ SourceRecipeIdSchema,
11+ SourceRecipeRevisionIdSchema,
12+ WorkspaceIdSchema,
13+} from "../ids";
914
1015 export const SourceKindSchema = Schema.Literal(
1116 "mcp",
@@ -60,6 +65,8 @@ export const StringMapSchema = Schema.Record({ · 48 unmodified lines
6065 const sourceRowSchemaOverrides = {
6166 workspaceId: WorkspaceIdSchema,
6267 sourceId: SourceIdSchema,
68+ recipeId: SourceRecipeIdSchema,
69+ recipeRevisionId: SourceRecipeRevisionIdSchema,
6370 kind: SourceKindSchema,
6471 status: SourceStatusSchema,
6572 transport: Schema.NullOr(SourceTransportSchema),
@@ -74,12 +81,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines
7481 Schema.Struct({
7582 id: SourceIdSchema,
7683 workspaceId: WorkspaceIdSchema,
84+ recipeId: SourceRecipeIdSchema,
85+ recipeRevisionId: SourceRecipeRevisionIdSchema,
7786 name: Schema.String,
7887 kind: SourceKindSchema,
7988 endpoint: Schema.String,
8089 status: SourceStatusSchema,
8190 enabled: Schema.Boolean,
8291 namespace: Schema.NullOr(Schema.String),
92+ bindingConfigJson: Schema.NullOr(Schema.String),
8393 transport: Schema.NullOr(SourceTransportSchema),
8494 queryParamsJson: Schema.NullOr(Schema.String),
8595 headersJson: Schema.NullOr(Schema.String),
@@ -96,12 +106,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 10 unmodified lines
96106 decode: (row, _input) => ({
97107 id: row.sourceId,
98108 workspaceId: row.workspaceId,
109+ recipeId: row.recipeId,
110+ recipeRevisionId: row.recipeRevisionId,
99111 name: row.name,
100112 kind: row.kind,
101113 endpoint: row.endpoint,
102114 status: row.status,
103115 enabled: row.enabled,
104116 namespace: row.namespace,
117+ bindingConfigJson: row.bindingConfigJson,
105118 transport: row.transport,
106119 queryParamsJson: row.queryParamsJson,
107120 headersJson: row.headersJson,
@@ -116,12 +129,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines
116129 encode: (source, _output) => ({
117130 workspaceId: source.workspaceId,
118131 sourceId: source.id,
132+ recipeId: source.recipeId,
133+ recipeRevisionId: source.recipeRevisionId,
119134 name: source.name,
120135 kind: source.kind,
121136 endpoint: source.endpoint,
122137 status: source.status,
123138 enabled: source.enabled,
124139 namespace: source.namespace,
140+ bindingConfigJson: source.bindingConfigJson,
125141 transport: source.transport,
126142 queryParamsJson: source.queryParamsJson,
127143 headersJson: source.headersJson,