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 @@
1ALTER TABLE "sources" ADD COLUMN "recipe_id" text;--> statement-breakpoint
2ALTER TABLE "sources" ADD COLUMN "recipe_revision_id" text;--> statement-breakpoint
3ALTER TABLE "sources" ADD COLUMN "binding_config_json" text;--> statement-breakpoint
4
5CREATE 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
29CREATE 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
40CREATE 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
60CREATE 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
91CREATE 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
104UPDATE "sources"
105SET
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
110INSERT 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)
122SELECT
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"
148FROM "sources" AS "s";--> statement-breakpoint
149
150INSERT 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)
160SELECT
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"
192FROM "sources" AS "s";--> statement-breakpoint
193
194INSERT 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)
205SELECT
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"
222FROM "sources" AS "s"
223WHERE "s"."source_document_text" IS NOT NULL;--> statement-breakpoint
224
225INSERT 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)
252SELECT
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"
303FROM "tool_artifacts" AS "ta"
304JOIN "sources" AS "s"
305 ON "s"."workspace_id" = "ta"."workspace_id"
306 AND "s"."source_id" = "ta"."source_id";--> statement-breakpoint
307
308WITH "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)
336UPDATE "source_recipe_revisions" AS "rev"
337SET
338 "manifest_json" = "mcp"."manifest_json",
339 "manifest_hash" = md5("mcp"."manifest_json")
340FROM "mcp_manifests" AS "mcp"
341WHERE "rev"."id" = "mcp"."recipe_revision_id";--> statement-breakpoint
342
343INSERT 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)
354SELECT
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"
364FROM "sources" AS "s"
365JOIN "source_recipe_revisions" AS "rev"
366 ON "rev"."id" = "s"."recipe_revision_id"
367WHERE "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
377ALTER TABLE "sources" ALTER COLUMN "recipe_id" SET NOT NULL;--> statement-breakpoint
378ALTER TABLE "sources" ALTER COLUMN "recipe_revision_id" SET NOT NULL;--> statement-breakpoint
379CREATE INDEX "sources_workspace_recipe_idx" ON "sources" ("workspace_id","recipe_id","updated_at","source_id");--> statement-breakpoint
380
381CREATE 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
398INSERT 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)
413SELECT
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"
430FROM "credentials" AS "c"
431JOIN "source_credential_bindings" AS "scb"
432 ON "scb"."workspace_id" = "c"."workspace_id"
433 AND "scb"."credential_id" = "c"."id";--> statement-breakpoint
434
435DROP TABLE "credentials";--> statement-breakpoint
436DROP TABLE "source_credential_bindings";--> statement-breakpoint
437CREATE INDEX "credentials_workspace_idx" ON "workspace_source_credentials" ("workspace_id","updated_at","id");--> statement-breakpoint
438CREATE UNIQUE INDEX "credentials_workspace_source_actor_idx" ON "workspace_source_credentials" ("workspace_id","source_id","actor_account_id");--> statement-breakpoint
439CREATE INDEX "credentials_workspace_source_idx" ON "workspace_source_credentials" ("workspace_id","source_id","updated_at","id");--> statement-breakpoint
440
441ALTER TABLE "source_auth_sessions" ADD COLUMN "actor_account_id" text;--> statement-breakpoint
442ALTER TABLE "source_auth_sessions" ADD COLUMN "provider_kind" text;--> statement-breakpoint
443ALTER TABLE "source_auth_sessions" ADD COLUMN "session_data_json" text;--> statement-breakpoint
444
445UPDATE "source_auth_sessions"
446SET
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
462ALTER TABLE "source_auth_sessions" ALTER COLUMN "provider_kind" SET NOT NULL;--> statement-breakpoint
463ALTER TABLE "source_auth_sessions" ALTER COLUMN "session_data_json" SET NOT NULL;--> statement-breakpoint
464DROP INDEX IF EXISTS "source_auth_sessions_pending_idx";--> statement-breakpoint
465ALTER TABLE "source_auth_sessions" DROP CONSTRAINT "source_auth_sessions_strategy_check";--> statement-breakpoint
466ALTER TABLE "source_auth_sessions" DROP COLUMN "strategy";--> statement-breakpoint
467ALTER TABLE "source_auth_sessions" DROP COLUMN "endpoint";--> statement-breakpoint
468ALTER TABLE "source_auth_sessions" DROP COLUMN "redirect_uri";--> statement-breakpoint
469ALTER TABLE "source_auth_sessions" DROP COLUMN "scope";--> statement-breakpoint
470ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_url";--> statement-breakpoint
471ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_url";--> statement-breakpoint
472ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_json";--> statement-breakpoint
473ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_metadata_json";--> statement-breakpoint
474ALTER TABLE "source_auth_sessions" DROP COLUMN "client_information_json";--> statement-breakpoint
475ALTER TABLE "source_auth_sessions" DROP COLUMN "code_verifier";--> statement-breakpoint
476ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_url";--> statement-breakpoint
477ALTER TABLE "source_auth_sessions" ADD CONSTRAINT "source_auth_sessions_provider_kind_check" CHECK ("provider_kind" in ('mcp_oauth', 'oauth2_pkce'));--> statement-breakpoint
478CREATE INDEX "source_auth_sessions_pending_idx" ON "source_auth_sessions" ("workspace_id","source_id","actor_account_id","status","updated_at","id");--> statement-breakpoint
479
480CREATE INDEX "source_recipes_provider_updated_idx" ON "source_recipes" ("provider_key","updated_at","id");--> statement-breakpoint
481CREATE INDEX "source_recipes_visibility_updated_idx" ON "source_recipes" ("visibility","updated_at","id");--> statement-breakpoint
482CREATE UNIQUE INDEX "source_recipe_revisions_recipe_revision_idx" ON "source_recipe_revisions" ("recipe_id","revision_number");--> statement-breakpoint
483CREATE UNIQUE INDEX "source_recipe_revisions_recipe_manifest_idx" ON "source_recipe_revisions" ("recipe_id","manifest_hash");--> statement-breakpoint
484CREATE INDEX "source_recipe_revisions_recipe_created_idx" ON "source_recipe_revisions" ("recipe_id","created_at","id");--> statement-breakpoint
485CREATE UNIQUE INDEX "source_recipe_documents_revision_kind_key_idx" ON "source_recipe_documents" ("recipe_revision_id","document_kind","document_key");--> statement-breakpoint
486CREATE INDEX "source_recipe_documents_revision_created_idx" ON "source_recipe_documents" ("recipe_revision_id","created_at","id");--> statement-breakpoint
487CREATE UNIQUE INDEX "source_recipe_operations_revision_operation_key_idx" ON "source_recipe_operations" ("recipe_revision_id","operation_key");--> statement-breakpoint
488CREATE INDEX "source_recipe_operations_revision_tool_idx" ON "source_recipe_operations" ("recipe_revision_id","tool_id","updated_at","id");--> statement-breakpoint
489CREATE INDEX "source_recipe_operations_search_text_idx" ON "source_recipe_operations" USING gin (to_tsvector('simple', "search_text"));--> statement-breakpoint
490CREATE UNIQUE INDEX "workspace_source_oauth_clients_workspace_source_provider_idx" ON "workspace_source_oauth_clients" ("workspace_id","source_id","provider_key");--> statement-breakpoint
491CREATE 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
241 (handlers) =>241 (handlers) =>
242 handlers242 handlers
243 .handle("startSourceAuth", ({ path, payload }) =>243 .handle("startSourceAuth", ({ path, payload }) =>
244 withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, () =>244 withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) =>
245 withPolicy(requireWriteSources(path.workspaceId))(245 withPolicy(requireWriteSources(path.workspaceId))(
246 Effect.gen(function* () {246 Effect.gen(function* () {
247 const request = yield* HttpServerRequest.HttpServerRequest;247 const request = yield* HttpServerRequest.HttpServerRequest;
248 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;248 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
249 return yield* sourceAuthService.startSourceOAuthSession({249 return yield* sourceAuthService.startSourceOAuthSession({
250 workspaceId: path.workspaceId,250 workspaceId: path.workspaceId,
251 actorAccountId: actor.principal.accountId,
251 baseUrl: resolveRequestOrigin(request),252 baseUrl: resolveRequestOrigin(request),
252 displayName: payload.name,253 displayName: payload.name,
253 provider: {254 provider: {
packages/control-plane/src/api/sources/http.ts+4−1
@@ -675,7 +675,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 674 unmodified lines
675 ),675 ),
676 )676 )
677 .handle("connect", ({ path, payload }) =>677 .handle("connect", ({ path, payload }) =>
678 withWorkspaceRequestActor("sources.connect", path.workspaceId, () =>678 withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) =>
679 withPolicy(requireWriteSources(path.workspaceId))(679 withPolicy(requireWriteSources(path.workspaceId))(
680 Effect.gen(function* () {680 Effect.gen(function* () {
681 const request = yield* HttpServerRequest.HttpServerRequest;681 const request = yield* HttpServerRequest.HttpServerRequest;
@@ -685,6 +685,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines
685 if (payload.kind === "mcp") {685 if (payload.kind === "mcp") {
686 return yield* sourceAuthService.connectMcpSource({686 return yield* sourceAuthService.connectMcpSource({
687 workspaceId: path.workspaceId,687 workspaceId: path.workspaceId,
688 actorAccountId: actor.principal.accountId,
688 endpoint: payload.endpoint,689 endpoint: payload.endpoint,
689 name: payload.name,690 name: payload.name,
690 namespace: payload.namespace,691 namespace: payload.namespace,
@@ -699,6 +700,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 8 unmodified lines
699 return yield* sourceAuthService.addExecutorSource(700 return yield* sourceAuthService.addExecutorSource(
700 {701 {
701 workspaceId: path.workspaceId,702 workspaceId: path.workspaceId,
703 actorAccountId: actor.principal.accountId,
702 executionId: null,704 executionId: null,
703 interactionId: null,705 interactionId: null,
704 kind: "openapi",706 kind: "openapi",
@@ -715,6 +717,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 10 unmodified lines
715 return yield* sourceAuthService.addExecutorSource(717 return yield* sourceAuthService.addExecutorSource(
716 {718 {
717 workspaceId: path.workspaceId,719 workspaceId: path.workspaceId,
720 actorAccountId: actor.principal.accountId,
718 executionId: null,721 executionId: null,
719 interactionId: null,722 interactionId: null,
720 kind: "graphql",723 kind: "graphql",
packages/control-plane/src/persistence/control-plane-rows.test.ts+184−28
@@ -9,17 +9,22 @@ import { · 8 unmodified lines
9 SecretMaterialIdSchema,9 SecretMaterialIdSchema,
10 SourceAuthSessionIdSchema,10 SourceAuthSessionIdSchema,
11 SourceIdSchema,11 SourceIdSchema,
12 SourceRecipeIdSchema,
13 SourceRecipeRevisionIdSchema,
12 WorkspaceIdSchema,14 WorkspaceIdSchema,
13} from "#schema";15} from "#schema";
14import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";17import * as Option from "effect/Option";
18import * as Scope from "effect/Scope";
16 19
17import {20import {
18 createSqlControlPlanePersistence,21 createSqlControlPlanePersistence,
19 type SqlControlPlanePersistence,22 type SqlControlPlanePersistence,
20} from "./index";23} from "./index";
24import { drizzleSchema } from "./schema";
21 25
22const makePersistence = Effect.acquireRelease(26const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
27 Effect.acquireRelease(
23 createSqlControlPlanePersistence({28 createSqlControlPlanePersistence({
24 localDataDir: ":memory:",29 localDataDir: ":memory:",
25 }),30 }),
@@ -28,20 +33,19 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines
28 try: () => persistence.close(),33 try: () => persistence.close(),
29 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),34 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
30 }).pipe(Effect.orDie),35 }).pipe(Effect.orDie),
31);36 );
32 37
33const seedWorkspaceCredentialState = (input: {38const seedWorkspaceSourceState = (input: {
34 persistence: SqlControlPlanePersistence;39 persistence: SqlControlPlanePersistence;
35 accountId: ReturnType<typeof AccountIdSchema.make>;40 accountId: ReturnType<typeof AccountIdSchema.make>;
36 organizationId: ReturnType<typeof OrganizationIdSchema.make>;41 organizationId: ReturnType<typeof OrganizationIdSchema.make>;
37 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;42 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
38 sourceId: ReturnType<typeof SourceIdSchema.make>;43 sourceId: ReturnType<typeof SourceIdSchema.make>;
39}) =>44}): Effect.Effect<void, unknown, never> =>
40 Effect.gen(function* () {45 Effect.gen(function* () {
41 const now = Date.now();46 const now = Date.now();
42 const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`);47 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
43 const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);48 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`);
44 const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
45 49
46 yield* input.persistence.rows.organizations.insert({50 yield* input.persistence.rows.organizations.insert({
47 id: input.organizationId,51 id: input.organizationId,
@@ -63,6 +67,8 @@ const seedWorkspaceCredentialState = (input: { · 15 unmodified lines
63 yield* input.persistence.rows.sources.insert({67 yield* input.persistence.rows.sources.insert({
64 id: input.sourceId,68 id: input.sourceId,
65 workspaceId: input.workspaceId,69 workspaceId: input.workspaceId,
70 recipeId,
71 recipeRevisionId,
66 name: "Github",72 name: "Github",
67 kind: "openapi",73 kind: "openapi",
68 endpoint: "https://api.github.com",74 endpoint: "https://api.github.com",
@@ -70,6 +76,7 @@ const seedWorkspaceCredentialState = (input: { · 1 unmodified line
70 enabled: true,76 enabled: true,
71 namespace: "github",77 namespace: "github",
72 transport: null,78 transport: null,
79 bindingConfigJson: null,
73 queryParamsJson: null,80 queryParamsJson: null,
74 headersJson: null,81 headersJson: null,
75 specUrl: "https://api.github.com/openapi.json",82 specUrl: "https://api.github.com/openapi.json",
@@ -80,6 +87,25 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines
80 createdAt: now,87 createdAt: now,
81 updatedAt: now,88 updatedAt: now,
82 });89 });
90 });
91
92const 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);
83 yield* input.persistence.rows.secretMaterials.upsert({109 yield* input.persistence.rows.secretMaterials.upsert({
84 id: tokenId,110 id: tokenId,
85 name: null,111 name: null,
@@ -99,6 +125,8 @@ const seedWorkspaceCredentialState = (input: { · 13 unmodified lines
99 yield* input.persistence.rows.credentials.upsert({125 yield* input.persistence.rows.credentials.upsert({
100 id: credentialId,126 id: credentialId,
101 workspaceId: input.workspaceId,127 workspaceId: input.workspaceId,
128 sourceId: input.sourceId,
129 actorAccountId: input.accountId,
102 authKind: "oauth2",130 authKind: "oauth2",
103 authHeaderName: "Authorization",131 authHeaderName: "Authorization",
104 authPrefix: "Bearer ",132 authPrefix: "Bearer ",
@@ -109,33 +137,29 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines
109 createdAt: now,137 createdAt: now,
110 updatedAt: now,138 updatedAt: now,
111 });139 });
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 });
120 yield* input.persistence.rows.sourceAuthSessions.upsert({140 yield* input.persistence.rows.sourceAuthSessions.upsert({
121 id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`),141 id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`),
122 workspaceId: input.workspaceId,142 workspaceId: input.workspaceId,
123 sourceId: input.sourceId,143 sourceId: input.sourceId,
144 actorAccountId: input.accountId,
124 executionId: null,145 executionId: null,
125 interactionId: null,146 interactionId: null,
126 strategy: "oauth2_authorization_code",147 providerKind: "mcp_oauth",
127 status: "pending",148 status: "pending",
128 endpoint: "https://api.github.com",
129 state: `state_${input.workspaceId}`,149 state: `state_${input.workspaceId}`,
130 redirectUri: "http://127.0.0.1/callback",150 sessionDataJson: JSON.stringify({
131 scope: null,151 kind: "mcp_oauth",
132 resourceMetadataUrl: null,152 endpoint: "https://api.github.com",
133 authorizationServerUrl: null,153 redirectUri: "http://127.0.0.1/callback",
134 resourceMetadataJson: null,154 scope: null,
135 authorizationServerMetadataJson: null,155 resourceMetadataUrl: null,
136 clientInformationJson: null,156 authorizationServerUrl: null,
137 codeVerifier: "verifier",157 resourceMetadataJson: null,
138 authorizationUrl: "https://example.com/auth",158 authorizationServerMetadataJson: null,
159 clientInformationJson: null,
160 codeVerifier: "verifier",
161 authorizationUrl: "https://example.com/auth",
162 }),
139 errorText: null,163 errorText: null,
140 completedAt: null,164 completedAt: null,
141 createdAt: now,165 createdAt: now,
@@ -176,6 +200,8 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines
176 yield* persistence.rows.sources.insert({200 yield* persistence.rows.sources.insert({
177 id: SourceIdSchema.make("src_1"),201 id: SourceIdSchema.make("src_1"),
178 workspaceId,202 workspaceId,
203 recipeId: SourceRecipeIdSchema.make("src_recipe_1"),
204 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),
179 name: "Github",205 name: "Github",
180 kind: "openapi",206 kind: "openapi",
181 endpoint: "https://api.github.com",207 endpoint: "https://api.github.com",
@@ -183,6 +209,7 @@ describe("control-plane-persistence-drizzle", () => { · 1 unmodified line
183 enabled: true,209 enabled: true,
184 namespace: "github",210 namespace: "github",
185 transport: null,211 transport: null,
212 bindingConfigJson: null,
186 queryParamsJson: null,213 queryParamsJson: null,
187 headersJson: null,214 headersJson: null,
188 specUrl: "https://api.github.com/openapi.json",215 specUrl: "https://api.github.com/openapi.json",
@@ -271,6 +298,137 @@ describe("control-plane-persistence-drizzle", () => { · 82 unmodified lines
271 }),298 }),
272 );299 );
273 300
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
274 it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () =>432 it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () =>
275 Effect.gen(function* () {433 Effect.gen(function* () {
276 const persistence = yield* makePersistence;434 const persistence = yield* makePersistence;
@@ -291,7 +449,6 @@ describe("control-plane-persistence-drizzle", () => { · 14 unmodified lines
291 expect(removed).toBe(true);449 expect(removed).toBe(true);
292 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);450 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
293 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);451 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
294 expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
295 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);452 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
296 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);453 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
297 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);454 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
@@ -318,7 +475,6 @@ describe("control-plane-persistence-drizzle", () => { · 20 unmodified lines
318 expect(removed).toBe(true);475 expect(removed).toBe(true);
319 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);476 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
320 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);477 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
321 expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
322 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);478 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
323 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);479 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
324 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);480 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
15 createPoliciesRepo,15 createPoliciesRepo,
16 createSecretMaterialsRepo,16 createSecretMaterialsRepo,
17 createSourceAuthSessionsRepo,17 createSourceAuthSessionsRepo,
18 createSourceCredentialBindingsRepo,18 createSourceOauthClientsRepo,
19 createSourceRecipeDocumentsRepo,
20 createSourceRecipeOperationsRepo,
21 createSourceRecipeRevisionsRepo,
22 createSourceRecipesRepo,
19 createSourcesRepo,23 createSourcesRepo,
20 createToolArtifactsRepo,24 createToolArtifactsRepo,
21 createWorkspacesRepo,25 createWorkspacesRepo,
@@ -29,6 +33,7 @@ import { · 7 unmodified lines
29 type SqlBackend,33 type SqlBackend,
30 type SqlRuntime,34 type SqlRuntime,
31} from "./sql-runtime";35} from "./sql-runtime";
36import { runPostMigrationRepairs } from "./post-migrations";
32 37
33export { tableNames, type DrizzleTables } from "./schema";38export { tableNames, type DrizzleTables } from "./schema";
34export {39export {
@@ -52,9 +57,13 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 17 unmodified lines
52 organizationMemberships: createOrganizationMembershipsRepo(client, tables),57 organizationMemberships: createOrganizationMembershipsRepo(client, tables),
53 workspaces: createWorkspacesRepo(client, tables),58 workspaces: createWorkspacesRepo(client, tables),
54 sources: createSourcesRepo(client, tables),59 sources: createSourcesRepo(client, tables),
60 sourceRecipes: createSourceRecipesRepo(client, tables),
61 sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),
62 sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables),
63 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
55 credentials: createCredentialsRepo(client, tables),64 credentials: createCredentialsRepo(client, tables),
65 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
56 toolArtifacts: createToolArtifactsRepo(client, tables),66 toolArtifacts: createToolArtifactsRepo(client, tables),
57 sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables),
58 secretMaterials: createSecretMaterialsRepo(client, tables),67 secretMaterials: createSecretMaterialsRepo(client, tables),
59 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),68 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
60 policies: createPoliciesRepo(client, tables),69 policies: createPoliciesRepo(client, tables),
@@ -127,14 +136,20 @@ export const createSqlControlPlanePersistence = ( · 66 unmodified lines
127 backend: runtime.backend,136 backend: runtime.backend,
128 db: runtime.db,137 db: runtime.db,
129 });138 });
139 const rows = createRows(client);
130 140
131 return {141 return {
132 backend: runtime.backend,142 backend: runtime.backend,
133 db: runtime.db,143 db: runtime.db,
134 rows: createRows(client),144 rows,
135 close: () => runtime.close(),145 close: () => runtime.close(),
136 } satisfies SqlControlPlanePersistence;146 } satisfies SqlControlPlanePersistence;
137 }),147 }),
148 Effect.flatMap((persistence) =>
149 runPostMigrationRepairs(persistence.rows).pipe(
150 Effect.mapError(toBootstrapError),
151 Effect.map(() => persistence),
152 )),
138 Effect.catchAll((error) =>153 Effect.catchAll((error) =>
139 closeRuntimeEffect(runtime).pipe(154 closeRuntimeEffect(runtime).pipe(
140 Effect.zipRight(Effect.fail(error)),155 Effect.zipRight(Effect.fail(error)),
packages/control-plane/src/persistence/post-migrations.test.ts+269−0
@@ -0,0 +1,269 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AccountIdSchema,
4 OrganizationIdSchema,
5 SourceIdSchema,
6 SourceRecipeIdSchema,
7 SourceRecipeRevisionIdSchema,
8 WorkspaceIdSchema,
9} from "#schema";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import * as Scope from "effect/Scope";
13import {
14 buildSchema,
15 getIntrospectionQuery,
16 graphqlSync,
17} from "graphql";
18
19import {
20 createSqlControlPlanePersistence,
21 type SqlControlPlanePersistence,
22} from "./index";
23import { runPostMigrationRepairs } from "./post-migrations";
24
25const 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
37const 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
152describe("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 @@
1import { randomUUID } from "node:crypto";
2
3import {
4 buildOpenApiToolPresentation,
5 compileOpenApiToolDefinitions,
6 extractOpenApiManifest,
7} from "@executor/codemode-openapi";
8import type {
9 StoredSourceRecipeDocumentRecord,
10 StoredSourceRecipeOperationRecord,
11 StoredSourceRecipeRevisionRecord,
12 StoredSourceRecord,
13} from "#schema";
14import * as Effect from "effect/Effect";
15
16import {
17 buildGraphqlToolPresentation,
18 compileGraphqlToolDefinitions,
19 extractGraphqlManifest,
20} from "../runtime/graphql-tools";
21import type { SqlControlPlaneRows } from "./index";
22
23const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
24 parts
25 .flatMap((part) => (part ? [part.trim()] : []))
26 .filter(Boolean)
27 .join(" ")
28 .toLowerCase();
29
30const 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
87const 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
143const 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
161const 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
204const 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.
249export 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
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { asc, eq } from "drizzle-orm";7import { and, asc, eq, inArray, isNull, or } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";11import { firstOption } from "./shared";
12 12
13const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);13const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);
14 14
15const 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
15export const createCredentialsRepo = (27export const createCredentialsRepo = (
16 client: DrizzleClient,28 client: DrizzleClient,
17 tables: DrizzleTables,29 tables: DrizzleTables,
@@ -30,12 +42,78 @@ export const createCredentialsRepo = ( · 12 unmodified lines
30 return rows.map((row) => decodeCredential(row));42 return rows.map((row) => decodeCredential(row));
31 }),43 }),
32 44
33 getById: (id: Credential["id"]) =>45 listByWorkspaceAndSourceId: (input: {
34 client.use("rows.credentials.get_by_id", async (db) => {46 workspaceId: Credential["workspaceId"];
47 sourceId: Credential["sourceId"];
48 }) =>
49 client.use("rows.credentials.list_by_workspace_source", async (db) => {
35 const rows = await db50 const rows = await db
36 .select()51 .select()
37 .from(tables.credentialsTable)52 .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 )
39 .limit(1);117 .limit(1);
40 118
41 const row = firstOption(rows);119 const row = firstOption(rows);
@@ -46,24 +124,96 @@ export const createCredentialsRepo = ( · 4 unmodified lines
46 124
47 upsert: (credential: Credential) =>125 upsert: (credential: Credential) =>
48 client.use("rows.credentials.upsert", async (db) => {126 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
49 await db165 await db
50 .insert(tables.credentialsTable)166 .insert(tables.credentialsTable)
51 .values(credential)167 .values(credential)
52 .onConflictDoUpdate({168 .onConflictDoUpdate({
53 target: [tables.credentialsTable.id],169 target: [
170 tables.credentialsTable.workspaceId,
171 tables.credentialsTable.sourceId,
172 tables.credentialsTable.actorAccountId,
173 ],
54 set: {174 set: {
55 ...withoutCreatedAt(credential),175 ...credentialUpdateSet(credential),
56 },176 },
57 });177 });
58 }),178 }),
59 179
60 removeById: (id: Credential["id"]) =>180 removeByWorkspaceSourceAndActor: (input: {
61 client.use("rows.credentials.remove", async (db) => {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) => {
62 const deleted = await db186 const deleted = await db
63 .delete(tables.credentialsTable)187 .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 )
65 .returning();197 .returning();
66 198
67 return deleted.length > 0;199 return deleted.length > 0;
68 }),200 }),
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 }),
69});219});
packages/control-plane/src/persistence/repos/index.ts+5−1
@@ -3,9 +3,13 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines
3export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";3export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
4export { createWorkspacesRepo } from "./workspaces-repo";4export { createWorkspacesRepo } from "./workspaces-repo";
5export { createSourcesRepo } from "./sources-repo";5export { createSourcesRepo } from "./sources-repo";
6export { createSourceRecipesRepo } from "./source-recipes-repo";
7export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
8export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo";
9export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
6export { createCredentialsRepo } from "./credentials-repo";10export { createCredentialsRepo } from "./credentials-repo";
11export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
7export { createToolArtifactsRepo } from "./tool-artifacts-repo";12export { createToolArtifactsRepo } from "./tool-artifacts-repo";
8export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo";
9export { createSecretMaterialsRepo } from "./secret-materials-repo";13export { createSecretMaterialsRepo } from "./secret-materials-repo";
10export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";14export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
11export { createPoliciesRepo } from "./policies-repo";15export { createPoliciesRepo } from "./policies-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+89−3
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
6} from "#schema";6} from "#schema";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import { Schema } from "effect";8import { Schema } from "effect";
9import { asc, eq, inArray } from "drizzle-orm";9import { and, asc, eq, inArray, or } from "drizzle-orm";
10 10
11import type { DrizzleClient } from "../client";11import type { DrizzleClient } from "../client";
12import type { DrizzleTables } from "../schema";12import type { DrizzleTables } from "../schema";
@@ -136,6 +136,13 @@ export const createOrganizationsRepo = ( · 123 unmodified lines
136 .select({ id: tables.executionsTable.id })136 .select({ id: tables.executionsTable.id })
137 .from(tables.executionsTable)137 .from(tables.executionsTable)
138 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));138 .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));
139 const credentials = await tx146 const credentials = await tx
140 .select({147 .select({
141 tokenProviderId: tables.credentialsTable.tokenProviderId,148 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -147,6 +154,28 @@ export const createOrganizationsRepo = ( · 5 unmodified lines
147 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));154 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
148 155
149 const executionIds = executionRows.map((execution) => execution.id);156 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 : [];
150 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);179 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
151 180
152 if (executionIds.length > 0) {181 if (executionIds.length > 0) {
@@ -157,6 +186,39 @@ export const createOrganizationsRepo = ( · 4 unmodified lines
157 );186 );
158 }187 }
159 188
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
160 await tx222 await tx
161 .delete(tables.executionsTable)223 .delete(tables.executionsTable)
162 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));224 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
@@ -166,17 +228,41 @@ export const createOrganizationsRepo = ( · 3 unmodified lines
166 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));228 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));
167 229
168 await tx230 await tx
169 .delete(tables.sourceCredentialBindingsTable)231 .delete(tables.workspaceSourceOauthClientsTable)
170 .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds));232 .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));
171 233
172 await tx234 await tx
173 .delete(tables.credentialsTable)235 .delete(tables.credentialsTable)
174 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));236 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
175 237
238 await tx
239 .delete(tables.toolArtifactsTable)
240 .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds));
241
176 await tx242 await tx
177 .delete(tables.sourcesTable)243 .delete(tables.sourcesTable)
178 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));244 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
179 245
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
180 await tx266 await tx
181 .delete(tables.policiesTable)267 .delete(tables.policiesTable)
182 .where(inArray(tables.policiesTable.workspaceId, workspaceIds));268 .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
105 tokenProviderId: tables.credentialsTable.tokenProviderId,105 tokenProviderId: tables.credentialsTable.tokenProviderId,
106 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,106 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,
107 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,107 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
108 sourceId: tables.sourceCredentialBindingsTable.sourceId,108 sourceId: tables.credentialsTable.sourceId,
109 sourceName: tables.sourcesTable.name,109 sourceName: tables.sourcesTable.name,
110 })110 })
111 .from(tables.credentialsTable)111 .from(tables.credentialsTable)
112 .innerJoin(
113 tables.sourceCredentialBindingsTable,
114 eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId),
115 )
116 .innerJoin(112 .innerJoin(
117 tables.sourcesTable,113 tables.sourcesTable,
118 and(114 and(
119 eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId),115 eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId),
120 eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId),116 eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId),
121 ),117 ),
122 )118 )
123 .where(119 .where(
packages/control-plane/src/persistence/repos/source-auth-sessions-repo.ts+35−25
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { and, asc, eq } from "drizzle-orm";7import { and, asc, eq, isNull } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
@@ -60,29 +60,39 @@ export const createSourceAuthSessionsRepo = ( · 49 unmodified lines
60 : Option.none<SourceAuthSession>();60 : Option.none<SourceAuthSession>();
61 }),61 }),
62 62
63 getPendingByWorkspaceAndSourceId: (63 getPendingByWorkspaceSourceAndActor: (input: {
64 workspaceId: SourceAuthSession["workspaceId"],64 workspaceId: SourceAuthSession["workspaceId"];
65 sourceId: SourceAuthSession["sourceId"],65 sourceId: SourceAuthSession["sourceId"];
66 ) =>66 actorAccountId: SourceAuthSession["actorAccountId"];
67 client.use("rows.source_auth_sessions.get_pending_by_workspace_and_source_id", async (db) => {67 }) =>
68 const rows = await db68 client.use(
69 .select()69 "rows.source_auth_sessions.get_pending_by_workspace_source_actor",
70 .from(tables.sourceAuthSessionsTable)70 async (db) => {
71 .where(71 const rows = await db
72 and(72 .select()
73 eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId),73 .from(tables.sourceAuthSessionsTable)
74 eq(tables.sourceAuthSessionsTable.sourceId, sourceId),74 .where(
75 eq(tables.sourceAuthSessionsTable.status, "pending"),75 and(
76 ),76 eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId),
77 )77 eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId),
78 .orderBy(asc(tables.sourceAuthSessionsTable.updatedAt), asc(tables.sourceAuthSessionsTable.id))78 input.actorAccountId === null
79 .limit(1);79 ? isNull(tables.sourceAuthSessionsTable.actorAccountId)
80 80 : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId),
81 const row = firstOption(rows);81 eq(tables.sourceAuthSessionsTable.status, "pending"),
82 return Option.isSome(row)82 ),
83 ? Option.some(decodeSourceAuthSession(row.value))83 )
84 : Option.none<SourceAuthSession>();84 .orderBy(
85 }),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 ),
86 96
87 insert: (session: SourceAuthSession) =>97 insert: (session: SourceAuthSession) =>
88 client.use("rows.source_auth_sessions.insert", async (db) => {98 client.use("rows.source_auth_sessions.insert", async (db) => {
@@ -123,7 +133,7 @@ export const createSourceAuthSessionsRepo = ( · 34 unmodified lines
123 workspaceId: SourceAuthSession["workspaceId"],133 workspaceId: SourceAuthSession["workspaceId"],
124 sourceId: SourceAuthSession["sourceId"],134 sourceId: SourceAuthSession["sourceId"],
125 ) =>135 ) =>
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) => {
127 const deleted = await db137 const deleted = await db
128 .delete(tables.sourceAuthSessionsTable)138 .delete(tables.sourceAuthSessionsTable)
129 .where(139 .where(
packages/control-plane/src/persistence/repos/source-oauth-clients-repo.ts+79−0
@@ -0,0 +1,79 @@
1import {
2 type WorkspaceSourceOauthClient,
3 WorkspaceSourceOauthClientSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { and, eq } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeWorkspaceSourceOauthClient = Schema.decodeUnknownSync(
14 WorkspaceSourceOauthClientSchema,
15);
16
17export 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 @@
1import {
2 type StoredSourceRecipeDocumentRecord,
3 StoredSourceRecipeDocumentRecordSchema,
4} from "#schema";
5import { Schema } from "effect";
6import { asc, eq, inArray } from "drizzle-orm";
7
8import type { DrizzleClient } from "../client";
9import type { DrizzleTables } from "../schema";
10
11const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync(
12 StoredSourceRecipeDocumentRecordSchema,
13);
14
15export 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 @@
1import {
2 type StoredSourceRecipeOperationRecord,
3 StoredSourceRecipeOperationRecordSchema,
4} from "#schema";
5import { Schema } from "effect";
6import { asc, eq, inArray } from "drizzle-orm";
7
8import type { DrizzleClient } from "../client";
9import type { DrizzleTables } from "../schema";
10
11const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync(
12 StoredSourceRecipeOperationRecordSchema,
13);
14
15export 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 @@
1import {
2 type StoredSourceRecipeRevisionRecord,
3 StoredSourceRecipeRevisionRecordSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { asc, eq, inArray } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeStoredSourceRecipeRevisionRecord = Schema.decodeUnknownSync(
14 StoredSourceRecipeRevisionRecordSchema,
15);
16
17type Mutable<T> = { -readonly [K in keyof T]: T[K] };
18type RecipeRevisionPatch = Partial<
19 Omit<Mutable<StoredSourceRecipeRevisionRecord>, "id" | "recipeId" | "revisionNumber" | "createdAt">
20>;
21
22export 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 @@
1import {
2 type StoredSourceRecipeRecord,
3 StoredSourceRecipeRecordSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { asc, eq } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeStoredSourceRecipeRecord = Schema.decodeUnknownSync(
14 StoredSourceRecipeRecordSchema,
15);
16
17export 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
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { and, asc, eq, or } from "drizzle-orm";7import { and, asc, count, eq, or } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
@@ -18,12 +18,15 @@ const toSourceUpdateSet = ( · 7 unmodified lines
18): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => {18): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => {
19 const set: Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> = {};19 const set: Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> = {};
20 20
21 if (patch.recipeId !== undefined) set.recipeId = patch.recipeId;
22 if (patch.recipeRevisionId !== undefined) set.recipeRevisionId = patch.recipeRevisionId;
21 if (patch.name !== undefined) set.name = patch.name;23 if (patch.name !== undefined) set.name = patch.name;
22 if (patch.kind !== undefined) set.kind = patch.kind;24 if (patch.kind !== undefined) set.kind = patch.kind;
23 if (patch.endpoint !== undefined) set.endpoint = patch.endpoint;25 if (patch.endpoint !== undefined) set.endpoint = patch.endpoint;
24 if (patch.status !== undefined) set.status = patch.status;26 if (patch.status !== undefined) set.status = patch.status;
25 if (patch.enabled !== undefined) set.enabled = patch.enabled;27 if (patch.enabled !== undefined) set.enabled = patch.enabled;
26 if (patch.namespace !== undefined) set.namespace = patch.namespace;28 if (patch.namespace !== undefined) set.namespace = patch.namespace;
29 if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson;
27 if (patch.transport !== undefined) set.transport = patch.transport;30 if (patch.transport !== undefined) set.transport = patch.transport;
28 if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson;31 if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson;
29 if (patch.headersJson !== undefined) set.headersJson = patch.headersJson;32 if (patch.headersJson !== undefined) set.headersJson = patch.headersJson;
@@ -43,6 +46,20 @@ export const createSourcesRepo = ( · 13 unmodified lines
43 client: DrizzleClient,46 client: DrizzleClient,
44 tables: DrizzleTables,47 tables: DrizzleTables,
45) => ({48) => ({
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
46 listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) =>63 listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) =>
47 client.use("rows.sources.list_by_workspace", async (db) => {64 client.use("rows.sources.list_by_workspace", async (db) => {
48 const rows = await db65 const rows = await db
@@ -76,6 +93,26 @@ export const createSourcesRepo = ( · 27 unmodified lines
76 : Option.none<StoredSourceRecord>();93 : Option.none<StoredSourceRecord>();
77 }),94 }),
78 95
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
79 insert: (source: StoredSourceRecord) =>116 insert: (source: StoredSourceRecord) =>
80 client.use("rows.sources.insert", async (db) => {117 client.use("rows.sources.insert", async (db) => {
81 await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source));118 await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source));
packages/control-plane/src/persistence/repos/workspaces-repo.ts+89−3
@@ -1,7 +1,7 @@
1import { type Workspace, WorkspaceSchema } from "#schema";1import { type Workspace, WorkspaceSchema } from "#schema";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import { Schema } from "effect";3import { Schema } from "effect";
4import { and, asc, eq, inArray } from "drizzle-orm";4import { and, asc, eq, inArray, or } from "drizzle-orm";
5 5
6import type { DrizzleClient } from "../client";6import type { DrizzleClient } from "../client";
7import type { DrizzleTables } from "../schema";7import type { DrizzleTables } from "../schema";
@@ -69,7 +69,16 @@ export const createWorkspacesRepo = ( · 61 unmodified lines
69 .select({ id: tables.executionsTable.id })69 .select({ id: tables.executionsTable.id })
70 .from(tables.executionsTable)70 .from(tables.executionsTable)
71 .where(eq(tables.executionsTable.workspaceId, workspaceId));71 .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));
72 const executionIds = executionRows.map((execution) => execution.id);79 const executionIds = executionRows.map((execution) => execution.id);
80 const sourceIds = sourceRows.map((source) => source.sourceId);
81 const recipeIds = sourceRows.map((source) => source.recipeId);
73 const credentials = await tx82 const credentials = await tx
74 .select({83 .select({
75 tokenProviderId: tables.credentialsTable.tokenProviderId,84 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -79,6 +88,26 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
79 })88 })
80 .from(tables.credentialsTable)89 .from(tables.credentialsTable)
81 .where(eq(tables.credentialsTable.workspaceId, workspaceId));90 .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 : [];
82 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);111 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
83 112
84 if (executionIds.length > 0) {113 if (executionIds.length > 0) {
@@ -87,6 +116,39 @@ export const createWorkspacesRepo = ( · 2 unmodified lines
87 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));116 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));
88 }117 }
89 118
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
90 await tx152 await tx
91 .delete(tables.executionsTable)153 .delete(tables.executionsTable)
92 .where(eq(tables.executionsTable.workspaceId, workspaceId));154 .where(eq(tables.executionsTable.workspaceId, workspaceId));
@@ -96,17 +158,41 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
96 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));158 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));
97 159
98 await tx160 await tx
99 .delete(tables.sourceCredentialBindingsTable)161 .delete(tables.workspaceSourceOauthClientsTable)
100 .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId));162 .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));
101 163
102 await tx164 await tx
103 .delete(tables.credentialsTable)165 .delete(tables.credentialsTable)
104 .where(eq(tables.credentialsTable.workspaceId, workspaceId));166 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
105 167
168 await tx
169 .delete(tables.toolArtifactsTable)
170 .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId));
171
106 await tx172 await tx
107 .delete(tables.sourcesTable)173 .delete(tables.sourcesTable)
108 .where(eq(tables.sourcesTable.workspaceId, workspaceId));174 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
109 175
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
110 await tx196 await tx
111 .delete(tables.policiesTable)197 .delete(tables.policiesTable)
112 .where(198 .where(
packages/control-plane/src/persistence/schema.ts+223−21
@@ -16,12 +16,16 @@ export const tableNames = { · 15 unmodified lines
16 organizationMemberships: "organization_memberships",16 organizationMemberships: "organization_memberships",
17 workspaces: "workspaces",17 workspaces: "workspaces",
18 sources: "sources",18 sources: "sources",
19 sourceRecipes: "source_recipes",
20 sourceRecipeRevisions: "source_recipe_revisions",
21 sourceRecipeDocuments: "source_recipe_documents",
22 sourceRecipeOperations: "source_recipe_operations",
19 toolArtifacts: "tool_artifacts",23 toolArtifacts: "tool_artifacts",
20 toolArtifactParameters: "tool_artifact_parameters",24 toolArtifactParameters: "tool_artifact_parameters",
21 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",25 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
22 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",26 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
23 credentials: "credentials",27 credentials: "workspace_source_credentials",
24 sourceCredentialBindings: "source_credential_bindings",28 workspaceSourceOauthClients: "workspace_source_oauth_clients",
25 secretMaterials: "secret_materials",29 secretMaterials: "secret_materials",
26 sourceAuthSessions: "source_auth_sessions",30 sourceAuthSessions: "source_auth_sessions",
27 policies: "policies",31 policies: "policies",
@@ -133,12 +137,15 @@ export const sourcesTable = pgTable( · 105 unmodified lines
133 {137 {
134 workspaceId: text("workspace_id").notNull(),138 workspaceId: text("workspace_id").notNull(),
135 sourceId: text("source_id").notNull(),139 sourceId: text("source_id").notNull(),
140 recipeId: text("recipe_id").notNull(),
141 recipeRevisionId: text("recipe_revision_id").notNull(),
136 name: text("name").notNull(),142 name: text("name").notNull(),
137 kind: text("kind").notNull(),143 kind: text("kind").notNull(),
138 endpoint: text("endpoint").notNull(),144 endpoint: text("endpoint").notNull(),
139 status: text("status").notNull(),145 status: text("status").notNull(),
140 enabled: boolean("enabled").notNull(),146 enabled: boolean("enabled").notNull(),
141 namespace: text("namespace"),147 namespace: text("namespace"),
148 bindingConfigJson: text("binding_config_json"),
142 transport: text("transport"),149 transport: text("transport"),
143 queryParamsJson: text("query_params_json"),150 queryParamsJson: text("query_params_json"),
144 headersJson: text("headers_json"),151 headersJson: text("headers_json"),
@@ -159,6 +166,12 @@ export const sourcesTable = pgTable( · 14 unmodified lines
159 table.updatedAt,166 table.updatedAt,
160 table.sourceId,167 table.sourceId,
161 ),168 ),
169 index("sources_workspace_recipe_idx").on(
170 table.workspaceId,
171 table.recipeId,
172 table.updatedAt,
173 table.sourceId,
174 ),
162 uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name),175 uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name),
163 check(176 check(
164 "sources_kind_check",177 "sources_kind_check",
@@ -175,11 +188,187 @@ export const sourcesTable = pgTable( · 10 unmodified lines
175 ],188 ],
176);189);
177 190
191export 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
239export 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
268export 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
306export 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
178export const credentialsTable = pgTable(365export const credentialsTable = pgTable(
179 tableNames.credentials,366 tableNames.credentials,
180 {367 {
181 id: text("id").notNull().primaryKey(),368 id: text("id").notNull().primaryKey(),
182 workspaceId: text("workspace_id").notNull(),369 workspaceId: text("workspace_id").notNull(),
370 sourceId: text("source_id").notNull(),
371 actorAccountId: text("actor_account_id"),
183 authKind: text("auth_kind").notNull(),372 authKind: text("auth_kind").notNull(),
184 authHeaderName: text("auth_header_name").notNull(),373 authHeaderName: text("auth_header_name").notNull(),
185 authPrefix: text("auth_prefix").notNull(),374 authPrefix: text("auth_prefix").notNull(),
@@ -196,6 +385,17 @@ export const credentialsTable = pgTable( · 10 unmodified lines
196 table.updatedAt,385 table.updatedAt,
197 table.id,386 table.id,
198 ),387 ),
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 ),
199 check(399 check(
200 "credentials_auth_kind_check",400 "credentials_auth_kind_check",
201 sql`${table.authKind} in ('bearer', 'oauth2')`,401 sql`${table.authKind} in ('bearer', 'oauth2')`,
@@ -203,22 +403,27 @@ export const credentialsTable = pgTable( · 1 unmodified line
203 ],403 ],
204);404);
205 405
206export const sourceCredentialBindingsTable = pgTable(406export const workspaceSourceOauthClientsTable = pgTable(
207 tableNames.sourceCredentialBindings,407 tableNames.workspaceSourceOauthClients,
208 {408 {
209 id: text("id").notNull().primaryKey(),409 id: text("id").notNull().primaryKey(),
210 workspaceId: text("workspace_id").notNull(),410 workspaceId: text("workspace_id").notNull(),
211 sourceId: text("source_id").notNull(),411 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"),
213 createdAt: bigint("created_at", { mode: "number" }).notNull(),417 createdAt: bigint("created_at", { mode: "number" }).notNull(),
214 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),418 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
215 },419 },
216 (table) => [420 (table) => [
217 uniqueIndex("source_credential_bindings_workspace_source_idx").on(421 uniqueIndex("workspace_source_oauth_clients_workspace_source_provider_idx").on(
218 table.workspaceId,422 table.workspaceId,
219 table.sourceId,423 table.sourceId,
424 table.providerKey,
220 ),425 ),
221 index("source_credential_bindings_workspace_idx").on(426 index("workspace_source_oauth_clients_workspace_idx").on(
222 table.workspaceId,427 table.workspaceId,
223 table.updatedAt,428 table.updatedAt,
224 table.sourceId,429 table.sourceId,
@@ -379,21 +584,13 @@ export const sourceAuthSessionsTable = pgTable( · 154 unmodified lines
379 id: text("id").notNull().primaryKey(),584 id: text("id").notNull().primaryKey(),
380 workspaceId: text("workspace_id").notNull(),585 workspaceId: text("workspace_id").notNull(),
381 sourceId: text("source_id").notNull(),586 sourceId: text("source_id").notNull(),
587 actorAccountId: text("actor_account_id"),
382 executionId: text("execution_id"),588 executionId: text("execution_id"),
383 interactionId: text("interaction_id"),589 interactionId: text("interaction_id"),
384 strategy: text("strategy").notNull(),590 providerKind: text("provider_kind").notNull(),
385 status: text("status").notNull(),591 status: text("status").notNull(),
386 endpoint: text("endpoint").notNull(),
387 state: text("state").notNull(),592 state: text("state").notNull(),
388 redirectUri: text("redirect_uri").notNull(),593 sessionDataJson: text("session_data_json").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"),
397 errorText: text("error_text"),594 errorText: text("error_text"),
398 completedAt: bigint("completed_at", { mode: "number" }),595 completedAt: bigint("completed_at", { mode: "number" }),
399 createdAt: bigint("created_at", { mode: "number" }).notNull(),596 createdAt: bigint("created_at", { mode: "number" }).notNull(),
@@ -408,14 +605,15 @@ export const sourceAuthSessionsTable = pgTable( · 8 unmodified lines
408 index("source_auth_sessions_pending_idx").on(605 index("source_auth_sessions_pending_idx").on(
409 table.workspaceId,606 table.workspaceId,
410 table.sourceId,607 table.sourceId,
608 table.actorAccountId,
411 table.status,609 table.status,
412 table.updatedAt,610 table.updatedAt,
413 table.id,611 table.id,
414 ),612 ),
415 uniqueIndex("source_auth_sessions_state_idx").on(table.state),613 uniqueIndex("source_auth_sessions_state_idx").on(table.state),
416 check(614 check(
417 "source_auth_sessions_strategy_check",615 "source_auth_sessions_provider_kind_check",
418 sql`${table.strategy} in ('oauth2_authorization_code')`,616 sql`${table.providerKind} in ('mcp_oauth', 'oauth2_pkce')`,
419 ),617 ),
420 check(618 check(
421 "source_auth_sessions_status_check",619 "source_auth_sessions_status_check",
@@ -561,12 +759,16 @@ export const drizzleSchema = { · 139 unmodified lines
561 organizationMembershipsTable,759 organizationMembershipsTable,
562 workspacesTable,760 workspacesTable,
563 sourcesTable,761 sourcesTable,
762 sourceRecipesTable,
763 sourceRecipeRevisionsTable,
764 sourceRecipeDocumentsTable,
765 sourceRecipeOperationsTable,
564 credentialsTable,766 credentialsTable,
767 workspaceSourceOauthClientsTable,
565 toolArtifactsTable,768 toolArtifactsTable,
566 toolArtifactParametersTable,769 toolArtifactParametersTable,
567 toolArtifactRequestBodyContentTypesTable,770 toolArtifactRequestBodyContentTypesTable,
568 toolArtifactRefHintKeysTable,771 toolArtifactRefHintKeysTable,
569 sourceCredentialBindingsTable,
570 secretMaterialsTable,772 secretMaterialsTable,
571 sourceAuthSessionsTable,773 sourceAuthSessionsTable,
572 policiesTable,774 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
29});29});
30 30
31describe("source-auth-service", () => {31describe("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
32 it("clears ephemeral OAuth session fields when failing a session", () => {46 it("clears ephemeral OAuth session fields when failing a session", () => {
33 const patch = createTerminalSourceAuthSessionPatch({47 const patch = createTerminalSourceAuthSessionPatch({
48 sessionDataJson: baseSessionDataJson,
34 status: "failed",49 status: "failed",
35 now: 123,50 now: 123,
36 errorText: "OAuth authorization failed",51 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"}',
41 });52 });
42 53
43 expect(patch).toMatchObject({54 expect(patch).toMatchObject({
55 sessionDataJson: baseSessionDataJson,
44 status: "failed",56 status: "failed",
45 errorText: "OAuth authorization failed",57 errorText: "OAuth authorization failed",
46 completedAt: 123,58 completedAt: 123,
47 updatedAt: 123,59 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"}',
55 });60 });
56 });61 });
57 62
58 it("clears ephemeral OAuth session fields when completing a session", () => {63 it("clears ephemeral OAuth session fields when completing a session", () => {
59 const patch = createTerminalSourceAuthSessionPatch({64 const patch = createTerminalSourceAuthSessionPatch({
65 sessionDataJson: baseSessionDataJson,
60 status: "completed",66 status: "completed",
61 now: 456,67 now: 456,
62 errorText: null,68 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"}',
67 });69 });
68 70
69 expect(patch).toMatchObject({71 expect(patch).toMatchObject({
72 sessionDataJson: baseSessionDataJson,
70 status: "completed",73 status: "completed",
71 errorText: null,74 errorText: null,
72 completedAt: 456,75 completedAt: 456,
73 updatedAt: 456,76 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"}',
81 });77 });
82 });78 });
83 79
packages/control-plane/src/runtime/source-auth-service.ts+186−75
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 type SqlControlPlaneRows,8 type SqlControlPlaneRows,
9} from "#persistence";9} from "#persistence";
10import {10import {
11 AccountId,
11 ExecutionIdSchema,12 ExecutionIdSchema,
12 type SecretMaterialPurpose,13 type SecretMaterialPurpose,
13 Source,14 Source,
@@ -163,27 +164,74 @@ const probeMcpSourceWithoutAuth = ( · 149 unmodified lines
163 });164 });
164 165
165export const createTerminalSourceAuthSessionPatch = (input: {166export const createTerminalSourceAuthSessionPatch = (input: {
167 sessionDataJson: string;
166 status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">;168 status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">;
167 now: number;169 now: number;
168 errorText: string | null;170 errorText: string | null;
169 resourceMetadataUrl?: string | null;
170 authorizationServerUrl?: string | null;
171 resourceMetadataJson?: string | null;
172 authorizationServerMetadataJson?: string | null;
173}) => ({171}) => ({
174 status: input.status,172 status: input.status,
175 errorText: input.errorText,173 errorText: input.errorText,
176 completedAt: input.now,174 completedAt: input.now,
177 updatedAt: input.now,175 updatedAt: input.now,
178 codeVerifier: null,176 sessionDataJson: input.sessionDataJson,
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,
185}) satisfies Partial<SourceAuthSession>;177}) satisfies Partial<SourceAuthSession>;
186 178
179type 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
193const encodeMcpSourceAuthSessionData = (
194 sessionData: McpSourceAuthSessionData,
195): string => JSON.stringify(sessionData);
196
197const 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
224const 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
187 235
188const completeLiveInteraction = (input: {236const completeLiveInteraction = (input: {
189 liveExecutionManager: LiveExecutionManager;237 liveExecutionManager: LiveExecutionManager;
@@ -217,6 +265,7 @@ const completeLiveInteraction = (input: { · 27 unmodified lines
217 });265 });
218 266
219const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: {267const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: {
268 actorAccountId?: AccountId | null;
220 status: Source["status"];269 status: Source["status"];
221 lastError?: string | null;270 lastError?: string | null;
222 auth?: Source["auth"];271 auth?: Source["auth"];
@@ -225,6 +274,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines
225 const latest = yield* loadSourceById(rows, {274 const latest = yield* loadSourceById(rows, {
226 workspaceId: source.workspaceId,275 workspaceId: source.workspaceId,
227 sourceId: source.id,276 sourceId: source.id,
277 actorAccountId: input.actorAccountId,
228 });278 });
229 279
230 return yield* persistSource(rows, {280 return yield* persistSource(rows, {
@@ -233,6 +283,8 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines
233 lastError: input.lastError ?? null,283 lastError: input.lastError ?? null,
234 auth: input.auth ?? latest.auth,284 auth: input.auth ?? latest.auth,
235 updatedAt: Date.now(),285 updatedAt: Date.now(),
286 }, {
287 actorAccountId: input.actorAccountId,
236 });288 });
237 });289 });
238 290
@@ -277,6 +329,7 @@ export type ExecutorAddSourceInput = · 38 unmodified lines
277 | {329 | {
278 kind?: "mcp";330 kind?: "mcp";
279 workspaceId: WorkspaceId;331 workspaceId: WorkspaceId;
332 actorAccountId?: AccountId | null;
280 executionId: SourceAuthSession["executionId"];333 executionId: SourceAuthSession["executionId"];
281 interactionId: SourceAuthSession["interactionId"];334 interactionId: SourceAuthSession["interactionId"];
282 endpoint: string;335 endpoint: string;
@@ -286,6 +339,7 @@ export type ExecutorAddSourceInput = · 3 unmodified lines
286 | {339 | {
287 kind: "openapi";340 kind: "openapi";
288 workspaceId: WorkspaceId;341 workspaceId: WorkspaceId;
342 actorAccountId?: AccountId | null;
289 executionId: SourceAuthSession["executionId"];343 executionId: SourceAuthSession["executionId"];
290 interactionId: SourceAuthSession["interactionId"];344 interactionId: SourceAuthSession["interactionId"];
291 endpoint: string;345 endpoint: string;
@@ -297,6 +351,7 @@ export type ExecutorAddSourceInput = · 5 unmodified lines
297 | {351 | {
298 kind: "graphql";352 kind: "graphql";
299 workspaceId: WorkspaceId;353 workspaceId: WorkspaceId;
354 actorAccountId?: AccountId | null;
300 executionId: SourceAuthSession["executionId"];355 executionId: SourceAuthSession["executionId"];
301 interactionId: SourceAuthSession["interactionId"];356 interactionId: SourceAuthSession["interactionId"];
302 endpoint: string;357 endpoint: string;
@@ -307,6 +362,7 @@ export type ExecutorAddSourceInput = · 4 unmodified lines
307 362
308export type ConnectMcpSourceInput = {363export type ConnectMcpSourceInput = {
309 workspaceId: WorkspaceId;364 workspaceId: WorkspaceId;
365 actorAccountId?: AccountId | null;
310 sourceId?: Source["id"] | null;366 sourceId?: Source["id"] | null;
311 endpoint: string;367 endpoint: string;
312 name?: string | null;368 name?: string | null;
@@ -332,6 +388,7 @@ export type SourceOAuthProviderInput = { · 19 unmodified lines
332 388
333export type StartSourceOAuthSessionInput = {389export type StartSourceOAuthSessionInput = {
334 workspaceId: WorkspaceId;390 workspaceId: WorkspaceId;
391 actorAccountId?: AccountId | null;
335 provider: SourceOAuthProviderInput;392 provider: SourceOAuthProviderInput;
336 baseUrl?: string | null;393 baseUrl?: string | null;
337 displayName?: string | null;394 displayName?: string | null;
@@ -482,6 +539,7 @@ const connectMcpSourceInternal = (input: { · 144 unmodified lines
482 getLocalServerBaseUrl?: () => string | undefined;539 getLocalServerBaseUrl?: () => string | undefined;
483 baseUrl?: string | null;540 baseUrl?: string | null;
484 workspaceId: WorkspaceId;541 workspaceId: WorkspaceId;
542 actorAccountId?: AccountId | null;
485 sourceId?: Source["id"] | null;543 sourceId?: Source["id"] | null;
486 executionId?: SourceAuthSession["executionId"];544 executionId?: SourceAuthSession["executionId"];
487 interactionId?: SourceAuthSession["interactionId"];545 interactionId?: SourceAuthSession["interactionId"];
@@ -502,6 +560,7 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines
502 ? loadSourceById(input.rows, {560 ? loadSourceById(input.rows, {
503 workspaceId: input.workspaceId,561 workspaceId: input.workspaceId,
504 sourceId: input.sourceId,562 sourceId: input.sourceId,
563 actorAccountId: input.actorAccountId,
505 }).pipe(564 }).pipe(
506 Effect.flatMap((source) =>565 Effect.flatMap((source) =>
507 source.kind === "mcp"566 source.kind === "mcp"
@@ -509,7 +568,9 @@ const connectMcpSourceInternal = (input: { · 1 unmodified line
509 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),568 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
510 ),569 ),
511 )570 )
512 : loadSourcesInWorkspace(input.rows, input.workspaceId).pipe(571 : loadSourcesInWorkspace(input.rows, input.workspaceId, {
572 actorAccountId: input.actorAccountId,
573 }).pipe(
513 Effect.map((sources) =>574 Effect.map((sources) =>
514 sources.find(575 sources.find(
515 (source) =>576 (source) =>
@@ -569,7 +630,9 @@ const connectMcpSourceInternal = (input: { · 53 unmodified lines
569 now,630 now,
570 });631 });
571 632
572 const persistedDraft = yield* persistSource(input.rows, draftSource);633 const persistedDraft = yield* persistSource(input.rows, draftSource, {
634 actorAccountId: input.actorAccountId,
635 });
573 yield* syncSourceToolArtifacts({636 yield* syncSourceToolArtifacts({
574 rows: input.rows,637 rows: input.rows,
575 source: persistedDraft,638 source: persistedDraft,
@@ -588,6 +651,7 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines
588 onRight: (result) =>651 onRight: (result) =>
589 Effect.gen(function* () {652 Effect.gen(function* () {
590 const connected = yield* updateSourceStatus(input.rows, persistedDraft, {653 const connected = yield* updateSourceStatus(input.rows, persistedDraft, {
654 actorAccountId: input.actorAccountId,
591 status: "connected",655 status: "connected",
592 lastError: null,656 lastError: null,
593 auth: { kind: "none" },657 auth: { kind: "none" },
@@ -603,6 +667,7 @@ const connectMcpSourceInternal = (input: { · 9 unmodified lines
603 return yield* Either.match(indexed, {667 return yield* Either.match(indexed, {
604 onLeft: (error) =>668 onLeft: (error) =>
605 updateSourceStatus(input.rows, connected, {669 updateSourceStatus(input.rows, connected, {
670 actorAccountId: input.actorAccountId,
606 status: "error",671 status: "error",
607 lastError: error.message,672 lastError: error.message,
608 }).pipe(673 }).pipe(
@@ -642,6 +707,7 @@ const connectMcpSourceInternal = (input: { · 33 unmodified lines
642 });707 });
643 708
644 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {709 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
710 actorAccountId: input.actorAccountId,
645 status: "auth_required",711 status: "auth_required",
646 lastError: null,712 lastError: null,
647 });713 });
@@ -651,21 +717,25 @@ const connectMcpSourceInternal = (input: { · 3 unmodified lines
651 id: sessionId,717 id: sessionId,
652 workspaceId: input.workspaceId,718 workspaceId: input.workspaceId,
653 sourceId: authRequiredSource.id,719 sourceId: authRequiredSource.id,
720 actorAccountId: input.actorAccountId ?? null,
654 executionId: input.executionId ?? null,721 executionId: input.executionId ?? null,
655 interactionId: input.interactionId ?? null,722 interactionId: input.interactionId ?? null,
656 strategy: "oauth2_authorization_code",723 providerKind: "mcp_oauth",
657 status: "pending",724 status: "pending",
658 endpoint: normalizedEndpoint,
659 state,725 state,
660 redirectUri: redirectUrl,726 sessionDataJson: encodeMcpSourceAuthSessionData({
661 scope: null,727 kind: "mcp_oauth",
662 resourceMetadataUrl: oauthStart.resourceMetadataUrl,728 endpoint: normalizedEndpoint,
663 authorizationServerUrl: oauthStart.authorizationServerUrl,729 redirectUri: redirectUrl,
664 resourceMetadataJson: oauthStart.resourceMetadataJson,730 scope: null,
665 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,731 resourceMetadataUrl: oauthStart.resourceMetadataUrl,
666 clientInformationJson: oauthStart.clientInformationJson,732 authorizationServerUrl: oauthStart.authorizationServerUrl,
667 codeVerifier: oauthStart.codeVerifier,733 resourceMetadataJson: oauthStart.resourceMetadataJson,
668 authorizationUrl: oauthStart.authorizationUrl,734 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
735 clientInformationJson: oauthStart.clientInformationJson,
736 codeVerifier: oauthStart.codeVerifier,
737 authorizationUrl: oauthStart.authorizationUrl,
738 }),
669 errorText: null,739 errorText: null,
670 completedAt: null,740 completedAt: null,
671 createdAt: sessionNow,741 createdAt: sessionNow,
@@ -694,6 +764,9 @@ const addExecutorHttpSource = (input: { · 22 unmodified lines
694 const existingSources = yield* loadSourcesInWorkspace(764 const existingSources = yield* loadSourcesInWorkspace(
695 input.rows,765 input.rows,
696 input.sourceInput.workspaceId,766 input.sourceInput.workspaceId,
767 {
768 actorAccountId: input.sourceInput.actorAccountId,
769 },
697 );770 );
698 const existing = existingSources.find((source) => {771 const existing = existingSources.find((source) => {
699 if (source.kind !== input.sourceInput.kind) {772 if (source.kind !== input.sourceInput.kind) {
@@ -757,7 +830,9 @@ const addExecutorHttpSource = (input: { · 57 unmodified lines
757 now,830 now,
758 });831 });
759 832
760 const persistedDraft = yield* persistSource(input.rows, draftSource);833 const persistedDraft = yield* persistSource(input.rows, draftSource, {
834 actorAccountId: input.sourceInput.actorAccountId,
835 });
761 return {836 return {
762 kind: "credential_required",837 kind: "credential_required",
763 source: persistedDraft,838 source: persistedDraft,
@@ -802,7 +877,9 @@ const addExecutorHttpSource = (input: { · 38 unmodified lines
802 now,877 now,
803 });878 });
804 879
805 const persistedDraft = yield* persistSource(input.rows, draftSource);880 const persistedDraft = yield* persistSource(input.rows, draftSource, {
881 actorAccountId: input.sourceInput.actorAccountId,
882 });
806 const synced = yield* Effect.either(883 const synced = yield* Effect.either(
807 syncSourceToolArtifacts({884 syncSourceToolArtifacts({
808 rows: input.rows,885 rows: input.rows,
@@ -817,6 +894,7 @@ const addExecutorHttpSource = (input: { · 8 unmodified lines
817 return yield* Either.match(synced, {894 return yield* Either.match(synced, {
818 onLeft: (error) =>895 onLeft: (error) =>
819 updateSourceStatus(input.rows, persistedDraft, {896 updateSourceStatus(input.rows, persistedDraft, {
897 actorAccountId: input.sourceInput.actorAccountId,
820 status: "error",898 status: "error",
821 lastError: error.message,899 lastError: error.message,
822 }).pipe(900 }).pipe(
@@ -824,6 +902,7 @@ const addExecutorHttpSource = (input: { · 1 unmodified line
824 ),902 ),
825 onRight: () =>903 onRight: () =>
826 updateSourceStatus(input.rows, persistedDraft, {904 updateSourceStatus(input.rows, persistedDraft, {
905 actorAccountId: input.sourceInput.actorAccountId,
827 status: "connected",906 status: "connected",
828 lastError: null,907 lastError: null,
829 }).pipe(908 }).pipe(
@@ -841,6 +920,7 @@ type RuntimeSourceAuthServiceShape = { · 11 unmodified lines
841 getSourceById: (input: {920 getSourceById: (input: {
842 workspaceId: WorkspaceId;921 workspaceId: WorkspaceId;
843 sourceId: Source["id"];922 sourceId: Source["id"];
923 actorAccountId?: AccountId | null;
844 }) => Effect.Effect<Source, Error, never>;924 }) => Effect.Effect<Source, Error, never>;
845 getLocalServerBaseUrl: () => string | null;925 getLocalServerBaseUrl: () => string | null;
846 storeSecretMaterial: (input: {926 storeSecretMaterial: (input: {
@@ -869,6 +949,7 @@ type RuntimeSourceAuthServiceShape = { · 22 unmodified lines
869 completeSourceCredentialSetup: (input: {949 completeSourceCredentialSetup: (input: {
870 workspaceId: WorkspaceId;950 workspaceId: WorkspaceId;
871 sourceId: Source["id"];951 sourceId: Source["id"];
952 actorAccountId?: AccountId | null;
872 state: string;953 state: string;
873 code?: string | null;954 code?: string | null;
874 error?: string | null;955 error?: string | null;
@@ -897,10 +978,11 @@ export const createRuntimeSourceAuthService = (input: { · 22 unmodified lines
897 value,978 value,
898 }),979 }),
899 980
900 getSourceById: ({ workspaceId, sourceId }) =>981 getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>
901 loadSourceById(input.rows, {982 loadSourceById(input.rows, {
902 workspaceId,983 workspaceId,
903 sourceId,984 sourceId,
985 actorAccountId,
904 }),986 }),
905 987
906 addExecutorSource: (sourceInput, options) =>988 addExecutorSource: (sourceInput, options) =>
@@ -915,6 +997,7 @@ export const createRuntimeSourceAuthService = (input: { · 8 unmodified lines
915 rows: input.rows,997 rows: input.rows,
916 getLocalServerBaseUrl: input.getLocalServerBaseUrl,998 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
917 workspaceId: sourceInput.workspaceId,999 workspaceId: sourceInput.workspaceId,
1000 actorAccountId: sourceInput.actorAccountId,
918 executionId: sourceInput.executionId,1001 executionId: sourceInput.executionId,
919 interactionId: sourceInput.interactionId,1002 interactionId: sourceInput.interactionId,
920 endpoint: sourceInput.endpoint,1003 endpoint: sourceInput.endpoint,
@@ -930,6 +1013,7 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
930 rows: input.rows,1013 rows: input.rows,
931 getLocalServerBaseUrl: input.getLocalServerBaseUrl,1014 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
932 workspaceId: sourceInput.workspaceId,1015 workspaceId: sourceInput.workspaceId,
1016 actorAccountId: sourceInput.actorAccountId,
933 sourceId: sourceInput.sourceId,1017 sourceId: sourceInput.sourceId,
934 executionId: null,1018 executionId: null,
935 interactionId: null,1019 interactionId: null,
@@ -972,21 +1056,25 @@ export const createRuntimeSourceAuthService = (input: { · 36 unmodified lines
972 id: sessionId,1056 id: sessionId,
973 workspaceId: oauthInput.workspaceId,1057 workspaceId: oauthInput.workspaceId,
974 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),1058 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),
1059 actorAccountId: oauthInput.actorAccountId ?? null,
975 executionId: null,1060 executionId: null,
976 interactionId: null,1061 interactionId: null,
977 strategy: "oauth2_authorization_code",1062 providerKind: "mcp_oauth",
978 status: "pending",1063 status: "pending",
979 endpoint,
980 state,1064 state,
981 redirectUri: redirectUrl,1065 sessionDataJson: encodeMcpSourceAuthSessionData({
982 scope: oauthInput.provider.kind,1066 kind: "mcp_oauth",
983 resourceMetadataUrl: oauthStart.resourceMetadataUrl,1067 endpoint,
984 authorizationServerUrl: oauthStart.authorizationServerUrl,1068 redirectUri: redirectUrl,
985 resourceMetadataJson: oauthStart.resourceMetadataJson,1069 scope: oauthInput.provider.kind,
986 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,1070 resourceMetadataUrl: oauthStart.resourceMetadataUrl,
987 clientInformationJson: oauthStart.clientInformationJson,1071 authorizationServerUrl: oauthStart.authorizationServerUrl,
988 codeVerifier: oauthStart.codeVerifier,1072 resourceMetadataJson: oauthStart.resourceMetadataJson,
989 authorizationUrl: oauthStart.authorizationUrl,1073 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
1074 clientInformationJson: oauthStart.clientInformationJson,
1075 codeVerifier: oauthStart.codeVerifier,
1076 authorizationUrl: oauthStart.authorizationUrl,
1077 }),
990 errorText: null,1078 errorText: null,
991 completedAt: null,1079 completedAt: null,
992 createdAt: now,1080 createdAt: now,
@@ -1012,6 +1100,7 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
1012 }1100 }
1013 1101
1014 const session = sessionOption.value;1102 const session = sessionOption.value;
1103 const sessionData = decodeMcpSourceAuthSessionData(session);
1015 if (session.status === "completed") {1104 if (session.status === "completed") {
1016 return yield* Effect.fail(new Error(`Source auth session ${session.id} is already completed`));1105 return yield* Effect.fail(new Error(`Source auth session ${session.id} is already completed`));
1017 }1106 }
@@ -1027,13 +1116,10 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
1027 yield* input.rows.sourceAuthSessions.update(1116 yield* input.rows.sourceAuthSessions.update(
1028 session.id,1117 session.id,
1029 createTerminalSourceAuthSessionPatch({1118 createTerminalSourceAuthSessionPatch({
1119 sessionDataJson: session.sessionDataJson,
1030 status: "failed",1120 status: "failed",
1031 now: failedAt,1121 now: failedAt,
1032 errorText: reason,1122 errorText: reason,
1033 resourceMetadataUrl: session.resourceMetadataUrl,
1034 authorizationServerUrl: session.authorizationServerUrl,
1035 resourceMetadataJson: session.resourceMetadataJson,
1036 authorizationServerMetadataJson: session.authorizationServerMetadataJson,
1037 }),1123 }),
1038 );1124 );
1039 1125
@@ -1045,31 +1131,31 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
1045 return yield* Effect.fail(new Error("Missing OAuth authorization code"));1131 return yield* Effect.fail(new Error("Missing OAuth authorization code"));
1046 }1132 }
1047 1133
1048 if (session.codeVerifier === null) {1134 if (sessionData.codeVerifier === null) {
1049 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));1135 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
1050 }1136 }
1051 1137
1052 if (session.scope !== null && session.scope !== "mcp") {1138 if (sessionData.scope !== null && sessionData.scope !== "mcp") {
1053 return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${session.scope}`));1139 return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${sessionData.scope}`));
1054 }1140 }
1055 1141
1056 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({1142 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
1057 session: {1143 session: {
1058 endpoint: session.endpoint,1144 endpoint: sessionData.endpoint,
1059 redirectUrl: session.redirectUri,1145 redirectUrl: sessionData.redirectUri,
1060 codeVerifier: session.codeVerifier,1146 codeVerifier: sessionData.codeVerifier,
1061 resourceMetadataUrl: session.resourceMetadataUrl,1147 resourceMetadataUrl: sessionData.resourceMetadataUrl,
1062 authorizationServerUrl: session.authorizationServerUrl,1148 authorizationServerUrl: sessionData.authorizationServerUrl,
1063 resourceMetadataJson: session.resourceMetadataJson,1149 resourceMetadataJson: sessionData.resourceMetadataJson,
1064 authorizationServerMetadataJson: session.authorizationServerMetadataJson,1150 authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson,
1065 clientInformationJson: session.clientInformationJson,1151 clientInformationJson: sessionData.clientInformationJson,
1066 },1152 },
1067 code: authorizationCode,1153 code: authorizationCode,
1068 });1154 });
1069 1155
1070 const oauthSecretName = resolveSourceOAuthSecretName({1156 const oauthSecretName = resolveSourceOAuthSecretName({
1071 displayName: readSourceOAuthSessionDisplayName(session.state),1157 displayName: readSourceOAuthSessionDisplayName(session.state),
1072 endpoint: session.endpoint,1158 endpoint: sessionData.endpoint,
1073 });1159 });
1074 const accessTokenRef = yield* storeSecretMaterial({1160 const accessTokenRef = yield* storeSecretMaterial({
1075 purpose: "oauth_access_token",1161 purpose: "oauth_access_token",
@@ -1095,13 +1181,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
1095 yield* input.rows.sourceAuthSessions.update(1181 yield* input.rows.sourceAuthSessions.update(
1096 session.id,1182 session.id,
1097 createTerminalSourceAuthSessionPatch({1183 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 }),
1098 status: "completed",1195 status: "completed",
1099 now: Date.now(),1196 now: Date.now(),
1100 errorText: null,1197 errorText: null,
1101 resourceMetadataUrl: exchanged.resourceMetadataUrl,
1102 authorizationServerUrl: exchanged.authorizationServerUrl,
1103 resourceMetadataJson: exchanged.resourceMetadataJson,
1104 authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1105 }),1198 }),
1106 );1199 );
1107 1200
@@ -1114,6 +1207,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines
1114 completeSourceCredentialSetup: ({1207 completeSourceCredentialSetup: ({
1115 workspaceId,1208 workspaceId,
1116 sourceId,1209 sourceId,
1210 actorAccountId,
1117 state,1211 state,
1118 code,1212 code,
1119 error,1213 error,
@@ -1126,6 +1220,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines
1126 }1220 }
1127 1221
1128 const session = sessionOption.value;1222 const session = sessionOption.value;
1223 const sessionData = decodeMcpSourceAuthSessionData(session);
1129 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {1224 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {
1130 return yield* Effect.fail(1225 return yield* Effect.fail(
1131 new Error(1226 new Error(
@@ -1133,10 +1228,19 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line
1133 ),1228 ),
1134 );1229 );
1135 }1230 }
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 }
1136 1239
1137 const source = yield* loadSourceById(input.rows, {1240 const source = yield* loadSourceById(input.rows, {
1138 workspaceId: session.workspaceId,1241 workspaceId: session.workspaceId,
1139 sourceId: session.sourceId,1242 sourceId: session.sourceId,
1243 actorAccountId: session.actorAccountId,
1140 });1244 });
1141 1245
1142 if (session.status === "completed") {1246 if (session.status === "completed") {
@@ -1156,16 +1260,14 @@ export const createRuntimeSourceAuthService = (input: { · 13 unmodified lines
1156 yield* input.rows.sourceAuthSessions.update(1260 yield* input.rows.sourceAuthSessions.update(
1157 session.id,1261 session.id,
1158 createTerminalSourceAuthSessionPatch({1262 createTerminalSourceAuthSessionPatch({
1263 sessionDataJson: session.sessionDataJson,
1159 status: "failed",1264 status: "failed",
1160 now: failedAt,1265 now: failedAt,
1161 errorText: reason,1266 errorText: reason,
1162 resourceMetadataUrl: session.resourceMetadataUrl,
1163 authorizationServerUrl: session.authorizationServerUrl,
1164 resourceMetadataJson: session.resourceMetadataJson,
1165 authorizationServerMetadataJson: session.authorizationServerMetadataJson,
1166 }),1267 }),
1167 );1268 );
1168 const failedSource = yield* updateSourceStatus(input.rows, source, {1269 const failedSource = yield* updateSourceStatus(input.rows, source, {
1270 actorAccountId: session.actorAccountId,
1169 status: "error",1271 status: "error",
1170 lastError: reason,1272 lastError: reason,
1171 });1273 });
@@ -1191,20 +1293,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines
1191 return yield* Effect.fail(new Error("Missing OAuth authorization code"));1293 return yield* Effect.fail(new Error("Missing OAuth authorization code"));
1192 }1294 }
1193 1295
1194 if (session.codeVerifier === null) {1296 if (sessionData.codeVerifier === null) {
1195 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));1297 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
1196 }1298 }
1197 1299
1198 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({1300 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
1199 session: {1301 session: {
1200 endpoint: session.endpoint,1302 endpoint: sessionData.endpoint,
1201 redirectUrl: session.redirectUri,1303 redirectUrl: sessionData.redirectUri,
1202 codeVerifier: session.codeVerifier,1304 codeVerifier: sessionData.codeVerifier,
1203 resourceMetadataUrl: session.resourceMetadataUrl,1305 resourceMetadataUrl: sessionData.resourceMetadataUrl,
1204 authorizationServerUrl: session.authorizationServerUrl,1306 authorizationServerUrl: sessionData.authorizationServerUrl,
1205 resourceMetadataJson: session.resourceMetadataJson,1307 resourceMetadataJson: sessionData.resourceMetadataJson,
1206 authorizationServerMetadataJson: session.authorizationServerMetadataJson,1308 authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson,
1207 clientInformationJson: session.clientInformationJson,1309 clientInformationJson: sessionData.clientInformationJson,
1208 },1310 },
1209 code: authorizationCode,1311 code: authorizationCode,
1210 });1312 });
@@ -1228,6 +1330,7 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines
1228 1330
1229 const now = Date.now();1331 const now = Date.now();
1230 const connectedSource = yield* updateSourceStatus(input.rows, source, {1332 const connectedSource = yield* updateSourceStatus(input.rows, source, {
1333 actorAccountId: session.actorAccountId,
1231 status: "connected",1334 status: "connected",
1232 lastError: null,1335 lastError: null,
1233 auth: {1336 auth: {
@@ -1248,6 +1351,7 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines
1248 yield* Either.match(indexed, {1351 yield* Either.match(indexed, {
1249 onLeft: (error) =>1352 onLeft: (error) =>
1250 updateSourceStatus(input.rows, connectedSource, {1353 updateSourceStatus(input.rows, connectedSource, {
1354 actorAccountId: session.actorAccountId,
1251 status: "error",1355 status: "error",
1252 lastError: error.message,1356 lastError: error.message,
1253 }).pipe(1357 }).pipe(
@@ -1259,13 +1363,20 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
1259 yield* input.rows.sourceAuthSessions.update(1363 yield* input.rows.sourceAuthSessions.update(
1260 session.id,1364 session.id,
1261 createTerminalSourceAuthSessionPatch({1365 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 }),
1262 status: "completed",1377 status: "completed",
1263 now,1378 now,
1264 errorText: null,1379 errorText: null,
1265 resourceMetadataUrl: exchanged.resourceMetadataUrl,
1266 authorizationServerUrl: exchanged.authorizationServerUrl,
1267 resourceMetadataJson: exchanged.resourceMetadataJson,
1268 authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1269 }),1380 }),
1270 );1381 );
1271 1382
packages/control-plane/src/runtime/source-definitions.test.ts+435−0
@@ -0,0 +1,435 @@
1import { describe, expect, it } from "vitest";
2import * as Effect from "effect/Effect";
3
4import {
5 CredentialIdSchema,
6 SourceIdSchema,
7 WorkspaceIdSchema,
8 type Source,
9} from "#schema";
10
11import {
12 createSourceFromPayload,
13 projectSourceFromStorage,
14 splitSourceForStorage,
15 stableSourceRecipeId,
16 stableSourceRecipeRevisionId,
17 updateSourceFromPayload,
18} from "./source-definitions";
19import { namespaceFromSourceName } from "./tool-artifacts";
20
21const 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
43describe("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 @@
1import { createHash } from "node:crypto";
2
1import type {3import type {
2 CreateSourcePayload,4 CreateSourcePayload,
3 UpdateSourcePayload,5 UpdateSourcePayload,
4} from "#api";6} from "#api";
5import type {7import type {
8 AccountId,
6 Credential,9 Credential,
7 Source,10 Source,
8 SourceAuth,11 SourceAuth,
9 SourceCredentialBinding,12 SourceRecipeId,
10 SourceId,13 SourceRecipeImporterKind,
14 SourceRecipeKind,
15 SourceRecipeRevisionId,
11 StoredSourceRecord,16 StoredSourceRecord,
17 StoredSourceRecipeRecord,
18 StoredSourceRecipeRevisionRecord,
12 StringMap,19 StringMap,
13 WorkspaceId,20 WorkspaceId,
14} from "#schema";21} from "#schema";
15import { CredentialIdSchema } from "#schema";22import {
23 CredentialIdSchema,
24 SourceRecipeIdSchema,
25 SourceRecipeRevisionIdSchema,
26} from "#schema";
16import * as Effect from "effect/Effect";27import * as Effect from "effect/Effect";
17import * as Schema from "effect/Schema";28import * as Schema from "effect/Schema";
18 29
@@ -58,6 +69,134 @@ const parseStringMapJson = ( · 39 unmodified lines
58 ),69 ),
59 );70 );
60 71
72type 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
96const 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
130const 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
146const 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
162const 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
178const stableHash = (value: string): string =>
179 createHash("sha256").update(value).digest("hex").slice(0, 24);
180
181const 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
189export const sourceConfigSignature = (source: Source): string =>
190 JSON.stringify(sourceConfigFromSource(source));
191
192export const stableSourceRecipeId = (source: Source): SourceRecipeId =>
193 SourceRecipeIdSchema.make(`src_recipe_${stableHash(sourceRecipeSignature(source))}`);
194
195export const stableSourceRecipeRevisionId = (
196 source: Source,
197): SourceRecipeRevisionId =>
198 SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${stableHash(sourceConfigSignature(source))}`);
199
61const normalizeAuth = (200const normalizeAuth = (
62 auth: SourceAuth | undefined,201 auth: SourceAuth | undefined,
63): Effect.Effect<SourceAuth, Error, never> =>202): Effect.Effect<SourceAuth, Error, never> =>
@@ -175,7 +314,7 @@ const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, neve · 111 unmodified lines
175 314
176export const createSourceFromPayload = (input: {315export const createSourceFromPayload = (input: {
177 workspaceId: WorkspaceId;316 workspaceId: WorkspaceId;
178 sourceId: SourceId;317 sourceId: Source["id"];
179 payload: CreateSourcePayload;318 payload: CreateSourcePayload;
180 now: number;319 now: number;
181}): Effect.Effect<Source, Error, never> =>320}): Effect.Effect<Source, Error, never> =>
@@ -253,24 +392,65 @@ export const updateSourceFromPayload = (input: { · 71 unmodified lines
253 });392 });
254 });393 });
255 394
395export 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
412export 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
256export const splitSourceForStorage = (input: {432export const splitSourceForStorage = (input: {
257 source: Source;433 source: Source;
434 recipeId: SourceRecipeId;
435 recipeRevisionId: SourceRecipeRevisionId;
436 actorAccountId?: AccountId | null;
258 existingCredentialId?: Credential["id"] | null;437 existingCredentialId?: Credential["id"] | null;
259 existingBindingId?: SourceCredentialBinding["id"] | null;
260}): {438}): {
261 sourceRecord: StoredSourceRecord;439 sourceRecord: StoredSourceRecord;
262 credential: Credential | null;440 credential: Credential | null;
263 credentialBinding: SourceCredentialBinding | null;
264} => {441} => {
265 const sourceRecord: StoredSourceRecord = {442 const sourceRecord: StoredSourceRecord = {
266 id: input.source.id,443 id: input.source.id,
267 workspaceId: input.source.workspaceId,444 workspaceId: input.source.workspaceId,
445 recipeId: input.recipeId,
446 recipeRevisionId: input.recipeRevisionId,
268 name: input.source.name,447 name: input.source.name,
269 kind: input.source.kind,448 kind: input.source.kind,
270 endpoint: input.source.endpoint,449 endpoint: input.source.endpoint,
271 status: input.source.status,450 status: input.source.status,
272 enabled: input.source.enabled,451 enabled: input.source.enabled,
273 namespace: input.source.namespace,452 namespace: input.source.namespace,
453 bindingConfigJson: null,
274 transport: input.source.transport,454 transport: input.source.transport,
275 queryParamsJson: serializeStringMap(input.source.queryParams),455 queryParamsJson: serializeStringMap(input.source.queryParams),
276 headersJson: serializeStringMap(input.source.headers),456 headersJson: serializeStringMap(input.source.headers),
@@ -287,17 +467,17 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines
287 return {467 return {
288 sourceRecord,468 sourceRecord,
289 credential: null,469 credential: null,
290 credentialBinding: null,
291 };470 };
292 }471 }
293 472
294 const credentialId = input.existingCredentialId473 const credentialId = input.existingCredentialId
295 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);474 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
296 const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`;
297 475
298 const credential: Credential = {476 const credential: Credential = {
299 id: credentialId,477 id: credentialId,
300 workspaceId: input.source.workspaceId,478 workspaceId: input.source.workspaceId,
479 sourceId: input.source.id,
480 actorAccountId: input.actorAccountId ?? null,
301 authKind: input.source.auth.kind,481 authKind: input.source.auth.kind,
302 authHeaderName: input.source.auth.headerName,482 authHeaderName: input.source.auth.headerName,
303 authPrefix: input.source.auth.prefix,483 authPrefix: input.source.auth.prefix,
@@ -324,20 +504,11 @@ export const splitSourceForStorage = (input: { · 20 unmodified lines
324 return {504 return {
325 sourceRecord,505 sourceRecord,
326 credential,506 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 },
335 };507 };
336};508};
337 509
338export const projectSourceFromStorage = (input: {510export const projectSourceFromStorage = (input: {
339 sourceRecord: StoredSourceRecord;511 sourceRecord: StoredSourceRecord;
340 credentialBinding: SourceCredentialBinding | null;
341 credential: Credential | null;512 credential: Credential | null;
342}): Effect.Effect<Source, Error, never> =>513}): Effect.Effect<Source, Error, never> =>
343 Effect.gen(function* () {514 Effect.gen(function* () {
@@ -355,38 +526,32 @@ export const projectSourceFromStorage = (input: { · 11 unmodified lines
355 );526 );
356 527
357 let auth: SourceAuth = { kind: "none" };528 let auth: SourceAuth = { kind: "none" };
358 if (input.credentialBinding !== null) {529 if (input.credential !== null) {
359 const credential = input.credential;530 if (input.credential.authKind === "bearer") {
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") {
367 auth = {531 auth = {
368 kind: "bearer",532 kind: "bearer",
369 headerName: credential.authHeaderName,533 headerName: input.credential.authHeaderName,
370 prefix: credential.authPrefix,534 prefix: input.credential.authPrefix,
371 token: {535 token: {
372 providerId: credential.tokenProviderId,536 providerId: input.credential.tokenProviderId,
373 handle: credential.tokenHandle,537 handle: input.credential.tokenHandle,
374 },538 },
375 };539 };
376 } else {540 } else {
377 auth = {541 auth = {
378 kind: "oauth2",542 kind: "oauth2",
379 headerName: credential.authHeaderName,543 headerName: input.credential.authHeaderName,
380 prefix: credential.authPrefix,544 prefix: input.credential.authPrefix,
381 accessToken: {545 accessToken: {
382 providerId: credential.tokenProviderId,546 providerId: input.credential.tokenProviderId,
383 handle: credential.tokenHandle,547 handle: input.credential.tokenHandle,
384 },548 },
385 refreshToken:549 refreshToken:
386 credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null550 input.credential.refreshTokenProviderId !== null
551 && input.credential.refreshTokenHandle !== null
387 ? {552 ? {
388 providerId: credential.refreshTokenProviderId,553 providerId: input.credential.refreshTokenProviderId,
389 handle: credential.refreshTokenHandle,554 handle: input.credential.refreshTokenHandle,
390 }555 }
391 : null,556 : null,
392 };557 };
@@ -421,25 +586,20 @@ export const projectSourceFromStorage = (input: { · 28 unmodified lines
421 586
422export const projectSourcesFromStorage = (input: {587export const projectSourcesFromStorage = (input: {
423 sourceRecords: ReadonlyArray<StoredSourceRecord>;588 sourceRecords: ReadonlyArray<StoredSourceRecord>;
424 credentialBindings: ReadonlyArray<SourceCredentialBinding>;
425 credentials: ReadonlyArray<Credential>;589 credentials: ReadonlyArray<Credential>;
426}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {590}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
427 const bindingsBySourceId = new Map(591 const credentialsBySourceId = new Map<string, Credential>();
428 input.credentialBindings.map((binding) => [binding.sourceId, binding]),592
429 );593 for (const credential of input.credentials) {
430 const credentialsById = new Map(594 const existing = credentialsBySourceId.get(credential.sourceId);
431 input.credentials.map((credential) => [credential.id, credential]),595 if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) {
432 );596 credentialsBySourceId.set(credential.sourceId, credential);
597 }
598 }
433 599
434 return Effect.forEach(input.sourceRecords, (sourceRecord) => {600 return Effect.forEach(input.sourceRecords, (sourceRecord) =>
435 const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null;601 projectSourceFromStorage({
436 return projectSourceFromStorage({
437 sourceRecord,602 sourceRecord,
438 credentialBinding,603 credential: credentialsBySourceId.get(sourceRecord.id) ?? null,
439 credential:604 }));
440 credentialBinding === null
441 ? null
442 : credentialsById.get(credentialBinding.credentialId) ?? null,
443 });
444 });
445};605};
packages/control-plane/src/runtime/source-store.test.ts+347−18
@@ -11,11 +11,20 @@ import { · 10 unmodified lines
11} from "#schema";11} from "#schema";
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Scope from "effect/Scope";
14 15
15import { createSqlControlPlanePersistence } from "../persistence";16import {
17 createSqlControlPlanePersistence,
18 type SqlControlPlanePersistence,
19} from "../persistence";
20import {
21 stableSourceRecipeId,
22 stableSourceRecipeRevisionId,
23} from "./source-definitions";
16import { persistSource, removeSourceById } from "./source-store";24import { persistSource, removeSourceById } from "./source-store";
17 25
18const makePersistence = Effect.acquireRelease(26const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
27 Effect.acquireRelease(
19 createSqlControlPlanePersistence({28 createSqlControlPlanePersistence({
20 localDataDir: ":memory:",29 localDataDir: ":memory:",
21 }),30 }),
@@ -24,27 +33,30 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines
24 try: () => persistence.close(),33 try: () => persistence.close(),
25 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),34 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
26 }).pipe(Effect.orDie),35 }).pipe(Effect.orDie),
27);36 );
28 37
29const makeOpenApiSource = (input: {38const makeOpenApiSource = (input: {
30 workspaceId: Source["workspaceId"];39 workspaceId: Source["workspaceId"];
31 sourceId: Source["id"];40 sourceId: Source["id"];
32 now: number;41 now: number;
33 updatedAt?: number;42 updatedAt?: number;
43 name?: string;
44 endpoint?: string;
45 specUrl?: string;
34 auth: Source["auth"];46 auth: Source["auth"];
35}): Source => ({47}): Source => ({
36 id: input.sourceId,48 id: input.sourceId,
37 workspaceId: input.workspaceId,49 workspaceId: input.workspaceId,
38 name: "GitHub",50 name: input.name ?? "GitHub",
39 kind: "openapi",51 kind: "openapi",
40 endpoint: "https://api.github.com",52 endpoint: input.endpoint ?? "https://api.github.com",
41 status: "connected",53 status: "connected",
42 enabled: true,54 enabled: true,
43 namespace: "github",55 namespace: "github",
44 transport: null,56 transport: null,
45 queryParams: null,57 queryParams: null,
46 headers: null,58 headers: null,
47 specUrl: "https://example.com/openapi.json",59 specUrl: input.specUrl ?? "https://example.com/openapi.json",
48 defaultHeaders: null,60 defaultHeaders: null,
49 auth: input.auth,61 auth: input.auth,
50 sourceHash: null,62 sourceHash: null,
@@ -122,21 +134,25 @@ describe("source-store", () => { · 71 unmodified lines
122 id: SourceAuthSessionIdSchema.make("src_auth_source_store"),134 id: SourceAuthSessionIdSchema.make("src_auth_source_store"),
123 workspaceId,135 workspaceId,
124 sourceId,136 sourceId,
137 actorAccountId: accountId,
125 executionId: null,138 executionId: null,
126 interactionId: null,139 interactionId: null,
127 strategy: "oauth2_authorization_code",140 providerKind: "mcp_oauth",
128 status: "pending",141 status: "pending",
129 endpoint: "https://api.github.com",
130 state: "state_source_store",142 state: "state_source_store",
131 redirectUri: "http://127.0.0.1/callback",143 sessionDataJson: JSON.stringify({
132 scope: null,144 kind: "mcp_oauth",
133 resourceMetadataUrl: null,145 endpoint: "https://api.github.com",
134 authorizationServerUrl: null,146 redirectUri: "http://127.0.0.1/callback",
135 resourceMetadataJson: null,147 scope: null,
136 authorizationServerMetadataJson: null,148 resourceMetadataUrl: null,
137 clientInformationJson: null,149 authorizationServerUrl: null,
138 codeVerifier: "verifier",150 resourceMetadataJson: null,
139 authorizationUrl: "https://example.com/auth",151 authorizationServerMetadataJson: null,
152 clientInformationJson: null,
153 codeVerifier: "verifier",
154 authorizationUrl: "https://example.com/auth",
155 }),
140 errorText: null,156 errorText: null,
141 completedAt: null,157 completedAt: null,
142 createdAt: now,158 createdAt: now,
@@ -177,8 +193,321 @@ describe("source-store", () => { · 34 unmodified lines
177 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);193 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);
178 expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);194 expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);
179 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);195 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
180 expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0);
181 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);196 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
182 }),197 }),
183 );198 );
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 );
184});513});
packages/control-plane/src/runtime/source-store.ts+187−66
@@ -1,7 +1,10 @@
1import type {1import type {
2 AccountId,
2 Credential,3 Credential,
3 SecretRef,4 SecretRef,
4 Source,5 Source,
6 SourceRecipeId,
7 SourceRecipeRevisionId,
5 WorkspaceId,8 WorkspaceId,
6} from "#schema";9} from "#schema";
7import { type SqlControlPlaneRows } from "#persistence";10import { type SqlControlPlaneRows } from "#persistence";
@@ -9,8 +12,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line
9import * as Option from "effect/Option";12import * as Option from "effect/Option";
10 13
11import {14import {
15 createSourceRecipeRecord,
16 createSourceRecipeRevisionRecord,
12 projectSourceFromStorage,17 projectSourceFromStorage,
13 projectSourcesFromStorage,18 projectSourcesFromStorage,
19 stableSourceRecipeId,
20 stableSourceRecipeRevisionId,
14 splitSourceForStorage,21 splitSourceForStorage,
15} from "./source-definitions";22} from "./source-definitions";
16import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";23import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
@@ -54,25 +61,57 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 37 unmodified lines
54 );61 );
55 });62 });
56 63
64const 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
78const 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
57export const loadSourcesInWorkspace = (86export const loadSourcesInWorkspace = (
58 rows: SqlControlPlaneRows,87 rows: SqlControlPlaneRows,
59 workspaceId: WorkspaceId,88 workspaceId: WorkspaceId,
89 options: {
90 actorAccountId?: AccountId | null;
91 } = {},
60) =>92) =>
61 Effect.gen(function* () {93 Effect.gen(function* () {
62 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);94 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
63 const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
64 const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId);95 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 });
65 104
66 return yield* projectSourcesFromStorage({105 return yield* projectSourcesFromStorage({
67 sourceRecords,106 sourceRecords,
68 credentialBindings,107 credentials: filteredCredentials,
69 credentials,
70 });108 });
71 });109 });
72 110
73export const loadSourceById = (rows: SqlControlPlaneRows, input: {111export const loadSourceById = (rows: SqlControlPlaneRows, input: {
74 workspaceId: WorkspaceId;112 workspaceId: WorkspaceId;
75 sourceId: Source["id"];113 sourceId: Source["id"];
114 actorAccountId?: AccountId | null;
76}) =>115}) =>
77 Effect.gen(function* () {116 Effect.gen(function* () {
78 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(117 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
@@ -86,53 +125,79 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 7 unmodified lines
86 );125 );
87 }126 }
88 127
89 const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(128 const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({
90 input.workspaceId,129 workspaceId: input.workspaceId,
91 input.sourceId,130 sourceId: input.sourceId,
92 );131 });
93 const credential =132 const credential = selectPreferredCredential({
94 Option.isSome(credentialBinding)133 credentials,
95 ? yield* rows.credentials.getById(credentialBinding.value.credentialId)134 actorAccountId: input.actorAccountId,
96 : Option.none<Credential>();135 });
97 136
98 return yield* projectSourceFromStorage({137 return yield* projectSourceFromStorage({
99 sourceRecord: sourceRecord.value,138 sourceRecord: sourceRecord.value,
100 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,139 credential,
101 credential: Option.isSome(credential) ? credential.value : null,
102 });140 });
103 });141 });
104 142
105export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: {143const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: {
106 workspaceId: WorkspaceId;144 workspaceId: WorkspaceId;
107 sourceId: Source["id"];145 sourceId: Source["id"];
108}) =>146}) =>
109 Effect.gen(function* () {147 Effect.gen(function* () {
110 const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(148 const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
111 input.workspaceId,149 workspaceId: input.workspaceId,
112 input.sourceId,150 sourceId: input.sourceId,
113 );151 });
114
115 if (Option.isNone(existingBinding)) {
116 return false;
117 }
118 152
119 const existingCredential = yield* rows.credentials.getById(existingBinding.value.credentialId);153 yield* rows.credentials.removeByWorkspaceAndSourceId({
154 workspaceId: input.workspaceId,
155 sourceId: input.sourceId,
156 });
120 157
121 yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(158 yield* Effect.forEach(
122 input.workspaceId,159 existingCredentials,
123 input.sourceId,160 (credential) =>
161 cleanupCredentialSecretRefs(rows, {
162 previous: credential,
163 next: null,
164 }),
165 { discard: true },
124 );166 );
125 167
126 if (Option.isSome(existingCredential)) {168 return existingCredentials.length;
127 yield* rows.credentials.removeById(existingBinding.value.credentialId);169 });
170
171const 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);
128 }182 }
129 183
130 yield* cleanupCredentialSecretRefs(rows, {184 const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId);
131 previous: Option.isSome(existingCredential) ? existingCredential.value : null,185 if (recipeReferenceCount > 0) {
132 next: null,186 return;
133 });187 }
134 188
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);
136 });201 });
137 202
138export const removeSourceById = (rows: SqlControlPlaneRows, input: {203export const removeSourceById = (rows: SqlControlPlaneRows, input: {
@@ -140,34 +205,81 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 1 unmodified line
140 sourceId: Source["id"];205 sourceId: Source["id"];
141}) =>206}) =>
142 Effect.gen(function* () {207 Effect.gen(function* () {
208 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
209 if (Option.isNone(sourceRecord)) {
210 return false;
211 }
212
143 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(213 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
144 input.workspaceId,214 input.workspaceId,
145 input.sourceId,215 input.sourceId,
146 );216 );
147 yield* removeCredentialBindingForSource(rows, input);217 yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({
148 return yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);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;
149 });233 });
150 234
151export const persistSource = (rows: SqlControlPlaneRows, source: Source) =>235export const persistSource = (
236 rows: SqlControlPlaneRows,
237 source: Source,
238 options: {
239 actorAccountId?: AccountId | null;
240 } = {},
241) =>
152 Effect.gen(function* () {242 Effect.gen(function* () {
153 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);243 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
154 const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(244 const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
155 source.workspaceId,245 workspaceId: source.workspaceId,
156 source.id,246 sourceId: source.id,
157 );247 });
158 const existingCredential = Option.isSome(existingBinding)248 const existingCredential = selectExactCredential({
159 ? yield* rows.credentials.getById(existingBinding.value.credentialId)249 credentials: existingCredentials,
160 : Option.none<Credential>();250 actorAccountId: options.actorAccountId,
161 const existingCredentialId = Option.isSome(existingBinding)251 });
162 ? existingBinding.value.credentialId252
163 : null;253 const nextRecipeId = stableSourceRecipeId(source);
164 const existingBindingId = Option.isSome(existingBinding)254 const nextRecipeRevisionId = stableSourceRecipeRevisionId(source);
165 ? existingBinding.value.id255 const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);
166 : null;256 const nextRevision = createSourceRecipeRevisionRecord({
167 const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
168 source,257 source,
169 existingCredentialId,258 recipeId: nextRecipeId,
170 existingBindingId,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,
171 });283 });
172 284
173 if (Option.isNone(existing)) {285 if (Option.isNone(existing)) {
@@ -177,30 +289,39 @@ export const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 3 unmodified lines
177 id: _id,289 id: _id,
178 workspaceId: _workspaceId,290 workspaceId: _workspaceId,
179 createdAt: _createdAt,291 createdAt: _createdAt,
180 sourceDocumentText: _sourceDocumentText,
181 ...patch292 ...patch
182 } = sourceRecord;293 } = sourceRecord;
183 yield* rows.sources.update(source.workspaceId, source.id, patch);294 yield* rows.sources.update(source.workspaceId, source.id, patch);
184 }295 }
185 296
186 if (credential === null || credentialBinding === null) {297 yield* rows.sourceRecipes.upsert(nextRecipe);
187 if (Option.isSome(existingBinding)) {298 yield* rows.sourceRecipeRevisions.upsert(nextRevision);
188 yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(299
189 source.workspaceId,300 if (
190 source.id,301 Option.isSome(existing)
191 );302 && (
192 303 existing.value.recipeId !== nextRecipeId
193 if (Option.isSome(existingCredential)) {304 || existing.value.recipeRevisionId !== nextRecipeRevisionId
194 yield* rows.credentials.removeById(existingBinding.value.credentialId);305 )
195 }306 ) {
196 }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 });
197 } else {319 } else {
198 yield* rows.credentials.upsert(credential);320 yield* rows.credentials.upsert(credential);
199 yield* rows.sourceCredentialBindings.upsert(credentialBinding);
200 }321 }
201 322
202 yield* cleanupCredentialSecretRefs(rows, {323 yield* cleanupCredentialSecretRefs(rows, {
203 previous: Option.isSome(existingCredential) ? existingCredential.value : null,324 previous: existingCredential ?? null,
204 next: credential,325 next: credential,
205 });326 });
206 327
packages/control-plane/src/schema/enums.ts+22−0
@@ -20,6 +20,22 @@ export { · 19 unmodified lines
20 type SourceStatus,20 type SourceStatus,
21 type SourceTransport,21 type SourceTransport,
22} from "./models/source";22} from "./models/source";
23export {
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";
23export {39export {
24 SourceAuthInferenceSchema,40 SourceAuthInferenceSchema,
25 SourceDiscoveryAuthKindSchema,41 SourceDiscoveryAuthKindSchema,
@@ -40,6 +56,12 @@ export { · 14 unmodified lines
40 CredentialAuthKindSchema,56 CredentialAuthKindSchema,
41 type CredentialAuthKind,57 type CredentialAuthKind,
42} from "./models/credential";58} from "./models/credential";
59export {
60 SourceAuthSessionProviderKindSchema,
61 SourceAuthSessionStatusSchema,
62 type SourceAuthSessionProviderKind,
63 type SourceAuthSessionStatus,
64} from "./models/source-auth-session";
43export {65export {
44 PolicyApprovalModeSchema,66 PolicyApprovalModeSchema,
45 PolicyEffectSchema,67 PolicyEffectSchema,
packages/control-plane/src/schema/ids.ts+10−0
@@ -7,12 +7,19 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 6 unmodified lines
7);7);
8export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));8export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
9export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));9export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
10export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId"));
11export const SourceRecipeRevisionIdSchema = Schema.String.pipe(
12 Schema.brand("SourceRecipeRevisionId"),
13);
10export const SourceAuthSessionIdSchema = Schema.String.pipe(14export const SourceAuthSessionIdSchema = Schema.String.pipe(
11 Schema.brand("SourceAuthSessionId"),15 Schema.brand("SourceAuthSessionId"),
12);16);
13export const CredentialIdSchema = Schema.String.pipe(17export const CredentialIdSchema = Schema.String.pipe(
14 Schema.brand("CredentialId"),18 Schema.brand("CredentialId"),
15);19);
20export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
21 Schema.brand("WorkspaceSourceOauthClientId"),
22);
16export const SecretMaterialIdSchema = Schema.String.pipe(23export const SecretMaterialIdSchema = Schema.String.pipe(
17 Schema.brand("SecretMaterialId"),24 Schema.brand("SecretMaterialId"),
18);25);
@@ -28,8 +35,11 @@ export type OrganizationId = typeof OrganizationIdSchema.Type; · 9 unmodified lines
28export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;35export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;
29export type WorkspaceId = typeof WorkspaceIdSchema.Type;36export type WorkspaceId = typeof WorkspaceIdSchema.Type;
30export type SourceId = typeof SourceIdSchema.Type;37export type SourceId = typeof SourceIdSchema.Type;
38export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
39export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
31export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;40export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
32export type CredentialId = typeof CredentialIdSchema.Type;41export type CredentialId = typeof CredentialIdSchema.Type;
42export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
33export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;43export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
34export type PolicyId = typeof PolicyIdSchema.Type;44export type PolicyId = typeof PolicyIdSchema.Type;
35export type InstallationId = typeof InstallationIdSchema.Type;45export type InstallationId = typeof InstallationIdSchema.Type;
packages/control-plane/src/schema/index.ts+2−0
@@ -5,7 +5,9 @@ export * from "./ids"; · 4 unmodified lines
5export * from "./models/auth";5export * from "./models/auth";
6export * from "./models/workspace";6export * from "./models/workspace";
7export * from "./models/source";7export * from "./models/source";
8export * from "./models/source-recipe";
8export * from "./models/credential";9export * from "./models/credential";
10export * from "./models/source-oauth-client";
9export * from "./models/tool-artifact";11export * from "./models/tool-artifact";
10export * from "./models/secret-material";12export * from "./models/secret-material";
11export * from "./models/source-auth-session";13export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/credential.ts+4−19
@@ -1,12 +1,10 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";2import { Schema } from "effect";
3 3
4import {4import { credentialsTable } from "../../persistence/schema";
5 credentialsTable,
6 sourceCredentialBindingsTable,
7} from "../../persistence/schema";
8import { TimestampMsSchema } from "../common";5import { TimestampMsSchema } from "../common";
9import {6import {
7 AccountIdSchema,
10 CredentialIdSchema,8 CredentialIdSchema,
11 SourceIdSchema,9 SourceIdSchema,
12 WorkspaceIdSchema,10 WorkspaceIdSchema,
@@ -17,6 +15,8 @@ export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); · 4 unmodified lines
17const credentialSchemaOverrides = {15const credentialSchemaOverrides = {
18 id: CredentialIdSchema,16 id: CredentialIdSchema,
19 workspaceId: WorkspaceIdSchema,17 workspaceId: WorkspaceIdSchema,
18 sourceId: SourceIdSchema,
19 actorAccountId: Schema.NullOr(AccountIdSchema),
20 authKind: CredentialAuthKindSchema,20 authKind: CredentialAuthKindSchema,
21 authHeaderName: Schema.String,21 authHeaderName: Schema.String,
22 authPrefix: Schema.String,22 authPrefix: Schema.String,
@@ -28,25 +28,10 @@ const credentialSchemaOverrides = { · 5 unmodified lines
28 updatedAt: TimestampMsSchema,28 updatedAt: TimestampMsSchema,
29} as const;29} as const;
30 30
31const sourceCredentialBindingSchemaOverrides = {
32 id: Schema.String,
33 workspaceId: WorkspaceIdSchema,
34 sourceId: SourceIdSchema,
35 credentialId: CredentialIdSchema,
36 createdAt: TimestampMsSchema,
37 updatedAt: TimestampMsSchema,
38} as const;
39
40export const CredentialSchema = createSelectSchema(31export const CredentialSchema = createSelectSchema(
41 credentialsTable,32 credentialsTable,
42 credentialSchemaOverrides,33 credentialSchemaOverrides,
43);34);
44 35
45export const SourceCredentialBindingSchema = createSelectSchema(
46 sourceCredentialBindingsTable,
47 sourceCredentialBindingSchemaOverrides,
48);
49
50export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;36export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
51export type Credential = typeof CredentialSchema.Type;37export type Credential = typeof CredentialSchema.Type;
52export 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
4import { sourceAuthSessionsTable } from "../../persistence/schema";4import { sourceAuthSessionsTable } from "../../persistence/schema";
5import { TimestampMsSchema } from "../common";5import { TimestampMsSchema } from "../common";
6import {6import {
7 AccountIdSchema,
7 ExecutionIdSchema,8 ExecutionIdSchema,
8 ExecutionInteractionIdSchema,9 ExecutionInteractionIdSchema,
9 SourceAuthSessionIdSchema,10 SourceAuthSessionIdSchema,
@@ -11,8 +12,9 @@ import { · 1 unmodified line
11 WorkspaceIdSchema,12 WorkspaceIdSchema,
12} from "../ids";13} from "../ids";
13 14
14export const SourceAuthSessionStrategySchema = Schema.Literal(15export const SourceAuthSessionProviderKindSchema = Schema.Literal(
15 "oauth2_authorization_code",16 "mcp_oauth",
17 "oauth2_pkce",
16);18);
17 19
18export const SourceAuthSessionStatusSchema = Schema.Literal(20export const SourceAuthSessionStatusSchema = Schema.Literal(
@@ -26,9 +28,10 @@ const sourceAuthSessionSchemaOverrides = { · 7 unmodified lines
26 id: SourceAuthSessionIdSchema,28 id: SourceAuthSessionIdSchema,
27 workspaceId: WorkspaceIdSchema,29 workspaceId: WorkspaceIdSchema,
28 sourceId: SourceIdSchema,30 sourceId: SourceIdSchema,
31 actorAccountId: Schema.NullOr(AccountIdSchema),
29 executionId: Schema.NullOr(ExecutionIdSchema),32 executionId: Schema.NullOr(ExecutionIdSchema),
30 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),33 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),
31 strategy: SourceAuthSessionStrategySchema,34 providerKind: SourceAuthSessionProviderKindSchema,
32 status: SourceAuthSessionStatusSchema,35 status: SourceAuthSessionStatusSchema,
33 completedAt: Schema.NullOr(TimestampMsSchema),36 completedAt: Schema.NullOr(TimestampMsSchema),
34 createdAt: TimestampMsSchema,37 createdAt: TimestampMsSchema,
@@ -40,6 +43,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 5 unmodified lines
40 sourceAuthSessionSchemaOverrides,43 sourceAuthSessionSchemaOverrides,
41);44);
42 45
43export type SourceAuthSessionStrategy = typeof SourceAuthSessionStrategySchema.Type;46export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type;
44export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;47export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;
45export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;48export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;
packages/control-plane/src/schema/models/source-oauth-client.ts+23−0
@@ -0,0 +1,23 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import { workspaceSourceOauthClientsTable } from "../../persistence/schema";
5import { TimestampMsSchema } from "../common";
6import {
7 SourceIdSchema,
8 WorkspaceIdSchema,
9 WorkspaceSourceOauthClientIdSchema,
10} from "../ids";
11
12export 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
23export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type;
packages/control-plane/src/schema/models/source-recipe.ts+140−0
@@ -0,0 +1,140 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import {
5 sourceRecipeDocumentsTable,
6 sourceRecipeOperationsTable,
7 sourceRecipeRevisionsTable,
8 sourceRecipesTable,
9} from "../../persistence/schema";
10import { TimestampMsSchema } from "../common";
11import {
12 SourceRecipeIdSchema,
13 SourceRecipeRevisionIdSchema,
14} from "../ids";
15
16export const SourceRecipeKindSchema = Schema.Literal(
17 "http_recipe",
18 "graphql_recipe",
19 "mcp_recipe",
20 "internal_recipe",
21);
22
23export 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
33export const SourceRecipeVisibilitySchema = Schema.Literal(
34 "private",
35 "workspace",
36 "organization",
37 "public",
38);
39
40export const SourceRecipeDocumentKindSchema = Schema.Literal(
41 "google_discovery",
42 "openapi",
43 "postman_collection",
44 "postman_environment",
45 "graphql_introspection",
46 "mcp_manifest",
47);
48
49export const SourceRecipeTransportKindSchema = Schema.Literal(
50 "http",
51 "graphql",
52 "mcp",
53 "internal",
54);
55
56export const SourceRecipeOperationKindSchema = Schema.Literal(
57 "read",
58 "write",
59 "delete",
60 "unknown",
61);
62
63export const SourceRecipeOperationProviderKindSchema = Schema.Literal(
64 "mcp",
65 "openapi",
66 "graphql",
67 "internal",
68);
69
70const 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
80const recipeRevisionRowSchemaOverrides = {
81 id: SourceRecipeRevisionIdSchema,
82 recipeId: SourceRecipeIdSchema,
83 revisionNumber: Schema.Number,
84 createdAt: TimestampMsSchema,
85 updatedAt: TimestampMsSchema,
86} as const;
87
88const recipeDocumentRowSchemaOverrides = {
89 recipeRevisionId: SourceRecipeRevisionIdSchema,
90 documentKind: SourceRecipeDocumentKindSchema,
91 fetchedAt: Schema.NullOr(TimestampMsSchema),
92 createdAt: TimestampMsSchema,
93 updatedAt: TimestampMsSchema,
94} as const;
95
96const 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
108export const StoredSourceRecipeRecordSchema = createSelectSchema(
109 sourceRecipesTable,
110 recipeRowSchemaOverrides,
111);
112
113export const StoredSourceRecipeRevisionRecordSchema = createSelectSchema(
114 sourceRecipeRevisionsTable,
115 recipeRevisionRowSchemaOverrides,
116);
117
118export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema(
119 sourceRecipeDocumentsTable,
120 recipeDocumentRowSchemaOverrides,
121);
122
123export const StoredSourceRecipeOperationRecordSchema = createSelectSchema(
124 sourceRecipeOperationsTable,
125 recipeOperationRowSchemaOverrides,
126);
127
128export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type;
129export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type;
130export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type;
131export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type;
132export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type;
133export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type;
134export type SourceRecipeOperationProviderKind =
135 typeof SourceRecipeOperationProviderKindSchema.Type;
136export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type;
137export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type;
138export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type;
139export 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
5 5
6import { sourcesTable } from "../../persistence/schema";6import { sourcesTable } from "../../persistence/schema";
7import { TimestampMsSchema } from "../common";7import { TimestampMsSchema } from "../common";
8import { SourceIdSchema, WorkspaceIdSchema } from "../ids";8import {
9 SourceIdSchema,
10 SourceRecipeIdSchema,
11 SourceRecipeRevisionIdSchema,
12 WorkspaceIdSchema,
13} from "../ids";
9 14
10export const SourceKindSchema = Schema.Literal(15export const SourceKindSchema = Schema.Literal(
11 "mcp",16 "mcp",
@@ -60,6 +65,8 @@ export const StringMapSchema = Schema.Record({ · 48 unmodified lines
60const sourceRowSchemaOverrides = {65const sourceRowSchemaOverrides = {
61 workspaceId: WorkspaceIdSchema,66 workspaceId: WorkspaceIdSchema,
62 sourceId: SourceIdSchema,67 sourceId: SourceIdSchema,
68 recipeId: SourceRecipeIdSchema,
69 recipeRevisionId: SourceRecipeRevisionIdSchema,
63 kind: SourceKindSchema,70 kind: SourceKindSchema,
64 status: SourceStatusSchema,71 status: SourceStatusSchema,
65 transport: Schema.NullOr(SourceTransportSchema),72 transport: Schema.NullOr(SourceTransportSchema),
@@ -74,12 +81,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines
74 Schema.Struct({81 Schema.Struct({
75 id: SourceIdSchema,82 id: SourceIdSchema,
76 workspaceId: WorkspaceIdSchema,83 workspaceId: WorkspaceIdSchema,
84 recipeId: SourceRecipeIdSchema,
85 recipeRevisionId: SourceRecipeRevisionIdSchema,
77 name: Schema.String,86 name: Schema.String,
78 kind: SourceKindSchema,87 kind: SourceKindSchema,
79 endpoint: Schema.String,88 endpoint: Schema.String,
80 status: SourceStatusSchema,89 status: SourceStatusSchema,
81 enabled: Schema.Boolean,90 enabled: Schema.Boolean,
82 namespace: Schema.NullOr(Schema.String),91 namespace: Schema.NullOr(Schema.String),
92 bindingConfigJson: Schema.NullOr(Schema.String),
83 transport: Schema.NullOr(SourceTransportSchema),93 transport: Schema.NullOr(SourceTransportSchema),
84 queryParamsJson: Schema.NullOr(Schema.String),94 queryParamsJson: Schema.NullOr(Schema.String),
85 headersJson: Schema.NullOr(Schema.String),95 headersJson: Schema.NullOr(Schema.String),
@@ -96,12 +106,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 10 unmodified lines
96 decode: (row, _input) => ({106 decode: (row, _input) => ({
97 id: row.sourceId,107 id: row.sourceId,
98 workspaceId: row.workspaceId,108 workspaceId: row.workspaceId,
109 recipeId: row.recipeId,
110 recipeRevisionId: row.recipeRevisionId,
99 name: row.name,111 name: row.name,
100 kind: row.kind,112 kind: row.kind,
101 endpoint: row.endpoint,113 endpoint: row.endpoint,
102 status: row.status,114 status: row.status,
103 enabled: row.enabled,115 enabled: row.enabled,
104 namespace: row.namespace,116 namespace: row.namespace,
117 bindingConfigJson: row.bindingConfigJson,
105 transport: row.transport,118 transport: row.transport,
106 queryParamsJson: row.queryParamsJson,119 queryParamsJson: row.queryParamsJson,
107 headersJson: row.headersJson,120 headersJson: row.headersJson,
@@ -116,12 +129,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines
116 encode: (source, _output) => ({129 encode: (source, _output) => ({
117 workspaceId: source.workspaceId,130 workspaceId: source.workspaceId,
118 sourceId: source.id,131 sourceId: source.id,
132 recipeId: source.recipeId,
133 recipeRevisionId: source.recipeRevisionId,
119 name: source.name,134 name: source.name,
120 kind: source.kind,135 kind: source.kind,
121 endpoint: source.endpoint,136 endpoint: source.endpoint,
122 status: source.status,137 status: source.status,
123 enabled: source.enabled,138 enabled: source.enabled,
124 namespace: source.namespace,139 namespace: source.namespace,
140 bindingConfigJson: source.bindingConfigJson,
125 transport: source.transport,141 transport: source.transport,
126 queryParamsJson: source.queryParamsJson,142 queryParamsJson: source.queryParamsJson,
127 headersJson: source.headersJson,143 headersJson: source.headersJson,