History

Normalize plugin secret/connection refs + add usages SDK surface (#521)

* Add secret/connection usages SDK surface + UI placeholder Plugins gain optional `usagesForSecret` / `usagesForConnection` callbacks that the executor fans out across via `executor.secrets.usages(id)` and `executor.connections.usages(id)`. Secret/connection removal is now RESTRICT — refuses with `SecretInUseError` / `ConnectionInUseError` when any plugin reports the id as in use, surfacing the count to the caller. API gains GET `/secrets/:id/usages` and `/connections/:id/usages` endpoints. React atoms and a small "Used by …" footer on each row in the Secrets and Connections tabs round out the surface. No plugin implements `usagesForSecret`/`usagesForConnection` yet, so the footer renders nothing and remove still succeeds for everything. Plugin migration lands in follow-up commits. * Normalize graphql plugin secret/connection refs Move secret/connection references out of the JSON columns on graphql_source (auth, headers, query_params) into: - two new flat columns auth_kind + auth_connection_id (indexed) - two child tables graphql_source_header / graphql_source_query_param, each with kind/text_value/secret_id/secret_prefix and an indexed secret_id This lets usagesForSecret and usagesForConnection answer "where is X referenced?" with a single indexed SELECT instead of scanning JSON, and secrets.remove / connections.remove now correctly RESTRICT when a graphql source still references the id. Migration 0007 backfills existing JSON via json_each + json_extract, then drops the old columns. Three tests cover the backfill against a hand-seeded pre-migration DB; three plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize openapi plugin secret/connection refs Move secret/connection references out of the JSON columns on openapi_source and openapi_source_binding into: - openapi_source_binding gains kind/secret_id/connection_id/text_value columns (replacing the discriminated-union json value) - openapi_source_query_param: child table for query_params, mirrors graphql_source_query_param shape - openapi_source_spec_fetch_header / spec_fetch_query_param: child tables for invocation_config.specFetchCredentials.{headers,queryParams} Drops openapi_source.query_params and openapi_source.invocation_config JSON columns. headers and oauth2 stay JSON because they hold slot names rather than direct refs — the slots resolve through bindings, which ARE normalized. Plugin gains usagesForSecret / usagesForConnection that fan out across all four locations in one indexed SELECT each. secrets.remove now correctly RESTRICTs when an openapi binding still uses the secret. Migration 0008 backfills via json_extract / json_each, then drops the old columns. Three migration tests cover the backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize mcp plugin secret/connection refs Move secret/connection references out of the JSON `mcp_source.config` column into: - Flat columns on mcp_source: auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id (each indexed where it carries a ref). Replaces the McpConnectionAuth json blob. - mcp_source_header / mcp_source_query_param: child tables for the remote source's SecretBackedMap entries (same shape as graphql / openapi child tables). The remaining structural fields (transport, endpoint, command, args, etc.) stay as JSON in `config` because they're plugin-private and vary by transport. mcp_binding.binding stays JSON too — McpToolBinding carries no refs and inputSchema/outputSchema are arbitrary user JSON. Plugin gains usagesForSecret / usagesForConnection. Migration 0009 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out across header-auth and oauth2 sources. * Normalize google-discovery plugin secret/connection refs Move secret/connection references out of the JSON google_discovery_source.config column into: - Flat columns on google_discovery_source: auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes (each indexed where it carries a ref). Replaces the GoogleDiscoveryAuth json. - google_discovery_source_credential_header / google_discovery_source_credential_query_param: child tables for the optional GoogleDiscoveryFetchCredentials SecretBackedMap entries. The remaining structural fields (discoveryUrl, service, version, rootUrl, servicePath) stay in `config` as JSON — they're plugin-private and never carry refs. google_discovery_binding stays JSON too: the GoogleDiscoveryMethodBinding has no refs, and method input/output schemas are arbitrary user data. Plugin gains usagesForSecret / usagesForConnection. Migration 0010 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; one plugin-level test covers usage fan-out across auth_*_secret_id and auth_connection_id. * Consolidate plugin normalization into single migration Merge graphql/openapi/mcp/google-discovery normalization into one migration per app: 0007_normalize_plugin_secret_refs (local sqlite) and 0008_normalize_plugin_secret_refs (cloud pg, mcp-only since cloud doesn't ship google-discovery). Each test now imports a shared PRE_0007_SQL helper that seeds all four plugins' parent tables at the pre-migration shape — needed because the single consolidated migration touches every plugin in one transaction. * Address review: legacy openapi OAuth + strict usage gate + shadow-aware delete Three issues from review: 1. migrateOpenApi was early-returning when invocation_config column was missing (post-0007). Rows with legacy OAuth in oauth2 column silently lost their migration to a Connection. Fixed: - migrate-connections now reads oauth2 column even without invocation_config, and adapts its UPDATE statement accordingly. - Both 0007 (sqlite) and 0008 (pg) migrations COALESCE invocation_config.oauth2 into the oauth2 column before dropping invocation_config, so legacy data isn't lost mid-migration. 2. secrets.remove / connections.remove gated on a lenient usage helper that swallowed plugin failures into empty arrays — a transient plugin error could silently allow a delete that creates dangling refs. Added secretsUsagesStrict / connectionsUsagesStrict that propagate plugin failures as StorageFailure and wired them into the delete RESTRICT path. Display path keeps the lenient variant. 3. The RESTRICT check was global by id, conflicting with the documented shadowing model: deleting a user-scope override should reveal the outer fallback, but the global check blocked any delete whose id is referenced anywhere. Now scoped: only enforce RESTRICT when the delete would leave no row with that id (i.e., the reference would actually dangle).

Rhys Sullivan committed 6 months ago

Commit a2e98351 parent ea306d041 files · +10327 −211Browse files

Unified · Split

41 files
  1. apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql +388 −0
  2. apps/cloud/drizzle/meta/0008_snapshot.json +2594 −0
  3. apps/cloud/drizzle/meta/_journal.json +8 −1
  4. apps/cloud/src/services/executor-schema.ts +133 −7
  5. apps/local/drizzle/0007_normalize_plugin_secret_refs.sql +579 −0
  6. apps/local/drizzle/meta/0007_snapshot.json +2422 −0
  7. apps/local/drizzle/meta/_journal.json +7 −0
  8. apps/local/src/server/__test-helpers__/pre-0007-schema.ts +107 −0
  9. apps/local/src/server/executor-schema.ts +173 −15
  10. apps/local/src/server/migrate-connections.test.ts +96 −6
  11. apps/local/src/server/migrate-connections.ts +70 −17
  12. apps/local/src/server/migrate-google-discovery-bindings.test.ts +204 −0
  13. apps/local/src/server/migrate-graphql-bindings.test.ts +246 −0
  14. apps/local/src/server/migrate-mcp-bindings.test.ts +203 −0
  15. apps/local/src/server/migrate-openapi-bindings.test.ts +251 −0
  16. bun.lock +1 −1
  17. packages/core/api/src/connections/api.ts +16 −1
  18. packages/core/api/src/handlers/connections.ts +8 −0
  19. packages/core/api/src/handlers/secrets.ts +6 −0
  20. packages/core/api/src/secrets/api.ts +11 −1
  21. packages/core/execution/src/promise.ts +2 −0
  22. packages/core/sdk/src/errors.ts +28 −1
  23. packages/core/sdk/src/executor.ts +191 −6
  24. packages/core/sdk/src/index.ts +9 −0
  25. packages/core/sdk/src/plugin.ts +36 −3
  26. packages/core/sdk/src/usages.ts +42 −0
  27. packages/plugins/google-discovery/src/sdk/binding-store.ts +441 −15
  28. packages/plugins/google-discovery/src/sdk/plugin.test.ts +78 −0
  29. packages/plugins/google-discovery/src/sdk/plugin.ts +67 −0
  30. packages/plugins/graphql/src/sdk/plugin.test.ts +123 −0
  31. packages/plugins/graphql/src/sdk/plugin.ts +78 −0
  32. packages/plugins/graphql/src/sdk/store.ts +364 −58
  33. packages/plugins/mcp/src/sdk/binding-store.ts +481 −7
  34. packages/plugins/mcp/src/sdk/plugin.test.ts +76 −0
  35. packages/plugins/mcp/src/sdk/plugin.ts +67 −0
  36. packages/plugins/openapi/src/sdk/plugin.test.ts +105 −1
  37. packages/plugins/openapi/src/sdk/plugin.ts +77 −0
  38. packages/plugins/openapi/src/sdk/store.ts +417 −65
  39. packages/react/src/api/atoms.tsx +35 −1
  40. packages/react/src/pages/connections.tsx +45 −3
  41. packages/react/src/pages/secrets.tsx +42 −2
apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql+388−0
@@ -0,0 +1,388 @@
1+-- Normalize all plugin secret/connection refs out of JSON columns
2+-- into proper relational shape: graphql, openapi, mcp.
3+-- pg port of apps/local/drizzle/0007_normalize_plugin_secret_refs.sql.
4+-- (google-discovery is local-only — not in cloud's plugin list.)
5+
6+-- ============================================================
7+-- graphql
8+-- ============================================================
9+
10+-- Normalize graphql plugin: move secret/connection refs out of JSON
11+-- columns into proper relational shape so usagesForSecret /
12+-- usagesForConnection are one indexed SELECT instead of a JSON scan.
13+-- pg port of apps/local/drizzle/0007_normalize_graphql.sql.
14+
15+CREATE TABLE "graphql_source_header" (
16+ "id" text NOT NULL,
17+ "scope_id" text NOT NULL,
18+ "source_id" text NOT NULL,
19+ "name" text NOT NULL,
20+ "kind" text NOT NULL,
21+ "text_value" text,
22+ "secret_id" text,
23+ "secret_prefix" text,
24+ CONSTRAINT "graphql_source_header_scope_id_id_pk" PRIMARY KEY("scope_id","id")
25+);
26+--> statement-breakpoint
27+CREATE INDEX "graphql_source_header_scope_id_idx" ON "graphql_source_header" USING btree ("scope_id");--> statement-breakpoint
28+CREATE INDEX "graphql_source_header_source_id_idx" ON "graphql_source_header" USING btree ("source_id");--> statement-breakpoint
29+CREATE INDEX "graphql_source_header_secret_id_idx" ON "graphql_source_header" USING btree ("secret_id");--> statement-breakpoint
30+
31+CREATE TABLE "graphql_source_query_param" (
32+ "id" text NOT NULL,
33+ "scope_id" text NOT NULL,
34+ "source_id" text NOT NULL,
35+ "name" text NOT NULL,
36+ "kind" text NOT NULL,
37+ "text_value" text,
38+ "secret_id" text,
39+ "secret_prefix" text,
40+ CONSTRAINT "graphql_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id")
41+);
42+--> statement-breakpoint
43+CREATE INDEX "graphql_source_query_param_scope_id_idx" ON "graphql_source_query_param" USING btree ("scope_id");--> statement-breakpoint
44+CREATE INDEX "graphql_source_query_param_source_id_idx" ON "graphql_source_query_param" USING btree ("source_id");--> statement-breakpoint
45+CREATE INDEX "graphql_source_query_param_secret_id_idx" ON "graphql_source_query_param" USING btree ("secret_id");--> statement-breakpoint
46+
47+-- New auth columns. `auth_kind` defaults to "none" so existing rows that
48+-- predate this migration are valid even if the json was null.
49+ALTER TABLE "graphql_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
50+ALTER TABLE "graphql_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint
51+CREATE INDEX "graphql_source_auth_connection_id_idx" ON "graphql_source" USING btree ("auth_connection_id");--> statement-breakpoint
52+
53+-- Backfill auth from the JSON column. Missing keys yield NULL, so a row
54+-- with auth=NULL or kind="none" leaves auth_connection_id NULL and
55+-- auth_kind defaulted to "none".
56+UPDATE "graphql_source"
57+SET
58+ "auth_kind" = COALESCE("auth"->>'kind', 'none'),
59+ "auth_connection_id" = "auth"->>'connectionId'
60+WHERE "auth" IS NOT NULL;--> statement-breakpoint
61+
62+-- Backfill headers. For each (source, header_name) pair: if the value
63+-- is a json object with .secretId, write a kind=secret row; otherwise
64+-- write a kind=text row with the literal string. jsonb_each iterates
65+-- the keys of the headers object.
66+INSERT INTO "graphql_source_header"
67+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
68+SELECT
69+ s."scope_id",
70+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']',
71+ s."id",
72+ h.key,
73+ CASE
74+ WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret'
75+ ELSE 'text'
76+ END,
77+ CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END,
78+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END,
79+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END
80+FROM "graphql_source" s, jsonb_each(s."headers") h
81+WHERE s."headers" IS NOT NULL
82+ON CONFLICT DO NOTHING;--> statement-breakpoint
83+
84+-- Same for query_params.
85+INSERT INTO "graphql_source_query_param"
86+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
87+SELECT
88+ s."scope_id",
89+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']',
90+ s."id",
91+ q.key,
92+ CASE
93+ WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret'
94+ ELSE 'text'
95+ END,
96+ CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END,
97+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END,
98+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END
99+FROM "graphql_source" s, jsonb_each(s."query_params") q
100+WHERE s."query_params" IS NOT NULL
101+ON CONFLICT DO NOTHING;--> statement-breakpoint
102+
103+ALTER TABLE "graphql_source" DROP COLUMN "headers";--> statement-breakpoint
104+ALTER TABLE "graphql_source" DROP COLUMN "query_params";--> statement-breakpoint
105+ALTER TABLE "graphql_source" DROP COLUMN "auth";
106+
107+--> statement-breakpoint
108+
109+-- ============================================================
110+-- openapi
111+-- ============================================================
112+
113+-- Normalize openapi plugin: move every direct secret/connection ref out
114+-- of JSON columns into proper relational shape. pg port of
115+-- apps/local/drizzle/0008_normalize_openapi.sql.
116+
117+CREATE TABLE "openapi_source_query_param" (
118+ "id" text NOT NULL,
119+ "scope_id" text NOT NULL,
120+ "source_id" text NOT NULL,
121+ "name" text NOT NULL,
122+ "kind" text NOT NULL,
123+ "text_value" text,
124+ "secret_id" text,
125+ "secret_prefix" text,
126+ CONSTRAINT "openapi_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id")
127+);
128+--> statement-breakpoint
129+CREATE INDEX "openapi_source_query_param_scope_id_idx" ON "openapi_source_query_param" USING btree ("scope_id");--> statement-breakpoint
130+CREATE INDEX "openapi_source_query_param_source_id_idx" ON "openapi_source_query_param" USING btree ("source_id");--> statement-breakpoint
131+CREATE INDEX "openapi_source_query_param_secret_id_idx" ON "openapi_source_query_param" USING btree ("secret_id");--> statement-breakpoint
132+
133+CREATE TABLE "openapi_source_spec_fetch_header" (
134+ "id" text NOT NULL,
135+ "scope_id" text NOT NULL,
136+ "source_id" text NOT NULL,
137+ "name" text NOT NULL,
138+ "kind" text NOT NULL,
139+ "text_value" text,
140+ "secret_id" text,
141+ "secret_prefix" text,
142+ CONSTRAINT "openapi_source_spec_fetch_header_scope_id_id_pk" PRIMARY KEY("scope_id","id")
143+);
144+--> statement-breakpoint
145+CREATE INDEX "openapi_source_spec_fetch_header_scope_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("scope_id");--> statement-breakpoint
146+CREATE INDEX "openapi_source_spec_fetch_header_source_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("source_id");--> statement-breakpoint
147+CREATE INDEX "openapi_source_spec_fetch_header_secret_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("secret_id");--> statement-breakpoint
148+
149+CREATE TABLE "openapi_source_spec_fetch_query_param" (
150+ "id" text NOT NULL,
151+ "scope_id" text NOT NULL,
152+ "source_id" text NOT NULL,
153+ "name" text NOT NULL,
154+ "kind" text NOT NULL,
155+ "text_value" text,
156+ "secret_id" text,
157+ "secret_prefix" text,
158+ CONSTRAINT "openapi_source_spec_fetch_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id")
159+);
160+--> statement-breakpoint
161+CREATE INDEX "openapi_source_spec_fetch_query_param_scope_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("scope_id");--> statement-breakpoint
162+CREATE INDEX "openapi_source_spec_fetch_query_param_source_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("source_id");--> statement-breakpoint
163+CREATE INDEX "openapi_source_spec_fetch_query_param_secret_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("secret_id");--> statement-breakpoint
164+
165+-- New columns on openapi_source_binding to flatten the value json.
166+-- `kind` defaults to 'text' so the ALTER works on existing rows; the
167+-- backfill below stamps the real value.
168+ALTER TABLE "openapi_source_binding" ADD COLUMN "kind" text DEFAULT 'text' NOT NULL;--> statement-breakpoint
169+ALTER TABLE "openapi_source_binding" ADD COLUMN "secret_id" text;--> statement-breakpoint
170+ALTER TABLE "openapi_source_binding" ADD COLUMN "connection_id" text;--> statement-breakpoint
171+ALTER TABLE "openapi_source_binding" ADD COLUMN "text_value" text;--> statement-breakpoint
172+CREATE INDEX "openapi_source_binding_secret_id_idx" ON "openapi_source_binding" USING btree ("secret_id");--> statement-breakpoint
173+CREATE INDEX "openapi_source_binding_connection_id_idx" ON "openapi_source_binding" USING btree ("connection_id");--> statement-breakpoint
174+
175+UPDATE "openapi_source_binding"
176+SET
177+ "kind" = COALESCE("value"->>'kind', 'text'),
178+ "secret_id" = CASE WHEN "value"->>'kind' = 'secret' THEN "value"->>'secretId' ELSE NULL END,
179+ "connection_id" = CASE WHEN "value"->>'kind' = 'connection' THEN "value"->>'connectionId' ELSE NULL END,
180+ "text_value" = CASE WHEN "value"->>'kind' = 'text' THEN "value"->>'text' ELSE NULL END
181+WHERE "value" IS NOT NULL;--> statement-breakpoint
182+
183+INSERT INTO "openapi_source_query_param"
184+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
185+SELECT
186+ s."scope_id",
187+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']',
188+ s."id",
189+ q.key,
190+ CASE
191+ WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret'
192+ ELSE 'text'
193+ END,
194+ CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END,
195+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END,
196+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END
197+FROM "openapi_source" s, jsonb_each(s."query_params") q
198+WHERE s."query_params" IS NOT NULL
199+ON CONFLICT DO NOTHING;--> statement-breakpoint
200+
201+INSERT INTO "openapi_source_spec_fetch_header"
202+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
203+SELECT
204+ s."scope_id",
205+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']',
206+ s."id",
207+ h.key,
208+ CASE
209+ WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret'
210+ ELSE 'text'
211+ END,
212+ CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END,
213+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END,
214+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END
215+FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'headers') h
216+WHERE s."invocation_config"->'specFetchCredentials'->'headers' IS NOT NULL
217+ON CONFLICT DO NOTHING;--> statement-breakpoint
218+
219+INSERT INTO "openapi_source_spec_fetch_query_param"
220+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
221+SELECT
222+ s."scope_id",
223+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']',
224+ s."id",
225+ q.key,
226+ CASE
227+ WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret'
228+ ELSE 'text'
229+ END,
230+ CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END,
231+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END,
232+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END
233+FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'queryParams') q
234+WHERE s."invocation_config"->'specFetchCredentials'->'queryParams' IS NOT NULL
235+ON CONFLICT DO NOTHING;--> statement-breakpoint
236+
237+-- Preserve any legacy OAuth payload from invocation_config.oauth2 into
238+-- the still-existing oauth2 column before we drop invocation_config.
239+-- migrateLegacyConnections runs after drizzle migrations and reads
240+-- oauth2 to detect the legacy shape; without this, rows that only had
241+-- their OAuth payload under invocation_config.oauth2 would lose it.
242+UPDATE "openapi_source"
243+SET "oauth2" = "invocation_config"->'oauth2'
244+WHERE "oauth2" IS NULL
245+ AND "invocation_config"->'oauth2' IS NOT NULL;--> statement-breakpoint
246+
247+ALTER TABLE "openapi_source_binding" DROP COLUMN "value";--> statement-breakpoint
248+ALTER TABLE "openapi_source" DROP COLUMN "query_params";--> statement-breakpoint
249+ALTER TABLE "openapi_source" DROP COLUMN "invocation_config";
250+
251+--> statement-breakpoint
252+
253+-- ============================================================
254+-- mcp
255+-- ============================================================
256+
257+-- Normalize mcp plugin: lift the McpConnectionAuth secret/connection
258+-- refs and the SecretBackedMap headers/query_params out of
259+-- mcp_source.config JSON into proper columns / child tables. pg port
260+-- of apps/local/drizzle/0009_normalize_mcp.sql.
261+
262+CREATE TABLE "mcp_source_header" (
263+ "id" text NOT NULL,
264+ "scope_id" text NOT NULL,
265+ "source_id" text NOT NULL,
266+ "name" text NOT NULL,
267+ "kind" text NOT NULL,
268+ "text_value" text,
269+ "secret_id" text,
270+ "secret_prefix" text,
271+ CONSTRAINT "mcp_source_header_scope_id_id_pk" PRIMARY KEY("scope_id","id")
272+);
273+--> statement-breakpoint
274+CREATE INDEX "mcp_source_header_scope_id_idx" ON "mcp_source_header" USING btree ("scope_id");--> statement-breakpoint
275+CREATE INDEX "mcp_source_header_source_id_idx" ON "mcp_source_header" USING btree ("source_id");--> statement-breakpoint
276+CREATE INDEX "mcp_source_header_secret_id_idx" ON "mcp_source_header" USING btree ("secret_id");--> statement-breakpoint
277+
278+CREATE TABLE "mcp_source_query_param" (
279+ "id" text NOT NULL,
280+ "scope_id" text NOT NULL,
281+ "source_id" text NOT NULL,
282+ "name" text NOT NULL,
283+ "kind" text NOT NULL,
284+ "text_value" text,
285+ "secret_id" text,
286+ "secret_prefix" text,
287+ CONSTRAINT "mcp_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id")
288+);
289+--> statement-breakpoint
290+CREATE INDEX "mcp_source_query_param_scope_id_idx" ON "mcp_source_query_param" USING btree ("scope_id");--> statement-breakpoint
291+CREATE INDEX "mcp_source_query_param_source_id_idx" ON "mcp_source_query_param" USING btree ("source_id");--> statement-breakpoint
292+CREATE INDEX "mcp_source_query_param_secret_id_idx" ON "mcp_source_query_param" USING btree ("secret_id");--> statement-breakpoint
293+
294+ALTER TABLE "mcp_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
295+ALTER TABLE "mcp_source" ADD COLUMN "auth_header_name" text;--> statement-breakpoint
296+ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_id" text;--> statement-breakpoint
297+ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_prefix" text;--> statement-breakpoint
298+ALTER TABLE "mcp_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint
299+ALTER TABLE "mcp_source" ADD COLUMN "auth_client_id_secret_id" text;--> statement-breakpoint
300+ALTER TABLE "mcp_source" ADD COLUMN "auth_client_secret_secret_id" text;--> statement-breakpoint
301+CREATE INDEX "mcp_source_auth_secret_id_idx" ON "mcp_source" USING btree ("auth_secret_id");--> statement-breakpoint
302+CREATE INDEX "mcp_source_auth_connection_id_idx" ON "mcp_source" USING btree ("auth_connection_id");--> statement-breakpoint
303+CREATE INDEX "mcp_source_auth_client_id_secret_id_idx" ON "mcp_source" USING btree ("auth_client_id_secret_id");--> statement-breakpoint
304+CREATE INDEX "mcp_source_auth_client_secret_secret_id_idx" ON "mcp_source" USING btree ("auth_client_secret_secret_id");--> statement-breakpoint
305+
306+-- Only update rows with explicitly current-shape auth (kind=header w/
307+-- secretId, or kind=oauth2 w/ connectionId). Legacy inline-OAuth rows
308+-- are left untouched so the post-migrate migrateLegacyConnections
309+-- script can convert them to a Connection.
310+UPDATE "mcp_source"
311+SET
312+ "auth_kind" = "config"#>>'{auth,kind}',
313+ "auth_header_name" = "config"#>>'{auth,headerName}',
314+ "auth_secret_id" = "config"#>>'{auth,secretId}',
315+ "auth_secret_prefix" = "config"#>>'{auth,prefix}',
316+ "auth_connection_id" = "config"#>>'{auth,connectionId}',
317+ "auth_client_id_secret_id" = "config"#>>'{auth,clientIdSecretId}',
318+ "auth_client_secret_secret_id" = "config"#>>'{auth,clientSecretSecretId}'
319+WHERE "config" IS NOT NULL
320+ AND (
321+ (
322+ "config"#>>'{auth,kind}' = 'header'
323+ AND "config"#>>'{auth,secretId}' IS NOT NULL
324+ )
325+ OR (
326+ "config"#>>'{auth,kind}' = 'oauth2'
327+ AND "config"#>>'{auth,connectionId}' IS NOT NULL
328+ )
329+ );--> statement-breakpoint
330+
331+INSERT INTO "mcp_source_header"
332+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
333+SELECT
334+ s."scope_id",
335+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']',
336+ s."id",
337+ h.key,
338+ CASE
339+ WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret'
340+ ELSE 'text'
341+ END,
342+ CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END,
343+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END,
344+ CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END
345+FROM "mcp_source" s, jsonb_each(s."config"->'headers') h
346+WHERE s."config"->'headers' IS NOT NULL
347+ON CONFLICT DO NOTHING;--> statement-breakpoint
348+
349+INSERT INTO "mcp_source_query_param"
350+ ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
351+SELECT
352+ s."scope_id",
353+ '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']',
354+ s."id",
355+ q.key,
356+ CASE
357+ WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret'
358+ ELSE 'text'
359+ END,
360+ CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END,
361+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END,
362+ CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END
363+FROM "mcp_source" s, jsonb_each(s."config"->'queryParams') q
364+WHERE s."config"->'queryParams' IS NOT NULL
365+ON CONFLICT DO NOTHING;--> statement-breakpoint
366+
367+-- Strip already-copied fields from config JSON. headers/queryParams
368+-- are always safe; auth is only stripped on rows whose auth was the
369+-- current shape (legacy inline-OAuth rows keep config.auth so
370+-- migrateLegacyConnections can mint a Connection from it).
371+UPDATE "mcp_source"
372+SET "config" = "config" - 'headers' - 'queryParams'
373+WHERE "config" IS NOT NULL;--> statement-breakpoint
374+
375+UPDATE "mcp_source"
376+SET "config" = "config" - 'auth'
377+WHERE "config" IS NOT NULL
378+ AND (
379+ "config"#>>'{auth,kind}' = 'none'
380+ OR (
381+ "config"#>>'{auth,kind}' = 'header'
382+ AND "config"#>>'{auth,secretId}' IS NOT NULL
383+ )
384+ OR (
385+ "config"#>>'{auth,kind}' = 'oauth2'
386+ AND "config"#>>'{auth,connectionId}' IS NOT NULL
387+ )
388+ );
apps/cloud/drizzle/meta/0008_snapshot.json+2594−0
@@ -0,0 +1,2594 @@
1+{
2+ "id": "b8d89563-58e1-4e6b-8674-f502338978e2",
3+ "prevId": "f521e4d8-1eb4-4f84-8110-38fb5157aaca",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": [
64+ "account_id"
65+ ],
66+ "columnsTo": [
67+ "id"
68+ ],
69+ "onDelete": "cascade",
70+ "onUpdate": "no action"
71+ },
72+ "memberships_organization_id_organizations_id_fk": {
73+ "name": "memberships_organization_id_organizations_id_fk",
74+ "tableFrom": "memberships",
75+ "tableTo": "organizations",
76+ "columnsFrom": [
77+ "organization_id"
78+ ],
79+ "columnsTo": [
80+ "id"
81+ ],
82+ "onDelete": "cascade",
83+ "onUpdate": "no action"
84+ }
85+ },
86+ "compositePrimaryKeys": {
87+ "memberships_account_id_organization_id_pk": {
88+ "name": "memberships_account_id_organization_id_pk",
89+ "columns": [
90+ "account_id",
91+ "organization_id"
92+ ]
93+ }
94+ },
95+ "uniqueConstraints": {},
96+ "policies": {},
97+ "checkConstraints": {},
98+ "isRLSEnabled": false
99+ },
100+ "public.organizations": {
101+ "name": "organizations",
102+ "schema": "",
103+ "columns": {
104+ "id": {
105+ "name": "id",
106+ "type": "text",
107+ "primaryKey": true,
108+ "notNull": true
109+ },
110+ "name": {
111+ "name": "name",
112+ "type": "text",
113+ "primaryKey": false,
114+ "notNull": true
115+ },
116+ "created_at": {
117+ "name": "created_at",
118+ "type": "timestamp with time zone",
119+ "primaryKey": false,
120+ "notNull": true,
121+ "default": "now()"
122+ }
123+ },
124+ "indexes": {},
125+ "foreignKeys": {},
126+ "compositePrimaryKeys": {},
127+ "uniqueConstraints": {},
128+ "policies": {},
129+ "checkConstraints": {},
130+ "isRLSEnabled": false
131+ },
132+ "public.blob": {
133+ "name": "blob",
134+ "schema": "",
135+ "columns": {
136+ "namespace": {
137+ "name": "namespace",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true
141+ },
142+ "key": {
143+ "name": "key",
144+ "type": "text",
145+ "primaryKey": false,
146+ "notNull": true
147+ },
148+ "value": {
149+ "name": "value",
150+ "type": "text",
151+ "primaryKey": false,
152+ "notNull": true
153+ }
154+ },
155+ "indexes": {},
156+ "foreignKeys": {},
157+ "compositePrimaryKeys": {
158+ "blob_namespace_key_pk": {
159+ "name": "blob_namespace_key_pk",
160+ "columns": [
161+ "namespace",
162+ "key"
163+ ]
164+ }
165+ },
166+ "uniqueConstraints": {},
167+ "policies": {},
168+ "checkConstraints": {},
169+ "isRLSEnabled": false
170+ },
171+ "public.connection": {
172+ "name": "connection",
173+ "schema": "",
174+ "columns": {
175+ "id": {
176+ "name": "id",
177+ "type": "text",
178+ "primaryKey": false,
179+ "notNull": true
180+ },
181+ "scope_id": {
182+ "name": "scope_id",
183+ "type": "text",
184+ "primaryKey": false,
185+ "notNull": true
186+ },
187+ "provider": {
188+ "name": "provider",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true
192+ },
193+ "identity_label": {
194+ "name": "identity_label",
195+ "type": "text",
196+ "primaryKey": false,
197+ "notNull": false
198+ },
199+ "access_token_secret_id": {
200+ "name": "access_token_secret_id",
201+ "type": "text",
202+ "primaryKey": false,
203+ "notNull": true
204+ },
205+ "refresh_token_secret_id": {
206+ "name": "refresh_token_secret_id",
207+ "type": "text",
208+ "primaryKey": false,
209+ "notNull": false
210+ },
211+ "expires_at": {
212+ "name": "expires_at",
213+ "type": "bigint",
214+ "primaryKey": false,
215+ "notNull": false
216+ },
217+ "scope": {
218+ "name": "scope",
219+ "type": "text",
220+ "primaryKey": false,
221+ "notNull": false
222+ },
223+ "provider_state": {
224+ "name": "provider_state",
225+ "type": "jsonb",
226+ "primaryKey": false,
227+ "notNull": false
228+ },
229+ "created_at": {
230+ "name": "created_at",
231+ "type": "timestamp",
232+ "primaryKey": false,
233+ "notNull": true
234+ },
235+ "updated_at": {
236+ "name": "updated_at",
237+ "type": "timestamp",
238+ "primaryKey": false,
239+ "notNull": true
240+ }
241+ },
242+ "indexes": {
243+ "connection_scope_id_idx": {
244+ "name": "connection_scope_id_idx",
245+ "columns": [
246+ {
247+ "expression": "scope_id",
248+ "isExpression": false,
249+ "asc": true,
250+ "nulls": "last"
251+ }
252+ ],
253+ "isUnique": false,
254+ "concurrently": false,
255+ "method": "btree",
256+ "with": {}
257+ },
258+ "connection_provider_idx": {
259+ "name": "connection_provider_idx",
260+ "columns": [
261+ {
262+ "expression": "provider",
263+ "isExpression": false,
264+ "asc": true,
265+ "nulls": "last"
266+ }
267+ ],
268+ "isUnique": false,
269+ "concurrently": false,
270+ "method": "btree",
271+ "with": {}
272+ }
273+ },
274+ "foreignKeys": {},
275+ "compositePrimaryKeys": {
276+ "connection_scope_id_id_pk": {
277+ "name": "connection_scope_id_id_pk",
278+ "columns": [
279+ "scope_id",
280+ "id"
281+ ]
282+ }
283+ },
284+ "uniqueConstraints": {},
285+ "policies": {},
286+ "checkConstraints": {},
287+ "isRLSEnabled": false
288+ },
289+ "public.definition": {
290+ "name": "definition",
291+ "schema": "",
292+ "columns": {
293+ "id": {
294+ "name": "id",
295+ "type": "text",
296+ "primaryKey": false,
297+ "notNull": true
298+ },
299+ "scope_id": {
300+ "name": "scope_id",
301+ "type": "text",
302+ "primaryKey": false,
303+ "notNull": true
304+ },
305+ "source_id": {
306+ "name": "source_id",
307+ "type": "text",
308+ "primaryKey": false,
309+ "notNull": true
310+ },
311+ "plugin_id": {
312+ "name": "plugin_id",
313+ "type": "text",
314+ "primaryKey": false,
315+ "notNull": true
316+ },
317+ "name": {
318+ "name": "name",
319+ "type": "text",
320+ "primaryKey": false,
321+ "notNull": true
322+ },
323+ "schema": {
324+ "name": "schema",
325+ "type": "jsonb",
326+ "primaryKey": false,
327+ "notNull": true
328+ },
329+ "created_at": {
330+ "name": "created_at",
331+ "type": "timestamp",
332+ "primaryKey": false,
333+ "notNull": true
334+ }
335+ },
336+ "indexes": {
337+ "definition_scope_id_idx": {
338+ "name": "definition_scope_id_idx",
339+ "columns": [
340+ {
341+ "expression": "scope_id",
342+ "isExpression": false,
343+ "asc": true,
344+ "nulls": "last"
345+ }
346+ ],
347+ "isUnique": false,
348+ "concurrently": false,
349+ "method": "btree",
350+ "with": {}
351+ },
352+ "definition_source_id_idx": {
353+ "name": "definition_source_id_idx",
354+ "columns": [
355+ {
356+ "expression": "source_id",
357+ "isExpression": false,
358+ "asc": true,
359+ "nulls": "last"
360+ }
361+ ],
362+ "isUnique": false,
363+ "concurrently": false,
364+ "method": "btree",
365+ "with": {}
366+ },
367+ "definition_plugin_id_idx": {
368+ "name": "definition_plugin_id_idx",
369+ "columns": [
370+ {
371+ "expression": "plugin_id",
372+ "isExpression": false,
373+ "asc": true,
374+ "nulls": "last"
375+ }
376+ ],
377+ "isUnique": false,
378+ "concurrently": false,
379+ "method": "btree",
380+ "with": {}
381+ }
382+ },
383+ "foreignKeys": {},
384+ "compositePrimaryKeys": {
385+ "definition_scope_id_id_pk": {
386+ "name": "definition_scope_id_id_pk",
387+ "columns": [
388+ "scope_id",
389+ "id"
390+ ]
391+ }
392+ },
393+ "uniqueConstraints": {},
394+ "policies": {},
395+ "checkConstraints": {},
396+ "isRLSEnabled": false
397+ },
398+ "public.graphql_operation": {
399+ "name": "graphql_operation",
400+ "schema": "",
401+ "columns": {
402+ "id": {
403+ "name": "id",
404+ "type": "text",
405+ "primaryKey": false,
406+ "notNull": true
407+ },
408+ "scope_id": {
409+ "name": "scope_id",
410+ "type": "text",
411+ "primaryKey": false,
412+ "notNull": true
413+ },
414+ "source_id": {
415+ "name": "source_id",
416+ "type": "text",
417+ "primaryKey": false,
418+ "notNull": true
419+ },
420+ "binding": {
421+ "name": "binding",
422+ "type": "jsonb",
423+ "primaryKey": false,
424+ "notNull": true
425+ }
426+ },
427+ "indexes": {
428+ "graphql_operation_scope_id_idx": {
429+ "name": "graphql_operation_scope_id_idx",
430+ "columns": [
431+ {
432+ "expression": "scope_id",
433+ "isExpression": false,
434+ "asc": true,
435+ "nulls": "last"
436+ }
437+ ],
438+ "isUnique": false,
439+ "concurrently": false,
440+ "method": "btree",
441+ "with": {}
442+ },
443+ "graphql_operation_source_id_idx": {
444+ "name": "graphql_operation_source_id_idx",
445+ "columns": [
446+ {
447+ "expression": "source_id",
448+ "isExpression": false,
449+ "asc": true,
450+ "nulls": "last"
451+ }
452+ ],
453+ "isUnique": false,
454+ "concurrently": false,
455+ "method": "btree",
456+ "with": {}
457+ }
458+ },
459+ "foreignKeys": {},
460+ "compositePrimaryKeys": {
461+ "graphql_operation_scope_id_id_pk": {
462+ "name": "graphql_operation_scope_id_id_pk",
463+ "columns": [
464+ "scope_id",
465+ "id"
466+ ]
467+ }
468+ },
469+ "uniqueConstraints": {},
470+ "policies": {},
471+ "checkConstraints": {},
472+ "isRLSEnabled": false
473+ },
474+ "public.graphql_source": {
475+ "name": "graphql_source",
476+ "schema": "",
477+ "columns": {
478+ "id": {
479+ "name": "id",
480+ "type": "text",
481+ "primaryKey": false,
482+ "notNull": true
483+ },
484+ "scope_id": {
485+ "name": "scope_id",
486+ "type": "text",
487+ "primaryKey": false,
488+ "notNull": true
489+ },
490+ "name": {
491+ "name": "name",
492+ "type": "text",
493+ "primaryKey": false,
494+ "notNull": true
495+ },
496+ "endpoint": {
497+ "name": "endpoint",
498+ "type": "text",
499+ "primaryKey": false,
500+ "notNull": true
501+ },
502+ "auth_kind": {
503+ "name": "auth_kind",
504+ "type": "text",
505+ "primaryKey": false,
506+ "notNull": true,
507+ "default": "none"
508+ },
509+ "auth_connection_id": {
510+ "name": "auth_connection_id",
511+ "type": "text",
512+ "primaryKey": false,
513+ "notNull": false
514+ }
515+ },
516+ "indexes": {
517+ "graphql_source_scope_id_idx": {
518+ "name": "graphql_source_scope_id_idx",
519+ "columns": [
520+ {
521+ "expression": "scope_id",
522+ "isExpression": false,
523+ "asc": true,
524+ "nulls": "last"
525+ }
526+ ],
527+ "isUnique": false,
528+ "concurrently": false,
529+ "method": "btree",
530+ "with": {}
531+ },
532+ "graphql_source_auth_connection_id_idx": {
533+ "name": "graphql_source_auth_connection_id_idx",
534+ "columns": [
535+ {
536+ "expression": "auth_connection_id",
537+ "isExpression": false,
538+ "asc": true,
539+ "nulls": "last"
540+ }
541+ ],
542+ "isUnique": false,
543+ "concurrently": false,
544+ "method": "btree",
545+ "with": {}
546+ }
547+ },
548+ "foreignKeys": {},
549+ "compositePrimaryKeys": {
550+ "graphql_source_scope_id_id_pk": {
551+ "name": "graphql_source_scope_id_id_pk",
552+ "columns": [
553+ "scope_id",
554+ "id"
555+ ]
556+ }
557+ },
558+ "uniqueConstraints": {},
559+ "policies": {},
560+ "checkConstraints": {},
561+ "isRLSEnabled": false
562+ },
563+ "public.mcp_binding": {
564+ "name": "mcp_binding",
565+ "schema": "",
566+ "columns": {
567+ "id": {
568+ "name": "id",
569+ "type": "text",
570+ "primaryKey": false,
571+ "notNull": true
572+ },
573+ "scope_id": {
574+ "name": "scope_id",
575+ "type": "text",
576+ "primaryKey": false,
577+ "notNull": true
578+ },
579+ "source_id": {
580+ "name": "source_id",
581+ "type": "text",
582+ "primaryKey": false,
583+ "notNull": true
584+ },
585+ "binding": {
586+ "name": "binding",
587+ "type": "jsonb",
588+ "primaryKey": false,
589+ "notNull": true
590+ },
591+ "created_at": {
592+ "name": "created_at",
593+ "type": "timestamp",
594+ "primaryKey": false,
595+ "notNull": true
596+ }
597+ },
598+ "indexes": {
599+ "mcp_binding_scope_id_idx": {
600+ "name": "mcp_binding_scope_id_idx",
601+ "columns": [
602+ {
603+ "expression": "scope_id",
604+ "isExpression": false,
605+ "asc": true,
606+ "nulls": "last"
607+ }
608+ ],
609+ "isUnique": false,
610+ "concurrently": false,
611+ "method": "btree",
612+ "with": {}
613+ },
614+ "mcp_binding_source_id_idx": {
615+ "name": "mcp_binding_source_id_idx",
616+ "columns": [
617+ {
618+ "expression": "source_id",
619+ "isExpression": false,
620+ "asc": true,
621+ "nulls": "last"
622+ }
623+ ],
624+ "isUnique": false,
625+ "concurrently": false,
626+ "method": "btree",
627+ "with": {}
628+ }
629+ },
630+ "foreignKeys": {},
631+ "compositePrimaryKeys": {
632+ "mcp_binding_scope_id_id_pk": {
633+ "name": "mcp_binding_scope_id_id_pk",
634+ "columns": [
635+ "scope_id",
636+ "id"
637+ ]
638+ }
639+ },
640+ "uniqueConstraints": {},
641+ "policies": {},
642+ "checkConstraints": {},
643+ "isRLSEnabled": false
644+ },
645+ "public.mcp_source": {
646+ "name": "mcp_source",
647+ "schema": "",
648+ "columns": {
649+ "id": {
650+ "name": "id",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true
654+ },
655+ "scope_id": {
656+ "name": "scope_id",
657+ "type": "text",
658+ "primaryKey": false,
659+ "notNull": true
660+ },
661+ "name": {
662+ "name": "name",
663+ "type": "text",
664+ "primaryKey": false,
665+ "notNull": true
666+ },
667+ "config": {
668+ "name": "config",
669+ "type": "jsonb",
670+ "primaryKey": false,
671+ "notNull": true
672+ },
673+ "created_at": {
674+ "name": "created_at",
675+ "type": "timestamp",
676+ "primaryKey": false,
677+ "notNull": true
678+ },
679+ "auth_kind": {
680+ "name": "auth_kind",
681+ "type": "text",
682+ "primaryKey": false,
683+ "notNull": true,
684+ "default": "'none'"
685+ },
686+ "auth_header_name": {
687+ "name": "auth_header_name",
688+ "type": "text",
689+ "primaryKey": false,
690+ "notNull": false
691+ },
692+ "auth_secret_id": {
693+ "name": "auth_secret_id",
694+ "type": "text",
695+ "primaryKey": false,
696+ "notNull": false
697+ },
698+ "auth_secret_prefix": {
699+ "name": "auth_secret_prefix",
700+ "type": "text",
701+ "primaryKey": false,
702+ "notNull": false
703+ },
704+ "auth_connection_id": {
705+ "name": "auth_connection_id",
706+ "type": "text",
707+ "primaryKey": false,
708+ "notNull": false
709+ },
710+ "auth_client_id_secret_id": {
711+ "name": "auth_client_id_secret_id",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": false
715+ },
716+ "auth_client_secret_secret_id": {
717+ "name": "auth_client_secret_secret_id",
718+ "type": "text",
719+ "primaryKey": false,
720+ "notNull": false
721+ }
722+ },
723+ "indexes": {
724+ "mcp_source_scope_id_idx": {
725+ "name": "mcp_source_scope_id_idx",
726+ "columns": [
727+ {
728+ "expression": "scope_id",
729+ "isExpression": false,
730+ "asc": true,
731+ "nulls": "last"
732+ }
733+ ],
734+ "isUnique": false,
735+ "concurrently": false,
736+ "method": "btree",
737+ "with": {}
738+ },
739+ "mcp_source_auth_secret_id_idx": {
740+ "name": "mcp_source_auth_secret_id_idx",
741+ "columns": [
742+ {
743+ "expression": "auth_secret_id",
744+ "isExpression": false,
745+ "asc": true,
746+ "nulls": "last"
747+ }
748+ ],
749+ "isUnique": false,
750+ "concurrently": false,
751+ "method": "btree",
752+ "with": {}
753+ },
754+ "mcp_source_auth_connection_id_idx": {
755+ "name": "mcp_source_auth_connection_id_idx",
756+ "columns": [
757+ {
758+ "expression": "auth_connection_id",
759+ "isExpression": false,
760+ "asc": true,
761+ "nulls": "last"
762+ }
763+ ],
764+ "isUnique": false,
765+ "concurrently": false,
766+ "method": "btree",
767+ "with": {}
768+ },
769+ "mcp_source_auth_client_id_secret_id_idx": {
770+ "name": "mcp_source_auth_client_id_secret_id_idx",
771+ "columns": [
772+ {
773+ "expression": "auth_client_id_secret_id",
774+ "isExpression": false,
775+ "asc": true,
776+ "nulls": "last"
777+ }
778+ ],
779+ "isUnique": false,
780+ "concurrently": false,
781+ "method": "btree",
782+ "with": {}
783+ },
784+ "mcp_source_auth_client_secret_secret_id_idx": {
785+ "name": "mcp_source_auth_client_secret_secret_id_idx",
786+ "columns": [
787+ {
788+ "expression": "auth_client_secret_secret_id",
789+ "isExpression": false,
790+ "asc": true,
791+ "nulls": "last"
792+ }
793+ ],
794+ "isUnique": false,
795+ "concurrently": false,
796+ "method": "btree",
797+ "with": {}
798+ }
799+ },
800+ "foreignKeys": {},
801+ "compositePrimaryKeys": {
802+ "mcp_source_scope_id_id_pk": {
803+ "name": "mcp_source_scope_id_id_pk",
804+ "columns": [
805+ "scope_id",
806+ "id"
807+ ]
808+ }
809+ },
810+ "uniqueConstraints": {},
811+ "policies": {},
812+ "checkConstraints": {},
813+ "isRLSEnabled": false
814+ },
815+ "public.oauth2_session": {
816+ "name": "oauth2_session",
817+ "schema": "",
818+ "columns": {
819+ "id": {
820+ "name": "id",
821+ "type": "text",
822+ "primaryKey": false,
823+ "notNull": true
824+ },
825+ "scope_id": {
826+ "name": "scope_id",
827+ "type": "text",
828+ "primaryKey": false,
829+ "notNull": true
830+ },
831+ "plugin_id": {
832+ "name": "plugin_id",
833+ "type": "text",
834+ "primaryKey": false,
835+ "notNull": true
836+ },
837+ "strategy": {
838+ "name": "strategy",
839+ "type": "text",
840+ "primaryKey": false,
841+ "notNull": true
842+ },
843+ "connection_id": {
844+ "name": "connection_id",
845+ "type": "text",
846+ "primaryKey": false,
847+ "notNull": true
848+ },
849+ "token_scope": {
850+ "name": "token_scope",
851+ "type": "text",
852+ "primaryKey": false,
853+ "notNull": true
854+ },
855+ "redirect_url": {
856+ "name": "redirect_url",
857+ "type": "text",
858+ "primaryKey": false,
859+ "notNull": true
860+ },
861+ "payload": {
862+ "name": "payload",
863+ "type": "jsonb",
864+ "primaryKey": false,
865+ "notNull": true
866+ },
867+ "expires_at": {
868+ "name": "expires_at",
869+ "type": "bigint",
870+ "primaryKey": false,
871+ "notNull": true
872+ },
873+ "created_at": {
874+ "name": "created_at",
875+ "type": "timestamp",
876+ "primaryKey": false,
877+ "notNull": true
878+ }
879+ },
880+ "indexes": {
881+ "oauth2_session_scope_id_idx": {
882+ "name": "oauth2_session_scope_id_idx",
883+ "columns": [
884+ {
885+ "expression": "scope_id",
886+ "isExpression": false,
887+ "asc": true,
888+ "nulls": "last"
889+ }
890+ ],
891+ "isUnique": false,
892+ "concurrently": false,
893+ "method": "btree",
894+ "with": {}
895+ },
896+ "oauth2_session_plugin_id_idx": {
897+ "name": "oauth2_session_plugin_id_idx",
898+ "columns": [
899+ {
900+ "expression": "plugin_id",
901+ "isExpression": false,
902+ "asc": true,
903+ "nulls": "last"
904+ }
905+ ],
906+ "isUnique": false,
907+ "concurrently": false,
908+ "method": "btree",
909+ "with": {}
910+ },
911+ "oauth2_session_connection_id_idx": {
912+ "name": "oauth2_session_connection_id_idx",
913+ "columns": [
914+ {
915+ "expression": "connection_id",
916+ "isExpression": false,
917+ "asc": true,
918+ "nulls": "last"
919+ }
920+ ],
921+ "isUnique": false,
922+ "concurrently": false,
923+ "method": "btree",
924+ "with": {}
925+ }
926+ },
927+ "foreignKeys": {},
928+ "compositePrimaryKeys": {
929+ "oauth2_session_scope_id_id_pk": {
930+ "name": "oauth2_session_scope_id_id_pk",
931+ "columns": [
932+ "scope_id",
933+ "id"
934+ ]
935+ }
936+ },
937+ "uniqueConstraints": {},
938+ "policies": {},
939+ "checkConstraints": {},
940+ "isRLSEnabled": false
941+ },
942+ "public.openapi_operation": {
943+ "name": "openapi_operation",
944+ "schema": "",
945+ "columns": {
946+ "id": {
947+ "name": "id",
948+ "type": "text",
949+ "primaryKey": false,
950+ "notNull": true
951+ },
952+ "scope_id": {
953+ "name": "scope_id",
954+ "type": "text",
955+ "primaryKey": false,
956+ "notNull": true
957+ },
958+ "source_id": {
959+ "name": "source_id",
960+ "type": "text",
961+ "primaryKey": false,
962+ "notNull": true
963+ },
964+ "binding": {
965+ "name": "binding",
966+ "type": "jsonb",
967+ "primaryKey": false,
968+ "notNull": true
969+ }
970+ },
971+ "indexes": {
972+ "openapi_operation_scope_id_idx": {
973+ "name": "openapi_operation_scope_id_idx",
974+ "columns": [
975+ {
976+ "expression": "scope_id",
977+ "isExpression": false,
978+ "asc": true,
979+ "nulls": "last"
980+ }
981+ ],
982+ "isUnique": false,
983+ "concurrently": false,
984+ "method": "btree",
985+ "with": {}
986+ },
987+ "openapi_operation_source_id_idx": {
988+ "name": "openapi_operation_source_id_idx",
989+ "columns": [
990+ {
991+ "expression": "source_id",
992+ "isExpression": false,
993+ "asc": true,
994+ "nulls": "last"
995+ }
996+ ],
997+ "isUnique": false,
998+ "concurrently": false,
999+ "method": "btree",
1000+ "with": {}
1001+ }
1002+ },
1003+ "foreignKeys": {},
1004+ "compositePrimaryKeys": {
1005+ "openapi_operation_scope_id_id_pk": {
1006+ "name": "openapi_operation_scope_id_id_pk",
1007+ "columns": [
1008+ "scope_id",
1009+ "id"
1010+ ]
1011+ }
1012+ },
1013+ "uniqueConstraints": {},
1014+ "policies": {},
1015+ "checkConstraints": {},
1016+ "isRLSEnabled": false
1017+ },
1018+ "public.openapi_source": {
1019+ "name": "openapi_source",
1020+ "schema": "",
1021+ "columns": {
1022+ "id": {
1023+ "name": "id",
1024+ "type": "text",
1025+ "primaryKey": false,
1026+ "notNull": true
1027+ },
1028+ "scope_id": {
1029+ "name": "scope_id",
1030+ "type": "text",
1031+ "primaryKey": false,
1032+ "notNull": true
1033+ },
1034+ "name": {
1035+ "name": "name",
1036+ "type": "text",
1037+ "primaryKey": false,
1038+ "notNull": true
1039+ },
1040+ "spec": {
1041+ "name": "spec",
1042+ "type": "text",
1043+ "primaryKey": false,
1044+ "notNull": true
1045+ },
1046+ "source_url": {
1047+ "name": "source_url",
1048+ "type": "text",
1049+ "primaryKey": false,
1050+ "notNull": false
1051+ },
1052+ "base_url": {
1053+ "name": "base_url",
1054+ "type": "text",
1055+ "primaryKey": false,
1056+ "notNull": false
1057+ },
1058+ "headers": {
1059+ "name": "headers",
1060+ "type": "jsonb",
1061+ "primaryKey": false,
1062+ "notNull": false
1063+ },
1064+ "oauth2": {
1065+ "name": "oauth2",
1066+ "type": "jsonb",
1067+ "primaryKey": false,
1068+ "notNull": false
1069+ }
1070+ },
1071+ "indexes": {
1072+ "openapi_source_scope_id_idx": {
1073+ "name": "openapi_source_scope_id_idx",
1074+ "columns": [
1075+ {
1076+ "expression": "scope_id",
1077+ "isExpression": false,
1078+ "asc": true,
1079+ "nulls": "last"
1080+ }
1081+ ],
1082+ "isUnique": false,
1083+ "concurrently": false,
1084+ "method": "btree",
1085+ "with": {}
1086+ }
1087+ },
1088+ "foreignKeys": {},
1089+ "compositePrimaryKeys": {
1090+ "openapi_source_scope_id_id_pk": {
1091+ "name": "openapi_source_scope_id_id_pk",
1092+ "columns": [
1093+ "scope_id",
1094+ "id"
1095+ ]
1096+ }
1097+ },
1098+ "uniqueConstraints": {},
1099+ "policies": {},
1100+ "checkConstraints": {},
1101+ "isRLSEnabled": false
1102+ },
1103+ "public.openapi_source_binding": {
1104+ "name": "openapi_source_binding",
1105+ "schema": "",
1106+ "columns": {
1107+ "id": {
1108+ "name": "id",
1109+ "type": "text",
1110+ "primaryKey": true,
1111+ "notNull": true
1112+ },
1113+ "source_id": {
1114+ "name": "source_id",
1115+ "type": "text",
1116+ "primaryKey": false,
1117+ "notNull": true
1118+ },
1119+ "source_scope_id": {
1120+ "name": "source_scope_id",
1121+ "type": "text",
1122+ "primaryKey": false,
1123+ "notNull": true
1124+ },
1125+ "target_scope_id": {
1126+ "name": "target_scope_id",
1127+ "type": "text",
1128+ "primaryKey": false,
1129+ "notNull": true
1130+ },
1131+ "slot": {
1132+ "name": "slot",
1133+ "type": "text",
1134+ "primaryKey": false,
1135+ "notNull": true
1136+ },
1137+ "created_at": {
1138+ "name": "created_at",
1139+ "type": "timestamp",
1140+ "primaryKey": false,
1141+ "notNull": true
1142+ },
1143+ "updated_at": {
1144+ "name": "updated_at",
1145+ "type": "timestamp",
1146+ "primaryKey": false,
1147+ "notNull": true
1148+ },
1149+ "kind": {
1150+ "name": "kind",
1151+ "type": "text",
1152+ "primaryKey": false,
1153+ "notNull": true,
1154+ "default": "'text'"
1155+ },
1156+ "secret_id": {
1157+ "name": "secret_id",
1158+ "type": "text",
1159+ "primaryKey": false,
1160+ "notNull": false
1161+ },
1162+ "connection_id": {
1163+ "name": "connection_id",
1164+ "type": "text",
1165+ "primaryKey": false,
1166+ "notNull": false
1167+ },
1168+ "text_value": {
1169+ "name": "text_value",
1170+ "type": "text",
1171+ "primaryKey": false,
1172+ "notNull": false
1173+ }
1174+ },
1175+ "indexes": {
1176+ "openapi_source_binding_source_id_idx": {
1177+ "name": "openapi_source_binding_source_id_idx",
1178+ "columns": [
1179+ {
1180+ "expression": "source_id",
1181+ "isExpression": false,
1182+ "asc": true,
1183+ "nulls": "last"
1184+ }
1185+ ],
1186+ "isUnique": false,
1187+ "concurrently": false,
1188+ "method": "btree",
1189+ "with": {}
1190+ },
1191+ "openapi_source_binding_source_scope_id_idx": {
1192+ "name": "openapi_source_binding_source_scope_id_idx",
1193+ "columns": [
1194+ {
1195+ "expression": "source_scope_id",
1196+ "isExpression": false,
1197+ "asc": true,
1198+ "nulls": "last"
1199+ }
1200+ ],
1201+ "isUnique": false,
1202+ "concurrently": false,
1203+ "method": "btree",
1204+ "with": {}
1205+ },
1206+ "openapi_source_binding_target_scope_id_idx": {
1207+ "name": "openapi_source_binding_target_scope_id_idx",
1208+ "columns": [
1209+ {
1210+ "expression": "target_scope_id",
1211+ "isExpression": false,
1212+ "asc": true,
1213+ "nulls": "last"
1214+ }
1215+ ],
1216+ "isUnique": false,
1217+ "concurrently": false,
1218+ "method": "btree",
1219+ "with": {}
1220+ },
1221+ "openapi_source_binding_slot_idx": {
1222+ "name": "openapi_source_binding_slot_idx",
1223+ "columns": [
1224+ {
1225+ "expression": "slot",
1226+ "isExpression": false,
1227+ "asc": true,
1228+ "nulls": "last"
1229+ }
1230+ ],
1231+ "isUnique": false,
1232+ "concurrently": false,
1233+ "method": "btree",
1234+ "with": {}
1235+ },
1236+ "openapi_source_binding_secret_id_idx": {
1237+ "name": "openapi_source_binding_secret_id_idx",
1238+ "columns": [
1239+ {
1240+ "expression": "secret_id",
1241+ "isExpression": false,
1242+ "asc": true,
1243+ "nulls": "last"
1244+ }
1245+ ],
1246+ "isUnique": false,
1247+ "concurrently": false,
1248+ "method": "btree",
1249+ "with": {}
1250+ },
1251+ "openapi_source_binding_connection_id_idx": {
1252+ "name": "openapi_source_binding_connection_id_idx",
1253+ "columns": [
1254+ {
1255+ "expression": "connection_id",
1256+ "isExpression": false,
1257+ "asc": true,
1258+ "nulls": "last"
1259+ }
1260+ ],
1261+ "isUnique": false,
1262+ "concurrently": false,
1263+ "method": "btree",
1264+ "with": {}
1265+ }
1266+ },
1267+ "foreignKeys": {},
1268+ "compositePrimaryKeys": {},
1269+ "uniqueConstraints": {},
1270+ "policies": {},
1271+ "checkConstraints": {},
1272+ "isRLSEnabled": false
1273+ },
1274+ "public.secret": {
1275+ "name": "secret",
1276+ "schema": "",
1277+ "columns": {
1278+ "id": {
1279+ "name": "id",
1280+ "type": "text",
1281+ "primaryKey": false,
1282+ "notNull": true
1283+ },
1284+ "scope_id": {
1285+ "name": "scope_id",
1286+ "type": "text",
1287+ "primaryKey": false,
1288+ "notNull": true
1289+ },
1290+ "name": {
1291+ "name": "name",
1292+ "type": "text",
1293+ "primaryKey": false,
1294+ "notNull": true
1295+ },
1296+ "provider": {
1297+ "name": "provider",
1298+ "type": "text",
1299+ "primaryKey": false,
1300+ "notNull": true
1301+ },
1302+ "owned_by_connection_id": {
1303+ "name": "owned_by_connection_id",
1304+ "type": "text",
1305+ "primaryKey": false,
1306+ "notNull": false
1307+ },
1308+ "created_at": {
1309+ "name": "created_at",
1310+ "type": "timestamp",
1311+ "primaryKey": false,
1312+ "notNull": true
1313+ }
1314+ },
1315+ "indexes": {
1316+ "secret_scope_id_idx": {
1317+ "name": "secret_scope_id_idx",
1318+ "columns": [
1319+ {
1320+ "expression": "scope_id",
1321+ "isExpression": false,
1322+ "asc": true,
1323+ "nulls": "last"
1324+ }
1325+ ],
1326+ "isUnique": false,
1327+ "concurrently": false,
1328+ "method": "btree",
1329+ "with": {}
1330+ },
1331+ "secret_provider_idx": {
1332+ "name": "secret_provider_idx",
1333+ "columns": [
1334+ {
1335+ "expression": "provider",
1336+ "isExpression": false,
1337+ "asc": true,
1338+ "nulls": "last"
1339+ }
1340+ ],
1341+ "isUnique": false,
1342+ "concurrently": false,
1343+ "method": "btree",
1344+ "with": {}
1345+ },
1346+ "secret_owned_by_connection_id_idx": {
1347+ "name": "secret_owned_by_connection_id_idx",
1348+ "columns": [
1349+ {
1350+ "expression": "owned_by_connection_id",
1351+ "isExpression": false,
1352+ "asc": true,
1353+ "nulls": "last"
1354+ }
1355+ ],
1356+ "isUnique": false,
1357+ "concurrently": false,
1358+ "method": "btree",
1359+ "with": {}
1360+ }
1361+ },
1362+ "foreignKeys": {},
1363+ "compositePrimaryKeys": {
1364+ "secret_scope_id_id_pk": {
1365+ "name": "secret_scope_id_id_pk",
1366+ "columns": [
1367+ "scope_id",
1368+ "id"
1369+ ]
1370+ }
1371+ },
1372+ "uniqueConstraints": {},
1373+ "policies": {},
1374+ "checkConstraints": {},
1375+ "isRLSEnabled": false
1376+ },
1377+ "public.source": {
1378+ "name": "source",
1379+ "schema": "",
1380+ "columns": {
1381+ "id": {
1382+ "name": "id",
1383+ "type": "text",
1384+ "primaryKey": false,
1385+ "notNull": true
1386+ },
1387+ "scope_id": {
1388+ "name": "scope_id",
1389+ "type": "text",
1390+ "primaryKey": false,
1391+ "notNull": true
1392+ },
1393+ "plugin_id": {
1394+ "name": "plugin_id",
1395+ "type": "text",
1396+ "primaryKey": false,
1397+ "notNull": true
1398+ },
1399+ "kind": {
1400+ "name": "kind",
1401+ "type": "text",
1402+ "primaryKey": false,
1403+ "notNull": true
1404+ },
1405+ "name": {
1406+ "name": "name",
1407+ "type": "text",
1408+ "primaryKey": false,
1409+ "notNull": true
1410+ },
1411+ "url": {
1412+ "name": "url",
1413+ "type": "text",
1414+ "primaryKey": false,
1415+ "notNull": false
1416+ },
1417+ "can_remove": {
1418+ "name": "can_remove",
1419+ "type": "boolean",
1420+ "primaryKey": false,
1421+ "notNull": true,
1422+ "default": true
1423+ },
1424+ "can_refresh": {
1425+ "name": "can_refresh",
1426+ "type": "boolean",
1427+ "primaryKey": false,
1428+ "notNull": true,
1429+ "default": false
1430+ },
1431+ "can_edit": {
1432+ "name": "can_edit",
1433+ "type": "boolean",
1434+ "primaryKey": false,
1435+ "notNull": true,
1436+ "default": false
1437+ },
1438+ "created_at": {
1439+ "name": "created_at",
1440+ "type": "timestamp",
1441+ "primaryKey": false,
1442+ "notNull": true
1443+ },
1444+ "updated_at": {
1445+ "name": "updated_at",
1446+ "type": "timestamp",
1447+ "primaryKey": false,
1448+ "notNull": true
1449+ }
1450+ },
1451+ "indexes": {
1452+ "source_scope_id_idx": {
1453+ "name": "source_scope_id_idx",
1454+ "columns": [
1455+ {
1456+ "expression": "scope_id",
1457+ "isExpression": false,
1458+ "asc": true,
1459+ "nulls": "last"
1460+ }
1461+ ],
1462+ "isUnique": false,
1463+ "concurrently": false,
1464+ "method": "btree",
1465+ "with": {}
1466+ },
1467+ "source_plugin_id_idx": {
1468+ "name": "source_plugin_id_idx",
1469+ "columns": [
1470+ {
1471+ "expression": "plugin_id",
1472+ "isExpression": false,
1473+ "asc": true,
1474+ "nulls": "last"
1475+ }
1476+ ],
1477+ "isUnique": false,
1478+ "concurrently": false,
1479+ "method": "btree",
1480+ "with": {}
1481+ }
1482+ },
1483+ "foreignKeys": {},
1484+ "compositePrimaryKeys": {
1485+ "source_scope_id_id_pk": {
1486+ "name": "source_scope_id_id_pk",
1487+ "columns": [
1488+ "scope_id",
1489+ "id"
1490+ ]
1491+ }
1492+ },
1493+ "uniqueConstraints": {},
1494+ "policies": {},
1495+ "checkConstraints": {},
1496+ "isRLSEnabled": false
1497+ },
1498+ "public.tool": {
1499+ "name": "tool",
1500+ "schema": "",
1501+ "columns": {
1502+ "id": {
1503+ "name": "id",
1504+ "type": "text",
1505+ "primaryKey": false,
1506+ "notNull": true
1507+ },
1508+ "scope_id": {
1509+ "name": "scope_id",
1510+ "type": "text",
1511+ "primaryKey": false,
1512+ "notNull": true
1513+ },
1514+ "source_id": {
1515+ "name": "source_id",
1516+ "type": "text",
1517+ "primaryKey": false,
1518+ "notNull": true
1519+ },
1520+ "plugin_id": {
1521+ "name": "plugin_id",
1522+ "type": "text",
1523+ "primaryKey": false,
1524+ "notNull": true
1525+ },
1526+ "name": {
1527+ "name": "name",
1528+ "type": "text",
1529+ "primaryKey": false,
1530+ "notNull": true
1531+ },
1532+ "description": {
1533+ "name": "description",
1534+ "type": "text",
1535+ "primaryKey": false,
1536+ "notNull": true
1537+ },
1538+ "input_schema": {
1539+ "name": "input_schema",
1540+ "type": "jsonb",
1541+ "primaryKey": false,
1542+ "notNull": false
1543+ },
1544+ "output_schema": {
1545+ "name": "output_schema",
1546+ "type": "jsonb",
1547+ "primaryKey": false,
1548+ "notNull": false
1549+ },
1550+ "created_at": {
1551+ "name": "created_at",
1552+ "type": "timestamp",
1553+ "primaryKey": false,
1554+ "notNull": true
1555+ },
1556+ "updated_at": {
1557+ "name": "updated_at",
1558+ "type": "timestamp",
1559+ "primaryKey": false,
1560+ "notNull": true
1561+ }
1562+ },
1563+ "indexes": {
1564+ "tool_scope_id_idx": {
1565+ "name": "tool_scope_id_idx",
1566+ "columns": [
1567+ {
1568+ "expression": "scope_id",
1569+ "isExpression": false,
1570+ "asc": true,
1571+ "nulls": "last"
1572+ }
1573+ ],
1574+ "isUnique": false,
1575+ "concurrently": false,
1576+ "method": "btree",
1577+ "with": {}
1578+ },
1579+ "tool_source_id_idx": {
1580+ "name": "tool_source_id_idx",
1581+ "columns": [
1582+ {
1583+ "expression": "source_id",
1584+ "isExpression": false,
1585+ "asc": true,
1586+ "nulls": "last"
1587+ }
1588+ ],
1589+ "isUnique": false,
1590+ "concurrently": false,
1591+ "method": "btree",
1592+ "with": {}
1593+ },
1594+ "tool_plugin_id_idx": {
1595+ "name": "tool_plugin_id_idx",
1596+ "columns": [
1597+ {
1598+ "expression": "plugin_id",
1599+ "isExpression": false,
1600+ "asc": true,
1601+ "nulls": "last"
1602+ }
1603+ ],
1604+ "isUnique": false,
1605+ "concurrently": false,
1606+ "method": "btree",
1607+ "with": {}
1608+ }
1609+ },
1610+ "foreignKeys": {},
1611+ "compositePrimaryKeys": {
1612+ "tool_scope_id_id_pk": {
1613+ "name": "tool_scope_id_id_pk",
1614+ "columns": [
1615+ "scope_id",
1616+ "id"
1617+ ]
1618+ }
1619+ },
1620+ "uniqueConstraints": {},
1621+ "policies": {},
1622+ "checkConstraints": {},
1623+ "isRLSEnabled": false
1624+ },
1625+ "public.tool_policy": {
1626+ "name": "tool_policy",
1627+ "schema": "",
1628+ "columns": {
1629+ "id": {
1630+ "name": "id",
1631+ "type": "text",
1632+ "primaryKey": false,
1633+ "notNull": true
1634+ },
1635+ "scope_id": {
1636+ "name": "scope_id",
1637+ "type": "text",
1638+ "primaryKey": false,
1639+ "notNull": true
1640+ },
1641+ "pattern": {
1642+ "name": "pattern",
1643+ "type": "text",
1644+ "primaryKey": false,
1645+ "notNull": true
1646+ },
1647+ "action": {
1648+ "name": "action",
1649+ "type": "text",
1650+ "primaryKey": false,
1651+ "notNull": true
1652+ },
1653+ "position": {
1654+ "name": "position",
1655+ "type": "text",
1656+ "primaryKey": false,
1657+ "notNull": true
1658+ },
1659+ "created_at": {
1660+ "name": "created_at",
1661+ "type": "timestamp",
1662+ "primaryKey": false,
1663+ "notNull": true
1664+ },
1665+ "updated_at": {
1666+ "name": "updated_at",
1667+ "type": "timestamp",
1668+ "primaryKey": false,
1669+ "notNull": true
1670+ }
1671+ },
1672+ "indexes": {
1673+ "tool_policy_scope_id_position_idx": {
1674+ "name": "tool_policy_scope_id_position_idx",
1675+ "columns": [
1676+ {
1677+ "expression": "scope_id",
1678+ "isExpression": false,
1679+ "asc": true,
1680+ "nulls": "last"
1681+ },
1682+ {
1683+ "expression": "position",
1684+ "isExpression": false,
1685+ "asc": true,
1686+ "nulls": "last"
1687+ }
1688+ ],
1689+ "isUnique": false,
1690+ "concurrently": false,
1691+ "method": "btree",
1692+ "with": {}
1693+ }
1694+ },
1695+ "foreignKeys": {},
1696+ "compositePrimaryKeys": {
1697+ "tool_policy_scope_id_id_pk": {
1698+ "name": "tool_policy_scope_id_id_pk",
1699+ "columns": [
1700+ "scope_id",
1701+ "id"
1702+ ]
1703+ }
1704+ },
1705+ "uniqueConstraints": {},
1706+ "policies": {},
1707+ "checkConstraints": {},
1708+ "isRLSEnabled": false
1709+ },
1710+ "public.workos_vault_metadata": {
1711+ "name": "workos_vault_metadata",
1712+ "schema": "",
1713+ "columns": {
1714+ "id": {
1715+ "name": "id",
1716+ "type": "text",
1717+ "primaryKey": false,
1718+ "notNull": true
1719+ },
1720+ "scope_id": {
1721+ "name": "scope_id",
1722+ "type": "text",
1723+ "primaryKey": false,
1724+ "notNull": true
1725+ },
1726+ "name": {
1727+ "name": "name",
1728+ "type": "text",
1729+ "primaryKey": false,
1730+ "notNull": true
1731+ },
1732+ "purpose": {
1733+ "name": "purpose",
1734+ "type": "text",
1735+ "primaryKey": false,
1736+ "notNull": false
1737+ },
1738+ "created_at": {
1739+ "name": "created_at",
1740+ "type": "timestamp",
1741+ "primaryKey": false,
1742+ "notNull": true
1743+ }
1744+ },
1745+ "indexes": {
1746+ "workos_vault_metadata_scope_id_idx": {
1747+ "name": "workos_vault_metadata_scope_id_idx",
1748+ "columns": [
1749+ {
1750+ "expression": "scope_id",
1751+ "isExpression": false,
1752+ "asc": true,
1753+ "nulls": "last"
1754+ }
1755+ ],
1756+ "isUnique": false,
1757+ "concurrently": false,
1758+ "method": "btree",
1759+ "with": {}
1760+ }
1761+ },
1762+ "foreignKeys": {},
1763+ "compositePrimaryKeys": {
1764+ "workos_vault_metadata_scope_id_id_pk": {
1765+ "name": "workos_vault_metadata_scope_id_id_pk",
1766+ "columns": [
1767+ "scope_id",
1768+ "id"
1769+ ]
1770+ }
1771+ },
1772+ "uniqueConstraints": {},
1773+ "policies": {},
1774+ "checkConstraints": {},
1775+ "isRLSEnabled": false
1776+ },
1777+ "public.graphql_source_header": {
1778+ "name": "graphql_source_header",
1779+ "schema": "",
1780+ "columns": {
1781+ "id": {
1782+ "name": "id",
1783+ "type": "text",
1784+ "primaryKey": false,
1785+ "notNull": true
1786+ },
1787+ "scope_id": {
1788+ "name": "scope_id",
1789+ "type": "text",
1790+ "primaryKey": false,
1791+ "notNull": true
1792+ },
1793+ "source_id": {
1794+ "name": "source_id",
1795+ "type": "text",
1796+ "primaryKey": false,
1797+ "notNull": true
1798+ },
1799+ "name": {
1800+ "name": "name",
1801+ "type": "text",
1802+ "primaryKey": false,
1803+ "notNull": true
1804+ },
1805+ "kind": {
1806+ "name": "kind",
1807+ "type": "text",
1808+ "primaryKey": false,
1809+ "notNull": true
1810+ },
1811+ "text_value": {
1812+ "name": "text_value",
1813+ "type": "text",
1814+ "primaryKey": false,
1815+ "notNull": false
1816+ },
1817+ "secret_id": {
1818+ "name": "secret_id",
1819+ "type": "text",
1820+ "primaryKey": false,
1821+ "notNull": false
1822+ },
1823+ "secret_prefix": {
1824+ "name": "secret_prefix",
1825+ "type": "text",
1826+ "primaryKey": false,
1827+ "notNull": false
1828+ }
1829+ },
1830+ "indexes": {
1831+ "graphql_source_header_scope_id_idx": {
1832+ "name": "graphql_source_header_scope_id_idx",
1833+ "columns": [
1834+ {
1835+ "expression": "scope_id",
1836+ "isExpression": false,
1837+ "asc": true,
1838+ "nulls": "last"
1839+ }
1840+ ],
1841+ "isUnique": false,
1842+ "concurrently": false,
1843+ "method": "btree",
1844+ "with": {}
1845+ },
1846+ "graphql_source_header_source_id_idx": {
1847+ "name": "graphql_source_header_source_id_idx",
1848+ "columns": [
1849+ {
1850+ "expression": "source_id",
1851+ "isExpression": false,
1852+ "asc": true,
1853+ "nulls": "last"
1854+ }
1855+ ],
1856+ "isUnique": false,
1857+ "concurrently": false,
1858+ "method": "btree",
1859+ "with": {}
1860+ },
1861+ "graphql_source_header_secret_id_idx": {
1862+ "name": "graphql_source_header_secret_id_idx",
1863+ "columns": [
1864+ {
1865+ "expression": "secret_id",
1866+ "isExpression": false,
1867+ "asc": true,
1868+ "nulls": "last"
1869+ }
1870+ ],
1871+ "isUnique": false,
1872+ "concurrently": false,
1873+ "method": "btree",
1874+ "with": {}
1875+ }
1876+ },
1877+ "foreignKeys": {},
1878+ "compositePrimaryKeys": {
1879+ "graphql_source_header_scope_id_id_pk": {
1880+ "name": "graphql_source_header_scope_id_id_pk",
1881+ "columns": [
1882+ "scope_id",
1883+ "id"
1884+ ]
1885+ }
1886+ },
1887+ "uniqueConstraints": {},
1888+ "policies": {},
1889+ "checkConstraints": {},
1890+ "isRLSEnabled": false
1891+ },
1892+ "public.graphql_source_query_param": {
1893+ "name": "graphql_source_query_param",
1894+ "schema": "",
1895+ "columns": {
1896+ "id": {
1897+ "name": "id",
1898+ "type": "text",
1899+ "primaryKey": false,
1900+ "notNull": true
1901+ },
1902+ "scope_id": {
1903+ "name": "scope_id",
1904+ "type": "text",
1905+ "primaryKey": false,
1906+ "notNull": true
1907+ },
1908+ "source_id": {
1909+ "name": "source_id",
1910+ "type": "text",
1911+ "primaryKey": false,
1912+ "notNull": true
1913+ },
1914+ "name": {
1915+ "name": "name",
1916+ "type": "text",
1917+ "primaryKey": false,
1918+ "notNull": true
1919+ },
1920+ "kind": {
1921+ "name": "kind",
1922+ "type": "text",
1923+ "primaryKey": false,
1924+ "notNull": true
1925+ },
1926+ "text_value": {
1927+ "name": "text_value",
1928+ "type": "text",
1929+ "primaryKey": false,
1930+ "notNull": false
1931+ },
1932+ "secret_id": {
1933+ "name": "secret_id",
1934+ "type": "text",
1935+ "primaryKey": false,
1936+ "notNull": false
1937+ },
1938+ "secret_prefix": {
1939+ "name": "secret_prefix",
1940+ "type": "text",
1941+ "primaryKey": false,
1942+ "notNull": false
1943+ }
1944+ },
1945+ "indexes": {
1946+ "graphql_source_query_param_scope_id_idx": {
1947+ "name": "graphql_source_query_param_scope_id_idx",
1948+ "columns": [
1949+ {
1950+ "expression": "scope_id",
1951+ "isExpression": false,
1952+ "asc": true,
1953+ "nulls": "last"
1954+ }
1955+ ],
1956+ "isUnique": false,
1957+ "concurrently": false,
1958+ "method": "btree",
1959+ "with": {}
1960+ },
1961+ "graphql_source_query_param_source_id_idx": {
1962+ "name": "graphql_source_query_param_source_id_idx",
1963+ "columns": [
1964+ {
1965+ "expression": "source_id",
1966+ "isExpression": false,
1967+ "asc": true,
1968+ "nulls": "last"
1969+ }
1970+ ],
1971+ "isUnique": false,
1972+ "concurrently": false,
1973+ "method": "btree",
1974+ "with": {}
1975+ },
1976+ "graphql_source_query_param_secret_id_idx": {
1977+ "name": "graphql_source_query_param_secret_id_idx",
1978+ "columns": [
1979+ {
1980+ "expression": "secret_id",
1981+ "isExpression": false,
1982+ "asc": true,
1983+ "nulls": "last"
1984+ }
1985+ ],
1986+ "isUnique": false,
1987+ "concurrently": false,
1988+ "method": "btree",
1989+ "with": {}
1990+ }
1991+ },
1992+ "foreignKeys": {},
1993+ "compositePrimaryKeys": {
1994+ "graphql_source_query_param_scope_id_id_pk": {
1995+ "name": "graphql_source_query_param_scope_id_id_pk",
1996+ "columns": [
1997+ "scope_id",
1998+ "id"
1999+ ]
2000+ }
2001+ },
2002+ "uniqueConstraints": {},
2003+ "policies": {},
2004+ "checkConstraints": {},
2005+ "isRLSEnabled": false
2006+ },
2007+ "public.openapi_source_query_param": {
2008+ "name": "openapi_source_query_param",
2009+ "schema": "",
2010+ "columns": {
2011+ "id": {
2012+ "name": "id",
2013+ "type": "text",
2014+ "primaryKey": false,
2015+ "notNull": true
2016+ },
2017+ "scope_id": {
2018+ "name": "scope_id",
2019+ "type": "text",
2020+ "primaryKey": false,
2021+ "notNull": true
2022+ },
2023+ "source_id": {
2024+ "name": "source_id",
2025+ "type": "text",
2026+ "primaryKey": false,
2027+ "notNull": true
2028+ },
2029+ "name": {
2030+ "name": "name",
2031+ "type": "text",
2032+ "primaryKey": false,
2033+ "notNull": true
2034+ },
2035+ "kind": {
2036+ "name": "kind",
2037+ "type": "text",
2038+ "primaryKey": false,
2039+ "notNull": true
2040+ },
2041+ "text_value": {
2042+ "name": "text_value",
2043+ "type": "text",
2044+ "primaryKey": false,
2045+ "notNull": false
2046+ },
2047+ "secret_id": {
2048+ "name": "secret_id",
2049+ "type": "text",
2050+ "primaryKey": false,
2051+ "notNull": false
2052+ },
2053+ "secret_prefix": {
2054+ "name": "secret_prefix",
2055+ "type": "text",
2056+ "primaryKey": false,
2057+ "notNull": false
2058+ }
2059+ },
2060+ "indexes": {
2061+ "openapi_source_query_param_scope_id_idx": {
2062+ "name": "openapi_source_query_param_scope_id_idx",
2063+ "columns": [
2064+ {
2065+ "expression": "scope_id",
2066+ "isExpression": false,
2067+ "asc": true,
2068+ "nulls": "last"
2069+ }
2070+ ],
2071+ "isUnique": false,
2072+ "concurrently": false,
2073+ "method": "btree",
2074+ "with": {}
2075+ },
2076+ "openapi_source_query_param_source_id_idx": {
2077+ "name": "openapi_source_query_param_source_id_idx",
2078+ "columns": [
2079+ {
2080+ "expression": "source_id",
2081+ "isExpression": false,
2082+ "asc": true,
2083+ "nulls": "last"
2084+ }
2085+ ],
2086+ "isUnique": false,
2087+ "concurrently": false,
2088+ "method": "btree",
2089+ "with": {}
2090+ },
2091+ "openapi_source_query_param_secret_id_idx": {
2092+ "name": "openapi_source_query_param_secret_id_idx",
2093+ "columns": [
2094+ {
2095+ "expression": "secret_id",
2096+ "isExpression": false,
2097+ "asc": true,
2098+ "nulls": "last"
2099+ }
2100+ ],
2101+ "isUnique": false,
2102+ "concurrently": false,
2103+ "method": "btree",
2104+ "with": {}
2105+ }
2106+ },
2107+ "foreignKeys": {},
2108+ "compositePrimaryKeys": {
2109+ "openapi_source_query_param_scope_id_id_pk": {
2110+ "name": "openapi_source_query_param_scope_id_id_pk",
2111+ "columns": [
2112+ "scope_id",
2113+ "id"
2114+ ]
2115+ }
2116+ },
2117+ "uniqueConstraints": {},
2118+ "policies": {},
2119+ "checkConstraints": {},
2120+ "isRLSEnabled": false
2121+ },
2122+ "public.openapi_source_spec_fetch_header": {
2123+ "name": "openapi_source_spec_fetch_header",
2124+ "schema": "",
2125+ "columns": {
2126+ "id": {
2127+ "name": "id",
2128+ "type": "text",
2129+ "primaryKey": false,
2130+ "notNull": true
2131+ },
2132+ "scope_id": {
2133+ "name": "scope_id",
2134+ "type": "text",
2135+ "primaryKey": false,
2136+ "notNull": true
2137+ },
2138+ "source_id": {
2139+ "name": "source_id",
2140+ "type": "text",
2141+ "primaryKey": false,
2142+ "notNull": true
2143+ },
2144+ "name": {
2145+ "name": "name",
2146+ "type": "text",
2147+ "primaryKey": false,
2148+ "notNull": true
2149+ },
2150+ "kind": {
2151+ "name": "kind",
2152+ "type": "text",
2153+ "primaryKey": false,
2154+ "notNull": true
2155+ },
2156+ "text_value": {
2157+ "name": "text_value",
2158+ "type": "text",
2159+ "primaryKey": false,
2160+ "notNull": false
2161+ },
2162+ "secret_id": {
2163+ "name": "secret_id",
2164+ "type": "text",
2165+ "primaryKey": false,
2166+ "notNull": false
2167+ },
2168+ "secret_prefix": {
2169+ "name": "secret_prefix",
2170+ "type": "text",
2171+ "primaryKey": false,
2172+ "notNull": false
2173+ }
2174+ },
2175+ "indexes": {
2176+ "openapi_source_spec_fetch_header_scope_id_idx": {
2177+ "name": "openapi_source_spec_fetch_header_scope_id_idx",
2178+ "columns": [
2179+ {
2180+ "expression": "scope_id",
2181+ "isExpression": false,
2182+ "asc": true,
2183+ "nulls": "last"
2184+ }
2185+ ],
2186+ "isUnique": false,
2187+ "concurrently": false,
2188+ "method": "btree",
2189+ "with": {}
2190+ },
2191+ "openapi_source_spec_fetch_header_source_id_idx": {
2192+ "name": "openapi_source_spec_fetch_header_source_id_idx",
2193+ "columns": [
2194+ {
2195+ "expression": "source_id",
2196+ "isExpression": false,
2197+ "asc": true,
2198+ "nulls": "last"
2199+ }
2200+ ],
2201+ "isUnique": false,
2202+ "concurrently": false,
2203+ "method": "btree",
2204+ "with": {}
2205+ },
2206+ "openapi_source_spec_fetch_header_secret_id_idx": {
2207+ "name": "openapi_source_spec_fetch_header_secret_id_idx",
2208+ "columns": [
2209+ {
2210+ "expression": "secret_id",
2211+ "isExpression": false,
2212+ "asc": true,
2213+ "nulls": "last"
2214+ }
2215+ ],
2216+ "isUnique": false,
2217+ "concurrently": false,
2218+ "method": "btree",
2219+ "with": {}
2220+ }
2221+ },
2222+ "foreignKeys": {},
2223+ "compositePrimaryKeys": {
2224+ "openapi_source_spec_fetch_header_scope_id_id_pk": {
2225+ "name": "openapi_source_spec_fetch_header_scope_id_id_pk",
2226+ "columns": [
2227+ "scope_id",
2228+ "id"
2229+ ]
2230+ }
2231+ },
2232+ "uniqueConstraints": {},
2233+ "policies": {},
2234+ "checkConstraints": {},
2235+ "isRLSEnabled": false
2236+ },
2237+ "public.openapi_source_spec_fetch_query_param": {
2238+ "name": "openapi_source_spec_fetch_query_param",
2239+ "schema": "",
2240+ "columns": {
2241+ "id": {
2242+ "name": "id",
2243+ "type": "text",
2244+ "primaryKey": false,
2245+ "notNull": true
2246+ },
2247+ "scope_id": {
2248+ "name": "scope_id",
2249+ "type": "text",
2250+ "primaryKey": false,
2251+ "notNull": true
2252+ },
2253+ "source_id": {
2254+ "name": "source_id",
2255+ "type": "text",
2256+ "primaryKey": false,
2257+ "notNull": true
2258+ },
2259+ "name": {
2260+ "name": "name",
2261+ "type": "text",
2262+ "primaryKey": false,
2263+ "notNull": true
2264+ },
2265+ "kind": {
2266+ "name": "kind",
2267+ "type": "text",
2268+ "primaryKey": false,
2269+ "notNull": true
2270+ },
2271+ "text_value": {
2272+ "name": "text_value",
2273+ "type": "text",
2274+ "primaryKey": false,
2275+ "notNull": false
2276+ },
2277+ "secret_id": {
2278+ "name": "secret_id",
2279+ "type": "text",
2280+ "primaryKey": false,
2281+ "notNull": false
2282+ },
2283+ "secret_prefix": {
2284+ "name": "secret_prefix",
2285+ "type": "text",
2286+ "primaryKey": false,
2287+ "notNull": false
2288+ }
2289+ },
2290+ "indexes": {
2291+ "openapi_source_spec_fetch_query_param_scope_id_idx": {
2292+ "name": "openapi_source_spec_fetch_query_param_scope_id_idx",
2293+ "columns": [
2294+ {
2295+ "expression": "scope_id",
2296+ "isExpression": false,
2297+ "asc": true,
2298+ "nulls": "last"
2299+ }
2300+ ],
2301+ "isUnique": false,
2302+ "concurrently": false,
2303+ "method": "btree",
2304+ "with": {}
2305+ },
2306+ "openapi_source_spec_fetch_query_param_source_id_idx": {
2307+ "name": "openapi_source_spec_fetch_query_param_source_id_idx",
2308+ "columns": [
2309+ {
2310+ "expression": "source_id",
2311+ "isExpression": false,
2312+ "asc": true,
2313+ "nulls": "last"
2314+ }
2315+ ],
2316+ "isUnique": false,
2317+ "concurrently": false,
2318+ "method": "btree",
2319+ "with": {}
2320+ },
2321+ "openapi_source_spec_fetch_query_param_secret_id_idx": {
2322+ "name": "openapi_source_spec_fetch_query_param_secret_id_idx",
2323+ "columns": [
2324+ {
2325+ "expression": "secret_id",
2326+ "isExpression": false,
2327+ "asc": true,
2328+ "nulls": "last"
2329+ }
2330+ ],
2331+ "isUnique": false,
2332+ "concurrently": false,
2333+ "method": "btree",
2334+ "with": {}
2335+ }
2336+ },
2337+ "foreignKeys": {},
2338+ "compositePrimaryKeys": {
2339+ "openapi_source_spec_fetch_query_param_scope_id_id_pk": {
2340+ "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk",
2341+ "columns": [
2342+ "scope_id",
2343+ "id"
2344+ ]
2345+ }
2346+ },
2347+ "uniqueConstraints": {},
2348+ "policies": {},
2349+ "checkConstraints": {},
2350+ "isRLSEnabled": false
2351+ },
2352+ "public.mcp_source_header": {
2353+ "name": "mcp_source_header",
2354+ "schema": "",
2355+ "columns": {
2356+ "id": {
2357+ "name": "id",
2358+ "type": "text",
2359+ "primaryKey": false,
2360+ "notNull": true
2361+ },
2362+ "scope_id": {
2363+ "name": "scope_id",
2364+ "type": "text",
2365+ "primaryKey": false,
2366+ "notNull": true
2367+ },
2368+ "source_id": {
2369+ "name": "source_id",
2370+ "type": "text",
2371+ "primaryKey": false,
2372+ "notNull": true
2373+ },
2374+ "name": {
2375+ "name": "name",
2376+ "type": "text",
2377+ "primaryKey": false,
2378+ "notNull": true
2379+ },
2380+ "kind": {
2381+ "name": "kind",
2382+ "type": "text",
2383+ "primaryKey": false,
2384+ "notNull": true
2385+ },
2386+ "text_value": {
2387+ "name": "text_value",
2388+ "type": "text",
2389+ "primaryKey": false,
2390+ "notNull": false
2391+ },
2392+ "secret_id": {
2393+ "name": "secret_id",
2394+ "type": "text",
2395+ "primaryKey": false,
2396+ "notNull": false
2397+ },
2398+ "secret_prefix": {
2399+ "name": "secret_prefix",
2400+ "type": "text",
2401+ "primaryKey": false,
2402+ "notNull": false
2403+ }
2404+ },
2405+ "indexes": {
2406+ "mcp_source_header_scope_id_idx": {
2407+ "name": "mcp_source_header_scope_id_idx",
2408+ "columns": [
2409+ {
2410+ "expression": "scope_id",
2411+ "isExpression": false,
2412+ "asc": true,
2413+ "nulls": "last"
2414+ }
2415+ ],
2416+ "isUnique": false,
2417+ "concurrently": false,
2418+ "method": "btree",
2419+ "with": {}
2420+ },
2421+ "mcp_source_header_source_id_idx": {
2422+ "name": "mcp_source_header_source_id_idx",
2423+ "columns": [
2424+ {
2425+ "expression": "source_id",
2426+ "isExpression": false,
2427+ "asc": true,
2428+ "nulls": "last"
2429+ }
2430+ ],
2431+ "isUnique": false,
2432+ "concurrently": false,
2433+ "method": "btree",
2434+ "with": {}
2435+ },
2436+ "mcp_source_header_secret_id_idx": {
2437+ "name": "mcp_source_header_secret_id_idx",
2438+ "columns": [
2439+ {
2440+ "expression": "secret_id",
2441+ "isExpression": false,
2442+ "asc": true,
2443+ "nulls": "last"
2444+ }
2445+ ],
2446+ "isUnique": false,
2447+ "concurrently": false,
2448+ "method": "btree",
2449+ "with": {}
2450+ }
2451+ },
2452+ "foreignKeys": {},
2453+ "compositePrimaryKeys": {
2454+ "mcp_source_header_scope_id_id_pk": {
2455+ "name": "mcp_source_header_scope_id_id_pk",
2456+ "columns": [
2457+ "scope_id",
2458+ "id"
2459+ ]
2460+ }
2461+ },
2462+ "uniqueConstraints": {},
2463+ "policies": {},
2464+ "checkConstraints": {},
2465+ "isRLSEnabled": false
2466+ },
2467+ "public.mcp_source_query_param": {
2468+ "name": "mcp_source_query_param",
2469+ "schema": "",
2470+ "columns": {
2471+ "id": {
2472+ "name": "id",
2473+ "type": "text",
2474+ "primaryKey": false,
2475+ "notNull": true
2476+ },
2477+ "scope_id": {
2478+ "name": "scope_id",
2479+ "type": "text",
2480+ "primaryKey": false,
2481+ "notNull": true
2482+ },
2483+ "source_id": {
2484+ "name": "source_id",
2485+ "type": "text",
2486+ "primaryKey": false,
2487+ "notNull": true
2488+ },
2489+ "name": {
2490+ "name": "name",
2491+ "type": "text",
2492+ "primaryKey": false,
2493+ "notNull": true
2494+ },
2495+ "kind": {
2496+ "name": "kind",
2497+ "type": "text",
2498+ "primaryKey": false,
2499+ "notNull": true
2500+ },
2501+ "text_value": {
2502+ "name": "text_value",
2503+ "type": "text",
2504+ "primaryKey": false,
2505+ "notNull": false
2506+ },
2507+ "secret_id": {
2508+ "name": "secret_id",
2509+ "type": "text",
2510+ "primaryKey": false,
2511+ "notNull": false
2512+ },
2513+ "secret_prefix": {
2514+ "name": "secret_prefix",
2515+ "type": "text",
2516+ "primaryKey": false,
2517+ "notNull": false
2518+ }
2519+ },
2520+ "indexes": {
2521+ "mcp_source_query_param_scope_id_idx": {
2522+ "name": "mcp_source_query_param_scope_id_idx",
2523+ "columns": [
2524+ {
2525+ "expression": "scope_id",
2526+ "isExpression": false,
2527+ "asc": true,
2528+ "nulls": "last"
2529+ }
2530+ ],
2531+ "isUnique": false,
2532+ "concurrently": false,
2533+ "method": "btree",
2534+ "with": {}
2535+ },
2536+ "mcp_source_query_param_source_id_idx": {
2537+ "name": "mcp_source_query_param_source_id_idx",
2538+ "columns": [
2539+ {
2540+ "expression": "source_id",
2541+ "isExpression": false,
2542+ "asc": true,
2543+ "nulls": "last"
2544+ }
2545+ ],
2546+ "isUnique": false,
2547+ "concurrently": false,
2548+ "method": "btree",
2549+ "with": {}
2550+ },
2551+ "mcp_source_query_param_secret_id_idx": {
2552+ "name": "mcp_source_query_param_secret_id_idx",
2553+ "columns": [
2554+ {
2555+ "expression": "secret_id",
2556+ "isExpression": false,
2557+ "asc": true,
2558+ "nulls": "last"
2559+ }
2560+ ],
2561+ "isUnique": false,
2562+ "concurrently": false,
2563+ "method": "btree",
2564+ "with": {}
2565+ }
2566+ },
2567+ "foreignKeys": {},
2568+ "compositePrimaryKeys": {
2569+ "mcp_source_query_param_scope_id_id_pk": {
2570+ "name": "mcp_source_query_param_scope_id_id_pk",
2571+ "columns": [
2572+ "scope_id",
2573+ "id"
2574+ ]
2575+ }
2576+ },
2577+ "uniqueConstraints": {},
2578+ "policies": {},
2579+ "checkConstraints": {},
2580+ "isRLSEnabled": false
2581+ }
2582+ },
2583+ "enums": {},
2584+ "schemas": {},
2585+ "sequences": {},
2586+ "roles": {},
2587+ "policies": {},
2588+ "views": {},
2589+ "_meta": {
2590+ "columns": {},
2591+ "schemas": {},
2592+ "tables": {}
2593+ }
2594+}
apps/cloud/drizzle/meta/_journal.json+8−1
@@ -57,6 +57,13 @@ · 56 unmodified lines
5757 "when": 1777567556847,
5858 "tag": "0007_military_young_avengers",
5959 "breakpoints": true
60+ },
61+ {
62+ "idx": 8,
63+ "version": "7",
64+ "when": 1778004191000,
65+ "tag": "0008_normalize_plugin_secret_refs",
66+ "breakpoints": true
6067 }
6168 ]
62−}
69+}
apps/cloud/src/services/executor-schema.ts+133−7
@@ -122,9 +122,7 @@ export const openapi_source = pgTable("openapi_source", { · 121 unmodified lines
122122 source_url: text('source_url'),
123123 base_url: text('base_url'),
124124 headers: jsonb('headers'),
125− query_params: jsonb('query_params'),
126− oauth2: jsonb('oauth2'),
127− invocation_config: jsonb('invocation_config').notNull()
125+ oauth2: jsonb('oauth2')
128126 }, (table) => [
129127 primaryKey({ columns: [table.scope_id, table.id] }),
130128 index("openapi_source_scope_id_idx").on(table.scope_id),
@@ -147,7 +145,10 @@ export const openapi_source_binding = pgTable("openapi_source_binding", { · 16 unmodified lines
147145 source_scope_id: text('source_scope_id').notNull(),
148146 target_scope_id: text('target_scope_id').notNull(),
149147 slot: text('slot').notNull(),
150− value: jsonb('value').notNull(),
148+ kind: text('kind', { enum: ['secret', 'connection', 'text'] }).notNull(),
149+ secret_id: text('secret_id'),
150+ connection_id: text('connection_id'),
151+ text_value: text('text_value'),
151152 created_at: timestamp('created_at').notNull(),
152153 updated_at: timestamp('updated_at').notNull()
153154 }, (table) => [
@@ -155,6 +156,56 @@ export const openapi_source_binding = pgTable("openapi_source_binding", { · 1 unmodified line
155156 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
156157 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
157158 index("openapi_source_binding_slot_idx").on(table.slot),
159+ index("openapi_source_binding_secret_id_idx").on(table.secret_id),
160+ index("openapi_source_binding_connection_id_idx").on(table.connection_id),
161+]);
162+
163+export const openapi_source_query_param = pgTable("openapi_source_query_param", {
164+ id: text('id').notNull(),
165+ scope_id: text('scope_id').notNull(),
166+ source_id: text('source_id').notNull(),
167+ name: text('name').notNull(),
168+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
169+ text_value: text('text_value'),
170+ secret_id: text('secret_id'),
171+ secret_prefix: text('secret_prefix')
172+}, (table) => [
173+ primaryKey({ columns: [table.scope_id, table.id] }),
174+ index("openapi_source_query_param_scope_id_idx").on(table.scope_id),
175+ index("openapi_source_query_param_source_id_idx").on(table.source_id),
176+ index("openapi_source_query_param_secret_id_idx").on(table.secret_id),
177+]);
178+
179+export const openapi_source_spec_fetch_header = pgTable("openapi_source_spec_fetch_header", {
180+ id: text('id').notNull(),
181+ scope_id: text('scope_id').notNull(),
182+ source_id: text('source_id').notNull(),
183+ name: text('name').notNull(),
184+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
185+ text_value: text('text_value'),
186+ secret_id: text('secret_id'),
187+ secret_prefix: text('secret_prefix')
188+}, (table) => [
189+ primaryKey({ columns: [table.scope_id, table.id] }),
190+ index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id),
191+ index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id),
192+ index("openapi_source_spec_fetch_header_secret_id_idx").on(table.secret_id),
193+]);
194+
195+export const openapi_source_spec_fetch_query_param = pgTable("openapi_source_spec_fetch_query_param", {
196+ id: text('id').notNull(),
197+ scope_id: text('scope_id').notNull(),
198+ source_id: text('source_id').notNull(),
199+ name: text('name').notNull(),
200+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
201+ text_value: text('text_value'),
202+ secret_id: text('secret_id'),
203+ secret_prefix: text('secret_prefix')
204+}, (table) => [
205+ primaryKey({ columns: [table.scope_id, table.id] }),
206+ index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id),
207+ index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id),
208+ index("openapi_source_spec_fetch_query_param_secret_id_idx").on(table.secret_id),
158209 ]);
159210
160211 export const mcp_source = pgTable("mcp_source", {
@@ -162,10 +213,53 @@ export const mcp_source = pgTable("mcp_source", { · 1 unmodified line
162213 scope_id: text('scope_id').notNull(),
163214 name: text('name').notNull(),
164215 config: jsonb('config').notNull(),
216+ auth_kind: text('auth_kind', { enum: ['none', 'header', 'oauth2'] }).default("none").notNull(),
217+ auth_header_name: text('auth_header_name'),
218+ auth_secret_id: text('auth_secret_id'),
219+ auth_secret_prefix: text('auth_secret_prefix'),
220+ auth_connection_id: text('auth_connection_id'),
221+ auth_client_id_secret_id: text('auth_client_id_secret_id'),
222+ auth_client_secret_secret_id: text('auth_client_secret_secret_id'),
165223 created_at: timestamp('created_at').notNull()
166224 }, (table) => [
167225 primaryKey({ columns: [table.scope_id, table.id] }),
168226 index("mcp_source_scope_id_idx").on(table.scope_id),
227+ index("mcp_source_auth_secret_id_idx").on(table.auth_secret_id),
228+ index("mcp_source_auth_connection_id_idx").on(table.auth_connection_id),
229+ index("mcp_source_auth_client_id_secret_id_idx").on(table.auth_client_id_secret_id),
230+ index("mcp_source_auth_client_secret_secret_id_idx").on(table.auth_client_secret_secret_id),
231+]);
232+
233+export const mcp_source_header = pgTable("mcp_source_header", {
234+ id: text('id').notNull(),
235+ scope_id: text('scope_id').notNull(),
236+ source_id: text('source_id').notNull(),
237+ name: text('name').notNull(),
238+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
239+ text_value: text('text_value'),
240+ secret_id: text('secret_id'),
241+ secret_prefix: text('secret_prefix')
242+}, (table) => [
243+ primaryKey({ columns: [table.scope_id, table.id] }),
244+ index("mcp_source_header_scope_id_idx").on(table.scope_id),
245+ index("mcp_source_header_source_id_idx").on(table.source_id),
246+ index("mcp_source_header_secret_id_idx").on(table.secret_id),
247+]);
248+
249+export const mcp_source_query_param = pgTable("mcp_source_query_param", {
250+ id: text('id').notNull(),
251+ scope_id: text('scope_id').notNull(),
252+ source_id: text('source_id').notNull(),
253+ name: text('name').notNull(),
254+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
255+ text_value: text('text_value'),
256+ secret_id: text('secret_id'),
257+ secret_prefix: text('secret_prefix')
258+}, (table) => [
259+ primaryKey({ columns: [table.scope_id, table.id] }),
260+ index("mcp_source_query_param_scope_id_idx").on(table.scope_id),
261+ index("mcp_source_query_param_source_id_idx").on(table.source_id),
262+ index("mcp_source_query_param_secret_id_idx").on(table.secret_id),
169263 ]);
170264
171265 export const mcp_binding = pgTable("mcp_binding", {
@@ -185,12 +279,44 @@ export const graphql_source = pgTable("graphql_source", { · 13 unmodified lines
185279 scope_id: text('scope_id').notNull(),
186280 name: text('name').notNull(),
187281 endpoint: text('endpoint').notNull(),
188− headers: jsonb('headers'),
189− query_params: jsonb('query_params'),
190− auth: jsonb('auth')
282+ auth_kind: text('auth_kind', { enum: ['none', 'oauth2'] }).default("none").notNull(),
283+ auth_connection_id: text('auth_connection_id')
191284 }, (table) => [
192285 primaryKey({ columns: [table.scope_id, table.id] }),
193286 index("graphql_source_scope_id_idx").on(table.scope_id),
287+ index("graphql_source_auth_connection_id_idx").on(table.auth_connection_id),
288+]);
289+
290+export const graphql_source_header = pgTable("graphql_source_header", {
291+ id: text('id').notNull(),
292+ scope_id: text('scope_id').notNull(),
293+ source_id: text('source_id').notNull(),
294+ name: text('name').notNull(),
295+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
296+ text_value: text('text_value'),
297+ secret_id: text('secret_id'),
298+ secret_prefix: text('secret_prefix')
299+}, (table) => [
300+ primaryKey({ columns: [table.scope_id, table.id] }),
301+ index("graphql_source_header_scope_id_idx").on(table.scope_id),
302+ index("graphql_source_header_source_id_idx").on(table.source_id),
303+ index("graphql_source_header_secret_id_idx").on(table.secret_id),
304+]);
305+
306+export const graphql_source_query_param = pgTable("graphql_source_query_param", {
307+ id: text('id').notNull(),
308+ scope_id: text('scope_id').notNull(),
309+ source_id: text('source_id').notNull(),
310+ name: text('name').notNull(),
311+ kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
312+ text_value: text('text_value'),
313+ secret_id: text('secret_id'),
314+ secret_prefix: text('secret_prefix')
315+}, (table) => [
316+ primaryKey({ columns: [table.scope_id, table.id] }),
317+ index("graphql_source_query_param_scope_id_idx").on(table.scope_id),
318+ index("graphql_source_query_param_source_id_idx").on(table.source_id),
319+ index("graphql_source_query_param_secret_id_idx").on(table.secret_id),
194320 ]);
195321
196322 export const graphql_operation = pgTable("graphql_operation", {
apps/local/drizzle/0007_normalize_plugin_secret_refs.sql+579−0
@@ -0,0 +1,579 @@
1+-- Normalize all plugin secret/connection refs out of JSON columns
2+-- into proper relational shape: graphql, openapi, mcp,
3+-- google-discovery. Each block is a self-contained backfill of one
4+-- plugin's tables; collected here so the schema lands in one
5+-- migration step instead of four sequential ones.
6+--
7+-- Old shape (per plugin):
8+-- {plugin}_source.{auth, headers, query_params, ...}: json blobs
9+-- openapi_source_binding.value: json union
10+-- {plugin}_source.config.{auth, headers, queryParams}: nested json
11+--
12+-- New shape (per plugin):
13+-- Flat auth_* columns on the parent (auth_kind enum + per-kind id
14+-- columns, all indexed where they hold refs).
15+-- Child tables for SecretBackedMap entries: one row per (source,
16+-- header_name) keyed by JSON tuple [source_id,name] so user keys
17+-- with separators don't collide. `secret_id` indexed for the
18+-- usagesForSecret query.
19+
20+-- ============================================================
21+-- graphql
22+-- ============================================================
23+
24+-- Normalize graphql plugin: move secret/connection refs out of JSON
25+-- columns into proper relational shape so usagesForSecret /
26+-- usagesForConnection are one indexed SELECT instead of a JSON scan.
27+--
28+-- Old shape:
29+-- graphql_source.headers json Record<name, string | {secretId,prefix?}>
30+-- graphql_source.query_params json Record<name, string | {secretId,prefix?}>
31+-- graphql_source.auth json {kind:"none"} | {kind:"oauth2", connectionId}
32+--
33+-- New shape:
34+-- graphql_source.auth_kind enum("none","oauth2") NOT NULL
35+-- graphql_source.auth_connection_id text indexed nullable
36+-- graphql_source_header(scope_id, id, source_id, name, kind, text_value, secret_id, secret_prefix)
37+-- graphql_source_query_param(scope_id, id, source_id, name, kind, text_value, secret_id, secret_prefix)
38+
39+CREATE TABLE `graphql_source_header` (
40+ `id` text NOT NULL,
41+ `scope_id` text NOT NULL,
42+ `source_id` text NOT NULL,
43+ `name` text NOT NULL,
44+ `kind` text NOT NULL,
45+ `text_value` text,
46+ `secret_id` text,
47+ `secret_prefix` text,
48+ PRIMARY KEY(`scope_id`, `id`)
49+);
50+--> statement-breakpoint
51+CREATE INDEX `graphql_source_header_scope_id_idx` ON `graphql_source_header` (`scope_id`);--> statement-breakpoint
52+CREATE INDEX `graphql_source_header_source_id_idx` ON `graphql_source_header` (`source_id`);--> statement-breakpoint
53+CREATE INDEX `graphql_source_header_secret_id_idx` ON `graphql_source_header` (`secret_id`);--> statement-breakpoint
54+
55+CREATE TABLE `graphql_source_query_param` (
56+ `id` text NOT NULL,
57+ `scope_id` text NOT NULL,
58+ `source_id` text NOT NULL,
59+ `name` text NOT NULL,
60+ `kind` text NOT NULL,
61+ `text_value` text,
62+ `secret_id` text,
63+ `secret_prefix` text,
64+ PRIMARY KEY(`scope_id`, `id`)
65+);
66+--> statement-breakpoint
67+CREATE INDEX `graphql_source_query_param_scope_id_idx` ON `graphql_source_query_param` (`scope_id`);--> statement-breakpoint
68+CREATE INDEX `graphql_source_query_param_source_id_idx` ON `graphql_source_query_param` (`source_id`);--> statement-breakpoint
69+CREATE INDEX `graphql_source_query_param_secret_id_idx` ON `graphql_source_query_param` (`secret_id`);--> statement-breakpoint
70+
71+-- New auth columns. `auth_kind` defaults to "none" so existing rows that
72+-- predate this migration are valid even if the json was null.
73+ALTER TABLE `graphql_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
74+ALTER TABLE `graphql_source` ADD `auth_connection_id` text;--> statement-breakpoint
75+CREATE INDEX `graphql_source_auth_connection_id_idx` ON `graphql_source` (`auth_connection_id`);--> statement-breakpoint
76+
77+-- Backfill auth from the JSON column. json_extract returns NULL for
78+-- missing paths, so a row with auth=NULL or kind="none" leaves
79+-- auth_connection_id NULL and auth_kind defaulted to "none".
80+UPDATE `graphql_source`
81+SET
82+ `auth_kind` = COALESCE(json_extract(`auth`, '$.kind'), 'none'),
83+ `auth_connection_id` = json_extract(`auth`, '$.connectionId')
84+WHERE `auth` IS NOT NULL;--> statement-breakpoint
85+
86+-- Backfill headers. For each (source, header_name) pair: if the value
87+-- is a json object with .secretId, write a kind=secret row; otherwise
88+-- write a kind=text row with the literal string. json_each iterates
89+-- the keys of the headers object.
90+INSERT OR IGNORE INTO `graphql_source_header`
91+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
92+SELECT
93+ s.`scope_id`,
94+ json_array(s.`id`, h.`key`),
95+ s.`id`,
96+ h.`key`,
97+ CASE
98+ WHEN h.`type` = 'object' AND json_extract(h.`value`, '$.secretId') IS NOT NULL THEN 'secret'
99+ ELSE 'text'
100+ END,
101+ CASE WHEN h.`type` = 'object' THEN NULL ELSE h.`value` END,
102+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.secretId') ELSE NULL END,
103+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.prefix') ELSE NULL END
104+FROM `graphql_source` s, json_each(s.`headers`) h
105+WHERE s.`headers` IS NOT NULL;--> statement-breakpoint
106+
107+-- Same for query_params.
108+INSERT OR IGNORE INTO `graphql_source_query_param`
109+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
110+SELECT
111+ s.`scope_id`,
112+ json_array(s.`id`, q.`key`),
113+ s.`id`,
114+ q.`key`,
115+ CASE
116+ WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret'
117+ ELSE 'text'
118+ END,
119+ CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END,
120+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END,
121+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END
122+FROM `graphql_source` s, json_each(s.`query_params`) q
123+WHERE s.`query_params` IS NOT NULL;--> statement-breakpoint
124+
125+-- Drop the old JSON columns. SQLite ≥ 3.35 supports ALTER TABLE DROP
126+-- COLUMN directly; bun's bundled SQLite is well past that.
127+ALTER TABLE `graphql_source` DROP COLUMN `headers`;--> statement-breakpoint
128+ALTER TABLE `graphql_source` DROP COLUMN `query_params`;--> statement-breakpoint
129+ALTER TABLE `graphql_source` DROP COLUMN `auth`;
130+
131+--> statement-breakpoint
132+
133+-- ============================================================
134+-- openapi
135+-- ============================================================
136+
137+-- Normalize openapi plugin: move every direct secret/connection ref out
138+-- of JSON columns into proper relational shape.
139+--
140+-- Old shape:
141+-- openapi_source.query_params json Record<name, string | {secretId,prefix?}>
142+-- openapi_source.invocation_config json { specFetchCredentials?: { headers, queryParams } }
143+-- openapi_source_binding.value json discriminated union
144+-- {kind:"secret",secretId} | {kind:"connection",connectionId} | {kind:"text",text}
145+--
146+-- New shape:
147+-- openapi_source_binding gains kind/secret_id/connection_id/text_value columns.
148+-- `headers` / `oauth2` on openapi_source stay JSON because they hold
149+-- slot names, not direct refs — the actual credentials reach those
150+-- slots through openapi_source_binding rows, which ARE normalized.
151+-- openapi_source_query_param: child table, secret-backed entries.
152+-- openapi_source_spec_fetch_header / spec_fetch_query_param: child
153+-- tables for the equivalent maps inside specFetchCredentials.
154+
155+CREATE TABLE `openapi_source_query_param` (
156+ `id` text NOT NULL,
157+ `scope_id` text NOT NULL,
158+ `source_id` text NOT NULL,
159+ `name` text NOT NULL,
160+ `kind` text NOT NULL,
161+ `text_value` text,
162+ `secret_id` text,
163+ `secret_prefix` text,
164+ PRIMARY KEY(`scope_id`, `id`)
165+);
166+--> statement-breakpoint
167+CREATE INDEX `openapi_source_query_param_scope_id_idx` ON `openapi_source_query_param` (`scope_id`);--> statement-breakpoint
168+CREATE INDEX `openapi_source_query_param_source_id_idx` ON `openapi_source_query_param` (`source_id`);--> statement-breakpoint
169+CREATE INDEX `openapi_source_query_param_secret_id_idx` ON `openapi_source_query_param` (`secret_id`);--> statement-breakpoint
170+
171+CREATE TABLE `openapi_source_spec_fetch_header` (
172+ `id` text NOT NULL,
173+ `scope_id` text NOT NULL,
174+ `source_id` text NOT NULL,
175+ `name` text NOT NULL,
176+ `kind` text NOT NULL,
177+ `text_value` text,
178+ `secret_id` text,
179+ `secret_prefix` text,
180+ PRIMARY KEY(`scope_id`, `id`)
181+);
182+--> statement-breakpoint
183+CREATE INDEX `openapi_source_spec_fetch_header_scope_id_idx` ON `openapi_source_spec_fetch_header` (`scope_id`);--> statement-breakpoint
184+CREATE INDEX `openapi_source_spec_fetch_header_source_id_idx` ON `openapi_source_spec_fetch_header` (`source_id`);--> statement-breakpoint
185+CREATE INDEX `openapi_source_spec_fetch_header_secret_id_idx` ON `openapi_source_spec_fetch_header` (`secret_id`);--> statement-breakpoint
186+
187+CREATE TABLE `openapi_source_spec_fetch_query_param` (
188+ `id` text NOT NULL,
189+ `scope_id` text NOT NULL,
190+ `source_id` text NOT NULL,
191+ `name` text NOT NULL,
192+ `kind` text NOT NULL,
193+ `text_value` text,
194+ `secret_id` text,
195+ `secret_prefix` text,
196+ PRIMARY KEY(`scope_id`, `id`)
197+);
198+--> statement-breakpoint
199+CREATE INDEX `openapi_source_spec_fetch_query_param_scope_id_idx` ON `openapi_source_spec_fetch_query_param` (`scope_id`);--> statement-breakpoint
200+CREATE INDEX `openapi_source_spec_fetch_query_param_source_id_idx` ON `openapi_source_spec_fetch_query_param` (`source_id`);--> statement-breakpoint
201+CREATE INDEX `openapi_source_spec_fetch_query_param_secret_id_idx` ON `openapi_source_spec_fetch_query_param` (`secret_id`);--> statement-breakpoint
202+
203+-- New columns on openapi_source_binding to flatten the value json.
204+-- `kind` defaults to 'text' so the ALTER works on existing rows; the
205+-- backfill below stamps the real value.
206+ALTER TABLE `openapi_source_binding` ADD `kind` text DEFAULT 'text' NOT NULL;--> statement-breakpoint
207+ALTER TABLE `openapi_source_binding` ADD `secret_id` text;--> statement-breakpoint
208+ALTER TABLE `openapi_source_binding` ADD `connection_id` text;--> statement-breakpoint
209+ALTER TABLE `openapi_source_binding` ADD `text_value` text;--> statement-breakpoint
210+CREATE INDEX `openapi_source_binding_secret_id_idx` ON `openapi_source_binding` (`secret_id`);--> statement-breakpoint
211+CREATE INDEX `openapi_source_binding_connection_id_idx` ON `openapi_source_binding` (`connection_id`);--> statement-breakpoint
212+
213+-- Backfill the binding columns from the legacy `value` JSON. We pull
214+-- $.kind into `kind` directly; for each kind the matching id field
215+-- (`secretId` / `connectionId` / `text`) gets copied into the matching
216+-- column. Rows whose value JSON is malformed or missing $.kind fall
217+-- through to kind='text' with a NULL text_value — same as a missing
218+-- text binding, the source will surface "binding not configured" at
219+-- invoke time rather than crashing the migration.
220+UPDATE `openapi_source_binding`
221+SET
222+ `kind` = COALESCE(json_extract(`value`, '$.kind'), 'text'),
223+ `secret_id` = CASE WHEN json_extract(`value`, '$.kind') = 'secret' THEN json_extract(`value`, '$.secretId') ELSE NULL END,
224+ `connection_id` = CASE WHEN json_extract(`value`, '$.kind') = 'connection' THEN json_extract(`value`, '$.connectionId') ELSE NULL END,
225+ `text_value` = CASE WHEN json_extract(`value`, '$.kind') = 'text' THEN json_extract(`value`, '$.text') ELSE NULL END
226+WHERE `value` IS NOT NULL;--> statement-breakpoint
227+
228+-- Backfill openapi_source_query_param from openapi_source.query_params.
229+-- json_each iterates the keys of the query_params object. For each
230+-- entry: if the value is an object with .secretId, write a kind=secret
231+-- row; otherwise write a kind=text row with the literal string.
232+INSERT OR IGNORE INTO `openapi_source_query_param`
233+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
234+SELECT
235+ s.`scope_id`,
236+ json_array(s.`id`, q.`key`),
237+ s.`id`,
238+ q.`key`,
239+ CASE
240+ WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret'
241+ ELSE 'text'
242+ END,
243+ CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END,
244+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END,
245+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END
246+FROM `openapi_source` s, json_each(s.`query_params`) q
247+WHERE s.`query_params` IS NOT NULL;--> statement-breakpoint
248+
249+-- Backfill openapi_source_spec_fetch_header from
250+-- openapi_source.invocation_config.specFetchCredentials.headers. Same
251+-- shape as query_params; the JSON path is one level deeper.
252+INSERT OR IGNORE INTO `openapi_source_spec_fetch_header`
253+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
254+SELECT
255+ s.`scope_id`,
256+ json_array(s.`id`, h.`key`),
257+ s.`id`,
258+ h.`key`,
259+ CASE
260+ WHEN h.`type` = 'object' AND json_extract(h.`value`, '$.secretId') IS NOT NULL THEN 'secret'
261+ ELSE 'text'
262+ END,
263+ CASE WHEN h.`type` = 'object' THEN NULL ELSE h.`value` END,
264+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.secretId') ELSE NULL END,
265+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.prefix') ELSE NULL END
266+FROM `openapi_source` s, json_each(json_extract(s.`invocation_config`, '$.specFetchCredentials.headers')) h
267+WHERE json_extract(s.`invocation_config`, '$.specFetchCredentials.headers') IS NOT NULL;--> statement-breakpoint
268+
269+INSERT OR IGNORE INTO `openapi_source_spec_fetch_query_param`
270+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
271+SELECT
272+ s.`scope_id`,
273+ json_array(s.`id`, q.`key`),
274+ s.`id`,
275+ q.`key`,
276+ CASE
277+ WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret'
278+ ELSE 'text'
279+ END,
280+ CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END,
281+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END,
282+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END
283+FROM `openapi_source` s, json_each(json_extract(s.`invocation_config`, '$.specFetchCredentials.queryParams')) q
284+WHERE json_extract(s.`invocation_config`, '$.specFetchCredentials.queryParams') IS NOT NULL;--> statement-breakpoint
285+
286+-- Preserve any legacy OAuth payload from invocation_config.oauth2 into
287+-- the still-existing oauth2 column before we drop invocation_config.
288+-- migrateLegacyConnections runs after drizzle migrations and reads
289+-- oauth2 to detect the legacy shape; without this, rows that only had
290+-- their OAuth payload under invocation_config.oauth2 would lose it.
291+UPDATE `openapi_source`
292+SET `oauth2` = json_extract(`invocation_config`, '$.oauth2')
293+WHERE `oauth2` IS NULL
294+ AND json_extract(`invocation_config`, '$.oauth2') IS NOT NULL;--> statement-breakpoint
295+
296+-- Drop the legacy JSON columns now that everything is normalized.
297+ALTER TABLE `openapi_source_binding` DROP COLUMN `value`;--> statement-breakpoint
298+ALTER TABLE `openapi_source` DROP COLUMN `query_params`;--> statement-breakpoint
299+ALTER TABLE `openapi_source` DROP COLUMN `invocation_config`;
300+
301+--> statement-breakpoint
302+
303+-- ============================================================
304+-- mcp
305+-- ============================================================
306+
307+-- Normalize mcp plugin: lift the McpConnectionAuth secret/connection
308+-- refs and the SecretBackedMap headers/query_params out of
309+-- mcp_source.config JSON into proper columns / child tables.
310+--
311+-- Old shape:
312+-- mcp_source.config (json) — McpStoredSourceData discriminated union
313+-- remote: { transport, endpoint, remoteTransport?, queryParams?,
314+-- headers?, auth: McpConnectionAuth }
315+-- stdio: { transport, command, args?, env?, cwd? }
316+--
317+-- New shape:
318+-- mcp_source gains: auth_kind enum, auth_header_name, auth_secret_id,
319+-- auth_secret_prefix, auth_connection_id, auth_client_id_secret_id,
320+-- auth_client_secret_secret_id. The remaining structural fields
321+-- stay in `config` as JSON because they're plugin-private and
322+-- vary by transport.
323+-- mcp_source_header / mcp_source_query_param: child tables for
324+-- remote sources' SecretBackedMap entries (same column shape as
325+-- graphql_source_header / openapi_source_query_param).
326+
327+CREATE TABLE `mcp_source_header` (
328+ `id` text NOT NULL,
329+ `scope_id` text NOT NULL,
330+ `source_id` text NOT NULL,
331+ `name` text NOT NULL,
332+ `kind` text NOT NULL,
333+ `text_value` text,
334+ `secret_id` text,
335+ `secret_prefix` text,
336+ PRIMARY KEY(`scope_id`, `id`)
337+);
338+--> statement-breakpoint
339+CREATE INDEX `mcp_source_header_scope_id_idx` ON `mcp_source_header` (`scope_id`);--> statement-breakpoint
340+CREATE INDEX `mcp_source_header_source_id_idx` ON `mcp_source_header` (`source_id`);--> statement-breakpoint
341+CREATE INDEX `mcp_source_header_secret_id_idx` ON `mcp_source_header` (`secret_id`);--> statement-breakpoint
342+
343+CREATE TABLE `mcp_source_query_param` (
344+ `id` text NOT NULL,
345+ `scope_id` text NOT NULL,
346+ `source_id` text NOT NULL,
347+ `name` text NOT NULL,
348+ `kind` text NOT NULL,
349+ `text_value` text,
350+ `secret_id` text,
351+ `secret_prefix` text,
352+ PRIMARY KEY(`scope_id`, `id`)
353+);
354+--> statement-breakpoint
355+CREATE INDEX `mcp_source_query_param_scope_id_idx` ON `mcp_source_query_param` (`scope_id`);--> statement-breakpoint
356+CREATE INDEX `mcp_source_query_param_source_id_idx` ON `mcp_source_query_param` (`source_id`);--> statement-breakpoint
357+CREATE INDEX `mcp_source_query_param_secret_id_idx` ON `mcp_source_query_param` (`secret_id`);--> statement-breakpoint
358+
359+-- New auth columns. `auth_kind` defaults to "none" so the ALTER passes
360+-- on existing rows; the backfill below stamps the real value.
361+ALTER TABLE `mcp_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
362+ALTER TABLE `mcp_source` ADD `auth_header_name` text;--> statement-breakpoint
363+ALTER TABLE `mcp_source` ADD `auth_secret_id` text;--> statement-breakpoint
364+ALTER TABLE `mcp_source` ADD `auth_secret_prefix` text;--> statement-breakpoint
365+ALTER TABLE `mcp_source` ADD `auth_connection_id` text;--> statement-breakpoint
366+ALTER TABLE `mcp_source` ADD `auth_client_id_secret_id` text;--> statement-breakpoint
367+ALTER TABLE `mcp_source` ADD `auth_client_secret_secret_id` text;--> statement-breakpoint
368+CREATE INDEX `mcp_source_auth_secret_id_idx` ON `mcp_source` (`auth_secret_id`);--> statement-breakpoint
369+CREATE INDEX `mcp_source_auth_connection_id_idx` ON `mcp_source` (`auth_connection_id`);--> statement-breakpoint
370+CREATE INDEX `mcp_source_auth_client_id_secret_id_idx` ON `mcp_source` (`auth_client_id_secret_id`);--> statement-breakpoint
371+CREATE INDEX `mcp_source_auth_client_secret_secret_id_idx` ON `mcp_source` (`auth_client_secret_secret_id`);--> statement-breakpoint
372+
373+-- Backfill auth columns from config.auth — but only for rows whose
374+-- config.auth matches the *current* shape:
375+-- - kind=none (no extra fields)
376+-- - kind=header (secretId present)
377+-- - kind=oauth2 (connectionId present)
378+-- Truly-legacy rows (inline OAuth shape with accessTokenSecretId etc.)
379+-- are left untouched here so the post-migrate `migrateLegacyConnections`
380+-- script can convert them to a Connection and write the resulting
381+-- pointer to these columns. Setting auth_kind explicitly to NULL/none
382+-- on those rows would lose the legacy payload before it gets converted.
383+UPDATE `mcp_source`
384+SET
385+ `auth_kind` = json_extract(`config`, '$.auth.kind'),
386+ `auth_header_name` = json_extract(`config`, '$.auth.headerName'),
387+ `auth_secret_id` = json_extract(`config`, '$.auth.secretId'),
388+ `auth_secret_prefix` = json_extract(`config`, '$.auth.prefix'),
389+ `auth_connection_id` = json_extract(`config`, '$.auth.connectionId'),
390+ `auth_client_id_secret_id` = json_extract(`config`, '$.auth.clientIdSecretId'),
391+ `auth_client_secret_secret_id` = json_extract(`config`, '$.auth.clientSecretSecretId')
392+WHERE `config` IS NOT NULL
393+ AND (
394+ -- kind=none and "no auth at all" both leave auth_kind defaulted to
395+ -- 'none' (the column DEFAULT), so we only UPDATE rows that have a
396+ -- non-trivial current-shape auth payload to extract.
397+ (
398+ json_extract(`config`, '$.auth.kind') = 'header'
399+ AND json_extract(`config`, '$.auth.secretId') IS NOT NULL
400+ )
401+ OR (
402+ json_extract(`config`, '$.auth.kind') = 'oauth2'
403+ AND json_extract(`config`, '$.auth.connectionId') IS NOT NULL
404+ )
405+ );--> statement-breakpoint
406+
407+-- Backfill mcp_source_header from config.headers. Remote sources only;
408+-- stdio's config has no `.headers` key so json_each returns nothing.
409+INSERT OR IGNORE INTO `mcp_source_header`
410+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
411+SELECT
412+ s.`scope_id`,
413+ json_array(s.`id`, h.`key`),
414+ s.`id`,
415+ h.`key`,
416+ CASE
417+ WHEN h.`type` = 'object' AND json_extract(h.`value`, '$.secretId') IS NOT NULL THEN 'secret'
418+ ELSE 'text'
419+ END,
420+ CASE WHEN h.`type` = 'object' THEN NULL ELSE h.`value` END,
421+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.secretId') ELSE NULL END,
422+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.prefix') ELSE NULL END
423+FROM `mcp_source` s, json_each(json_extract(s.`config`, '$.headers')) h
424+WHERE json_extract(s.`config`, '$.headers') IS NOT NULL;--> statement-breakpoint
425+
426+INSERT OR IGNORE INTO `mcp_source_query_param`
427+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
428+SELECT
429+ s.`scope_id`,
430+ json_array(s.`id`, q.`key`),
431+ s.`id`,
432+ q.`key`,
433+ CASE
434+ WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret'
435+ ELSE 'text'
436+ END,
437+ CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END,
438+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END,
439+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END
440+FROM `mcp_source` s, json_each(json_extract(s.`config`, '$.queryParams')) q
441+WHERE json_extract(s.`config`, '$.queryParams') IS NOT NULL;--> statement-breakpoint
442+
443+-- Strip the now-extracted fields from the legacy config JSON. Skip
444+-- rows whose config.auth still holds a legacy inline-OAuth payload —
445+-- migrateLegacyConnections needs to read it to mint the matching
446+-- Connection. headers/queryParams are always safe to strip (already
447+-- copied to child tables). SQLite's json_remove returns the input
448+-- unchanged when a path is missing, so stdio rows pass through
449+-- cleanly.
450+UPDATE `mcp_source`
451+SET `config` = json_remove(`config`, '$.headers', '$.queryParams')
452+WHERE `config` IS NOT NULL;--> statement-breakpoint
453+
454+UPDATE `mcp_source`
455+SET `config` = json_remove(`config`, '$.auth')
456+WHERE `config` IS NOT NULL
457+ AND (
458+ json_extract(`config`, '$.auth.kind') = 'none'
459+ OR (
460+ json_extract(`config`, '$.auth.kind') = 'header'
461+ AND json_extract(`config`, '$.auth.secretId') IS NOT NULL
462+ )
463+ OR (
464+ json_extract(`config`, '$.auth.kind') = 'oauth2'
465+ AND json_extract(`config`, '$.auth.connectionId') IS NOT NULL
466+ )
467+ );
468+
469+--> statement-breakpoint
470+
471+-- ============================================================
472+-- google-discovery
473+-- ============================================================
474+
475+-- Normalize google-discovery plugin: lift the GoogleDiscoveryAuth and
476+-- the credentials.{headers,queryParams} SecretBackedMaps out of
477+-- google_discovery_source.config JSON.
478+--
479+-- Old shape:
480+-- google_discovery_source.config (json) — GoogleDiscoveryStoredSourceData
481+-- with `auth: {kind:"none"} | {kind:"oauth2", connectionId, clientId..., clientSecret..., scopes}`
482+-- and optional `credentials: { headers?, queryParams? }`
483+--
484+-- New shape:
485+-- google_discovery_source gains: auth_kind, auth_connection_id,
486+-- auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes.
487+-- google_discovery_source_credential_header / _query_param: child
488+-- tables for the SecretBackedMap entries.
489+
490+CREATE TABLE `google_discovery_source_credential_header` (
491+ `id` text NOT NULL,
492+ `scope_id` text NOT NULL,
493+ `source_id` text NOT NULL,
494+ `name` text NOT NULL,
495+ `kind` text NOT NULL,
496+ `text_value` text,
497+ `secret_id` text,
498+ `secret_prefix` text,
499+ PRIMARY KEY(`scope_id`, `id`)
500+);
501+--> statement-breakpoint
502+CREATE INDEX `google_discovery_source_credential_header_scope_id_idx` ON `google_discovery_source_credential_header` (`scope_id`);--> statement-breakpoint
503+CREATE INDEX `google_discovery_source_credential_header_source_id_idx` ON `google_discovery_source_credential_header` (`source_id`);--> statement-breakpoint
504+CREATE INDEX `google_discovery_source_credential_header_secret_id_idx` ON `google_discovery_source_credential_header` (`secret_id`);--> statement-breakpoint
505+
506+CREATE TABLE `google_discovery_source_credential_query_param` (
507+ `id` text NOT NULL,
508+ `scope_id` text NOT NULL,
509+ `source_id` text NOT NULL,
510+ `name` text NOT NULL,
511+ `kind` text NOT NULL,
512+ `text_value` text,
513+ `secret_id` text,
514+ `secret_prefix` text,
515+ PRIMARY KEY(`scope_id`, `id`)
516+);
517+--> statement-breakpoint
518+CREATE INDEX `google_discovery_source_credential_query_param_scope_id_idx` ON `google_discovery_source_credential_query_param` (`scope_id`);--> statement-breakpoint
519+CREATE INDEX `google_discovery_source_credential_query_param_source_id_idx` ON `google_discovery_source_credential_query_param` (`source_id`);--> statement-breakpoint
520+CREATE INDEX `google_discovery_source_credential_query_param_secret_id_idx` ON `google_discovery_source_credential_query_param` (`secret_id`);--> statement-breakpoint
521+
522+ALTER TABLE `google_discovery_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
523+ALTER TABLE `google_discovery_source` ADD `auth_connection_id` text;--> statement-breakpoint
524+ALTER TABLE `google_discovery_source` ADD `auth_client_id_secret_id` text;--> statement-breakpoint
525+ALTER TABLE `google_discovery_source` ADD `auth_client_secret_secret_id` text;--> statement-breakpoint
526+ALTER TABLE `google_discovery_source` ADD `auth_scopes` text;--> statement-breakpoint
527+CREATE INDEX `google_discovery_source_auth_connection_id_idx` ON `google_discovery_source` (`auth_connection_id`);--> statement-breakpoint
528+CREATE INDEX `google_discovery_source_auth_client_id_secret_id_idx` ON `google_discovery_source` (`auth_client_id_secret_id`);--> statement-breakpoint
529+CREATE INDEX `google_discovery_source_auth_client_secret_secret_id_idx` ON `google_discovery_source` (`auth_client_secret_secret_id`);--> statement-breakpoint
530+
531+-- Backfill auth columns from config.auth.
532+UPDATE `google_discovery_source`
533+SET
534+ `auth_kind` = COALESCE(json_extract(`config`, '$.auth.kind'), 'none'),
535+ `auth_connection_id` = json_extract(`config`, '$.auth.connectionId'),
536+ `auth_client_id_secret_id` = json_extract(`config`, '$.auth.clientIdSecretId'),
537+ `auth_client_secret_secret_id` = json_extract(`config`, '$.auth.clientSecretSecretId'),
538+ `auth_scopes` = json_extract(`config`, '$.auth.scopes')
539+WHERE `config` IS NOT NULL;--> statement-breakpoint
540+
541+-- Backfill credential header / query_param child rows.
542+INSERT OR IGNORE INTO `google_discovery_source_credential_header`
543+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
544+SELECT
545+ s.`scope_id`,
546+ json_array(s.`id`, h.`key`),
547+ s.`id`,
548+ h.`key`,
549+ CASE
550+ WHEN h.`type` = 'object' AND json_extract(h.`value`, '$.secretId') IS NOT NULL THEN 'secret'
551+ ELSE 'text'
552+ END,
553+ CASE WHEN h.`type` = 'object' THEN NULL ELSE h.`value` END,
554+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.secretId') ELSE NULL END,
555+ CASE WHEN h.`type` = 'object' THEN json_extract(h.`value`, '$.prefix') ELSE NULL END
556+FROM `google_discovery_source` s, json_each(json_extract(s.`config`, '$.credentials.headers')) h
557+WHERE json_extract(s.`config`, '$.credentials.headers') IS NOT NULL;--> statement-breakpoint
558+
559+INSERT OR IGNORE INTO `google_discovery_source_credential_query_param`
560+ (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
561+SELECT
562+ s.`scope_id`,
563+ json_array(s.`id`, q.`key`),
564+ s.`id`,
565+ q.`key`,
566+ CASE
567+ WHEN q.`type` = 'object' AND json_extract(q.`value`, '$.secretId') IS NOT NULL THEN 'secret'
568+ ELSE 'text'
569+ END,
570+ CASE WHEN q.`type` = 'object' THEN NULL ELSE q.`value` END,
571+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.secretId') ELSE NULL END,
572+ CASE WHEN q.`type` = 'object' THEN json_extract(q.`value`, '$.prefix') ELSE NULL END
573+FROM `google_discovery_source` s, json_each(json_extract(s.`config`, '$.credentials.queryParams')) q
574+WHERE json_extract(s.`config`, '$.credentials.queryParams') IS NOT NULL;--> statement-breakpoint
575+
576+-- Strip the extracted fields from the legacy config JSON.
577+UPDATE `google_discovery_source`
578+SET `config` = json_remove(`config`, '$.auth', '$.credentials')
579+WHERE `config` IS NOT NULL;
apps/local/drizzle/meta/0007_snapshot.json+2422−0
@@ -0,0 +1,2422 @@
1+{
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "44444444-5555-6666-7777-888888888888",
5+ "prevId": "7a331df8-ce69-4d97-b6ce-6bf3aff98b56",
6+ "tables": {
7+ "blob": {
8+ "name": "blob",
9+ "columns": {
10+ "namespace": {
11+ "name": "namespace",
12+ "type": "text",
13+ "primaryKey": false,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "key": {
18+ "name": "key",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "autoincrement": false
23+ },
24+ "value": {
25+ "name": "value",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false
30+ }
31+ },
32+ "indexes": {},
33+ "foreignKeys": {},
34+ "compositePrimaryKeys": {
35+ "blob_namespace_key_pk": {
36+ "columns": [
37+ "namespace",
38+ "key"
39+ ],
40+ "name": "blob_namespace_key_pk"
41+ }
42+ },
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "connection": {
47+ "name": "connection",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "scope_id": {
57+ "name": "scope_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "provider": {
64+ "name": "provider",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "identity_label": {
71+ "name": "identity_label",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": false,
75+ "autoincrement": false
76+ },
77+ "access_token_secret_id": {
78+ "name": "access_token_secret_id",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "autoincrement": false
83+ },
84+ "refresh_token_secret_id": {
85+ "name": "refresh_token_secret_id",
86+ "type": "text",
87+ "primaryKey": false,
88+ "notNull": false,
89+ "autoincrement": false
90+ },
91+ "expires_at": {
92+ "name": "expires_at",
93+ "type": "integer",
94+ "primaryKey": false,
95+ "notNull": false,
96+ "autoincrement": false
97+ },
98+ "scope": {
99+ "name": "scope",
100+ "type": "text",
101+ "primaryKey": false,
102+ "notNull": false,
103+ "autoincrement": false
104+ },
105+ "provider_state": {
106+ "name": "provider_state",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": false,
110+ "autoincrement": false
111+ },
112+ "created_at": {
113+ "name": "created_at",
114+ "type": "integer",
115+ "primaryKey": false,
116+ "notNull": true,
117+ "autoincrement": false
118+ },
119+ "updated_at": {
120+ "name": "updated_at",
121+ "type": "integer",
122+ "primaryKey": false,
123+ "notNull": true,
124+ "autoincrement": false
125+ }
126+ },
127+ "indexes": {
128+ "connection_scope_id_idx": {
129+ "name": "connection_scope_id_idx",
130+ "columns": [
131+ "scope_id"
132+ ],
133+ "isUnique": false
134+ },
135+ "connection_provider_idx": {
136+ "name": "connection_provider_idx",
137+ "columns": [
138+ "provider"
139+ ],
140+ "isUnique": false
141+ }
142+ },
143+ "foreignKeys": {},
144+ "compositePrimaryKeys": {
145+ "connection_scope_id_id_pk": {
146+ "columns": [
147+ "scope_id",
148+ "id"
149+ ],
150+ "name": "connection_scope_id_id_pk"
151+ }
152+ },
153+ "uniqueConstraints": {},
154+ "checkConstraints": {}
155+ },
156+ "definition": {
157+ "name": "definition",
158+ "columns": {
159+ "id": {
160+ "name": "id",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": true,
164+ "autoincrement": false
165+ },
166+ "scope_id": {
167+ "name": "scope_id",
168+ "type": "text",
169+ "primaryKey": false,
170+ "notNull": true,
171+ "autoincrement": false
172+ },
173+ "source_id": {
174+ "name": "source_id",
175+ "type": "text",
176+ "primaryKey": false,
177+ "notNull": true,
178+ "autoincrement": false
179+ },
180+ "plugin_id": {
181+ "name": "plugin_id",
182+ "type": "text",
183+ "primaryKey": false,
184+ "notNull": true,
185+ "autoincrement": false
186+ },
187+ "name": {
188+ "name": "name",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true,
192+ "autoincrement": false
193+ },
194+ "schema": {
195+ "name": "schema",
196+ "type": "text",
197+ "primaryKey": false,
198+ "notNull": true,
199+ "autoincrement": false
200+ },
201+ "created_at": {
202+ "name": "created_at",
203+ "type": "integer",
204+ "primaryKey": false,
205+ "notNull": true,
206+ "autoincrement": false
207+ }
208+ },
209+ "indexes": {
210+ "definition_scope_id_idx": {
211+ "name": "definition_scope_id_idx",
212+ "columns": [
213+ "scope_id"
214+ ],
215+ "isUnique": false
216+ },
217+ "definition_source_id_idx": {
218+ "name": "definition_source_id_idx",
219+ "columns": [
220+ "source_id"
221+ ],
222+ "isUnique": false
223+ },
224+ "definition_plugin_id_idx": {
225+ "name": "definition_plugin_id_idx",
226+ "columns": [
227+ "plugin_id"
228+ ],
229+ "isUnique": false
230+ }
231+ },
232+ "foreignKeys": {},
233+ "compositePrimaryKeys": {
234+ "definition_scope_id_id_pk": {
235+ "columns": [
236+ "scope_id",
237+ "id"
238+ ],
239+ "name": "definition_scope_id_id_pk"
240+ }
241+ },
242+ "uniqueConstraints": {},
243+ "checkConstraints": {}
244+ },
245+ "google_discovery_binding": {
246+ "name": "google_discovery_binding",
247+ "columns": {
248+ "id": {
249+ "name": "id",
250+ "type": "text",
251+ "primaryKey": false,
252+ "notNull": true,
253+ "autoincrement": false
254+ },
255+ "scope_id": {
256+ "name": "scope_id",
257+ "type": "text",
258+ "primaryKey": false,
259+ "notNull": true,
260+ "autoincrement": false
261+ },
262+ "source_id": {
263+ "name": "source_id",
264+ "type": "text",
265+ "primaryKey": false,
266+ "notNull": true,
267+ "autoincrement": false
268+ },
269+ "binding": {
270+ "name": "binding",
271+ "type": "text",
272+ "primaryKey": false,
273+ "notNull": true,
274+ "autoincrement": false
275+ },
276+ "created_at": {
277+ "name": "created_at",
278+ "type": "integer",
279+ "primaryKey": false,
280+ "notNull": true,
281+ "autoincrement": false
282+ }
283+ },
284+ "indexes": {
285+ "google_discovery_binding_scope_id_idx": {
286+ "name": "google_discovery_binding_scope_id_idx",
287+ "columns": [
288+ "scope_id"
289+ ],
290+ "isUnique": false
291+ },
292+ "google_discovery_binding_source_id_idx": {
293+ "name": "google_discovery_binding_source_id_idx",
294+ "columns": [
295+ "source_id"
296+ ],
297+ "isUnique": false
298+ }
299+ },
300+ "foreignKeys": {},
301+ "compositePrimaryKeys": {
302+ "google_discovery_binding_scope_id_id_pk": {
303+ "columns": [
304+ "scope_id",
305+ "id"
306+ ],
307+ "name": "google_discovery_binding_scope_id_id_pk"
308+ }
309+ },
310+ "uniqueConstraints": {},
311+ "checkConstraints": {}
312+ },
313+ "google_discovery_source": {
314+ "name": "google_discovery_source",
315+ "columns": {
316+ "id": {
317+ "name": "id",
318+ "type": "text",
319+ "primaryKey": false,
320+ "notNull": true,
321+ "autoincrement": false
322+ },
323+ "scope_id": {
324+ "name": "scope_id",
325+ "type": "text",
326+ "primaryKey": false,
327+ "notNull": true,
328+ "autoincrement": false
329+ },
330+ "name": {
331+ "name": "name",
332+ "type": "text",
333+ "primaryKey": false,
334+ "notNull": true,
335+ "autoincrement": false
336+ },
337+ "config": {
338+ "name": "config",
339+ "type": "text",
340+ "primaryKey": false,
341+ "notNull": true,
342+ "autoincrement": false
343+ },
344+ "created_at": {
345+ "name": "created_at",
346+ "type": "integer",
347+ "primaryKey": false,
348+ "notNull": true,
349+ "autoincrement": false
350+ },
351+ "updated_at": {
352+ "name": "updated_at",
353+ "type": "integer",
354+ "primaryKey": false,
355+ "notNull": true,
356+ "autoincrement": false
357+ },
358+ "auth_kind": {
359+ "name": "auth_kind",
360+ "type": "text",
361+ "primaryKey": false,
362+ "notNull": true,
363+ "autoincrement": false,
364+ "default": "'none'"
365+ },
366+ "auth_connection_id": {
367+ "name": "auth_connection_id",
368+ "type": "text",
369+ "primaryKey": false,
370+ "notNull": false,
371+ "autoincrement": false
372+ },
373+ "auth_client_id_secret_id": {
374+ "name": "auth_client_id_secret_id",
375+ "type": "text",
376+ "primaryKey": false,
377+ "notNull": false,
378+ "autoincrement": false
379+ },
380+ "auth_client_secret_secret_id": {
381+ "name": "auth_client_secret_secret_id",
382+ "type": "text",
383+ "primaryKey": false,
384+ "notNull": false,
385+ "autoincrement": false
386+ },
387+ "auth_scopes": {
388+ "name": "auth_scopes",
389+ "type": "text",
390+ "primaryKey": false,
391+ "notNull": false,
392+ "autoincrement": false
393+ }
394+ },
395+ "indexes": {
396+ "google_discovery_source_scope_id_idx": {
397+ "name": "google_discovery_source_scope_id_idx",
398+ "columns": [
399+ "scope_id"
400+ ],
401+ "isUnique": false
402+ },
403+ "google_discovery_source_auth_connection_id_idx": {
404+ "name": "google_discovery_source_auth_connection_id_idx",
405+ "columns": [
406+ "auth_connection_id"
407+ ],
408+ "isUnique": false
409+ },
410+ "google_discovery_source_auth_client_id_secret_id_idx": {
411+ "name": "google_discovery_source_auth_client_id_secret_id_idx",
412+ "columns": [
413+ "auth_client_id_secret_id"
414+ ],
415+ "isUnique": false
416+ },
417+ "google_discovery_source_auth_client_secret_secret_id_idx": {
418+ "name": "google_discovery_source_auth_client_secret_secret_id_idx",
419+ "columns": [
420+ "auth_client_secret_secret_id"
421+ ],
422+ "isUnique": false
423+ }
424+ },
425+ "foreignKeys": {},
426+ "compositePrimaryKeys": {
427+ "google_discovery_source_scope_id_id_pk": {
428+ "columns": [
429+ "scope_id",
430+ "id"
431+ ],
432+ "name": "google_discovery_source_scope_id_id_pk"
433+ }
434+ },
435+ "uniqueConstraints": {},
436+ "checkConstraints": {}
437+ },
438+ "graphql_operation": {
439+ "name": "graphql_operation",
440+ "columns": {
441+ "id": {
442+ "name": "id",
443+ "type": "text",
444+ "primaryKey": false,
445+ "notNull": true,
446+ "autoincrement": false
447+ },
448+ "scope_id": {
449+ "name": "scope_id",
450+ "type": "text",
451+ "primaryKey": false,
452+ "notNull": true,
453+ "autoincrement": false
454+ },
455+ "source_id": {
456+ "name": "source_id",
457+ "type": "text",
458+ "primaryKey": false,
459+ "notNull": true,
460+ "autoincrement": false
461+ },
462+ "binding": {
463+ "name": "binding",
464+ "type": "text",
465+ "primaryKey": false,
466+ "notNull": true,
467+ "autoincrement": false
468+ }
469+ },
470+ "indexes": {
471+ "graphql_operation_scope_id_idx": {
472+ "name": "graphql_operation_scope_id_idx",
473+ "columns": [
474+ "scope_id"
475+ ],
476+ "isUnique": false
477+ },
478+ "graphql_operation_source_id_idx": {
479+ "name": "graphql_operation_source_id_idx",
480+ "columns": [
481+ "source_id"
482+ ],
483+ "isUnique": false
484+ }
485+ },
486+ "foreignKeys": {},
487+ "compositePrimaryKeys": {
488+ "graphql_operation_scope_id_id_pk": {
489+ "columns": [
490+ "scope_id",
491+ "id"
492+ ],
493+ "name": "graphql_operation_scope_id_id_pk"
494+ }
495+ },
496+ "uniqueConstraints": {},
497+ "checkConstraints": {}
498+ },
499+ "graphql_source": {
500+ "name": "graphql_source",
501+ "columns": {
502+ "id": {
503+ "name": "id",
504+ "type": "text",
505+ "primaryKey": false,
506+ "notNull": true,
507+ "autoincrement": false
508+ },
509+ "scope_id": {
510+ "name": "scope_id",
511+ "type": "text",
512+ "primaryKey": false,
513+ "notNull": true,
514+ "autoincrement": false
515+ },
516+ "name": {
517+ "name": "name",
518+ "type": "text",
519+ "primaryKey": false,
520+ "notNull": true,
521+ "autoincrement": false
522+ },
523+ "endpoint": {
524+ "name": "endpoint",
525+ "type": "text",
526+ "primaryKey": false,
527+ "notNull": true,
528+ "autoincrement": false
529+ },
530+ "auth_kind": {
531+ "name": "auth_kind",
532+ "type": "text",
533+ "primaryKey": false,
534+ "notNull": true,
535+ "autoincrement": false,
536+ "default": "'none'"
537+ },
538+ "auth_connection_id": {
539+ "name": "auth_connection_id",
540+ "type": "text",
541+ "primaryKey": false,
542+ "notNull": false,
543+ "autoincrement": false
544+ }
545+ },
546+ "indexes": {
547+ "graphql_source_scope_id_idx": {
548+ "name": "graphql_source_scope_id_idx",
549+ "columns": [
550+ "scope_id"
551+ ],
552+ "isUnique": false
553+ },
554+ "graphql_source_auth_connection_id_idx": {
555+ "name": "graphql_source_auth_connection_id_idx",
556+ "columns": [
557+ "auth_connection_id"
558+ ],
559+ "isUnique": false
560+ }
561+ },
562+ "foreignKeys": {},
563+ "compositePrimaryKeys": {
564+ "graphql_source_scope_id_id_pk": {
565+ "columns": [
566+ "scope_id",
567+ "id"
568+ ],
569+ "name": "graphql_source_scope_id_id_pk"
570+ }
571+ },
572+ "uniqueConstraints": {},
573+ "checkConstraints": {}
574+ },
575+ "mcp_binding": {
576+ "name": "mcp_binding",
577+ "columns": {
578+ "id": {
579+ "name": "id",
580+ "type": "text",
581+ "primaryKey": false,
582+ "notNull": true,
583+ "autoincrement": false
584+ },
585+ "scope_id": {
586+ "name": "scope_id",
587+ "type": "text",
588+ "primaryKey": false,
589+ "notNull": true,
590+ "autoincrement": false
591+ },
592+ "source_id": {
593+ "name": "source_id",
594+ "type": "text",
595+ "primaryKey": false,
596+ "notNull": true,
597+ "autoincrement": false
598+ },
599+ "binding": {
600+ "name": "binding",
601+ "type": "text",
602+ "primaryKey": false,
603+ "notNull": true,
604+ "autoincrement": false
605+ },
606+ "created_at": {
607+ "name": "created_at",
608+ "type": "integer",
609+ "primaryKey": false,
610+ "notNull": true,
611+ "autoincrement": false
612+ }
613+ },
614+ "indexes": {
615+ "mcp_binding_scope_id_idx": {
616+ "name": "mcp_binding_scope_id_idx",
617+ "columns": [
618+ "scope_id"
619+ ],
620+ "isUnique": false
621+ },
622+ "mcp_binding_source_id_idx": {
623+ "name": "mcp_binding_source_id_idx",
624+ "columns": [
625+ "source_id"
626+ ],
627+ "isUnique": false
628+ }
629+ },
630+ "foreignKeys": {},
631+ "compositePrimaryKeys": {
632+ "mcp_binding_scope_id_id_pk": {
633+ "columns": [
634+ "scope_id",
635+ "id"
636+ ],
637+ "name": "mcp_binding_scope_id_id_pk"
638+ }
639+ },
640+ "uniqueConstraints": {},
641+ "checkConstraints": {}
642+ },
643+ "mcp_source": {
644+ "name": "mcp_source",
645+ "columns": {
646+ "id": {
647+ "name": "id",
648+ "type": "text",
649+ "primaryKey": false,
650+ "notNull": true,
651+ "autoincrement": false
652+ },
653+ "scope_id": {
654+ "name": "scope_id",
655+ "type": "text",
656+ "primaryKey": false,
657+ "notNull": true,
658+ "autoincrement": false
659+ },
660+ "name": {
661+ "name": "name",
662+ "type": "text",
663+ "primaryKey": false,
664+ "notNull": true,
665+ "autoincrement": false
666+ },
667+ "config": {
668+ "name": "config",
669+ "type": "text",
670+ "primaryKey": false,
671+ "notNull": true,
672+ "autoincrement": false
673+ },
674+ "created_at": {
675+ "name": "created_at",
676+ "type": "integer",
677+ "primaryKey": false,
678+ "notNull": true,
679+ "autoincrement": false
680+ },
681+ "auth_kind": {
682+ "name": "auth_kind",
683+ "type": "text",
684+ "primaryKey": false,
685+ "notNull": true,
686+ "autoincrement": false,
687+ "default": "'none'"
688+ },
689+ "auth_header_name": {
690+ "name": "auth_header_name",
691+ "type": "text",
692+ "primaryKey": false,
693+ "notNull": false,
694+ "autoincrement": false
695+ },
696+ "auth_secret_id": {
697+ "name": "auth_secret_id",
698+ "type": "text",
699+ "primaryKey": false,
700+ "notNull": false,
701+ "autoincrement": false
702+ },
703+ "auth_secret_prefix": {
704+ "name": "auth_secret_prefix",
705+ "type": "text",
706+ "primaryKey": false,
707+ "notNull": false,
708+ "autoincrement": false
709+ },
710+ "auth_connection_id": {
711+ "name": "auth_connection_id",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": false,
715+ "autoincrement": false
716+ },
717+ "auth_client_id_secret_id": {
718+ "name": "auth_client_id_secret_id",
719+ "type": "text",
720+ "primaryKey": false,
721+ "notNull": false,
722+ "autoincrement": false
723+ },
724+ "auth_client_secret_secret_id": {
725+ "name": "auth_client_secret_secret_id",
726+ "type": "text",
727+ "primaryKey": false,
728+ "notNull": false,
729+ "autoincrement": false
730+ }
731+ },
732+ "indexes": {
733+ "mcp_source_scope_id_idx": {
734+ "name": "mcp_source_scope_id_idx",
735+ "columns": [
736+ "scope_id"
737+ ],
738+ "isUnique": false
739+ },
740+ "mcp_source_auth_secret_id_idx": {
741+ "name": "mcp_source_auth_secret_id_idx",
742+ "columns": [
743+ "auth_secret_id"
744+ ],
745+ "isUnique": false
746+ },
747+ "mcp_source_auth_connection_id_idx": {
748+ "name": "mcp_source_auth_connection_id_idx",
749+ "columns": [
750+ "auth_connection_id"
751+ ],
752+ "isUnique": false
753+ },
754+ "mcp_source_auth_client_id_secret_id_idx": {
755+ "name": "mcp_source_auth_client_id_secret_id_idx",
756+ "columns": [
757+ "auth_client_id_secret_id"
758+ ],
759+ "isUnique": false
760+ },
761+ "mcp_source_auth_client_secret_secret_id_idx": {
762+ "name": "mcp_source_auth_client_secret_secret_id_idx",
763+ "columns": [
764+ "auth_client_secret_secret_id"
765+ ],
766+ "isUnique": false
767+ }
768+ },
769+ "foreignKeys": {},
770+ "compositePrimaryKeys": {
771+ "mcp_source_scope_id_id_pk": {
772+ "columns": [
773+ "scope_id",
774+ "id"
775+ ],
776+ "name": "mcp_source_scope_id_id_pk"
777+ }
778+ },
779+ "uniqueConstraints": {},
780+ "checkConstraints": {}
781+ },
782+ "oauth2_session": {
783+ "name": "oauth2_session",
784+ "columns": {
785+ "id": {
786+ "name": "id",
787+ "type": "text",
788+ "primaryKey": false,
789+ "notNull": true,
790+ "autoincrement": false
791+ },
792+ "scope_id": {
793+ "name": "scope_id",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": true,
797+ "autoincrement": false
798+ },
799+ "plugin_id": {
800+ "name": "plugin_id",
801+ "type": "text",
802+ "primaryKey": false,
803+ "notNull": true,
804+ "autoincrement": false
805+ },
806+ "strategy": {
807+ "name": "strategy",
808+ "type": "text",
809+ "primaryKey": false,
810+ "notNull": true,
811+ "autoincrement": false
812+ },
813+ "connection_id": {
814+ "name": "connection_id",
815+ "type": "text",
816+ "primaryKey": false,
817+ "notNull": true,
818+ "autoincrement": false
819+ },
820+ "token_scope": {
821+ "name": "token_scope",
822+ "type": "text",
823+ "primaryKey": false,
824+ "notNull": true,
825+ "autoincrement": false
826+ },
827+ "redirect_url": {
828+ "name": "redirect_url",
829+ "type": "text",
830+ "primaryKey": false,
831+ "notNull": true,
832+ "autoincrement": false
833+ },
834+ "payload": {
835+ "name": "payload",
836+ "type": "text",
837+ "primaryKey": false,
838+ "notNull": true,
839+ "autoincrement": false
840+ },
841+ "expires_at": {
842+ "name": "expires_at",
843+ "type": "integer",
844+ "primaryKey": false,
845+ "notNull": true,
846+ "autoincrement": false
847+ },
848+ "created_at": {
849+ "name": "created_at",
850+ "type": "integer",
851+ "primaryKey": false,
852+ "notNull": true,
853+ "autoincrement": false
854+ }
855+ },
856+ "indexes": {
857+ "oauth2_session_scope_id_idx": {
858+ "name": "oauth2_session_scope_id_idx",
859+ "columns": [
860+ "scope_id"
861+ ],
862+ "isUnique": false
863+ },
864+ "oauth2_session_plugin_id_idx": {
865+ "name": "oauth2_session_plugin_id_idx",
866+ "columns": [
867+ "plugin_id"
868+ ],
869+ "isUnique": false
870+ },
871+ "oauth2_session_connection_id_idx": {
872+ "name": "oauth2_session_connection_id_idx",
873+ "columns": [
874+ "connection_id"
875+ ],
876+ "isUnique": false
877+ }
878+ },
879+ "foreignKeys": {},
880+ "compositePrimaryKeys": {
881+ "oauth2_session_scope_id_id_pk": {
882+ "columns": [
883+ "scope_id",
884+ "id"
885+ ],
886+ "name": "oauth2_session_scope_id_id_pk"
887+ }
888+ },
889+ "uniqueConstraints": {},
890+ "checkConstraints": {}
891+ },
892+ "openapi_operation": {
893+ "name": "openapi_operation",
894+ "columns": {
895+ "id": {
896+ "name": "id",
897+ "type": "text",
898+ "primaryKey": false,
899+ "notNull": true,
900+ "autoincrement": false
901+ },
902+ "scope_id": {
903+ "name": "scope_id",
904+ "type": "text",
905+ "primaryKey": false,
906+ "notNull": true,
907+ "autoincrement": false
908+ },
909+ "source_id": {
910+ "name": "source_id",
911+ "type": "text",
912+ "primaryKey": false,
913+ "notNull": true,
914+ "autoincrement": false
915+ },
916+ "binding": {
917+ "name": "binding",
918+ "type": "text",
919+ "primaryKey": false,
920+ "notNull": true,
921+ "autoincrement": false
922+ }
923+ },
924+ "indexes": {
925+ "openapi_operation_scope_id_idx": {
926+ "name": "openapi_operation_scope_id_idx",
927+ "columns": [
928+ "scope_id"
929+ ],
930+ "isUnique": false
931+ },
932+ "openapi_operation_source_id_idx": {
933+ "name": "openapi_operation_source_id_idx",
934+ "columns": [
935+ "source_id"
936+ ],
937+ "isUnique": false
938+ }
939+ },
940+ "foreignKeys": {},
941+ "compositePrimaryKeys": {
942+ "openapi_operation_scope_id_id_pk": {
943+ "columns": [
944+ "scope_id",
945+ "id"
946+ ],
947+ "name": "openapi_operation_scope_id_id_pk"
948+ }
949+ },
950+ "uniqueConstraints": {},
951+ "checkConstraints": {}
952+ },
953+ "openapi_source": {
954+ "name": "openapi_source",
955+ "columns": {
956+ "id": {
957+ "name": "id",
958+ "type": "text",
959+ "primaryKey": false,
960+ "notNull": true,
961+ "autoincrement": false
962+ },
963+ "scope_id": {
964+ "name": "scope_id",
965+ "type": "text",
966+ "primaryKey": false,
967+ "notNull": true,
968+ "autoincrement": false
969+ },
970+ "name": {
971+ "name": "name",
972+ "type": "text",
973+ "primaryKey": false,
974+ "notNull": true,
975+ "autoincrement": false
976+ },
977+ "spec": {
978+ "name": "spec",
979+ "type": "text",
980+ "primaryKey": false,
981+ "notNull": true,
982+ "autoincrement": false
983+ },
984+ "source_url": {
985+ "name": "source_url",
986+ "type": "text",
987+ "primaryKey": false,
988+ "notNull": false,
989+ "autoincrement": false
990+ },
991+ "base_url": {
992+ "name": "base_url",
993+ "type": "text",
994+ "primaryKey": false,
995+ "notNull": false,
996+ "autoincrement": false
997+ },
998+ "headers": {
999+ "name": "headers",
1000+ "type": "text",
1001+ "primaryKey": false,
1002+ "notNull": false,
1003+ "autoincrement": false
1004+ },
1005+ "oauth2": {
1006+ "name": "oauth2",
1007+ "type": "text",
1008+ "primaryKey": false,
1009+ "notNull": false,
1010+ "autoincrement": false
1011+ }
1012+ },
1013+ "indexes": {
1014+ "openapi_source_scope_id_idx": {
1015+ "name": "openapi_source_scope_id_idx",
1016+ "columns": [
1017+ "scope_id"
1018+ ],
1019+ "isUnique": false
1020+ }
1021+ },
1022+ "foreignKeys": {},
1023+ "compositePrimaryKeys": {
1024+ "openapi_source_scope_id_id_pk": {
1025+ "columns": [
1026+ "scope_id",
1027+ "id"
1028+ ],
1029+ "name": "openapi_source_scope_id_id_pk"
1030+ }
1031+ },
1032+ "uniqueConstraints": {},
1033+ "checkConstraints": {}
1034+ },
1035+ "openapi_source_binding": {
1036+ "name": "openapi_source_binding",
1037+ "columns": {
1038+ "id": {
1039+ "name": "id",
1040+ "type": "text",
1041+ "primaryKey": true,
1042+ "notNull": true,
1043+ "autoincrement": false
1044+ },
1045+ "source_id": {
1046+ "name": "source_id",
1047+ "type": "text",
1048+ "primaryKey": false,
1049+ "notNull": true,
1050+ "autoincrement": false
1051+ },
1052+ "source_scope_id": {
1053+ "name": "source_scope_id",
1054+ "type": "text",
1055+ "primaryKey": false,
1056+ "notNull": true,
1057+ "autoincrement": false
1058+ },
1059+ "target_scope_id": {
1060+ "name": "target_scope_id",
1061+ "type": "text",
1062+ "primaryKey": false,
1063+ "notNull": true,
1064+ "autoincrement": false
1065+ },
1066+ "slot": {
1067+ "name": "slot",
1068+ "type": "text",
1069+ "primaryKey": false,
1070+ "notNull": true,
1071+ "autoincrement": false
1072+ },
1073+ "created_at": {
1074+ "name": "created_at",
1075+ "type": "integer",
1076+ "primaryKey": false,
1077+ "notNull": true,
1078+ "autoincrement": false
1079+ },
1080+ "updated_at": {
1081+ "name": "updated_at",
1082+ "type": "integer",
1083+ "primaryKey": false,
1084+ "notNull": true,
1085+ "autoincrement": false
1086+ },
1087+ "kind": {
1088+ "name": "kind",
1089+ "type": "text",
1090+ "primaryKey": false,
1091+ "notNull": true,
1092+ "autoincrement": false,
1093+ "default": "'text'"
1094+ },
1095+ "secret_id": {
1096+ "name": "secret_id",
1097+ "type": "text",
1098+ "primaryKey": false,
1099+ "notNull": false,
1100+ "autoincrement": false
1101+ },
1102+ "connection_id": {
1103+ "name": "connection_id",
1104+ "type": "text",
1105+ "primaryKey": false,
1106+ "notNull": false,
1107+ "autoincrement": false
1108+ },
1109+ "text_value": {
1110+ "name": "text_value",
1111+ "type": "text",
1112+ "primaryKey": false,
1113+ "notNull": false,
1114+ "autoincrement": false
1115+ }
1116+ },
1117+ "indexes": {
1118+ "openapi_source_binding_source_id_idx": {
1119+ "name": "openapi_source_binding_source_id_idx",
1120+ "columns": [
1121+ "source_id"
1122+ ],
1123+ "isUnique": false
1124+ },
1125+ "openapi_source_binding_source_scope_id_idx": {
1126+ "name": "openapi_source_binding_source_scope_id_idx",
1127+ "columns": [
1128+ "source_scope_id"
1129+ ],
1130+ "isUnique": false
1131+ },
1132+ "openapi_source_binding_target_scope_id_idx": {
1133+ "name": "openapi_source_binding_target_scope_id_idx",
1134+ "columns": [
1135+ "target_scope_id"
1136+ ],
1137+ "isUnique": false
1138+ },
1139+ "openapi_source_binding_slot_idx": {
1140+ "name": "openapi_source_binding_slot_idx",
1141+ "columns": [
1142+ "slot"
1143+ ],
1144+ "isUnique": false
1145+ },
1146+ "openapi_source_binding_secret_id_idx": {
1147+ "name": "openapi_source_binding_secret_id_idx",
1148+ "columns": [
1149+ "secret_id"
1150+ ],
1151+ "isUnique": false
1152+ },
1153+ "openapi_source_binding_connection_id_idx": {
1154+ "name": "openapi_source_binding_connection_id_idx",
1155+ "columns": [
1156+ "connection_id"
1157+ ],
1158+ "isUnique": false
1159+ }
1160+ },
1161+ "foreignKeys": {},
1162+ "compositePrimaryKeys": {},
1163+ "uniqueConstraints": {},
1164+ "checkConstraints": {}
1165+ },
1166+ "secret": {
1167+ "name": "secret",
1168+ "columns": {
1169+ "id": {
1170+ "name": "id",
1171+ "type": "text",
1172+ "primaryKey": false,
1173+ "notNull": true,
1174+ "autoincrement": false
1175+ },
1176+ "scope_id": {
1177+ "name": "scope_id",
1178+ "type": "text",
1179+ "primaryKey": false,
1180+ "notNull": true,
1181+ "autoincrement": false
1182+ },
1183+ "name": {
1184+ "name": "name",
1185+ "type": "text",
1186+ "primaryKey": false,
1187+ "notNull": true,
1188+ "autoincrement": false
1189+ },
1190+ "provider": {
1191+ "name": "provider",
1192+ "type": "text",
1193+ "primaryKey": false,
1194+ "notNull": true,
1195+ "autoincrement": false
1196+ },
1197+ "owned_by_connection_id": {
1198+ "name": "owned_by_connection_id",
1199+ "type": "text",
1200+ "primaryKey": false,
1201+ "notNull": false,
1202+ "autoincrement": false
1203+ },
1204+ "created_at": {
1205+ "name": "created_at",
1206+ "type": "integer",
1207+ "primaryKey": false,
1208+ "notNull": true,
1209+ "autoincrement": false
1210+ }
1211+ },
1212+ "indexes": {
1213+ "secret_scope_id_idx": {
1214+ "name": "secret_scope_id_idx",
1215+ "columns": [
1216+ "scope_id"
1217+ ],
1218+ "isUnique": false
1219+ },
1220+ "secret_provider_idx": {
1221+ "name": "secret_provider_idx",
1222+ "columns": [
1223+ "provider"
1224+ ],
1225+ "isUnique": false
1226+ },
1227+ "secret_owned_by_connection_id_idx": {
1228+ "name": "secret_owned_by_connection_id_idx",
1229+ "columns": [
1230+ "owned_by_connection_id"
1231+ ],
1232+ "isUnique": false
1233+ }
1234+ },
1235+ "foreignKeys": {},
1236+ "compositePrimaryKeys": {
1237+ "secret_scope_id_id_pk": {
1238+ "columns": [
1239+ "scope_id",
1240+ "id"
1241+ ],
1242+ "name": "secret_scope_id_id_pk"
1243+ }
1244+ },
1245+ "uniqueConstraints": {},
1246+ "checkConstraints": {}
1247+ },
1248+ "source": {
1249+ "name": "source",
1250+ "columns": {
1251+ "id": {
1252+ "name": "id",
1253+ "type": "text",
1254+ "primaryKey": false,
1255+ "notNull": true,
1256+ "autoincrement": false
1257+ },
1258+ "scope_id": {
1259+ "name": "scope_id",
1260+ "type": "text",
1261+ "primaryKey": false,
1262+ "notNull": true,
1263+ "autoincrement": false
1264+ },
1265+ "plugin_id": {
1266+ "name": "plugin_id",
1267+ "type": "text",
1268+ "primaryKey": false,
1269+ "notNull": true,
1270+ "autoincrement": false
1271+ },
1272+ "kind": {
1273+ "name": "kind",
1274+ "type": "text",
1275+ "primaryKey": false,
1276+ "notNull": true,
1277+ "autoincrement": false
1278+ },
1279+ "name": {
1280+ "name": "name",
1281+ "type": "text",
1282+ "primaryKey": false,
1283+ "notNull": true,
1284+ "autoincrement": false
1285+ },
1286+ "url": {
1287+ "name": "url",
1288+ "type": "text",
1289+ "primaryKey": false,
1290+ "notNull": false,
1291+ "autoincrement": false
1292+ },
1293+ "can_remove": {
1294+ "name": "can_remove",
1295+ "type": "integer",
1296+ "primaryKey": false,
1297+ "notNull": true,
1298+ "autoincrement": false,
1299+ "default": true
1300+ },
1301+ "can_refresh": {
1302+ "name": "can_refresh",
1303+ "type": "integer",
1304+ "primaryKey": false,
1305+ "notNull": true,
1306+ "autoincrement": false,
1307+ "default": false
1308+ },
1309+ "can_edit": {
1310+ "name": "can_edit",
1311+ "type": "integer",
1312+ "primaryKey": false,
1313+ "notNull": true,
1314+ "autoincrement": false,
1315+ "default": false
1316+ },
1317+ "created_at": {
1318+ "name": "created_at",
1319+ "type": "integer",
1320+ "primaryKey": false,
1321+ "notNull": true,
1322+ "autoincrement": false
1323+ },
1324+ "updated_at": {
1325+ "name": "updated_at",
1326+ "type": "integer",
1327+ "primaryKey": false,
1328+ "notNull": true,
1329+ "autoincrement": false
1330+ }
1331+ },
1332+ "indexes": {
1333+ "source_scope_id_idx": {
1334+ "name": "source_scope_id_idx",
1335+ "columns": [
1336+ "scope_id"
1337+ ],
1338+ "isUnique": false
1339+ },
1340+ "source_plugin_id_idx": {
1341+ "name": "source_plugin_id_idx",
1342+ "columns": [
1343+ "plugin_id"
1344+ ],
1345+ "isUnique": false
1346+ }
1347+ },
1348+ "foreignKeys": {},
1349+ "compositePrimaryKeys": {
1350+ "source_scope_id_id_pk": {
1351+ "columns": [
1352+ "scope_id",
1353+ "id"
1354+ ],
1355+ "name": "source_scope_id_id_pk"
1356+ }
1357+ },
1358+ "uniqueConstraints": {},
1359+ "checkConstraints": {}
1360+ },
1361+ "tool": {
1362+ "name": "tool",
1363+ "columns": {
1364+ "id": {
1365+ "name": "id",
1366+ "type": "text",
1367+ "primaryKey": false,
1368+ "notNull": true,
1369+ "autoincrement": false
1370+ },
1371+ "scope_id": {
1372+ "name": "scope_id",
1373+ "type": "text",
1374+ "primaryKey": false,
1375+ "notNull": true,
1376+ "autoincrement": false
1377+ },
1378+ "source_id": {
1379+ "name": "source_id",
1380+ "type": "text",
1381+ "primaryKey": false,
1382+ "notNull": true,
1383+ "autoincrement": false
1384+ },
1385+ "plugin_id": {
1386+ "name": "plugin_id",
1387+ "type": "text",
1388+ "primaryKey": false,
1389+ "notNull": true,
1390+ "autoincrement": false
1391+ },
1392+ "name": {
1393+ "name": "name",
1394+ "type": "text",
1395+ "primaryKey": false,
1396+ "notNull": true,
1397+ "autoincrement": false
1398+ },
1399+ "description": {
1400+ "name": "description",
1401+ "type": "text",
1402+ "primaryKey": false,
1403+ "notNull": true,
1404+ "autoincrement": false
1405+ },
1406+ "input_schema": {
1407+ "name": "input_schema",
1408+ "type": "text",
1409+ "primaryKey": false,
1410+ "notNull": false,
1411+ "autoincrement": false
1412+ },
1413+ "output_schema": {
1414+ "name": "output_schema",
1415+ "type": "text",
1416+ "primaryKey": false,
1417+ "notNull": false,
1418+ "autoincrement": false
1419+ },
1420+ "created_at": {
1421+ "name": "created_at",
1422+ "type": "integer",
1423+ "primaryKey": false,
1424+ "notNull": true,
1425+ "autoincrement": false
1426+ },
1427+ "updated_at": {
1428+ "name": "updated_at",
1429+ "type": "integer",
1430+ "primaryKey": false,
1431+ "notNull": true,
1432+ "autoincrement": false
1433+ }
1434+ },
1435+ "indexes": {
1436+ "tool_scope_id_idx": {
1437+ "name": "tool_scope_id_idx",
1438+ "columns": [
1439+ "scope_id"
1440+ ],
1441+ "isUnique": false
1442+ },
1443+ "tool_source_id_idx": {
1444+ "name": "tool_source_id_idx",
1445+ "columns": [
1446+ "source_id"
1447+ ],
1448+ "isUnique": false
1449+ },
1450+ "tool_plugin_id_idx": {
1451+ "name": "tool_plugin_id_idx",
1452+ "columns": [
1453+ "plugin_id"
1454+ ],
1455+ "isUnique": false
1456+ }
1457+ },
1458+ "foreignKeys": {},
1459+ "compositePrimaryKeys": {
1460+ "tool_scope_id_id_pk": {
1461+ "columns": [
1462+ "scope_id",
1463+ "id"
1464+ ],
1465+ "name": "tool_scope_id_id_pk"
1466+ }
1467+ },
1468+ "uniqueConstraints": {},
1469+ "checkConstraints": {}
1470+ },
1471+ "tool_policy": {
1472+ "name": "tool_policy",
1473+ "columns": {
1474+ "id": {
1475+ "name": "id",
1476+ "type": "text",
1477+ "primaryKey": false,
1478+ "notNull": true,
1479+ "autoincrement": false
1480+ },
1481+ "scope_id": {
1482+ "name": "scope_id",
1483+ "type": "text",
1484+ "primaryKey": false,
1485+ "notNull": true,
1486+ "autoincrement": false
1487+ },
1488+ "pattern": {
1489+ "name": "pattern",
1490+ "type": "text",
1491+ "primaryKey": false,
1492+ "notNull": true,
1493+ "autoincrement": false
1494+ },
1495+ "action": {
1496+ "name": "action",
1497+ "type": "text",
1498+ "primaryKey": false,
1499+ "notNull": true,
1500+ "autoincrement": false
1501+ },
1502+ "position": {
1503+ "name": "position",
1504+ "type": "text",
1505+ "primaryKey": false,
1506+ "notNull": true,
1507+ "autoincrement": false
1508+ },
1509+ "created_at": {
1510+ "name": "created_at",
1511+ "type": "integer",
1512+ "primaryKey": false,
1513+ "notNull": true,
1514+ "autoincrement": false
1515+ },
1516+ "updated_at": {
1517+ "name": "updated_at",
1518+ "type": "integer",
1519+ "primaryKey": false,
1520+ "notNull": true,
1521+ "autoincrement": false
1522+ }
1523+ },
1524+ "indexes": {
1525+ "tool_policy_scope_id_position_idx": {
1526+ "name": "tool_policy_scope_id_position_idx",
1527+ "columns": [
1528+ "scope_id",
1529+ "position"
1530+ ],
1531+ "isUnique": false
1532+ }
1533+ },
1534+ "foreignKeys": {},
1535+ "compositePrimaryKeys": {
1536+ "tool_policy_scope_id_id_pk": {
1537+ "columns": [
1538+ "scope_id",
1539+ "id"
1540+ ],
1541+ "name": "tool_policy_scope_id_id_pk"
1542+ }
1543+ },
1544+ "uniqueConstraints": {},
1545+ "checkConstraints": {}
1546+ },
1547+ "graphql_source_header": {
1548+ "name": "graphql_source_header",
1549+ "columns": {
1550+ "id": {
1551+ "name": "id",
1552+ "type": "text",
1553+ "primaryKey": false,
1554+ "notNull": true,
1555+ "autoincrement": false
1556+ },
1557+ "scope_id": {
1558+ "name": "scope_id",
1559+ "type": "text",
1560+ "primaryKey": false,
1561+ "notNull": true,
1562+ "autoincrement": false
1563+ },
1564+ "source_id": {
1565+ "name": "source_id",
1566+ "type": "text",
1567+ "primaryKey": false,
1568+ "notNull": true,
1569+ "autoincrement": false
1570+ },
1571+ "name": {
1572+ "name": "name",
1573+ "type": "text",
1574+ "primaryKey": false,
1575+ "notNull": true,
1576+ "autoincrement": false
1577+ },
1578+ "kind": {
1579+ "name": "kind",
1580+ "type": "text",
1581+ "primaryKey": false,
1582+ "notNull": true,
1583+ "autoincrement": false
1584+ },
1585+ "text_value": {
1586+ "name": "text_value",
1587+ "type": "text",
1588+ "primaryKey": false,
1589+ "notNull": false,
1590+ "autoincrement": false
1591+ },
1592+ "secret_id": {
1593+ "name": "secret_id",
1594+ "type": "text",
1595+ "primaryKey": false,
1596+ "notNull": false,
1597+ "autoincrement": false
1598+ },
1599+ "secret_prefix": {
1600+ "name": "secret_prefix",
1601+ "type": "text",
1602+ "primaryKey": false,
1603+ "notNull": false,
1604+ "autoincrement": false
1605+ }
1606+ },
1607+ "indexes": {
1608+ "graphql_source_header_scope_id_idx": {
1609+ "name": "graphql_source_header_scope_id_idx",
1610+ "columns": [
1611+ "scope_id"
1612+ ],
1613+ "isUnique": false
1614+ },
1615+ "graphql_source_header_source_id_idx": {
1616+ "name": "graphql_source_header_source_id_idx",
1617+ "columns": [
1618+ "source_id"
1619+ ],
1620+ "isUnique": false
1621+ },
1622+ "graphql_source_header_secret_id_idx": {
1623+ "name": "graphql_source_header_secret_id_idx",
1624+ "columns": [
1625+ "secret_id"
1626+ ],
1627+ "isUnique": false
1628+ }
1629+ },
1630+ "foreignKeys": {},
1631+ "compositePrimaryKeys": {
1632+ "graphql_source_header_scope_id_id_pk": {
1633+ "columns": [
1634+ "scope_id",
1635+ "id"
1636+ ],
1637+ "name": "graphql_source_header_scope_id_id_pk"
1638+ }
1639+ },
1640+ "uniqueConstraints": {},
1641+ "checkConstraints": {}
1642+ },
1643+ "graphql_source_query_param": {
1644+ "name": "graphql_source_query_param",
1645+ "columns": {
1646+ "id": {
1647+ "name": "id",
1648+ "type": "text",
1649+ "primaryKey": false,
1650+ "notNull": true,
1651+ "autoincrement": false
1652+ },
1653+ "scope_id": {
1654+ "name": "scope_id",
1655+ "type": "text",
1656+ "primaryKey": false,
1657+ "notNull": true,
1658+ "autoincrement": false
1659+ },
1660+ "source_id": {
1661+ "name": "source_id",
1662+ "type": "text",
1663+ "primaryKey": false,
1664+ "notNull": true,
1665+ "autoincrement": false
1666+ },
1667+ "name": {
1668+ "name": "name",
1669+ "type": "text",
1670+ "primaryKey": false,
1671+ "notNull": true,
1672+ "autoincrement": false
1673+ },
1674+ "kind": {
1675+ "name": "kind",
1676+ "type": "text",
1677+ "primaryKey": false,
1678+ "notNull": true,
1679+ "autoincrement": false
1680+ },
1681+ "text_value": {
1682+ "name": "text_value",
1683+ "type": "text",
1684+ "primaryKey": false,
1685+ "notNull": false,
1686+ "autoincrement": false
1687+ },
1688+ "secret_id": {
1689+ "name": "secret_id",
1690+ "type": "text",
1691+ "primaryKey": false,
1692+ "notNull": false,
1693+ "autoincrement": false
1694+ },
1695+ "secret_prefix": {
1696+ "name": "secret_prefix",
1697+ "type": "text",
1698+ "primaryKey": false,
1699+ "notNull": false,
1700+ "autoincrement": false
1701+ }
1702+ },
1703+ "indexes": {
1704+ "graphql_source_query_param_scope_id_idx": {
1705+ "name": "graphql_source_query_param_scope_id_idx",
1706+ "columns": [
1707+ "scope_id"
1708+ ],
1709+ "isUnique": false
1710+ },
1711+ "graphql_source_query_param_source_id_idx": {
1712+ "name": "graphql_source_query_param_source_id_idx",
1713+ "columns": [
1714+ "source_id"
1715+ ],
1716+ "isUnique": false
1717+ },
1718+ "graphql_source_query_param_secret_id_idx": {
1719+ "name": "graphql_source_query_param_secret_id_idx",
1720+ "columns": [
1721+ "secret_id"
1722+ ],
1723+ "isUnique": false
1724+ }
1725+ },
1726+ "foreignKeys": {},
1727+ "compositePrimaryKeys": {
1728+ "graphql_source_query_param_scope_id_id_pk": {
1729+ "columns": [
1730+ "scope_id",
1731+ "id"
1732+ ],
1733+ "name": "graphql_source_query_param_scope_id_id_pk"
1734+ }
1735+ },
1736+ "uniqueConstraints": {},
1737+ "checkConstraints": {}
1738+ },
1739+ "openapi_source_query_param": {
1740+ "name": "openapi_source_query_param",
1741+ "columns": {
1742+ "id": {
1743+ "name": "id",
1744+ "type": "text",
1745+ "primaryKey": false,
1746+ "notNull": true,
1747+ "autoincrement": false
1748+ },
1749+ "scope_id": {
1750+ "name": "scope_id",
1751+ "type": "text",
1752+ "primaryKey": false,
1753+ "notNull": true,
1754+ "autoincrement": false
1755+ },
1756+ "source_id": {
1757+ "name": "source_id",
1758+ "type": "text",
1759+ "primaryKey": false,
1760+ "notNull": true,
1761+ "autoincrement": false
1762+ },
1763+ "name": {
1764+ "name": "name",
1765+ "type": "text",
1766+ "primaryKey": false,
1767+ "notNull": true,
1768+ "autoincrement": false
1769+ },
1770+ "kind": {
1771+ "name": "kind",
1772+ "type": "text",
1773+ "primaryKey": false,
1774+ "notNull": true,
1775+ "autoincrement": false
1776+ },
1777+ "text_value": {
1778+ "name": "text_value",
1779+ "type": "text",
1780+ "primaryKey": false,
1781+ "notNull": false,
1782+ "autoincrement": false
1783+ },
1784+ "secret_id": {
1785+ "name": "secret_id",
1786+ "type": "text",
1787+ "primaryKey": false,
1788+ "notNull": false,
1789+ "autoincrement": false
1790+ },
1791+ "secret_prefix": {
1792+ "name": "secret_prefix",
1793+ "type": "text",
1794+ "primaryKey": false,
1795+ "notNull": false,
1796+ "autoincrement": false
1797+ }
1798+ },
1799+ "indexes": {
1800+ "openapi_source_query_param_scope_id_idx": {
1801+ "name": "openapi_source_query_param_scope_id_idx",
1802+ "columns": [
1803+ "scope_id"
1804+ ],
1805+ "isUnique": false
1806+ },
1807+ "openapi_source_query_param_source_id_idx": {
1808+ "name": "openapi_source_query_param_source_id_idx",
1809+ "columns": [
1810+ "source_id"
1811+ ],
1812+ "isUnique": false
1813+ },
1814+ "openapi_source_query_param_secret_id_idx": {
1815+ "name": "openapi_source_query_param_secret_id_idx",
1816+ "columns": [
1817+ "secret_id"
1818+ ],
1819+ "isUnique": false
1820+ }
1821+ },
1822+ "foreignKeys": {},
1823+ "compositePrimaryKeys": {
1824+ "openapi_source_query_param_scope_id_id_pk": {
1825+ "columns": [
1826+ "scope_id",
1827+ "id"
1828+ ],
1829+ "name": "openapi_source_query_param_scope_id_id_pk"
1830+ }
1831+ },
1832+ "uniqueConstraints": {},
1833+ "checkConstraints": {}
1834+ },
1835+ "openapi_source_spec_fetch_header": {
1836+ "name": "openapi_source_spec_fetch_header",
1837+ "columns": {
1838+ "id": {
1839+ "name": "id",
1840+ "type": "text",
1841+ "primaryKey": false,
1842+ "notNull": true,
1843+ "autoincrement": false
1844+ },
1845+ "scope_id": {
1846+ "name": "scope_id",
1847+ "type": "text",
1848+ "primaryKey": false,
1849+ "notNull": true,
1850+ "autoincrement": false
1851+ },
1852+ "source_id": {
1853+ "name": "source_id",
1854+ "type": "text",
1855+ "primaryKey": false,
1856+ "notNull": true,
1857+ "autoincrement": false
1858+ },
1859+ "name": {
1860+ "name": "name",
1861+ "type": "text",
1862+ "primaryKey": false,
1863+ "notNull": true,
1864+ "autoincrement": false
1865+ },
1866+ "kind": {
1867+ "name": "kind",
1868+ "type": "text",
1869+ "primaryKey": false,
1870+ "notNull": true,
1871+ "autoincrement": false
1872+ },
1873+ "text_value": {
1874+ "name": "text_value",
1875+ "type": "text",
1876+ "primaryKey": false,
1877+ "notNull": false,
1878+ "autoincrement": false
1879+ },
1880+ "secret_id": {
1881+ "name": "secret_id",
1882+ "type": "text",
1883+ "primaryKey": false,
1884+ "notNull": false,
1885+ "autoincrement": false
1886+ },
1887+ "secret_prefix": {
1888+ "name": "secret_prefix",
1889+ "type": "text",
1890+ "primaryKey": false,
1891+ "notNull": false,
1892+ "autoincrement": false
1893+ }
1894+ },
1895+ "indexes": {
1896+ "openapi_source_spec_fetch_header_scope_id_idx": {
1897+ "name": "openapi_source_spec_fetch_header_scope_id_idx",
1898+ "columns": [
1899+ "scope_id"
1900+ ],
1901+ "isUnique": false
1902+ },
1903+ "openapi_source_spec_fetch_header_source_id_idx": {
1904+ "name": "openapi_source_spec_fetch_header_source_id_idx",
1905+ "columns": [
1906+ "source_id"
1907+ ],
1908+ "isUnique": false
1909+ },
1910+ "openapi_source_spec_fetch_header_secret_id_idx": {
1911+ "name": "openapi_source_spec_fetch_header_secret_id_idx",
1912+ "columns": [
1913+ "secret_id"
1914+ ],
1915+ "isUnique": false
1916+ }
1917+ },
1918+ "foreignKeys": {},
1919+ "compositePrimaryKeys": {
1920+ "openapi_source_spec_fetch_header_scope_id_id_pk": {
1921+ "columns": [
1922+ "scope_id",
1923+ "id"
1924+ ],
1925+ "name": "openapi_source_spec_fetch_header_scope_id_id_pk"
1926+ }
1927+ },
1928+ "uniqueConstraints": {},
1929+ "checkConstraints": {}
1930+ },
1931+ "openapi_source_spec_fetch_query_param": {
1932+ "name": "openapi_source_spec_fetch_query_param",
1933+ "columns": {
1934+ "id": {
1935+ "name": "id",
1936+ "type": "text",
1937+ "primaryKey": false,
1938+ "notNull": true,
1939+ "autoincrement": false
1940+ },
1941+ "scope_id": {
1942+ "name": "scope_id",
1943+ "type": "text",
1944+ "primaryKey": false,
1945+ "notNull": true,
1946+ "autoincrement": false
1947+ },
1948+ "source_id": {
1949+ "name": "source_id",
1950+ "type": "text",
1951+ "primaryKey": false,
1952+ "notNull": true,
1953+ "autoincrement": false
1954+ },
1955+ "name": {
1956+ "name": "name",
1957+ "type": "text",
1958+ "primaryKey": false,
1959+ "notNull": true,
1960+ "autoincrement": false
1961+ },
1962+ "kind": {
1963+ "name": "kind",
1964+ "type": "text",
1965+ "primaryKey": false,
1966+ "notNull": true,
1967+ "autoincrement": false
1968+ },
1969+ "text_value": {
1970+ "name": "text_value",
1971+ "type": "text",
1972+ "primaryKey": false,
1973+ "notNull": false,
1974+ "autoincrement": false
1975+ },
1976+ "secret_id": {
1977+ "name": "secret_id",
1978+ "type": "text",
1979+ "primaryKey": false,
1980+ "notNull": false,
1981+ "autoincrement": false
1982+ },
1983+ "secret_prefix": {
1984+ "name": "secret_prefix",
1985+ "type": "text",
1986+ "primaryKey": false,
1987+ "notNull": false,
1988+ "autoincrement": false
1989+ }
1990+ },
1991+ "indexes": {
1992+ "openapi_source_spec_fetch_query_param_scope_id_idx": {
1993+ "name": "openapi_source_spec_fetch_query_param_scope_id_idx",
1994+ "columns": [
1995+ "scope_id"
1996+ ],
1997+ "isUnique": false
1998+ },
1999+ "openapi_source_spec_fetch_query_param_source_id_idx": {
2000+ "name": "openapi_source_spec_fetch_query_param_source_id_idx",
2001+ "columns": [
2002+ "source_id"
2003+ ],
2004+ "isUnique": false
2005+ },
2006+ "openapi_source_spec_fetch_query_param_secret_id_idx": {
2007+ "name": "openapi_source_spec_fetch_query_param_secret_id_idx",
2008+ "columns": [
2009+ "secret_id"
2010+ ],
2011+ "isUnique": false
2012+ }
2013+ },
2014+ "foreignKeys": {},
2015+ "compositePrimaryKeys": {
2016+ "openapi_source_spec_fetch_query_param_scope_id_id_pk": {
2017+ "columns": [
2018+ "scope_id",
2019+ "id"
2020+ ],
2021+ "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk"
2022+ }
2023+ },
2024+ "uniqueConstraints": {},
2025+ "checkConstraints": {}
2026+ },
2027+ "mcp_source_header": {
2028+ "name": "mcp_source_header",
2029+ "columns": {
2030+ "id": {
2031+ "name": "id",
2032+ "type": "text",
2033+ "primaryKey": false,
2034+ "notNull": true,
2035+ "autoincrement": false
2036+ },
2037+ "scope_id": {
2038+ "name": "scope_id",
2039+ "type": "text",
2040+ "primaryKey": false,
2041+ "notNull": true,
2042+ "autoincrement": false
2043+ },
2044+ "source_id": {
2045+ "name": "source_id",
2046+ "type": "text",
2047+ "primaryKey": false,
2048+ "notNull": true,
2049+ "autoincrement": false
2050+ },
2051+ "name": {
2052+ "name": "name",
2053+ "type": "text",
2054+ "primaryKey": false,
2055+ "notNull": true,
2056+ "autoincrement": false
2057+ },
2058+ "kind": {
2059+ "name": "kind",
2060+ "type": "text",
2061+ "primaryKey": false,
2062+ "notNull": true,
2063+ "autoincrement": false
2064+ },
2065+ "text_value": {
2066+ "name": "text_value",
2067+ "type": "text",
2068+ "primaryKey": false,
2069+ "notNull": false,
2070+ "autoincrement": false
2071+ },
2072+ "secret_id": {
2073+ "name": "secret_id",
2074+ "type": "text",
2075+ "primaryKey": false,
2076+ "notNull": false,
2077+ "autoincrement": false
2078+ },
2079+ "secret_prefix": {
2080+ "name": "secret_prefix",
2081+ "type": "text",
2082+ "primaryKey": false,
2083+ "notNull": false,
2084+ "autoincrement": false
2085+ }
2086+ },
2087+ "indexes": {
2088+ "mcp_source_header_scope_id_idx": {
2089+ "name": "mcp_source_header_scope_id_idx",
2090+ "columns": [
2091+ "scope_id"
2092+ ],
2093+ "isUnique": false
2094+ },
2095+ "mcp_source_header_source_id_idx": {
2096+ "name": "mcp_source_header_source_id_idx",
2097+ "columns": [
2098+ "source_id"
2099+ ],
2100+ "isUnique": false
2101+ },
2102+ "mcp_source_header_secret_id_idx": {
2103+ "name": "mcp_source_header_secret_id_idx",
2104+ "columns": [
2105+ "secret_id"
2106+ ],
2107+ "isUnique": false
2108+ }
2109+ },
2110+ "foreignKeys": {},
2111+ "compositePrimaryKeys": {
2112+ "mcp_source_header_scope_id_id_pk": {
2113+ "columns": [
2114+ "scope_id",
2115+ "id"
2116+ ],
2117+ "name": "mcp_source_header_scope_id_id_pk"
2118+ }
2119+ },
2120+ "uniqueConstraints": {},
2121+ "checkConstraints": {}
2122+ },
2123+ "mcp_source_query_param": {
2124+ "name": "mcp_source_query_param",
2125+ "columns": {
2126+ "id": {
2127+ "name": "id",
2128+ "type": "text",
2129+ "primaryKey": false,
2130+ "notNull": true,
2131+ "autoincrement": false
2132+ },
2133+ "scope_id": {
2134+ "name": "scope_id",
2135+ "type": "text",
2136+ "primaryKey": false,
2137+ "notNull": true,
2138+ "autoincrement": false
2139+ },
2140+ "source_id": {
2141+ "name": "source_id",
2142+ "type": "text",
2143+ "primaryKey": false,
2144+ "notNull": true,
2145+ "autoincrement": false
2146+ },
2147+ "name": {
2148+ "name": "name",
2149+ "type": "text",
2150+ "primaryKey": false,
2151+ "notNull": true,
2152+ "autoincrement": false
2153+ },
2154+ "kind": {
2155+ "name": "kind",
2156+ "type": "text",
2157+ "primaryKey": false,
2158+ "notNull": true,
2159+ "autoincrement": false
2160+ },
2161+ "text_value": {
2162+ "name": "text_value",
2163+ "type": "text",
2164+ "primaryKey": false,
2165+ "notNull": false,
2166+ "autoincrement": false
2167+ },
2168+ "secret_id": {
2169+ "name": "secret_id",
2170+ "type": "text",
2171+ "primaryKey": false,
2172+ "notNull": false,
2173+ "autoincrement": false
2174+ },
2175+ "secret_prefix": {
2176+ "name": "secret_prefix",
2177+ "type": "text",
2178+ "primaryKey": false,
2179+ "notNull": false,
2180+ "autoincrement": false
2181+ }
2182+ },
2183+ "indexes": {
2184+ "mcp_source_query_param_scope_id_idx": {
2185+ "name": "mcp_source_query_param_scope_id_idx",
2186+ "columns": [
2187+ "scope_id"
2188+ ],
2189+ "isUnique": false
2190+ },
2191+ "mcp_source_query_param_source_id_idx": {
2192+ "name": "mcp_source_query_param_source_id_idx",
2193+ "columns": [
2194+ "source_id"
2195+ ],
2196+ "isUnique": false
2197+ },
2198+ "mcp_source_query_param_secret_id_idx": {
2199+ "name": "mcp_source_query_param_secret_id_idx",
2200+ "columns": [
2201+ "secret_id"
2202+ ],
2203+ "isUnique": false
2204+ }
2205+ },
2206+ "foreignKeys": {},
2207+ "compositePrimaryKeys": {
2208+ "mcp_source_query_param_scope_id_id_pk": {
2209+ "columns": [
2210+ "scope_id",
2211+ "id"
2212+ ],
2213+ "name": "mcp_source_query_param_scope_id_id_pk"
2214+ }
2215+ },
2216+ "uniqueConstraints": {},
2217+ "checkConstraints": {}
2218+ },
2219+ "google_discovery_source_credential_header": {
2220+ "name": "google_discovery_source_credential_header",
2221+ "columns": {
2222+ "id": {
2223+ "name": "id",
2224+ "type": "text",
2225+ "primaryKey": false,
2226+ "notNull": true,
2227+ "autoincrement": false
2228+ },
2229+ "scope_id": {
2230+ "name": "scope_id",
2231+ "type": "text",
2232+ "primaryKey": false,
2233+ "notNull": true,
2234+ "autoincrement": false
2235+ },
2236+ "source_id": {
2237+ "name": "source_id",
2238+ "type": "text",
2239+ "primaryKey": false,
2240+ "notNull": true,
2241+ "autoincrement": false
2242+ },
2243+ "name": {
2244+ "name": "name",
2245+ "type": "text",
2246+ "primaryKey": false,
2247+ "notNull": true,
2248+ "autoincrement": false
2249+ },
2250+ "kind": {
2251+ "name": "kind",
2252+ "type": "text",
2253+ "primaryKey": false,
2254+ "notNull": true,
2255+ "autoincrement": false
2256+ },
2257+ "text_value": {
2258+ "name": "text_value",
2259+ "type": "text",
2260+ "primaryKey": false,
2261+ "notNull": false,
2262+ "autoincrement": false
2263+ },
2264+ "secret_id": {
2265+ "name": "secret_id",
2266+ "type": "text",
2267+ "primaryKey": false,
2268+ "notNull": false,
2269+ "autoincrement": false
2270+ },
2271+ "secret_prefix": {
2272+ "name": "secret_prefix",
2273+ "type": "text",
2274+ "primaryKey": false,
2275+ "notNull": false,
2276+ "autoincrement": false
2277+ }
2278+ },
2279+ "indexes": {
2280+ "google_discovery_source_credential_header_scope_id_idx": {
2281+ "name": "google_discovery_source_credential_header_scope_id_idx",
2282+ "columns": [
2283+ "scope_id"
2284+ ],
2285+ "isUnique": false
2286+ },
2287+ "google_discovery_source_credential_header_source_id_idx": {
2288+ "name": "google_discovery_source_credential_header_source_id_idx",
2289+ "columns": [
2290+ "source_id"
2291+ ],
2292+ "isUnique": false
2293+ },
2294+ "google_discovery_source_credential_header_secret_id_idx": {
2295+ "name": "google_discovery_source_credential_header_secret_id_idx",
2296+ "columns": [
2297+ "secret_id"
2298+ ],
2299+ "isUnique": false
2300+ }
2301+ },
2302+ "foreignKeys": {},
2303+ "compositePrimaryKeys": {
2304+ "google_discovery_source_credential_header_scope_id_id_pk": {
2305+ "columns": [
2306+ "scope_id",
2307+ "id"
2308+ ],
2309+ "name": "google_discovery_source_credential_header_scope_id_id_pk"
2310+ }
2311+ },
2312+ "uniqueConstraints": {},
2313+ "checkConstraints": {}
2314+ },
2315+ "google_discovery_source_credential_query_param": {
2316+ "name": "google_discovery_source_credential_query_param",
2317+ "columns": {
2318+ "id": {
2319+ "name": "id",
2320+ "type": "text",
2321+ "primaryKey": false,
2322+ "notNull": true,
2323+ "autoincrement": false
2324+ },
2325+ "scope_id": {
2326+ "name": "scope_id",
2327+ "type": "text",
2328+ "primaryKey": false,
2329+ "notNull": true,
2330+ "autoincrement": false
2331+ },
2332+ "source_id": {
2333+ "name": "source_id",
2334+ "type": "text",
2335+ "primaryKey": false,
2336+ "notNull": true,
2337+ "autoincrement": false
2338+ },
2339+ "name": {
2340+ "name": "name",
2341+ "type": "text",
2342+ "primaryKey": false,
2343+ "notNull": true,
2344+ "autoincrement": false
2345+ },
2346+ "kind": {
2347+ "name": "kind",
2348+ "type": "text",
2349+ "primaryKey": false,
2350+ "notNull": true,
2351+ "autoincrement": false
2352+ },
2353+ "text_value": {
2354+ "name": "text_value",
2355+ "type": "text",
2356+ "primaryKey": false,
2357+ "notNull": false,
2358+ "autoincrement": false
2359+ },
2360+ "secret_id": {
2361+ "name": "secret_id",
2362+ "type": "text",
2363+ "primaryKey": false,
2364+ "notNull": false,
2365+ "autoincrement": false
2366+ },
2367+ "secret_prefix": {
2368+ "name": "secret_prefix",
2369+ "type": "text",
2370+ "primaryKey": false,
2371+ "notNull": false,
2372+ "autoincrement": false
2373+ }
2374+ },
2375+ "indexes": {
2376+ "google_discovery_source_credential_query_param_scope_id_idx": {
2377+ "name": "google_discovery_source_credential_query_param_scope_id_idx",
2378+ "columns": [
2379+ "scope_id"
2380+ ],
2381+ "isUnique": false
2382+ },
2383+ "google_discovery_source_credential_query_param_source_id_idx": {
2384+ "name": "google_discovery_source_credential_query_param_source_id_idx",
2385+ "columns": [
2386+ "source_id"
2387+ ],
2388+ "isUnique": false
2389+ },
2390+ "google_discovery_source_credential_query_param_secret_id_idx": {
2391+ "name": "google_discovery_source_credential_query_param_secret_id_idx",
2392+ "columns": [
2393+ "secret_id"
2394+ ],
2395+ "isUnique": false
2396+ }
2397+ },
2398+ "foreignKeys": {},
2399+ "compositePrimaryKeys": {
2400+ "google_discovery_source_credential_query_param_scope_id_id_pk": {
2401+ "columns": [
2402+ "scope_id",
2403+ "id"
2404+ ],
2405+ "name": "google_discovery_source_credential_query_param_scope_id_id_pk"
2406+ }
2407+ },
2408+ "uniqueConstraints": {},
2409+ "checkConstraints": {}
2410+ }
2411+ },
2412+ "views": {},
2413+ "enums": {},
2414+ "_meta": {
2415+ "schemas": {},
2416+ "tables": {},
2417+ "columns": {}
2418+ },
2419+ "internal": {
2420+ "indexes": {}
2421+ }
2422+}
apps/local/drizzle/meta/_journal.json+7−0
@@ -50,6 +50,13 @@ · 49 unmodified lines
5050 "when": 1777850000001,
5151 "tag": "0006_neat_terror",
5252 "breakpoints": true
53+ },
54+ {
55+ "idx": 7,
56+ "version": "6",
57+ "when": 1778100000000,
58+ "tag": "0007_normalize_plugin_secret_refs",
59+ "breakpoints": true
5360 }
5461 ]
5562 }
apps/local/src/server/__test-helpers__/pre-0007-schema.ts+107−0
@@ -0,0 +1,107 @@
1+// Shared pre-migration schema used by the four migrate-*-bindings.test.ts
2+// suites. Each test seeds this hand-rolled DDL (the DB shape immediately
3+// after 0006_neat_terror), then runs drizzle's migrator which executes
4+// only `0007_normalize_plugin_secret_refs.sql` thanks to the stamp.
5+
6+import { Database } from "bun:sqlite";
7+
8+export const PRE_0007_SQL = `
9+ CREATE TABLE __drizzle_migrations (
10+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11+ hash TEXT NOT NULL,
12+ created_at NUMERIC
13+ );
14+
15+ CREATE TABLE graphql_source (
16+ id TEXT NOT NULL,
17+ scope_id TEXT NOT NULL,
18+ name TEXT NOT NULL,
19+ endpoint TEXT NOT NULL,
20+ headers TEXT,
21+ query_params TEXT,
22+ auth TEXT,
23+ PRIMARY KEY (scope_id, id)
24+ );
25+
26+ CREATE TABLE graphql_operation (
27+ id TEXT NOT NULL,
28+ scope_id TEXT NOT NULL,
29+ source_id TEXT NOT NULL,
30+ binding TEXT NOT NULL,
31+ PRIMARY KEY (scope_id, id)
32+ );
33+
34+ CREATE TABLE openapi_source (
35+ id TEXT NOT NULL,
36+ scope_id TEXT NOT NULL,
37+ name TEXT NOT NULL,
38+ spec TEXT NOT NULL,
39+ source_url TEXT,
40+ base_url TEXT,
41+ headers TEXT,
42+ query_params TEXT,
43+ oauth2 TEXT,
44+ invocation_config TEXT NOT NULL,
45+ PRIMARY KEY (scope_id, id)
46+ );
47+
48+ CREATE TABLE openapi_source_binding (
49+ id TEXT PRIMARY KEY NOT NULL,
50+ source_id TEXT NOT NULL,
51+ source_scope_id TEXT NOT NULL,
52+ target_scope_id TEXT NOT NULL,
53+ slot TEXT NOT NULL,
54+ value TEXT NOT NULL,
55+ created_at INTEGER NOT NULL,
56+ updated_at INTEGER NOT NULL
57+ );
58+
59+ CREATE TABLE mcp_source (
60+ id TEXT NOT NULL,
61+ scope_id TEXT NOT NULL,
62+ name TEXT NOT NULL,
63+ config TEXT NOT NULL,
64+ created_at INTEGER NOT NULL,
65+ PRIMARY KEY (scope_id, id)
66+ );
67+
68+ CREATE TABLE mcp_binding (
69+ id TEXT NOT NULL,
70+ scope_id TEXT NOT NULL,
71+ source_id TEXT NOT NULL,
72+ binding TEXT NOT NULL,
73+ created_at INTEGER NOT NULL,
74+ PRIMARY KEY (scope_id, id)
75+ );
76+
77+ CREATE TABLE google_discovery_source (
78+ id TEXT NOT NULL,
79+ scope_id TEXT NOT NULL,
80+ name TEXT NOT NULL,
81+ config TEXT NOT NULL,
82+ created_at INTEGER NOT NULL,
83+ updated_at INTEGER NOT NULL,
84+ PRIMARY KEY (scope_id, id)
85+ );
86+
87+ CREATE TABLE google_discovery_binding (
88+ id TEXT NOT NULL,
89+ scope_id TEXT NOT NULL,
90+ source_id TEXT NOT NULL,
91+ binding TEXT NOT NULL,
92+ created_at INTEGER NOT NULL,
93+ PRIMARY KEY (scope_id, id)
94+ );
95+`;
96+
97+// 0006_neat_terror.when. drizzle's sqlite migrator picks the latest
98+// `created_at` from __drizzle_migrations and skips any migration whose
99+// folderMillis (from the journal) is <= that timestamp.
100+export const STAMP_BEFORE = 1777850000001;
101+
102+export const stampPriorMigrationsApplied = (db: Database) => {
103+ db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)").run(
104+ "pre-0007-marker",
105+ STAMP_BEFORE,
106+ );
107+};
apps/local/src/server/executor-schema.ts+173−15
@@ -122,9 +122,7 @@ export const openapi_source = sqliteTable("openapi_source", { · 121 unmodified lines
122122 source_url: text('source_url'),
123123 base_url: text('base_url'),
124124 headers: text('headers', { mode: "json" }),
125− query_params: text('query_params', { mode: "json" }),
126− oauth2: text('oauth2', { mode: "json" }),
127− invocation_config: text('invocation_config', { mode: "json" }).notNull()
125+ oauth2: text('oauth2', { mode: "json" })
128126 }, (table) => [
129127 primaryKey({ columns: [table.scope_id, table.id] }),
130128 index("openapi_source_scope_id_idx").on(table.scope_id),
@@ -147,7 +145,10 @@ export const openapi_source_binding = sqliteTable("openapi_source_binding", { · 16 unmodified lines
147145 source_scope_id: text('source_scope_id').notNull(),
148146 target_scope_id: text('target_scope_id').notNull(),
149147 slot: text('slot').notNull(),
150− value: text('value', { mode: "json" }).notNull(),
148+ kind: text({ enum: ['secret', 'connection', 'text'] }).notNull(),
149+ secret_id: text('secret_id'),
150+ connection_id: text('connection_id'),
151+ text_value: text('text_value'),
151152 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
152153 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
153154 }, (table) => [
@@ -155,6 +156,56 @@ export const openapi_source_binding = sqliteTable("openapi_source_binding", { · 1 unmodified line
155156 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
156157 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
157158 index("openapi_source_binding_slot_idx").on(table.slot),
159+ index("openapi_source_binding_secret_id_idx").on(table.secret_id),
160+ index("openapi_source_binding_connection_id_idx").on(table.connection_id),
161+]);
162+
163+export const openapi_source_query_param = sqliteTable("openapi_source_query_param", {
164+ id: text('id').notNull(),
165+ scope_id: text('scope_id').notNull(),
166+ source_id: text('source_id').notNull(),
167+ name: text('name').notNull(),
168+ kind: text({ enum: ['text', 'secret'] }).notNull(),
169+ text_value: text('text_value'),
170+ secret_id: text('secret_id'),
171+ secret_prefix: text('secret_prefix')
172+}, (table) => [
173+ primaryKey({ columns: [table.scope_id, table.id] }),
174+ index("openapi_source_query_param_scope_id_idx").on(table.scope_id),
175+ index("openapi_source_query_param_source_id_idx").on(table.source_id),
176+ index("openapi_source_query_param_secret_id_idx").on(table.secret_id),
177+]);
178+
179+export const openapi_source_spec_fetch_header = sqliteTable("openapi_source_spec_fetch_header", {
180+ id: text('id').notNull(),
181+ scope_id: text('scope_id').notNull(),
182+ source_id: text('source_id').notNull(),
183+ name: text('name').notNull(),
184+ kind: text({ enum: ['text', 'secret'] }).notNull(),
185+ text_value: text('text_value'),
186+ secret_id: text('secret_id'),
187+ secret_prefix: text('secret_prefix')
188+}, (table) => [
189+ primaryKey({ columns: [table.scope_id, table.id] }),
190+ index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id),
191+ index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id),
192+ index("openapi_source_spec_fetch_header_secret_id_idx").on(table.secret_id),
193+]);
194+
195+export const openapi_source_spec_fetch_query_param = sqliteTable("openapi_source_spec_fetch_query_param", {
196+ id: text('id').notNull(),
197+ scope_id: text('scope_id').notNull(),
198+ source_id: text('source_id').notNull(),
199+ name: text('name').notNull(),
200+ kind: text({ enum: ['text', 'secret'] }).notNull(),
201+ text_value: text('text_value'),
202+ secret_id: text('secret_id'),
203+ secret_prefix: text('secret_prefix')
204+}, (table) => [
205+ primaryKey({ columns: [table.scope_id, table.id] }),
206+ index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id),
207+ index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id),
208+ index("openapi_source_spec_fetch_query_param_secret_id_idx").on(table.secret_id),
158209 ]);
159210
160211 export const mcp_source = sqliteTable("mcp_source", {
@@ -162,10 +213,53 @@ export const mcp_source = sqliteTable("mcp_source", { · 1 unmodified line
162213 scope_id: text('scope_id').notNull(),
163214 name: text('name').notNull(),
164215 config: text('config', { mode: "json" }).notNull(),
216+ auth_kind: text({ enum: ['none', 'header', 'oauth2'] }).default("none").notNull(),
217+ auth_header_name: text('auth_header_name'),
218+ auth_secret_id: text('auth_secret_id'),
219+ auth_secret_prefix: text('auth_secret_prefix'),
220+ auth_connection_id: text('auth_connection_id'),
221+ auth_client_id_secret_id: text('auth_client_id_secret_id'),
222+ auth_client_secret_secret_id: text('auth_client_secret_secret_id'),
165223 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()
166224 }, (table) => [
167225 primaryKey({ columns: [table.scope_id, table.id] }),
168226 index("mcp_source_scope_id_idx").on(table.scope_id),
227+ index("mcp_source_auth_secret_id_idx").on(table.auth_secret_id),
228+ index("mcp_source_auth_connection_id_idx").on(table.auth_connection_id),
229+ index("mcp_source_auth_client_id_secret_id_idx").on(table.auth_client_id_secret_id),
230+ index("mcp_source_auth_client_secret_secret_id_idx").on(table.auth_client_secret_secret_id),
231+]);
232+
233+export const mcp_source_header = sqliteTable("mcp_source_header", {
234+ id: text('id').notNull(),
235+ scope_id: text('scope_id').notNull(),
236+ source_id: text('source_id').notNull(),
237+ name: text('name').notNull(),
238+ kind: text({ enum: ['text', 'secret'] }).notNull(),
239+ text_value: text('text_value'),
240+ secret_id: text('secret_id'),
241+ secret_prefix: text('secret_prefix')
242+}, (table) => [
243+ primaryKey({ columns: [table.scope_id, table.id] }),
244+ index("mcp_source_header_scope_id_idx").on(table.scope_id),
245+ index("mcp_source_header_source_id_idx").on(table.source_id),
246+ index("mcp_source_header_secret_id_idx").on(table.secret_id),
247+]);
248+
249+export const mcp_source_query_param = sqliteTable("mcp_source_query_param", {
250+ id: text('id').notNull(),
251+ scope_id: text('scope_id').notNull(),
252+ source_id: text('source_id').notNull(),
253+ name: text('name').notNull(),
254+ kind: text({ enum: ['text', 'secret'] }).notNull(),
255+ text_value: text('text_value'),
256+ secret_id: text('secret_id'),
257+ secret_prefix: text('secret_prefix')
258+}, (table) => [
259+ primaryKey({ columns: [table.scope_id, table.id] }),
260+ index("mcp_source_query_param_scope_id_idx").on(table.scope_id),
261+ index("mcp_source_query_param_source_id_idx").on(table.source_id),
262+ index("mcp_source_query_param_secret_id_idx").on(table.secret_id),
169263 ]);
170264
171265 export const mcp_binding = sqliteTable("mcp_binding", {
@@ -185,11 +279,51 @@ export const google_discovery_source = sqliteTable("google_discovery_source", { · 13 unmodified lines
185279 scope_id: text('scope_id').notNull(),
186280 name: text('name').notNull(),
187281 config: text('config', { mode: "json" }).notNull(),
282+ auth_kind: text({ enum: ['none', 'oauth2'] }).default("none").notNull(),
283+ auth_connection_id: text('auth_connection_id'),
284+ auth_client_id_secret_id: text('auth_client_id_secret_id'),
285+ auth_client_secret_secret_id: text('auth_client_secret_secret_id'),
286+ auth_scopes: text('auth_scopes', { mode: "json" }),
188287 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
189288 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
190289 }, (table) => [
191290 primaryKey({ columns: [table.scope_id, table.id] }),
192291 index("google_discovery_source_scope_id_idx").on(table.scope_id),
292+ index("google_discovery_source_auth_connection_id_idx").on(table.auth_connection_id),
293+ index("google_discovery_source_auth_client_id_secret_id_idx").on(table.auth_client_id_secret_id),
294+ index("google_discovery_source_auth_client_secret_secret_id_idx").on(table.auth_client_secret_secret_id),
295+]);
296+
297+export const google_discovery_source_credential_header = sqliteTable("google_discovery_source_credential_header", {
298+ id: text('id').notNull(),
299+ scope_id: text('scope_id').notNull(),
300+ source_id: text('source_id').notNull(),
301+ name: text('name').notNull(),
302+ kind: text({ enum: ['text', 'secret'] }).notNull(),
303+ text_value: text('text_value'),
304+ secret_id: text('secret_id'),
305+ secret_prefix: text('secret_prefix')
306+}, (table) => [
307+ primaryKey({ columns: [table.scope_id, table.id] }),
308+ index("google_discovery_source_credential_header_scope_id_idx").on(table.scope_id),
309+ index("google_discovery_source_credential_header_source_id_idx").on(table.source_id),
310+ index("google_discovery_source_credential_header_secret_id_idx").on(table.secret_id),
311+]);
312+
313+export const google_discovery_source_credential_query_param = sqliteTable("google_discovery_source_credential_query_param", {
314+ id: text('id').notNull(),
315+ scope_id: text('scope_id').notNull(),
316+ source_id: text('source_id').notNull(),
317+ name: text('name').notNull(),
318+ kind: text({ enum: ['text', 'secret'] }).notNull(),
319+ text_value: text('text_value'),
320+ secret_id: text('secret_id'),
321+ secret_prefix: text('secret_prefix')
322+}, (table) => [
323+ primaryKey({ columns: [table.scope_id, table.id] }),
324+ index("google_discovery_source_credential_query_param_scope_id_idx").on(table.scope_id),
325+ index("google_discovery_source_credential_query_param_source_id_idx").on(table.source_id),
326+ index("google_discovery_source_credential_query_param_secret_id_idx").on(table.secret_id),
193327 ]);
194328
195329 export const google_discovery_binding = sqliteTable("google_discovery_binding", {
@@ -209,12 +343,44 @@ export const graphql_source = sqliteTable("graphql_source", { · 13 unmodified lines
209343 scope_id: text('scope_id').notNull(),
210344 name: text('name').notNull(),
211345 endpoint: text('endpoint').notNull(),
212− headers: text('headers', { mode: "json" }),
213− query_params: text('query_params', { mode: "json" }),
214− auth: text('auth', { mode: "json" })
346+ auth_kind: text({ enum: ['none', 'oauth2'] }).default("none").notNull(),
347+ auth_connection_id: text('auth_connection_id')
215348 }, (table) => [
216349 primaryKey({ columns: [table.scope_id, table.id] }),
217350 index("graphql_source_scope_id_idx").on(table.scope_id),
351+ index("graphql_source_auth_connection_id_idx").on(table.auth_connection_id),
352+]);
353+
354+export const graphql_source_header = sqliteTable("graphql_source_header", {
355+ id: text('id').notNull(),
356+ scope_id: text('scope_id').notNull(),
357+ source_id: text('source_id').notNull(),
358+ name: text('name').notNull(),
359+ kind: text({ enum: ['text', 'secret'] }).notNull(),
360+ text_value: text('text_value'),
361+ secret_id: text('secret_id'),
362+ secret_prefix: text('secret_prefix')
363+}, (table) => [
364+ primaryKey({ columns: [table.scope_id, table.id] }),
365+ index("graphql_source_header_scope_id_idx").on(table.scope_id),
366+ index("graphql_source_header_source_id_idx").on(table.source_id),
367+ index("graphql_source_header_secret_id_idx").on(table.secret_id),
368+]);
369+
370+export const graphql_source_query_param = sqliteTable("graphql_source_query_param", {
371+ id: text('id').notNull(),
372+ scope_id: text('scope_id').notNull(),
373+ source_id: text('source_id').notNull(),
374+ name: text('name').notNull(),
375+ kind: text({ enum: ['text', 'secret'] }).notNull(),
376+ text_value: text('text_value'),
377+ secret_id: text('secret_id'),
378+ secret_prefix: text('secret_prefix')
379+}, (table) => [
380+ primaryKey({ columns: [table.scope_id, table.id] }),
381+ index("graphql_source_query_param_scope_id_idx").on(table.scope_id),
382+ index("graphql_source_query_param_source_id_idx").on(table.source_id),
383+ index("graphql_source_query_param_secret_id_idx").on(table.secret_id),
218384 ]);
219385
220386 export const graphql_operation = sqliteTable("graphql_operation", {
@@ -228,11 +394,3 @@ export const graphql_operation = sqliteTable("graphql_operation", { · 7 unmodified lines
228394 index("graphql_operation_source_id_idx").on(table.source_id),
229395 ]);
230396
231−export const blob = sqliteTable("blob", {
232− namespace: text('namespace').notNull(),
233− key: text('key').notNull(),
234− value: text('value').notNull()
235−}, (table) => [
236− primaryKey({ columns: [table.namespace, table.key] }),
237−]);
238−
apps/local/src/server/migrate-connections.test.ts+96−6
@@ -86,13 +86,21 @@ describe("migrateLegacyConnections", () => { · 85 unmodified lines
8686 refresh_token_secret_id: "refresh-token",
8787 });
8888
89+ // Post-0009 the canonical auth lives in dedicated columns. The
90+ // legacy migrator strips config.auth and writes the new pointer to
91+ // auth_kind / auth_connection_id directly.
8992 const source = db
90− .prepare("SELECT config FROM mcp_source WHERE scope_id = ? AND id = ?")
91− .get("scope-1", "remote-mcp") as { readonly config: string };
92− expect(JSON.parse(source.config).auth).toEqual({
93− kind: "oauth2",
94− connectionId: "mcp-oauth2-remote-mcp",
95− });
93+ .prepare(
94+ "SELECT config, auth_kind, auth_connection_id FROM mcp_source WHERE scope_id = ? AND id = ?",
95+ )
96+ .get("scope-1", "remote-mcp") as {
97+ readonly config: string;
98+ readonly auth_kind: string;
99+ readonly auth_connection_id: string;
100+ };
101+ expect(JSON.parse(source.config).auth).toBeUndefined();
102+ expect(source.auth_kind).toBe("oauth2");
103+ expect(source.auth_connection_id).toBe("mcp-oauth2-remote-mcp");
96104
97105 const ownedSecrets = db
98106 .prepare(
@@ -112,4 +120,86 @@ describe("migrateLegacyConnections", () => { · 13 unmodified lines
112120
113121 db.close();
114122 });
123+
124+ it("backfills legacy OpenAPI OAuth from oauth2 column after invocation_config has been dropped", async () => {
125+ const db = new Database(join(workDir, "data.db"));
126+ migrate(drizzle(db), {
127+ migrationsFolder: join(import.meta.dirname, "../../drizzle"),
128+ });
129+
130+ expect(columnNames(db, "openapi_source")).not.toContain("invocation_config");
131+
132+ const now = Date.now();
133+ db.prepare(
134+ "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)",
135+ ).run("scope-1", "client-id", "Client ID", "keychain", now);
136+ db.prepare(
137+ "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)",
138+ ).run("scope-1", "client-secret", "Client secret", "keychain", now);
139+ db.prepare(
140+ "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)",
141+ ).run("scope-1", "access-token", "Access token", "keychain", now);
142+
143+ db.prepare(
144+ "INSERT INTO openapi_source (scope_id, id, name, spec, oauth2) VALUES (?, ?, ?, ?, ?)",
145+ ).run(
146+ "scope-1",
147+ "legacy-openapi",
148+ "Legacy OpenAPI",
149+ JSON.stringify({
150+ openapi: "3.0.0",
151+ info: { title: "Legacy", version: "1" },
152+ paths: {},
153+ components: {
154+ securitySchemes: {
155+ oauth2: {
156+ type: "oauth2",
157+ flows: {
158+ clientCredentials: {
159+ tokenUrl: "https://example.com/oauth/token",
160+ scopes: { read: "read" },
161+ },
162+ },
163+ },
164+ },
165+ },
166+ }),
167+ JSON.stringify({
168+ kind: "oauth2",
169+ securitySchemeName: "oauth2",
170+ flow: "clientCredentials",
171+ tokenUrl: "https://example.com/oauth/token",
172+ clientIdSecretId: "client-id",
173+ clientSecretSecretId: "client-secret",
174+ accessTokenSecretId: "access-token",
175+ refreshTokenSecretId: null,
176+ tokenType: "Bearer",
177+ expiresAt: null,
178+ scope: "read",
179+ scopes: ["read"],
180+ }),
181+ );
182+
183+ await migrateLegacyConnections(db);
184+
185+ const connection = db
186+ .prepare(
187+ "SELECT id, provider, access_token_secret_id FROM connection WHERE scope_id = ?",
188+ )
189+ .get("scope-1") as
190+ | { readonly id: string; readonly provider: string; readonly access_token_secret_id: string }
191+ | undefined;
192+ expect(connection?.provider).toBe("openapi:oauth2");
193+ expect(connection?.access_token_secret_id).toBe("access-token");
194+
195+ // The oauth2 column should now hold the new pointer shape with connectionId.
196+ const source = db
197+ .prepare("SELECT oauth2 FROM openapi_source WHERE scope_id = ? AND id = ?")
198+ .get("scope-1", "legacy-openapi") as { readonly oauth2: string };
199+ const oauth2 = JSON.parse(source.oauth2);
200+ expect(oauth2.kind).toBe("oauth2");
201+ expect(oauth2.connectionId).toBe(connection?.id);
202+
203+ db.close();
204+ });
115205 });
apps/local/src/server/migrate-connections.ts+70−17
@@ -250,16 +250,32 @@ type OpenApiRow = { · 249 unmodified lines
250250
251251 const migrateOpenApi = async (sqlite: Database): Promise<void> => {
252252 if (!tableExists(sqlite, "openapi_source")) return;
253+ // After the plugin normalization migration, `invocation_config` is
254+ // gone (specFetchCredentials moved to child tables). The `oauth2`
255+ // column stays JSON — that's the canonical source for the OAuth
256+ // pointer. Pre-migration, both columns mirror each other; post-
257+ // migration, only `oauth2` is left. We read both when available and
258+ // fall back to `oauth2` so legacy data isn't silently skipped.
259+ const hasInvocationConfig = columnExists(
260+ sqlite,
261+ "openapi_source",
262+ "invocation_config",
263+ );
264+ const selectCols = hasInvocationConfig
265+ ? "scope_id, id, name, spec, invocation_config, oauth2"
266+ : "scope_id, id, name, spec, NULL AS invocation_config, oauth2";
253267 const rows = sqlite
254− .prepare(
255− "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source",
256− )
268+ .prepare(`SELECT ${selectCols} FROM openapi_source`)
257269 .all() as ReadonlyArray<OpenApiRow>;
258270 if (rows.length === 0) return;
259271
260− const updateSource = sqlite.prepare(
261− "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",
262− );
272+ const updateSource = hasInvocationConfig
273+ ? sqlite.prepare(
274+ "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",
275+ )
276+ : sqlite.prepare(
277+ "UPDATE openapi_source SET oauth2 = ? WHERE scope_id = ? AND id = ?",
278+ );
263279
264280 for (const row of rows) {
265281 let invocation: Record<string, unknown> = {};
@@ -340,13 +356,21 @@ const migrateOpenApi = async (sqlite: Database): Promise<void> => { · 74 unmodified lines
340356 });
341357 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
342358 if (err) throw new Error(err);
343− const nextInvocation = { ...invocation, oauth2: oauth2Pointer };
344− updateSource.run(
345− JSON.stringify(oauth2Pointer),
346− JSON.stringify(nextInvocation),
347− row.scope_id,
348− row.id,
349− );
359+ if (hasInvocationConfig) {
360+ const nextInvocation = { ...invocation, oauth2: oauth2Pointer };
361+ updateSource.run(
362+ JSON.stringify(oauth2Pointer),
363+ JSON.stringify(nextInvocation),
364+ row.scope_id,
365+ row.id,
366+ );
367+ } else {
368+ updateSource.run(
369+ JSON.stringify(oauth2Pointer),
370+ row.scope_id,
371+ row.id,
372+ );
373+ }
350374 });
351375 try {
352376 txn();
@@ -407,9 +431,23 @@ const migrateMcp = (sqlite: Database): void => { · 54 unmodified lines
407431 .all() as ReadonlyArray<McpRow>;
408432 if (rows.length === 0) return;
409433
410− const updateSource = sqlite.prepare(
434+ // Post-0009 mcp_source has dedicated auth_kind / auth_connection_id
435+ // columns. The auth ETL inside drizzle migration 0009 only fires for
436+ // rows whose config.auth carries `connectionId` — i.e., already
437+ // post-Connection shape. Truly-legacy rows (inline OAuth shape with
438+ // accessTokenSecretId) survive unchanged and fall to this script.
439+ // Detect them, mint a Connection, rewire owned secrets, then write
440+ // the result to the new columns (not back into config.auth, which
441+ // 0009 stripped).
442+ const hasAuthColumns = columnExists(sqlite, "mcp_source", "auth_kind");
443+ const updateConfig = sqlite.prepare(
411444 "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?",
412445 );
446+ const updateConfigAndAuth = hasAuthColumns
447+ ? sqlite.prepare(
448+ "UPDATE mcp_source SET config = ?, auth_kind = 'oauth2', auth_connection_id = ? WHERE scope_id = ? AND id = ?",
449+ )
450+ : null;
413451
414452 for (const row of rows) {
415453 let config: Record<string, unknown> = {};
@@ -447,8 +485,14 @@ const migrateMcp = (sqlite: Database): void => { · 31 unmodified lines
447485 resourceMetadataUrl: legacy.resourceMetadataUrl,
448486 resourceMetadata: null,
449487 };
450− const authPointer = { kind: "oauth2" as const, connectionId };
451− const nextConfig = { ...config, auth: authPointer };
488+ // Strip auth from config — post-0009 the canonical home is the
489+ // auth_* columns. Pre-0009 we still write the pointer back into
490+ // config.auth so the older code path keeps working.
491+ const { auth: _unused, ...configWithoutAuth } = config;
492+ void _unused;
493+ const nextConfig = hasAuthColumns
494+ ? configWithoutAuth
495+ : { ...config, auth: { kind: "oauth2" as const, connectionId } };
452496
453497 const secretIds = [legacy.accessTokenSecretId];
454498 const secretNames = [`Connection ${connectionId} access token`];
@@ -471,7 +515,16 @@ const migrateMcp = (sqlite: Database): void => { · 16 unmodified lines
471515 });
472516 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
473517 if (err) throw new Error(err);
474− updateSource.run(JSON.stringify(nextConfig), row.scope_id, row.id);
518+ if (updateConfigAndAuth) {
519+ updateConfigAndAuth.run(
520+ JSON.stringify(nextConfig),
521+ connectionId,
522+ row.scope_id,
523+ row.id,
524+ );
525+ } else {
526+ updateConfig.run(JSON.stringify(nextConfig), row.scope_id, row.id);
527+ }
475528 });
476529 try {
477530 txn();
apps/local/src/server/migrate-google-discovery-bindings.test.ts+204−0
@@ -0,0 +1,204 @@
1+// End-to-end test for the google-discovery portion of
2+// `0007_normalize_plugin_secret_refs.sql`. Seeds a
3+// google_discovery_source row with the legacy json shape (config
4+// containing auth/credentials), runs the migration, asserts the new
5+// columns and child tables are populated.
6+
7+import { describe, expect, it } from "@effect/vitest";
8+import { Database } from "bun:sqlite";
9+import { mkdtempSync, rmSync } from "node:fs";
10+import { join } from "node:path";
11+import { tmpdir } from "node:os";
12+import { drizzle } from "drizzle-orm/bun-sqlite";
13+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14+
15+import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16+
17+const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18+
19+describe("0007_normalize_plugin_secret_refs (google-discovery)", () => {
20+ it("flattens oauth2 auth into columns", () => {
21+ const dir = mkdtempSync(join(tmpdir(), "gd-mig-"));
22+ const dbPath = join(dir, "test.sqlite");
23+ try {
24+ const db = new Database(dbPath);
25+ db.exec(PRE_0007_SQL);
26+ stampPriorMigrationsApplied(db);
27+
28+ db.prepare(
29+ "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
30+ ).run(
31+ "default-scope",
32+ "drive",
33+ "Drive",
34+ JSON.stringify({
35+ name: "Drive",
36+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
37+ service: "drive",
38+ version: "v3",
39+ rootUrl: "https://www.googleapis.com/",
40+ servicePath: "drive/v3/",
41+ auth: {
42+ kind: "oauth2",
43+ connectionId: "conn-1",
44+ clientIdSecretId: "client-id",
45+ clientSecretSecretId: "client-secret",
46+ scopes: ["https://www.googleapis.com/auth/drive"],
47+ },
48+ }),
49+ Date.now(),
50+ Date.now(),
51+ );
52+
53+ db.close();
54+ const drizzleDb = drizzle(new Database(dbPath));
55+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
56+
57+ const after = new Database(dbPath, { readonly: true });
58+ const row = after
59+ .prepare(
60+ "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, config FROM google_discovery_source WHERE id = ?",
61+ )
62+ .get("drive") as Record<string, string | null>;
63+ expect(row.auth_kind).toBe("oauth2");
64+ expect(row.auth_connection_id).toBe("conn-1");
65+ expect(row.auth_client_id_secret_id).toBe("client-id");
66+ expect(row.auth_client_secret_secret_id).toBe("client-secret");
67+ // auth_scopes column is text-typed (string[] gets stored as JSON in sqlite).
68+ expect(row.auth_scopes).toContain("drive");
69+ // The auth key should be stripped from config json.
70+ const config = JSON.parse(row.config!);
71+ expect(config.auth).toBeUndefined();
72+ expect(config.service).toBe("drive");
73+ after.close();
74+ } finally {
75+ rmSync(dir, { recursive: true, force: true });
76+ }
77+ });
78+
79+ it("explodes credentials.headers and queryParams into child rows", () => {
80+ const dir = mkdtempSync(join(tmpdir(), "gd-mig-"));
81+ const dbPath = join(dir, "test.sqlite");
82+ try {
83+ const db = new Database(dbPath);
84+ db.exec(PRE_0007_SQL);
85+ stampPriorMigrationsApplied(db);
86+
87+ db.prepare(
88+ "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
89+ ).run(
90+ "default-scope",
91+ "with-creds",
92+ "With Creds",
93+ JSON.stringify({
94+ name: "With Creds",
95+ discoveryUrl: "https://example.com/discovery",
96+ service: "svc",
97+ version: "v1",
98+ rootUrl: "https://example.com/",
99+ servicePath: "svc/v1/",
100+ auth: { kind: "none" },
101+ credentials: {
102+ headers: {
103+ "X-Static": "literal",
104+ Authorization: { secretId: "tok-secret", prefix: "Bearer " },
105+ },
106+ queryParams: {
107+ api_key: { secretId: "key-secret" },
108+ },
109+ },
110+ }),
111+ Date.now(),
112+ Date.now(),
113+ );
114+
115+ db.close();
116+ const drizzleDb = drizzle(new Database(dbPath));
117+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
118+
119+ const after = new Database(dbPath, { readonly: true });
120+ const headers = after
121+ .prepare(
122+ "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE source_id = ? ORDER BY name",
123+ )
124+ .all("with-creds") as ReadonlyArray<Record<string, string | null>>;
125+ expect(headers).toHaveLength(2);
126+ const byName = new Map(headers.map((h) => [h.name!, h]));
127+ expect(byName.get("X-Static")).toMatchObject({
128+ kind: "text",
129+ text_value: "literal",
130+ });
131+ expect(byName.get("Authorization")).toMatchObject({
132+ kind: "secret",
133+ secret_id: "tok-secret",
134+ secret_prefix: "Bearer ",
135+ });
136+
137+ const params = after
138+ .prepare(
139+ "SELECT name, secret_id FROM google_discovery_source_credential_query_param WHERE source_id = ?",
140+ )
141+ .all("with-creds") as ReadonlyArray<Record<string, string>>;
142+ expect(params).toHaveLength(1);
143+ expect(params[0]).toMatchObject({ name: "api_key", secret_id: "key-secret" });
144+
145+ after.close();
146+ } finally {
147+ rmSync(dir, { recursive: true, force: true });
148+ }
149+ });
150+
151+ it("survives auth.kind=none with no credentials", () => {
152+ const dir = mkdtempSync(join(tmpdir(), "gd-mig-"));
153+ const dbPath = join(dir, "test.sqlite");
154+ try {
155+ const db = new Database(dbPath);
156+ db.exec(PRE_0007_SQL);
157+ stampPriorMigrationsApplied(db);
158+
159+ db.prepare(
160+ "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
161+ ).run(
162+ "default-scope",
163+ "bare",
164+ "Bare",
165+ JSON.stringify({
166+ name: "Bare",
167+ discoveryUrl: "https://example.com/discovery",
168+ service: "svc",
169+ version: "v1",
170+ rootUrl: "https://example.com/",
171+ servicePath: "svc/v1/",
172+ auth: { kind: "none" },
173+ }),
174+ Date.now(),
175+ Date.now(),
176+ );
177+
178+ db.close();
179+ const drizzleDb = drizzle(new Database(dbPath));
180+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
181+
182+ const after = new Database(dbPath, { readonly: true });
183+ const row = after
184+ .prepare(
185+ "SELECT auth_kind, auth_connection_id, auth_scopes FROM google_discovery_source WHERE id = ?",
186+ )
187+ .get("bare") as Record<string, string | null>;
188+ expect(row.auth_kind).toBe("none");
189+ expect(row.auth_connection_id).toBeNull();
190+
191+ const headerCount = (
192+ after
193+ .prepare(
194+ "SELECT count(*) as n FROM google_discovery_source_credential_header WHERE source_id = ?",
195+ )
196+ .get("bare") as { n: number }
197+ ).n;
198+ expect(headerCount).toBe(0);
199+ after.close();
200+ } finally {
201+ rmSync(dir, { recursive: true, force: true });
202+ }
203+ });
204+});
apps/local/src/server/migrate-graphql-bindings.test.ts+246−0
@@ -0,0 +1,246 @@
1+// End-to-end test for the graphql portion of
2+// `0007_normalize_plugin_secret_refs.sql`: seed a DB at the
3+// pre-migration (0006) shape with json-blob headers/query_params/auth,
4+// run the migration, assert that the JSON unpacks into the new
5+// normalized columns / child tables and that the JSON columns are gone.
6+
7+import { describe, expect, it } from "@effect/vitest";
8+import { Database } from "bun:sqlite";
9+import { mkdtempSync, rmSync } from "node:fs";
10+import { join } from "node:path";
11+import { tmpdir } from "node:os";
12+import { drizzle } from "drizzle-orm/bun-sqlite";
13+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14+
15+import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16+
17+const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18+
19+describe("0007_normalize_plugin_secret_refs (graphql)", () => {
20+ it("flattens auth json into auth_kind/auth_connection_id columns", () => {
21+ const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
22+ const dbPath = join(dir, "test.sqlite");
23+ try {
24+ const db = new Database(dbPath);
25+ db.exec(PRE_0007_SQL);
26+ stampPriorMigrationsApplied(db);
27+
28+ db.prepare(
29+ "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)",
30+ ).run(
31+ "default-scope",
32+ "github",
33+ "GitHub",
34+ "https://api.github.com/graphql",
35+ JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }),
36+ );
37+
38+ db.close();
39+
40+ const drizzleDb = drizzle(new Database(dbPath));
41+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
42+
43+ const after = new Database(dbPath, { readonly: true });
44+ const row = after
45+ .prepare(
46+ "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?",
47+ )
48+ .get("github") as { auth_kind: string; auth_connection_id: string };
49+ expect(row.auth_kind).toBe("oauth2");
50+ expect(row.auth_connection_id).toBe("conn-1");
51+ // Old json column is gone.
52+ const cols = after
53+ .prepare("PRAGMA table_info('graphql_source')")
54+ .all() as ReadonlyArray<{ name: string }>;
55+ expect(cols.some((c) => c.name === "auth")).toBe(false);
56+ expect(cols.some((c) => c.name === "headers")).toBe(false);
57+ expect(cols.some((c) => c.name === "query_params")).toBe(false);
58+ after.close();
59+ } finally {
60+ rmSync(dir, { recursive: true, force: true });
61+ }
62+ });
63+
64+ it("explodes header/query_param json into child rows", () => {
65+ const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
66+ const dbPath = join(dir, "test.sqlite");
67+ try {
68+ const db = new Database(dbPath);
69+ db.exec(PRE_0007_SQL);
70+ stampPriorMigrationsApplied(db);
71+
72+ const headers = {
73+ // Literal text header.
74+ "X-Static": "literal-value",
75+ // Secret-backed header without prefix.
76+ Authorization: { secretId: "sec-token" },
77+ // Secret-backed with prefix.
78+ "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " },
79+ };
80+ const queryParams = {
81+ api_key: { secretId: "sec-key" },
82+ };
83+
84+ db.prepare(
85+ "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)",
86+ ).run(
87+ "default-scope",
88+ "example",
89+ "Example",
90+ "https://example.com/graphql",
91+ JSON.stringify(headers),
92+ JSON.stringify(queryParams),
93+ JSON.stringify({ kind: "none" }),
94+ );
95+
96+ db.close();
97+
98+ const drizzleDb = drizzle(new Database(dbPath));
99+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
100+
101+ const after = new Database(dbPath, { readonly: true });
102+ const headerRows = after
103+ .prepare(
104+ "SELECT name, kind, text_value, secret_id, secret_prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name",
105+ )
106+ .all("example") as ReadonlyArray<{
107+ name: string;
108+ kind: string;
109+ text_value: string | null;
110+ secret_id: string | null;
111+ secret_prefix: string | null;
112+ }>;
113+ expect(headerRows).toHaveLength(3);
114+
115+ const byName = new Map(headerRows.map((r) => [r.name, r]));
116+ expect(byName.get("X-Static")).toMatchObject({
117+ kind: "text",
118+ text_value: "literal-value",
119+ secret_id: null,
120+ });
121+ expect(byName.get("Authorization")).toMatchObject({
122+ kind: "secret",
123+ text_value: null,
124+ secret_id: "sec-token",
125+ secret_prefix: null,
126+ });
127+ expect(byName.get("X-Bearer")).toMatchObject({
128+ kind: "secret",
129+ secret_id: "sec-bearer",
130+ secret_prefix: "Bearer ",
131+ });
132+
133+ const paramRow = after
134+ .prepare(
135+ "SELECT kind, secret_id FROM graphql_source_query_param WHERE source_id = ?",
136+ )
137+ .get("example") as { kind: string; secret_id: string };
138+ expect(paramRow).toMatchObject({ kind: "secret", secret_id: "sec-key" });
139+
140+ after.close();
141+ } finally {
142+ rmSync(dir, { recursive: true, force: true });
143+ }
144+ });
145+
146+ it("handles graphql_source rows with null json (empty config)", () => {
147+ const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
148+ const dbPath = join(dir, "test.sqlite");
149+ try {
150+ const db = new Database(dbPath);
151+ db.exec(PRE_0007_SQL);
152+ stampPriorMigrationsApplied(db);
153+
154+ db.prepare(
155+ "INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)",
156+ ).run("default-scope", "bare", "Bare", "https://bare.example/graphql");
157+ db.close();
158+
159+ const drizzleDb = drizzle(new Database(dbPath));
160+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
161+
162+ const after = new Database(dbPath, { readonly: true });
163+ const row = after
164+ .prepare(
165+ "SELECT auth_kind, auth_connection_id FROM graphql_source WHERE id = ?",
166+ )
167+ .get("bare") as { auth_kind: string; auth_connection_id: string | null };
168+ expect(row.auth_kind).toBe("none");
169+ expect(row.auth_connection_id).toBeNull();
170+
171+ const headerCount = (
172+ after
173+ .prepare(
174+ "SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?",
175+ )
176+ .get("bare") as { n: number }
177+ ).n;
178+ expect(headerCount).toBe(0);
179+ after.close();
180+ } finally {
181+ rmSync(dir, { recursive: true, force: true });
182+ }
183+ });
184+
185+ it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => {
186+ const dir = mkdtempSync(join(tmpdir(), "graphql-mig-"));
187+ const dbPath = join(dir, "test.sqlite");
188+ try {
189+ const db = new Database(dbPath);
190+ db.exec(PRE_0007_SQL);
191+ stampPriorMigrationsApplied(db);
192+
193+ const insert = db.prepare(
194+ "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)",
195+ );
196+ insert.run(
197+ "default-scope",
198+ "a:b",
199+ "First",
200+ "https://first.example/graphql",
201+ JSON.stringify({ c: "first" }),
202+ );
203+ insert.run(
204+ "default-scope",
205+ "a",
206+ "Second",
207+ "https://second.example/graphql",
208+ JSON.stringify({ "b:c": "second" }),
209+ );
210+ db.close();
211+
212+ const drizzleDb = drizzle(new Database(dbPath));
213+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
214+
215+ const after = new Database(dbPath, { readonly: true });
216+ const rows = after
217+ .prepare(
218+ "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name",
219+ )
220+ .all() as ReadonlyArray<{
221+ id: string;
222+ source_id: string;
223+ name: string;
224+ text_value: string;
225+ }>;
226+ expect(rows).toHaveLength(2);
227+ expect(rows).toEqual([
228+ {
229+ id: '["a","b:c"]',
230+ source_id: "a",
231+ name: "b:c",
232+ text_value: "second",
233+ },
234+ {
235+ id: '["a:b","c"]',
236+ source_id: "a:b",
237+ name: "c",
238+ text_value: "first",
239+ },
240+ ]);
241+ after.close();
242+ } finally {
243+ rmSync(dir, { recursive: true, force: true });
244+ }
245+ });
246+});
apps/local/src/server/migrate-mcp-bindings.test.ts+203−0
@@ -0,0 +1,203 @@
1+// End-to-end test for the mcp portion of
2+// `0007_normalize_plugin_secret_refs.sql`. Seeds an mcp_source row
3+// with the legacy json shape (config containing auth/headers/
4+// queryParams), runs the migration runner, asserts the auth columns
5+// are populated and the child tables hold the secret-backed entries.
6+
7+import { describe, expect, it } from "@effect/vitest";
8+import { Database } from "bun:sqlite";
9+import { mkdtempSync, rmSync } from "node:fs";
10+import { join } from "node:path";
11+import { tmpdir } from "node:os";
12+import { drizzle } from "drizzle-orm/bun-sqlite";
13+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14+
15+import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16+
17+const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18+
19+describe("0007_normalize_plugin_secret_refs (mcp)", () => {
20+ it("flattens header auth into auth_kind/auth_secret_id columns", () => {
21+ const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
22+ const dbPath = join(dir, "test.sqlite");
23+ try {
24+ const db = new Database(dbPath);
25+ db.exec(PRE_0007_SQL);
26+ stampPriorMigrationsApplied(db);
27+
28+ db.prepare(
29+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
30+ ).run(
31+ "default-scope",
32+ "remote-headers",
33+ "Remote Headers",
34+ JSON.stringify({
35+ transport: "remote",
36+ endpoint: "https://example.com/mcp",
37+ auth: {
38+ kind: "header",
39+ headerName: "X-API-Key",
40+ secretId: "tok-secret",
41+ prefix: "Bearer ",
42+ },
43+ }),
44+ Date.now(),
45+ );
46+
47+ db.close();
48+ const drizzleDb = drizzle(new Database(dbPath));
49+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
50+
51+ const after = new Database(dbPath, { readonly: true });
52+ const row = after
53+ .prepare(
54+ "SELECT auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, config FROM mcp_source WHERE id = ?",
55+ )
56+ .get("remote-headers") as {
57+ auth_kind: string;
58+ auth_header_name: string;
59+ auth_secret_id: string;
60+ auth_secret_prefix: string;
61+ config: string;
62+ };
63+ expect(row.auth_kind).toBe("header");
64+ expect(row.auth_header_name).toBe("X-API-Key");
65+ expect(row.auth_secret_id).toBe("tok-secret");
66+ expect(row.auth_secret_prefix).toBe("Bearer ");
67+ // The auth key should be stripped from config json after migration.
68+ const config = JSON.parse(row.config);
69+ expect(config.auth).toBeUndefined();
70+ expect(config.transport).toBe("remote");
71+ expect(config.endpoint).toBe("https://example.com/mcp");
72+ after.close();
73+ } finally {
74+ rmSync(dir, { recursive: true, force: true });
75+ }
76+ });
77+
78+ it("flattens oauth2 auth and explodes headers into child rows", () => {
79+ const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
80+ const dbPath = join(dir, "test.sqlite");
81+ try {
82+ const db = new Database(dbPath);
83+ db.exec(PRE_0007_SQL);
84+ stampPriorMigrationsApplied(db);
85+
86+ db.prepare(
87+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
88+ ).run(
89+ "default-scope",
90+ "remote-oauth",
91+ "Remote OAuth",
92+ JSON.stringify({
93+ transport: "remote",
94+ endpoint: "https://oauth.example/mcp",
95+ headers: {
96+ "X-Trace": "static",
97+ "X-Token": { secretId: "extra-tok" },
98+ },
99+ queryParams: {
100+ org: { secretId: "org-id-secret" },
101+ },
102+ auth: {
103+ kind: "oauth2",
104+ connectionId: "conn-1",
105+ clientIdSecretId: "client-id-sec",
106+ clientSecretSecretId: "client-secret-sec",
107+ },
108+ }),
109+ Date.now(),
110+ );
111+
112+ db.close();
113+ const drizzleDb = drizzle(new Database(dbPath));
114+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
115+
116+ const after = new Database(dbPath, { readonly: true });
117+ const row = after
118+ .prepare(
119+ "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id FROM mcp_source WHERE id = ?",
120+ )
121+ .get("remote-oauth") as Record<string, string | null>;
122+ expect(row.auth_kind).toBe("oauth2");
123+ expect(row.auth_connection_id).toBe("conn-1");
124+ expect(row.auth_client_id_secret_id).toBe("client-id-sec");
125+ expect(row.auth_client_secret_secret_id).toBe("client-secret-sec");
126+
127+ const headers = after
128+ .prepare(
129+ "SELECT name, kind, text_value, secret_id FROM mcp_source_header WHERE source_id = ? ORDER BY name",
130+ )
131+ .all("remote-oauth") as ReadonlyArray<Record<string, string | null>>;
132+ expect(headers).toHaveLength(2);
133+ const byName = new Map(headers.map((h) => [h.name, h]));
134+ expect(byName.get("X-Trace")).toMatchObject({
135+ kind: "text",
136+ text_value: "static",
137+ });
138+ expect(byName.get("X-Token")).toMatchObject({
139+ kind: "secret",
140+ secret_id: "extra-tok",
141+ });
142+
143+ const params = after
144+ .prepare(
145+ "SELECT name, secret_id FROM mcp_source_query_param WHERE source_id = ?",
146+ )
147+ .all("remote-oauth") as ReadonlyArray<Record<string, string>>;
148+ expect(params).toHaveLength(1);
149+ expect(params[0]).toMatchObject({ name: "org", secret_id: "org-id-secret" });
150+
151+ after.close();
152+ } finally {
153+ rmSync(dir, { recursive: true, force: true });
154+ }
155+ });
156+
157+ it("leaves stdio sources alone (no auth, no headers, no queryParams)", () => {
158+ const dir = mkdtempSync(join(tmpdir(), "mcp-mig-"));
159+ const dbPath = join(dir, "test.sqlite");
160+ try {
161+ const db = new Database(dbPath);
162+ db.exec(PRE_0007_SQL);
163+ stampPriorMigrationsApplied(db);
164+
165+ db.prepare(
166+ "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
167+ ).run(
168+ "default-scope",
169+ "stdio-only",
170+ "Stdio",
171+ JSON.stringify({
172+ transport: "stdio",
173+ command: "/usr/bin/server",
174+ args: ["--flag"],
175+ }),
176+ Date.now(),
177+ );
178+
179+ db.close();
180+ const drizzleDb = drizzle(new Database(dbPath));
181+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
182+
183+ const after = new Database(dbPath, { readonly: true });
184+ const row = after
185+ .prepare(
186+ "SELECT auth_kind, auth_secret_id, config FROM mcp_source WHERE id = ?",
187+ )
188+ .get("stdio-only") as {
189+ auth_kind: string;
190+ auth_secret_id: string | null;
191+ config: string;
192+ };
193+ expect(row.auth_kind).toBe("none");
194+ expect(row.auth_secret_id).toBeNull();
195+ const config = JSON.parse(row.config);
196+ expect(config.transport).toBe("stdio");
197+ expect(config.command).toBe("/usr/bin/server");
198+ after.close();
199+ } finally {
200+ rmSync(dir, { recursive: true, force: true });
201+ }
202+ });
203+});
apps/local/src/server/migrate-openapi-bindings.test.ts+251−0
@@ -0,0 +1,251 @@
1+// End-to-end test for the openapi portion of
2+// `0007_normalize_plugin_secret_refs.sql`. Seeds the pre-migration
3+// shape (json blobs on openapi_source.query_params,
4+// openapi_source.invocation_config.specFetchCredentials.*, and
5+// openapi_source_binding.value), runs the migration runner, asserts
6+// the new flat columns + child tables match.
7+
8+import { describe, expect, it } from "@effect/vitest";
9+import { Database } from "bun:sqlite";
10+import { mkdtempSync, rmSync } from "node:fs";
11+import { join } from "node:path";
12+import { tmpdir } from "node:os";
13+import { drizzle } from "drizzle-orm/bun-sqlite";
14+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
15+
16+import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
17+
18+const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
19+
20+describe("0007_normalize_plugin_secret_refs (openapi)", () => {
21+ it("flattens openapi_source_binding.value into kind/secret_id/connection_id/text_value", () => {
22+ const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
23+ const dbPath = join(dir, "test.sqlite");
24+ try {
25+ const db = new Database(dbPath);
26+ db.exec(PRE_0007_SQL);
27+ stampPriorMigrationsApplied(db);
28+
29+ // Seed three bindings, one per kind.
30+ const insert = db.prepare(
31+ "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
32+ );
33+ const now = Date.now();
34+ insert.run(
35+ "b1",
36+ "src",
37+ "default-scope",
38+ "default-scope",
39+ "header:authorization",
40+ JSON.stringify({ kind: "secret", secretId: "tok-secret" }),
41+ now,
42+ now,
43+ );
44+ insert.run(
45+ "b2",
46+ "src",
47+ "default-scope",
48+ "default-scope",
49+ "oauth2:default:connection",
50+ JSON.stringify({ kind: "connection", connectionId: "conn-1" }),
51+ now,
52+ now,
53+ );
54+ insert.run(
55+ "b3",
56+ "src",
57+ "default-scope",
58+ "default-scope",
59+ "header:x-static",
60+ JSON.stringify({ kind: "text", text: "literal" }),
61+ now,
62+ now,
63+ );
64+
65+ // Need the parent openapi_source row so the source_id FK ergonomics
66+ // are satisfied for any cascading delete logic — though the binding
67+ // table has no DB-level FK, code paths assume the parent exists.
68+ db.prepare(
69+ "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)",
70+ ).run("default-scope", "src", "Source", "{}", "{}");
71+
72+ db.close();
73+
74+ const drizzleDb = drizzle(new Database(dbPath));
75+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
76+
77+ const after = new Database(dbPath, { readonly: true });
78+ const rows = after
79+ .prepare(
80+ "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id",
81+ )
82+ .all() as ReadonlyArray<{
83+ id: string;
84+ kind: string;
85+ secret_id: string | null;
86+ connection_id: string | null;
87+ text_value: string | null;
88+ }>;
89+ expect(rows).toHaveLength(3);
90+ expect(rows[0]).toMatchObject({
91+ id: "b1",
92+ kind: "secret",
93+ secret_id: "tok-secret",
94+ connection_id: null,
95+ text_value: null,
96+ });
97+ expect(rows[1]).toMatchObject({
98+ id: "b2",
99+ kind: "connection",
100+ secret_id: null,
101+ connection_id: "conn-1",
102+ text_value: null,
103+ });
104+ expect(rows[2]).toMatchObject({
105+ id: "b3",
106+ kind: "text",
107+ secret_id: null,
108+ connection_id: null,
109+ text_value: "literal",
110+ });
111+ // value json column dropped.
112+ const cols = after
113+ .prepare("PRAGMA table_info('openapi_source_binding')")
114+ .all() as ReadonlyArray<{ name: string }>;
115+ expect(cols.some((c) => c.name === "value")).toBe(false);
116+ after.close();
117+ } finally {
118+ rmSync(dir, { recursive: true, force: true });
119+ }
120+ });
121+
122+ it("explodes query_params and specFetchCredentials json into child rows", () => {
123+ const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
124+ const dbPath = join(dir, "test.sqlite");
125+ try {
126+ const db = new Database(dbPath);
127+ db.exec(PRE_0007_SQL);
128+ stampPriorMigrationsApplied(db);
129+
130+ const queryParams = {
131+ api_key: { secretId: "qp-secret" },
132+ flag: "true",
133+ };
134+ const invocationConfig = {
135+ specFetchCredentials: {
136+ headers: {
137+ Authorization: { secretId: "fetch-tok", prefix: "Bearer " },
138+ },
139+ queryParams: { token: { secretId: "fetch-qp" } },
140+ },
141+ };
142+
143+ db.prepare(
144+ "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)",
145+ ).run(
146+ "default-scope",
147+ "src",
148+ "Source",
149+ "{}",
150+ JSON.stringify(queryParams),
151+ JSON.stringify(invocationConfig),
152+ );
153+
154+ db.close();
155+
156+ const drizzleDb = drizzle(new Database(dbPath));
157+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
158+
159+ const after = new Database(dbPath, { readonly: true });
160+
161+ const qpRows = after
162+ .prepare(
163+ "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name",
164+ )
165+ .all("src") as ReadonlyArray<{
166+ name: string;
167+ kind: string;
168+ text_value: string | null;
169+ secret_id: string | null;
170+ }>;
171+ expect(qpRows).toHaveLength(2);
172+ const byName = new Map(qpRows.map((r) => [r.name, r]));
173+ expect(byName.get("api_key")).toMatchObject({
174+ kind: "secret",
175+ secret_id: "qp-secret",
176+ });
177+ expect(byName.get("flag")).toMatchObject({
178+ kind: "text",
179+ text_value: "true",
180+ });
181+
182+ const fetchHeaders = after
183+ .prepare(
184+ "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?",
185+ )
186+ .all("src") as ReadonlyArray<{
187+ name: string;
188+ kind: string;
189+ secret_id: string | null;
190+ secret_prefix: string | null;
191+ }>;
192+ expect(fetchHeaders).toHaveLength(1);
193+ expect(fetchHeaders[0]).toMatchObject({
194+ name: "Authorization",
195+ kind: "secret",
196+ secret_id: "fetch-tok",
197+ secret_prefix: "Bearer ",
198+ });
199+
200+ const fetchQp = after
201+ .prepare(
202+ "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?",
203+ )
204+ .all("src") as ReadonlyArray<{ name: string; secret_id: string }>;
205+ expect(fetchQp).toHaveLength(1);
206+ expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" });
207+
208+ // Old json columns dropped.
209+ const cols = after
210+ .prepare("PRAGMA table_info('openapi_source')")
211+ .all() as ReadonlyArray<{ name: string }>;
212+ expect(cols.some((c) => c.name === "query_params")).toBe(false);
213+ expect(cols.some((c) => c.name === "invocation_config")).toBe(false);
214+ after.close();
215+ } finally {
216+ rmSync(dir, { recursive: true, force: true });
217+ }
218+ });
219+
220+ it("survives empty / missing json on bindings and sources", () => {
221+ const dir = mkdtempSync(join(tmpdir(), "openapi-mig-"));
222+ const dbPath = join(dir, "test.sqlite");
223+ try {
224+ const db = new Database(dbPath);
225+ db.exec(PRE_0007_SQL);
226+ stampPriorMigrationsApplied(db);
227+
228+ // Source with empty invocation_config and no query_params.
229+ db.prepare(
230+ "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)",
231+ ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({}));
232+
233+ db.close();
234+ const drizzleDb = drizzle(new Database(dbPath));
235+ migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
236+
237+ const after = new Database(dbPath, { readonly: true });
238+ const qpCount = (
239+ after
240+ .prepare(
241+ "SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?",
242+ )
243+ .get("bare") as { n: number }
244+ ).n;
245+ expect(qpCount).toBe(0);
246+ after.close();
247+ } finally {
248+ rmSync(dir, { recursive: true, force: true });
249+ }
250+ });
251+});
bun.lock+1−1
@@ -1939,7 +1939,7 @@ · 1938 unmodified lines
19391939
19401940 "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
19411941
1942− "@rhyssul/portless": ["@rhyssul/portless@0.13.0", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-6Hc5jcNVmEmbQUK4YT6hGNxNbDob8ZrB6izFLxZOq8P9ExS6eeBJQA9J55K67unNGWOiA77xUaSBut3SzqcYgA=="],
1942+ "@rhyssul/portless": ["@rhyssul/portless@0.13.3", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-oPvwXJIIkRg2i1CUEUsz8sAdFSf0Fzzv+NmaacAsi6ZZTHxDOofbO6fGInVnbESIFOu4k8a/rXOZRF0aqLAUgw=="],
19431943
19441944 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
19451945
packages/core/api/src/connections/api.ts+16−1
@@ -3,7 +3,9 @@ import { Schema } from "effect"; · 2 unmodified lines
33
44 import {
55 ConnectionId,
6+ ConnectionInUseError,
67 ScopeId,
8+ Usage,
79 } from "@executor-js/sdk";
810
911 import { InternalError } from "../observability";
@@ -34,6 +36,8 @@ const ConnectionRefResponse = Schema.Struct({ · 24 unmodified lines
3436 // Group
3537 // ---------------------------------------------------------------------------
3638
39+const ConnectionInUse = ConnectionInUseError.annotate({ httpApiStatus: 409 });
40+
3741 export const ConnectionsApi = HttpApiGroup.make("connections")
3842 .add(
3943 HttpApiEndpoint.get("list", "/scopes/:scopeId/connections", {
@@ -46,6 +50,17 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 6 unmodified lines
4650 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/connections/:connectionId", {
4751 params: ConnectionParams,
4852 success: Schema.Struct({ removed: Schema.Boolean }),
49− error: InternalError,
53+ error: [InternalError, ConnectionInUse],
5054 }),
55+ )
56+ .add(
57+ HttpApiEndpoint.get(
58+ "usages",
59+ "/scopes/:scopeId/connections/:connectionId/usages",
60+ {
61+ params: ConnectionParams,
62+ success: Schema.Array(Usage),
63+ error: InternalError,
64+ },
65+ ),
5166 );
packages/core/api/src/handlers/connections.ts+8−0
@@ -40,5 +40,13 @@ export const ConnectionsHandlers = HttpApiBuilder.group( · 39 unmodified lines
4040 return { removed: true };
4141 }),
4242 ),
43+ )
44+ .handle("usages", ({ params: path }) =>
45+ capture(
46+ Effect.gen(function* () {
47+ const executor = yield* ExecutorService;
48+ return yield* executor.connections.usages(path.connectionId);
49+ }),
50+ ),
4351 ),
4452 );
packages/core/api/src/handlers/secrets.ts+6−0
@@ -51,5 +51,11 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 50 unmodified lines
5151 yield* executor.secrets.remove(path.secretId);
5252 return { removed: true };
5353 })),
54+ )
55+ .handle("usages", ({ params: path }) =>
56+ capture(Effect.gen(function* () {
57+ const executor = yield* ExecutorService;
58+ return yield* executor.secrets.usages(path.secretId);
59+ })),
5460 ),
5561 );
packages/core/api/src/secrets/api.ts+11−1
@@ -3,9 +3,11 @@ import { Schema } from "effect"; · 2 unmodified lines
33 import {
44 ScopeId,
55 SecretId,
6+ SecretInUseError,
67 SecretNotFoundError,
78 SecretOwnedByConnectionError,
89 SecretResolutionError,
10+ Usage,
911 } from "@executor-js/sdk";
1012
1113 import { InternalError } from "../observability";
@@ -52,6 +54,7 @@ const SecretResolution = SecretResolutionError.annotate( · 40 unmodified lines
5254 const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate(
5355 { httpApiStatus: 409 },
5456 );
57+const SecretInUse = SecretInUseError.annotate({ httpApiStatus: 409 });
5558
5659 // ---------------------------------------------------------------------------
5760 // Group
@@ -84,6 +87,13 @@ export const SecretsApi = HttpApiGroup.make("secrets") · 26 unmodified lines
8487 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", {
8588 params: SecretParams,
8689 success: Schema.Struct({ removed: Schema.Boolean }),
87− error: [InternalError, SecretNotFound, SecretOwnedByConnection],
90+ error: [InternalError, SecretNotFound, SecretOwnedByConnection, SecretInUse],
91+ }),
92+ )
93+ .add(
94+ HttpApiEndpoint.get("usages", "/scopes/:scopeId/secrets/:secretId/usages", {
95+ params: SecretParams,
96+ success: Schema.Array(Usage),
97+ error: InternalError,
8898 }),
8999 );
packages/core/execution/src/promise.ts+2−0
@@ -101,6 +101,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ · 100 unmodified lines
101101 set: (input) => fromPromise(() => pe.secrets.set(input)),
102102 remove: (id) => fromPromise(() => pe.secrets.remove(id)),
103103 list: () => fromPromise(() => pe.secrets.list()),
104+ usages: (id) => fromPromise(() => pe.secrets.usages(id)),
104105 providers: () => fromPromise(() => pe.secrets.providers()),
105106 },
106107 connections: {
@@ -111,6 +112,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ · 4 unmodified lines
111112 setIdentityLabel: (id, label) => fromPromise(() => pe.connections.setIdentityLabel(id, label)),
112113 accessToken: (id) => fromPromise(() => pe.connections.accessToken(id)),
113114 remove: (id) => fromPromise(() => pe.connections.remove(id)),
115+ usages: (id) => fromPromise(() => pe.connections.usages(id)),
114116 providers: () => fromPromise(() => pe.connections.providers()),
115117 },
116118 oauth: {
packages/core/sdk/src/errors.ts+28−1
@@ -98,6 +98,21 @@ export class SecretOwnedByConnectionError extends Schema.TaggedErrorClass<Secret · 97 unmodified lines
9898 },
9999 ) {}
100100
101+/** Raised when `secrets.remove(id)` is called on a secret that's still
102+ * referenced by one or more sources / bindings across plugins. The UI's
103+ * "Used by" list tells the user which sources to detach first. App-
104+ * level RESTRICT — the codebase doesn't enforce DB-level FKs because
105+ * composite `(scope_id, id)` PKs make single-column references
106+ * impossible to constrain in sqlite. `usageCount` is a hint for the
107+ * caller; the full list is queryable via `secrets.usages(id)`. */
108+export class SecretInUseError extends Schema.TaggedErrorClass<SecretInUseError>()(
109+ "SecretInUseError",
110+ {
111+ secretId: SecretId,
112+ usageCount: Schema.Number,
113+ },
114+) {}
115+
101116 // ---------------------------------------------------------------------------
102117 // Connections
103118 // ---------------------------------------------------------------------------
@@ -141,6 +156,16 @@ export class ConnectionReauthRequiredError extends Schema.TaggedErrorClass<Conne · 37 unmodified lines
141156 },
142157 ) {}
143158
159+/** Raised when `connections.remove(id)` is called on a connection that's
160+ * still referenced by sources / bindings. Mirrors `SecretInUseError`. */
161+export class ConnectionInUseError extends Schema.TaggedErrorClass<ConnectionInUseError>()(
162+ "ConnectionInUseError",
163+ {
164+ connectionId: ConnectionId,
165+ usageCount: Schema.Number,
166+ },
167+) {}
168+
144169 // ---------------------------------------------------------------------------
145170 // Union type for convenience in signatures.
146171 // ---------------------------------------------------------------------------
@@ -156,7 +181,9 @@ export type ExecutorError = · 9 unmodified lines
156181 | SecretNotFoundError
157182 | SecretResolutionError
158183 | SecretOwnedByConnectionError
184+ | SecretInUseError
159185 | ConnectionNotFoundError
160186 | ConnectionProviderNotRegisteredError
161187 | ConnectionRefreshNotSupportedError
162− | ConnectionReauthRequiredError;
188+ | ConnectionReauthRequiredError
189+ | ConnectionInUseError;
packages/core/sdk/src/executor.ts+191−6
@@ -44,12 +44,14 @@ import { · 43 unmodified lines
4444 type ElicitationRequest,
4545 } from "./elicitation";
4646 import {
47+ ConnectionInUseError,
4748 ConnectionNotFoundError,
4849 ConnectionProviderNotRegisteredError,
4950 ConnectionReauthRequiredError,
5051 ConnectionRefreshNotSupportedError,
5152 NoHandlerError,
5253 PluginNotLoadedError,
54+ SecretInUseError,
5355 SecretOwnedByConnectionError,
5456 SourceRemovalNotAllowedError,
5557 ToolBlockedError,
@@ -84,6 +86,7 @@ import { · 28 unmodified lines
8486 SetSecretInput,
8587 type SecretProvider,
8688 } from "./secrets";
89+import { Usage } from "./usages";
8790 import {
8891 ToolSchema,
8992 type Source,
@@ -211,11 +214,22 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 121 unmodified lines
211214 ) => Effect.Effect<SecretRef, StorageFailure>;
212215 /** Delete a bare (non-connection-owned) secret. Connection-owned
213216 * secrets are rejected with `SecretOwnedByConnectionError` — use
214− * `connections.remove` instead. */
217+ * `connections.remove` instead. Refuses with `SecretInUseError`
218+ * if any plugin reports the secret as in use; the caller should
219+ * show the `usages(id)` list and ask the user to detach first. */
215220 readonly remove: (
216221 id: string,
217− ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;
222+ ) => Effect.Effect<
223+ void,
224+ SecretOwnedByConnectionError | SecretInUseError | StorageFailure
225+ >;
218226 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
227+ /** All places this secret is referenced — fans out across every
228+ * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by"
229+ * list and by `remove` for its RESTRICT check. */
230+ readonly usages: (
231+ id: string,
232+ ) => Effect.Effect<readonly Usage[], StorageFailure>;
219233 readonly providers: () => Effect.Effect<readonly string[]>;
220234 };
221235
@@ -251,7 +265,16 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 29 unmodified lines
251265 | ConnectionRefreshError
252266 | StorageFailure
253267 >;
254− readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
268+ /** Refuses with `ConnectionInUseError` if any plugin reports the
269+ * connection as in use. */
270+ readonly remove: (
271+ id: string,
272+ ) => Effect.Effect<void, ConnectionInUseError | StorageFailure>;
273+ /** All places this connection is referenced — fans out across every
274+ * plugin's `usagesForConnection`. */
275+ readonly usages: (
276+ id: string,
277+ ) => Effect.Effect<readonly Usage[], StorageFailure>;
255278 readonly providers: () => Effect.Effect<readonly string[]>;
256279 };
257280
@@ -945,9 +968,129 @@ export const createExecutor = < · 687 unmodified lines
945968 });
946969 });
947970
971+ // Fan out across every plugin that contributes `usagesForSecret`. Each
972+ // plugin queries its own normalized columns through its scoped adapter,
973+ // so scope filtering is automatic.
974+ //
975+ // The display path (`secretsUsages` / `connectionsUsages` from the API)
976+ // calls `*Lenient`: per-plugin errors become a logWarning so one buggy
977+ // plugin can't break the UI footer. The delete RESTRICT path
978+ // (`secretsRemove` / `connectionsRemove`) calls `*Strict`: per-plugin
979+ // errors fail the whole call so a transient plugin failure can't be
980+ // mistaken for "no usages" and let through a delete that creates
981+ // dangling refs.
982+ const secretsUsagesStrict = (
983+ id: string,
984+ ): Effect.Effect<readonly Usage[], StorageFailure> =>
985+ Effect.gen(function* () {
986+ const secretId = SecretId.make(id);
987+ const perPlugin = yield* Effect.all(
988+ [...runtimes.values()]
989+ .filter((r) => r.plugin.usagesForSecret)
990+ .map((r) =>
991+ r.plugin.usagesForSecret!({
992+ ctx: r.ctx,
993+ args: { secretId },
994+ }).pipe(
995+ Effect.mapError(
996+ (cause): StorageFailure =>
997+ new StorageError({
998+ message: `usagesForSecret failed for plugin ${r.plugin.id}`,
999+ cause,
1000+ }),
1001+ ),
1002+ ),
1003+ ),
1004+ { concurrency: "unbounded" },
1005+ );
1006+ return perPlugin.flat();
1007+ });
1008+
1009+ const secretsUsages = (
1010+ id: string,
1011+ ): Effect.Effect<readonly Usage[], StorageFailure> =>
1012+ Effect.gen(function* () {
1013+ const secretId = SecretId.make(id);
1014+ const perPlugin = yield* Effect.all(
1015+ [...runtimes.values()]
1016+ .filter((r) => r.plugin.usagesForSecret)
1017+ .map((r) =>
1018+ r.plugin.usagesForSecret!({
1019+ ctx: r.ctx,
1020+ args: { secretId },
1021+ }).pipe(
1022+ Effect.catchCause((cause: unknown) =>
1023+ Effect.logWarning(
1024+ `usagesForSecret failed for plugin ${r.plugin.id}`,
1025+ cause,
1026+ ).pipe(Effect.as([] as readonly Usage[])),
1027+ ),
1028+ ),
1029+ ),
1030+ { concurrency: "unbounded" },
1031+ );
1032+ return perPlugin.flat();
1033+ });
1034+
1035+ const connectionsUsagesStrict = (
1036+ id: string,
1037+ ): Effect.Effect<readonly Usage[], StorageFailure> =>
1038+ Effect.gen(function* () {
1039+ const connectionId = ConnectionId.make(id);
1040+ const perPlugin = yield* Effect.all(
1041+ [...runtimes.values()]
1042+ .filter((r) => r.plugin.usagesForConnection)
1043+ .map((r) =>
1044+ r.plugin.usagesForConnection!({
1045+ ctx: r.ctx,
1046+ args: { connectionId },
1047+ }).pipe(
1048+ Effect.mapError(
1049+ (cause): StorageFailure =>
1050+ new StorageError({
1051+ message: `usagesForConnection failed for plugin ${r.plugin.id}`,
1052+ cause,
1053+ }),
1054+ ),
1055+ ),
1056+ ),
1057+ { concurrency: "unbounded" },
1058+ );
1059+ return perPlugin.flat();
1060+ });
1061+
1062+ const connectionsUsages = (
1063+ id: string,
1064+ ): Effect.Effect<readonly Usage[], StorageFailure> =>
1065+ Effect.gen(function* () {
1066+ const connectionId = ConnectionId.make(id);
1067+ const perPlugin = yield* Effect.all(
1068+ [...runtimes.values()]
1069+ .filter((r) => r.plugin.usagesForConnection)
1070+ .map((r) =>
1071+ r.plugin.usagesForConnection!({
1072+ ctx: r.ctx,
1073+ args: { connectionId },
1074+ }).pipe(
1075+ Effect.catchCause((cause: unknown) =>
1076+ Effect.logWarning(
1077+ `usagesForConnection failed for plugin ${r.plugin.id}`,
1078+ cause,
1079+ ).pipe(Effect.as([] as readonly Usage[])),
1080+ ),
1081+ ),
1082+ ),
1083+ { concurrency: "unbounded" },
1084+ );
1085+ return perPlugin.flat();
1086+ });
1087+
9481088 const secretsRemove = (
9491089 id: string,
950− ): Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure> =>
1090+ ): Effect.Effect<
1091+ void,
1092+ SecretOwnedByConnectionError | SecretInUseError | StorageFailure
1093+ > =>
9511094 Effect.gen(function* () {
9521095 // Remove is shadowing-aware: drop only the innermost-scope row.
9531096 // Removing a user-scope override on a secret that also has an
@@ -973,6 +1116,27 @@ export const createExecutor = < · 19 unmodified lines
9731116 }),
9741117 );
9751118 }
1119+ // RESTRICT: refuse if any source/binding still references this
1120+ // secret AND deleting the innermost row would leave the reference
1121+ // dangling. With shadowing, deleting a user-scope override still
1122+ // leaves outer-scope rows that the reference resolves to — that
1123+ // case is safe to allow. Only block when this is the last row
1124+ // with this id across the entire scope stack.
1125+ // Strict variant: per-plugin failures fail the gate (vs. lenient
1126+ // display path that swallows them) so we never silently let a
1127+ // reference dangle on a transient error.
1128+ const willDangle = rows.length <= 1;
1129+ if (willDangle) {
1130+ const usages = yield* secretsUsagesStrict(id);
1131+ if (usages.length > 0) {
1132+ return yield* Effect.fail(
1133+ new SecretInUseError({
1134+ secretId: SecretId.make(id),
1135+ usageCount: usages.length,
1136+ }),
1137+ );
1138+ }
1139+ }
9761140 const targetScope = (target?.scope_id as string | undefined) ??
9771141 scopeIds[0]!;
9781142
@@ -1488,10 +1652,29 @@ export const createExecutor = < · 509 unmodified lines
14881652
14891653 const connectionsRemove = (
14901654 id: string,
1491− ): Effect.Effect<void, StorageFailure> =>
1655+ ): Effect.Effect<void, ConnectionInUseError | StorageFailure> =>
14921656 Effect.gen(function* () {
1493− const row = yield* findInnermostConnectionRow(id);
1657+ const allRows = yield* core.findMany({
1658+ model: "connection",
1659+ where: [{ field: "id", value: id }],
1660+ });
1661+ const row = findInnermost(allRows as readonly ConnectionRow[]);
14941662 if (!row) return;
1663+ // RESTRICT: refuse if any source/binding still references this
1664+ // connection AND deleting the innermost row would leave the
1665+ // reference dangling. Same shadowing rationale as `secretsRemove`.
1666+ const willDangle = allRows.length <= 1;
1667+ if (willDangle) {
1668+ const usages = yield* connectionsUsagesStrict(id);
1669+ if (usages.length > 0) {
1670+ return yield* Effect.fail(
1671+ new ConnectionInUseError({
1672+ connectionId: ConnectionId.make(id),
1673+ usageCount: usages.length,
1674+ }),
1675+ );
1676+ }
1677+ }
14951678 const scope = row.scope_id as string;
14961679 yield* adapter.transaction(() =>
14971680 Effect.gen(function* () {
@@ -2800,6 +2983,7 @@ export const createExecutor = < · 1302 unmodified lines
28002983 set: secretsSet,
28012984 remove: secretsRemove,
28022985 list: secretsList,
2986+ usages: secretsUsages,
28032987 providers: () =>
28042988 Effect.sync(
28052989 () => Array.from(secretProviders.keys()) as readonly string[],
@@ -2813,6 +2997,7 @@ export const createExecutor = < · 7 unmodified lines
28132997 setIdentityLabel: connectionsSetIdentityLabel,
28142998 accessToken: connectionsAccessToken,
28152999 remove: connectionsRemove,
3000+ usages: connectionsUsages,
28163001 providers: () =>
28173002 Effect.sync(
28183003 () =>
packages/core/sdk/src/index.ts+9−0
@@ -55,10 +55,12 @@ export { · 54 unmodified lines
5555 SecretNotFoundError,
5656 SecretResolutionError,
5757 SecretOwnedByConnectionError,
58+ SecretInUseError,
5859 ConnectionNotFoundError,
5960 ConnectionProviderNotRegisteredError,
6061 ConnectionRefreshNotSupportedError,
6162 ConnectionReauthRequiredError,
63+ ConnectionInUseError,
6264 type ExecutorError,
6365 } from "./errors";
6466
@@ -118,6 +120,13 @@ export { · 53 unmodified lines
118120 type ResolveSecretBackedMapOptions,
119121 } from "./secret-backed-value";
120122
123+// Usage tracking — secret/connection refs across plugins
124+export {
125+ Usage,
126+ type UsagesForSecretInput,
127+ type UsagesForConnectionInput,
128+} from "./usages";
129+
121130 // Connections
122131 export {
123132 ConnectionRef,
packages/core/sdk/src/plugin.ts+36−3
@@ -29,15 +29,18 @@ import type { · 28 unmodified lines
2929 ElicitationResponse,
3030 } from "./elicitation";
3131 import type {
32+ ConnectionInUseError,
3233 ConnectionNotFoundError,
3334 ConnectionProviderNotRegisteredError,
3435 ConnectionReauthRequiredError,
3536 ConnectionRefreshNotSupportedError,
37+ SecretInUseError,
3638 SecretOwnedByConnectionError,
3739 } from "./errors";
3840 import type { OAuthService } from "./oauth";
3941 import type { Scope } from "./scope";
4042 import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";
43+import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./usages";
4144
4245 // ---------------------------------------------------------------------------
4346 // StorageDeps — backing passed to a plugin's `storage` factory. The only
@@ -160,10 +163,15 @@ export interface PluginCtx<TStore = unknown> { · 116 unmodified lines
160163 /** Delete a secret from its pinned provider and the core table.
161164 * Rejects with `SecretOwnedByConnectionError` if the row is owned
162165 * by a connection — callers must go through `connections.remove`
163− * to drop the whole sign-in. */
166+ * to drop the whole sign-in. Rejects with `SecretInUseError` if
167+ * any plugin reports the secret as in use; the caller should ask
168+ * the user to detach the listed sources first. */
164169 readonly remove: (
165170 id: string,
166− ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;
171+ ) => Effect.Effect<
172+ void,
173+ SecretOwnedByConnectionError | SecretInUseError | StorageFailure
174+ >;
167175 };
168176
169177 /** Connections — product-level sign-in state. Owns backing secret
@@ -206,7 +214,12 @@ export interface PluginCtx<TStore = unknown> { · 36 unmodified lines
206214 | ConnectionRefreshError
207215 | StorageFailure
208216 >;
209− readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
217+ /** Refuses with `ConnectionInUseError` if any plugin reports the
218+ * connection as in use. Caller surfaces the `usages` list to the
219+ * user. */
220+ readonly remove: (
221+ id: string,
222+ ) => Effect.Effect<void, ConnectionInUseError | StorageFailure>;
210223 };
211224
212225 /** Shared OAuth service. Plugins use this to probe/start/complete OAuth
@@ -460,6 +473,26 @@ export interface PluginSpec< · 247 unmodified lines
460473 readonly toolRows: readonly ToolRow[];
461474 }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
462475
476+ /** Find every place a secret id is referenced by this plugin's stored
477+ * rows. Implementations query their normalized columns (e.g.
478+ * `WHERE secret_id = $1`) and return one `Usage` per hit, with
479+ * `ownerKind` / `slot` tagging the location. The executor fans out
480+ * across all plugins and the result powers the Secrets-tab "Used
481+ * by" list and the deletion-blocking check in `secrets.remove`.
482+ *
483+ * Plugins that never store secret refs (secret-provider-only
484+ * plugins like keychain / file-secrets / 1password) omit this. */
485+ readonly usagesForSecret?: (input: {
486+ readonly ctx: PluginCtx<TStore>;
487+ readonly args: UsagesForSecretInput;
488+ }) => Effect.Effect<readonly Usage[], unknown>;
489+
490+ /** Same shape as `usagesForSecret`, but for connection refs. */
491+ readonly usagesForConnection?: (input: {
492+ readonly ctx: PluginCtx<TStore>;
493+ readonly args: UsagesForConnectionInput;
494+ }) => Effect.Effect<readonly Usage[], unknown>;
495+
463496 /** Called when `executor.sources.remove(id)` targets a source owned
464497 * by this plugin. Plugin-side cleanup only; the executor deletes
465498 * the core source/tool rows after this callback returns, inside
packages/core/sdk/src/usages.ts+42−0
@@ -0,0 +1,42 @@
1+import { Schema } from "effect";
2+
3+import { ScopeId, SecretId, ConnectionId } from "./ids";
4+
5+// ---------------------------------------------------------------------------
6+// Usage — one row per place a secret or connection is referenced. Each
7+// plugin contributes its own usages via `usagesForSecret` /
8+// `usagesForConnection`; the executor fans out and concatenates.
9+//
10+// `pluginId` identifies the plugin that owns the reference. `ownerKind`
11+// is plugin-defined (e.g. "openapi-source-oauth2", "mcp-source-auth",
12+// "graphql-source-header"); the UI groups by it for a "used in N
13+// sources / M bindings" summary. `slot` describes which field within
14+// the owner holds the ref ("oauth2.client_secret", "header:Authorization",
15+// "binding:value") so the user can locate it.
16+//
17+// `ownerName` is resolved by JOIN at query time from the parent source /
18+// binding row. It's nullable because a plugin may have an owner that has
19+// no human-readable name (e.g. an unnamed binding row).
20+//
21+// `scopeId` is the scope the owner row lives in — plugins query through
22+// their scoped adapter (which auto-filters by `scope_id IN (stack)`), so
23+// usages from outer scopes naturally surface alongside inner ones; the
24+// UI uses the scope to render a per-scope label next to each entry.
25+// ---------------------------------------------------------------------------
26+
27+export class Usage extends Schema.Class<Usage>("Usage")({
28+ pluginId: Schema.String,
29+ scopeId: ScopeId,
30+ ownerKind: Schema.String,
31+ ownerId: Schema.String,
32+ ownerName: Schema.NullOr(Schema.String),
33+ slot: Schema.String,
34+}) {}
35+
36+export interface UsagesForSecretInput {
37+ readonly secretId: SecretId;
38+}
39+
40+export interface UsagesForConnectionInput {
41+ readonly connectionId: ConnectionId;
42+}
packages/plugins/google-discovery/src/sdk/binding-store.ts+441−15
@@ -21,7 +21,13 @@ import { · 20 unmodified lines
2121 type StorageFailure,
2222 } from "@executor-js/sdk/core";
2323
24−import { GoogleDiscoveryMethodBinding, GoogleDiscoveryStoredSourceData } from "./types";
24+import {
25+ GoogleDiscoveryMethodBinding,
26+ GoogleDiscoveryStoredSourceData,
27+ type GoogleDiscoveryAuth,
28+ type GoogleDiscoveryCredentialValue,
29+ type GoogleDiscoveryFetchCredentials,
30+} from "./types";
2531
2632 // ---------------------------------------------------------------------------
2733 // OAuth session TTL
@@ -39,11 +45,58 @@ export const googleDiscoverySchema = defineSchema({ · 11 unmodified lines
3945 id: { type: "string", required: true },
4046 scope_id: { type: "string", required: true, index: true },
4147 name: { type: "string", required: true },
48+ // Plugin-private structural config minus auth/credentials —
49+ // discoveryUrl, service, version, rootUrl, servicePath. These
50+ // never carry refs.
4251 config: { type: "json", required: true },
52+ // Flattened GoogleDiscoveryAuth.
53+ auth_kind: {
54+ type: ["none", "oauth2"],
55+ required: true,
56+ defaultValue: "none",
57+ },
58+ auth_connection_id: { type: "string", required: false, index: true },
59+ auth_client_id_secret_id: {
60+ type: "string",
61+ required: false,
62+ index: true,
63+ },
64+ auth_client_secret_secret_id: {
65+ type: "string",
66+ required: false,
67+ index: true,
68+ },
69+ // Stored as a string[] (JSON-backed but not a ref-bearing column).
70+ // Empty array when auth_kind is "none".
71+ auth_scopes: { type: "string[]", required: false },
4372 created_at: { type: "date", required: true },
4473 updated_at: { type: "date", required: true },
4574 },
4675 },
76+ google_discovery_source_credential_header: {
77+ fields: {
78+ id: { type: "string", required: true },
79+ scope_id: { type: "string", required: true, index: true },
80+ source_id: { type: "string", required: true, index: true },
81+ name: { type: "string", required: true },
82+ kind: { type: ["text", "secret"], required: true },
83+ text_value: { type: "string", required: false },
84+ secret_id: { type: "string", required: false, index: true },
85+ secret_prefix: { type: "string", required: false },
86+ },
87+ },
88+ google_discovery_source_credential_query_param: {
89+ fields: {
90+ id: { type: "string", required: true },
91+ scope_id: { type: "string", required: true, index: true },
92+ source_id: { type: "string", required: true, index: true },
93+ name: { type: "string", required: true },
94+ kind: { type: ["text", "secret"], required: true },
95+ text_value: { type: "string", required: false },
96+ secret_id: { type: "string", required: false, index: true },
97+ secret_prefix: { type: "string", required: false },
98+ },
99+ },
47100 google_discovery_binding: {
48101 fields: {
49102 id: { type: "string", required: true },
@@ -93,6 +146,112 @@ const decodeJson = (value: unknown): unknown => { · 43 unmodified lines
93146 }
94147 };
95148
149+// --- auth column packing/unpacking ------------------------------------------
150+
151+interface AuthColumns {
152+ readonly auth_kind: "none" | "oauth2";
153+ readonly auth_connection_id?: string;
154+ readonly auth_client_id_secret_id?: string;
155+ readonly auth_client_secret_secret_id?: string;
156+ // Mutable rather than readonly so the typed adapter's RowInput shape
157+ // (which expects `string[]`, not `readonly string[]`) is satisfied.
158+ readonly auth_scopes?: string[];
159+}
160+
161+const authToColumns = (auth: GoogleDiscoveryAuth): AuthColumns => {
162+ if (auth.kind === "oauth2") {
163+ return {
164+ auth_kind: "oauth2",
165+ auth_connection_id: auth.connectionId,
166+ auth_client_id_secret_id: auth.clientIdSecretId,
167+ auth_client_secret_secret_id: auth.clientSecretSecretId ?? undefined,
168+ auth_scopes: [...auth.scopes],
169+ };
170+ }
171+ return { auth_kind: "none" };
172+};
173+
174+const columnsToAuth = (row: Record<string, unknown>): GoogleDiscoveryAuth => {
175+ if (
176+ row.auth_kind === "oauth2" &&
177+ typeof row.auth_connection_id === "string" &&
178+ typeof row.auth_client_id_secret_id === "string"
179+ ) {
180+ const csec = row.auth_client_secret_secret_id as string | null | undefined;
181+ const scopes = (row.auth_scopes as readonly string[] | null | undefined) ?? [];
182+ return {
183+ kind: "oauth2",
184+ connectionId: row.auth_connection_id,
185+ clientIdSecretId: row.auth_client_id_secret_id,
186+ clientSecretSecretId: csec ?? null,
187+ scopes: [...scopes],
188+ };
189+ }
190+ return { kind: "none" };
191+};
192+
193+// --- SecretBackedValue maps <-> child rows ----------------------------------
194+
195+interface CredentialRow {
196+ readonly id: string;
197+ readonly scope_id: string;
198+ readonly source_id: string;
199+ readonly name: string;
200+ readonly kind: "text" | "secret";
201+ readonly text_value?: string;
202+ readonly secret_id?: string;
203+ readonly secret_prefix?: string;
204+ readonly [k: string]: unknown;
205+}
206+
207+const valueMapToRows = (
208+ sourceId: string,
209+ scope: string,
210+ values: Record<string, GoogleDiscoveryCredentialValue> | undefined,
211+): readonly CredentialRow[] => {
212+ if (!values) return [];
213+ return Object.entries(values).map(([name, value]) => {
214+ const id = JSON.stringify([sourceId, name]);
215+ if (typeof value === "string") {
216+ return {
217+ id,
218+ scope_id: scope,
219+ source_id: sourceId,
220+ name,
221+ kind: "text",
222+ text_value: value,
223+ };
224+ }
225+ return {
226+ id,
227+ scope_id: scope,
228+ source_id: sourceId,
229+ name,
230+ kind: "secret",
231+ secret_id: value.secretId,
232+ secret_prefix: value.prefix,
233+ };
234+ });
235+};
236+
237+const rowsToValueMap = (
238+ rows: readonly Record<string, unknown>[],
239+): Record<string, GoogleDiscoveryCredentialValue> => {
240+ const out: Record<string, GoogleDiscoveryCredentialValue> = {};
241+ for (const row of rows) {
242+ const name = row.name as string;
243+ if (row.kind === "secret" && typeof row.secret_id === "string") {
244+ const prefix = row.secret_prefix as string | undefined | null;
245+ out[name] = prefix
246+ ? { secretId: row.secret_id, prefix }
247+ : { secretId: row.secret_id };
248+ } else if (row.kind === "text" && typeof row.text_value === "string") {
249+ out[name] = row.text_value;
250+ }
251+ }
252+ return out;
253+};
254+
96255 // ---------------------------------------------------------------------------
97256 // Store interface
98257 // ---------------------------------------------------------------------------
@@ -154,6 +313,52 @@ export interface GoogleDiscoveryStore { · 55 unmodified lines
154313 sourceId: string,
155314 scope: string,
156315 ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>;
316+
317+ // ---------------------------------------------------------------------
318+ // Usage lookups — back `usagesForSecret` / `usagesForConnection`.
319+ // ---------------------------------------------------------------------
320+
321+ /** Source rows whose oauth2 auth columns reference the given secret id.
322+ * `slot` distinguishes client_id vs client_secret. */
323+ readonly findSourcesBySecret: (
324+ secretId: string,
325+ ) => Effect.Effect<
326+ readonly {
327+ readonly namespace: string;
328+ readonly scope_id: string;
329+ readonly name: string;
330+ readonly slot: string;
331+ }[],
332+ StorageFailure
333+ >;
334+
335+ /** Source rows whose oauth2 auth points at the given connection id. */
336+ readonly findSourcesByConnection: (
337+ connectionId: string,
338+ ) => Effect.Effect<
339+ readonly {
340+ readonly namespace: string;
341+ readonly scope_id: string;
342+ readonly name: string;
343+ readonly slot: string;
344+ }[],
345+ StorageFailure
346+ >;
347+
348+ /** Credential header / query_param child rows referencing the secret. */
349+ readonly findCredentialRowsBySecret: (secretId: string) => Effect.Effect<
350+ readonly {
351+ readonly kind: "credential_header" | "credential_query_param";
352+ readonly source_id: string;
353+ readonly scope_id: string;
354+ readonly name: string;
355+ }[],
356+ StorageFailure
357+ >;
358+
359+ readonly lookupSourceNames: (
360+ keys: readonly string[],
361+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>;
157362 }
158363
159364 // ---------------------------------------------------------------------------
@@ -245,6 +450,8 @@ export const makeGoogleDiscoveryStore = ( · 85 unmodified lines
245450 putSource: (source) =>
246451 Effect.gen(function* () {
247452 const now = new Date();
453+ // Wipe the source row + every child row before recreating —
454+ // matches putSource's "fully replace" semantic.
248455 yield* db.delete({
249456 model: "google_discovery_source",
250457 where: [
@@ -252,18 +459,29 @@ export const makeGoogleDiscoveryStore = ( · 1 unmodified line
252459 { field: "scope_id", value: source.scope },
253460 ],
254461 });
462+ yield* deleteSourceChildren(source.namespace, source.scope);
463+
464+ const encoded = stripExtractedFields(
465+ encodeStoredSourceData(source.config) as Record<string, unknown>,
466+ );
255467 yield* db.create({
256468 model: "google_discovery_source",
257469 data: {
258470 id: source.namespace,
259471 scope_id: source.scope,
260472 name: source.name,
261− config: toJsonRecord(encodeStoredSourceData(source.config)),
473+ config: toJsonRecord(encoded),
262474 created_at: now,
263475 updated_at: now,
476+ ...authToColumns(source.config.auth),
264477 },
265478 forceAllowId: true,
266479 });
480+ yield* writeCredentialRows(
481+ source.namespace,
482+ source.scope,
483+ source.config.credentials,
484+ );
267485 }),
268486
269487 updateSourceMeta: (sourceId, scope, update) =>
@@ -276,12 +494,7 @@ export const makeGoogleDiscoveryStore = ( · 6 unmodified lines
276494 ],
277495 });
278496 if (!row) return;
279− const config = decodeStoredSourceData(decodeJson(row.config));
280− const nextConfig = new GoogleDiscoveryStoredSourceData({
281− ...config,
282− name: update.name ?? config.name,
283− auth: update.auth ?? config.auth,
284− });
497+ const auth = update.auth ?? columnsToAuth(row);
285498 yield* db.update({
286499 model: "google_discovery_source",
287500 where: [
@@ -290,22 +503,23 @@ export const makeGoogleDiscoveryStore = ( · 2 unmodified lines
290503 ],
291504 update: {
292505 name: update.name ?? (row.name as string),
293− config: toJsonRecord(encodeStoredSourceData(nextConfig)),
294506 updated_at: new Date(),
507+ ...authToColumns(auth),
295508 },
296509 });
297510 }),
298511
299512 removeSource: (sourceId, scope) =>
300− db
301− .delete({
513+ Effect.gen(function* () {
514+ yield* deleteSourceChildren(sourceId, scope);
515+ yield* db.delete({
302516 model: "google_discovery_source",
303517 where: [
304518 { field: "id", value: sourceId },
305519 { field: "scope_id", value: scope },
306520 ],
307− })
308− .pipe(Effect.asVoid),
521+ });
522+ }),
309523
310524 getSource: (sourceId, scope) =>
311525 Effect.gen(function* () {
@@ -321,7 +535,7 @@ export const makeGoogleDiscoveryStore = ( · 9 unmodified lines
321535 namespace: row.id as string,
322536 scope: row.scope_id as string,
323537 name: row.name as string,
324− config: decodeStoredSourceData(decodeJson(row.config)),
538+ config: yield* hydrateStoredSourceData(row, sourceId, scope),
325539 };
326540 }),
327541
@@ -335,7 +549,219 @@ export const makeGoogleDiscoveryStore = ( · 7 unmodified lines
335549 ],
336550 });
337551 if (!row) return null;
338− return decodeStoredSourceData(decodeJson(row.config));
552+ return yield* hydrateStoredSourceData(row, sourceId, scope);
553+ }),
554+
555+ findSourcesBySecret: (secretId) =>
556+ Effect.gen(function* () {
557+ const [byClientId, byClientSecret] = yield* Effect.all(
558+ [
559+ db.findMany({
560+ model: "google_discovery_source",
561+ where: [
562+ { field: "auth_client_id_secret_id", value: secretId },
563+ ],
564+ }),
565+ db.findMany({
566+ model: "google_discovery_source",
567+ where: [
568+ { field: "auth_client_secret_secret_id", value: secretId },
569+ ],
570+ }),
571+ ],
572+ { concurrency: "unbounded" },
573+ );
574+ const out: {
575+ readonly namespace: string;
576+ readonly scope_id: string;
577+ readonly name: string;
578+ readonly slot: string;
579+ }[] = [];
580+ for (const r of byClientId) {
581+ out.push({
582+ namespace: r.id as string,
583+ scope_id: r.scope_id as string,
584+ name: r.name as string,
585+ slot: "auth.oauth2.client_id",
586+ });
587+ }
588+ for (const r of byClientSecret) {
589+ out.push({
590+ namespace: r.id as string,
591+ scope_id: r.scope_id as string,
592+ name: r.name as string,
593+ slot: "auth.oauth2.client_secret",
594+ });
595+ }
596+ return out;
597+ }),
598+
599+ findSourcesByConnection: (connectionId) =>
600+ db
601+ .findMany({
602+ model: "google_discovery_source",
603+ where: [{ field: "auth_connection_id", value: connectionId }],
604+ })
605+ .pipe(
606+ Effect.map((rows) =>
607+ rows.map((r) => ({
608+ namespace: r.id as string,
609+ scope_id: r.scope_id as string,
610+ name: r.name as string,
611+ slot: "auth.oauth2.connection",
612+ })),
613+ ),
614+ ),
615+
616+ findCredentialRowsBySecret: (secretId) =>
617+ Effect.gen(function* () {
618+ const [headers, params] = yield* Effect.all(
619+ [
620+ db.findMany({
621+ model: "google_discovery_source_credential_header",
622+ where: [{ field: "secret_id", value: secretId }],
623+ }),
624+ db.findMany({
625+ model: "google_discovery_source_credential_query_param",
626+ where: [{ field: "secret_id", value: secretId }],
627+ }),
628+ ],
629+ { concurrency: "unbounded" },
630+ );
631+ return [
632+ ...headers.map((r) => ({
633+ kind: "credential_header" as const,
634+ source_id: r.source_id as string,
635+ scope_id: r.scope_id as string,
636+ name: r.name as string,
637+ })),
638+ ...params.map((r) => ({
639+ kind: "credential_query_param" as const,
640+ source_id: r.source_id as string,
641+ scope_id: r.scope_id as string,
642+ name: r.name as string,
643+ })),
644+ ];
645+ }),
646+
647+ lookupSourceNames: (keys) =>
648+ Effect.gen(function* () {
649+ if (keys.length === 0) return new Map<string, string>();
650+ const rows = yield* db.findMany({ model: "google_discovery_source" });
651+ const requested = new Set(keys);
652+ const out = new Map<string, string>();
653+ for (const r of rows) {
654+ const key = `${r.scope_id as string}:${r.id as string}`;
655+ if (requested.has(key)) out.set(key, r.name as string);
656+ }
657+ return out;
339658 }),
340659 };
660+
661+ // ---------------------------------------------------------------------
662+ // Closure helpers (depend on `db`).
663+ // ---------------------------------------------------------------------
664+
665+ function deleteSourceChildren(sourceId: string, scope: string) {
666+ // Drop only credential child rows. Bindings live independently and
667+ // are managed via putBinding / removeBindingsBySource — wiping them
668+ // here would break putSource (which legitimately keeps existing
669+ // bindings) and the test for "registers and invokes ... tools".
670+ return Effect.gen(function* () {
671+ for (const model of [
672+ "google_discovery_source_credential_header",
673+ "google_discovery_source_credential_query_param",
674+ ] as const) {
675+ yield* db.deleteMany({
676+ model,
677+ where: [
678+ { field: "source_id", value: sourceId },
679+ { field: "scope_id", value: scope },
680+ ],
681+ });
682+ }
683+ });
684+ }
685+
686+ function writeCredentialRows(
687+ sourceId: string,
688+ scope: string,
689+ credentials: GoogleDiscoveryFetchCredentials | undefined,
690+ ) {
691+ return Effect.gen(function* () {
692+ if (!credentials) return;
693+ const headerRows = valueMapToRows(sourceId, scope, credentials.headers);
694+ if (headerRows.length > 0) {
695+ yield* db.createMany({
696+ model: "google_discovery_source_credential_header",
697+ data: headerRows,
698+ forceAllowId: true,
699+ });
700+ }
701+ const paramRows = valueMapToRows(
702+ sourceId,
703+ scope,
704+ credentials.queryParams,
705+ );
706+ if (paramRows.length > 0) {
707+ yield* db.createMany({
708+ model: "google_discovery_source_credential_query_param",
709+ data: paramRows,
710+ forceAllowId: true,
711+ });
712+ }
713+ });
714+ }
715+
716+ function hydrateStoredSourceData(
717+ row: Record<string, unknown>,
718+ sourceId: string,
719+ scope: string,
720+ ): Effect.Effect<GoogleDiscoveryStoredSourceData, StorageFailure> {
721+ return Effect.gen(function* () {
722+ const partial = decodeJson(row.config) as Record<string, unknown>;
723+ const headerRows = yield* db.findMany({
724+ model: "google_discovery_source_credential_header",
725+ where: [
726+ { field: "source_id", value: sourceId },
727+ { field: "scope_id", value: scope },
728+ ],
729+ });
730+ const paramRows = yield* db.findMany({
731+ model: "google_discovery_source_credential_query_param",
732+ where: [
733+ { field: "source_id", value: sourceId },
734+ { field: "scope_id", value: scope },
735+ ],
736+ });
737+ const headers = rowsToValueMap(headerRows);
738+ const queryParams = rowsToValueMap(paramRows);
739+ const credentials =
740+ Object.keys(headers).length === 0 &&
741+ Object.keys(queryParams).length === 0
742+ ? undefined
743+ : {
744+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
745+ ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
746+ };
747+ const reassembled = {
748+ ...partial,
749+ auth: columnsToAuth(row),
750+ ...(credentials ? { credentials } : {}),
751+ };
752+ return decodeStoredSourceData(reassembled);
753+ });
754+ }
755+};
756+
757+// Strip auth/credentials from the encoded source-data shape. Those
758+// moved to columns and child tables; the remaining structural fields
759+// live in the `config` JSON.
760+const stripExtractedFields = (
761+ encoded: Record<string, unknown>,
762+): Record<string, unknown> => {
763+ const { auth, credentials, ...rest } = encoded;
764+ void auth;
765+ void credentials;
766+ return rest;
341767 };
packages/plugins/google-discovery/src/sdk/plugin.test.ts+78−0
@@ -625,4 +625,82 @@ describe("Google Discovery plugin", () => { · 624 unmodified lines
625625 }
626626 }),
627627 );
628+
629+ // -------------------------------------------------------------------------
630+ // Usage tracking — refs land on auth_* columns and the credential
631+ // child tables. `usagesForSecret` / `usagesForConnection` should
632+ // surface them all.
633+ // -------------------------------------------------------------------------
634+
635+ it.effect("usagesForSecret returns refs across auth + credential rows", () =>
636+ Effect.gen(function* () {
637+ const handle = yield* Effect.promise(() => startServer());
638+ try {
639+ const executor = yield* createExecutor(
640+ makeTestConfig({
641+ plugins: [
642+ makeMemorySecretsPlugin()(),
643+ googleDiscoveryPlugin(),
644+ ] as const,
645+ }),
646+ );
647+ try {
648+ const connectionId = ConnectionId.make(
649+ "google-discovery-oauth2-usages",
650+ );
651+ yield* executor.connections.create(
652+ new CreateConnectionInput({
653+ id: connectionId,
654+ scope: ScopeId.make("test-scope"),
655+ provider: "oauth2",
656+ identityLabel: "Drive Usages",
657+ accessToken: new TokenMaterial({
658+ secretId: SecretId.make(`${connectionId}.access_token`),
659+ name: "Drive Access Token",
660+ value: "secret-token",
661+ }),
662+ refreshToken: null,
663+ expiresAt: null,
664+ oauthScope: null,
665+ providerState: null,
666+ }),
667+ );
668+
669+ yield* executor.googleDiscovery.addSource({
670+ name: "Drive (Usages)",
671+ scope: "test-scope",
672+ discoveryUrl: handle.discoveryUrl,
673+ namespace: "drive_u",
674+ auth: {
675+ kind: "oauth2",
676+ connectionId,
677+ clientIdSecretId: "shared-secret",
678+ clientSecretSecretId: null,
679+ scopes: [],
680+ },
681+ });
682+
683+ // The auth.client_id_secret_id alone holds `shared-secret`.
684+ const usages = yield* executor.secrets.usages(
685+ SecretId.make("shared-secret"),
686+ );
687+ expect(usages.length).toBe(1);
688+ expect(usages[0]).toMatchObject({
689+ pluginId: "google-discovery",
690+ ownerKind: "google-discovery-source",
691+ ownerId: "drive_u",
692+ slot: "auth.oauth2.client_id",
693+ });
694+
695+ const connUsages = yield* executor.connections.usages(connectionId);
696+ expect(connUsages.length).toBe(1);
697+ expect(connUsages[0].slot).toBe("auth.oauth2.connection");
698+ } finally {
699+ yield* executor.close();
700+ }
701+ } finally {
702+ yield* Effect.promise(() => handle.close());
703+ }
704+ }),
705+ );
628706 });
packages/plugins/google-discovery/src/sdk/plugin.ts+67−0
@@ -1,7 +1,9 @@
11 import { Effect, Option } from "effect";
22
33 import {
4+ ScopeId,
45 SourceDetectionResult,
6+ Usage,
57 definePlugin,
68 resolveSecretBackedMap,
79 type PluginCtx,
@@ -435,6 +437,71 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 427 unmodified lines
435437 yield* typedCtx.storage.removeSource(sourceId, scope);
436438 }),
437439
440+ // Aggregate usages across the auth columns and the credential child
441+ // tables. Each is one indexed SELECT in the store; the merge plus a
442+ // single source-name JOIN happens here.
443+ usagesForSecret: ({ ctx, args }) =>
444+ Effect.gen(function* () {
445+ const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
446+ const sources = yield* typedCtx.storage.findSourcesBySecret(
447+ args.secretId,
448+ );
449+ const childRows = yield* typedCtx.storage.findCredentialRowsBySecret(
450+ args.secretId,
451+ );
452+ const sourceKeys = new Set<string>();
453+ for (const s of sources) sourceKeys.add(`${s.scope_id}:${s.namespace}`);
454+ for (const r of childRows)
455+ sourceKeys.add(`${r.scope_id}:${r.source_id}`);
456+ const names = yield* typedCtx.storage.lookupSourceNames([...sourceKeys]);
457+
458+ const out: Usage[] = [];
459+ for (const s of sources) {
460+ out.push(
461+ new Usage({
462+ pluginId: "google-discovery",
463+ scopeId: ScopeId.make(s.scope_id),
464+ ownerKind: "google-discovery-source",
465+ ownerId: s.namespace,
466+ ownerName: names.get(`${s.scope_id}:${s.namespace}`) ?? s.name,
467+ slot: s.slot,
468+ }),
469+ );
470+ }
471+ for (const r of childRows) {
472+ out.push(
473+ new Usage({
474+ pluginId: "google-discovery",
475+ scopeId: ScopeId.make(r.scope_id),
476+ ownerKind: `google-discovery-source-${r.kind.replace(/_/g, "-")}`,
477+ ownerId: r.source_id,
478+ ownerName: names.get(`${r.scope_id}:${r.source_id}`) ?? null,
479+ slot: `${r.kind}:${r.name}`,
480+ }),
481+ );
482+ }
483+ return out;
484+ }),
485+
486+ usagesForConnection: ({ ctx, args }) =>
487+ Effect.gen(function* () {
488+ const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>;
489+ const sources = yield* typedCtx.storage.findSourcesByConnection(
490+ args.connectionId,
491+ );
492+ return sources.map(
493+ (s) =>
494+ new Usage({
495+ pluginId: "google-discovery",
496+ scopeId: ScopeId.make(s.scope_id),
497+ ownerKind: "google-discovery-source",
498+ ownerId: s.namespace,
499+ ownerName: s.name,
500+ slot: s.slot,
501+ }),
502+ );
503+ }),
504+
438505 detect: ({ url }) =>
439506 Effect.gen(function* () {
440507 const trimmed = url.trim();
packages/plugins/graphql/src/sdk/plugin.test.ts+123−0
@@ -507,4 +507,127 @@ describe("graphqlPlugin", () => { · 506 unmodified lines
507507 expect(orgView?.endpoint).toBe("http://org.example.com/graphql");
508508 }),
509509 );
510+
511+ // -------------------------------------------------------------------------
512+ // Usage tracking — `usagesForSecret` and `usagesForConnection` should
513+ // surface every reference to a secret/connection across the plugin's
514+ // normalized child tables, and `secrets.remove` / `connections.remove`
515+ // should refuse while a reference exists.
516+ // -------------------------------------------------------------------------
517+
518+ it.effect("usagesForSecret returns one Usage per header/query_param ref", () =>
519+ Effect.gen(function* () {
520+ const executor = yield* createExecutor(
521+ makeTestConfig({
522+ plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
523+ }),
524+ );
525+
526+ yield* executor.secrets.set({
527+ id: SecretId.make("api-key"),
528+ scope: ScopeId.make(TEST_SCOPE),
529+ name: "API Key",
530+ value: "abc123",
531+ provider: "memory",
532+ });
533+
534+ yield* executor.graphql.addSource({
535+ endpoint: "http://localhost:4000/graphql",
536+ scope: TEST_SCOPE,
537+ introspectionJson,
538+ namespace: "with_secret",
539+ headers: { Authorization: { secretId: "api-key", prefix: "Bearer " } },
540+ queryParams: { token: { secretId: "api-key" } },
541+ });
542+
543+ const usages = yield* executor.secrets.usages(SecretId.make("api-key"));
544+ // Two refs: one header, one query param.
545+ expect(usages.length).toBe(2);
546+ const slots = usages.map((u) => u.slot).sort();
547+ expect(slots).toEqual(["header:Authorization", "query_param:token"]);
548+ expect(usages.every((u) => u.pluginId === "graphql")).toBe(true);
549+ expect(usages.every((u) => u.ownerId === "with_secret")).toBe(true);
550+ }),
551+ );
552+
553+ it.effect("secrets.remove refuses while a graphql source still uses it", () =>
554+ Effect.gen(function* () {
555+ const executor = yield* createExecutor(
556+ makeTestConfig({
557+ plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
558+ }),
559+ );
560+
561+ yield* executor.secrets.set({
562+ id: SecretId.make("locked"),
563+ scope: ScopeId.make(TEST_SCOPE),
564+ name: "Locked",
565+ value: "v",
566+ provider: "memory",
567+ });
568+
569+ yield* executor.graphql.addSource({
570+ endpoint: "http://localhost:4000/graphql",
571+ scope: TEST_SCOPE,
572+ introspectionJson,
573+ namespace: "ref",
574+ headers: { "X-Token": { secretId: "locked" } },
575+ });
576+
577+ const result = yield* executor.secrets.remove(SecretId.make("locked")).pipe(
578+ Effect.flip,
579+ );
580+ expect((result as { _tag: string })._tag).toBe("SecretInUseError");
581+
582+ // After detaching the source, remove succeeds.
583+ yield* executor.graphql.removeSource("ref", TEST_SCOPE);
584+ yield* executor.secrets.remove(SecretId.make("locked"));
585+ }),
586+ );
587+
588+ it.effect("usagesForConnection returns one Usage per source", () =>
589+ Effect.gen(function* () {
590+ const executor = yield* createExecutor(
591+ makeTestConfig({
592+ plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
593+ }),
594+ );
595+
596+ const connectionId = ConnectionId.make("graphql-conn");
597+ yield* executor.connections.create(
598+ new CreateConnectionInput({
599+ id: connectionId,
600+ scope: ScopeId.make(TEST_SCOPE),
601+ provider: "oauth2",
602+ identityLabel: "Conn",
603+ accessToken: new TokenMaterial({
604+ secretId: SecretId.make(`${connectionId}.access_token`),
605+ name: "Access Token",
606+ value: "tok",
607+ }),
608+ refreshToken: null,
609+ expiresAt: null,
610+ oauthScope: null,
611+ providerState: null,
612+ }),
613+ );
614+
615+ yield* executor.graphql.addSource({
616+ endpoint: "http://localhost:4000/graphql",
617+ scope: TEST_SCOPE,
618+ introspectionJson,
619+ namespace: "oauth_ref",
620+ auth: { kind: "oauth2", connectionId },
621+ });
622+
623+ const usages = yield* executor.connections.usages(connectionId);
624+ expect(usages.length).toBe(1);
625+ expect(usages[0]).toMatchObject({
626+ pluginId: "graphql",
627+ ownerKind: "graphql-source-auth",
628+ ownerId: "oauth_ref",
629+ slot: "auth.oauth2",
630+ });
631+ }),
632+ );
510633 });
packages/plugins/graphql/src/sdk/plugin.ts+78−0
@@ -7,7 +7,9 @@ import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; · 6 unmodified lines
77
88 import {
99 definePlugin,
10+ ScopeId,
1011 SourceDetectionResult,
12+ Usage,
1113 type StorageFailure,
1214 type ToolAnnotations,
1315 type ToolRow,
@@ -632,6 +634,82 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 618 unmodified lines
632634 removeSource: ({ ctx, sourceId, scope }) =>
633635 ctx.storage.removeSource(sourceId, scope),
634636
637+ // Look up every place this secret appears across the plugin's two
638+ // child tables (`graphql_source_header`, `graphql_source_query_param`).
639+ // The store runs behind the scoped adapter so reads automatically
640+ // walk the executor's scope stack — no scope arg needed.
641+ usagesForSecret: ({ ctx, args }) =>
642+ Effect.gen(function* () {
643+ // Adapter access via the underlying typed view on the store deps.
644+ // We thread it through `ctx.storage` rather than re-grabbing it
645+ // because the store already owns the typed adapter handle; expose
646+ // a single helper rather than re-implementing the where/joins.
647+ const headerRows = yield* ctx.storage.findHeaderRowsBySecret(
648+ args.secretId,
649+ );
650+ const paramRows = yield* ctx.storage.findQueryParamRowsBySecret(
651+ args.secretId,
652+ );
653+
654+ // Resolve owner names by joining to graphql_source. We batch the
655+ // distinct (source_id, scope_id) pairs to one findMany rather
656+ // than N+1 lookups.
657+ const sourceKeys = new Set<string>();
658+ for (const r of [...headerRows, ...paramRows]) {
659+ sourceKeys.add(`${r.scope_id}:${r.source_id}`);
660+ }
661+ const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
662+
663+ const out: Usage[] = [];
664+ for (const r of headerRows) {
665+ out.push(
666+ new Usage({
667+ pluginId: "graphql",
668+ scopeId: ScopeId.make(r.scope_id),
669+ ownerKind: "graphql-source-header",
670+ ownerId: r.source_id,
671+ ownerName:
672+ sources.get(`${r.scope_id}:${r.source_id}`) ?? null,
673+ slot: `header:${r.name}`,
674+ }),
675+ );
676+ }
677+ for (const r of paramRows) {
678+ out.push(
679+ new Usage({
680+ pluginId: "graphql",
681+ scopeId: ScopeId.make(r.scope_id),
682+ ownerKind: "graphql-source-query-param",
683+ ownerId: r.source_id,
684+ ownerName:
685+ sources.get(`${r.scope_id}:${r.source_id}`) ?? null,
686+ slot: `query_param:${r.name}`,
687+ }),
688+ );
689+ }
690+ return out;
691+ }),
692+
693+ usagesForConnection: ({ ctx, args }) =>
694+ Effect.gen(function* () {
695+ // OAuth refs only appear in graphql_source.auth_connection_id —
696+ // one indexed lookup. No child tables to scan.
697+ const sources = yield* ctx.storage.findSourcesByConnection(
698+ args.connectionId,
699+ );
700+ return sources.map(
701+ (s) =>
702+ new Usage({
703+ pluginId: "graphql",
704+ scopeId: ScopeId.make(s.scope),
705+ ownerKind: "graphql-source-auth",
706+ ownerId: s.namespace,
707+ ownerName: s.name,
708+ slot: "auth.oauth2",
709+ }),
710+ );
711+ }),
712+
635713 detect: ({ url }) =>
636714 Effect.gen(function* () {
637715 const trimmed = url.trim();
packages/plugins/graphql/src/sdk/store.ts+364−58
@@ -14,9 +14,20 @@ import { · 13 unmodified lines
1414 } from "./types";
1515
1616 // ---------------------------------------------------------------------------
17−// Schema — two tables:
18−// - graphql_source: endpoint + headers + display name per source
19−// - graphql_operation: per-tool OperationBinding blob keyed by tool id
17+// Schema — four tables:
18+// - graphql_source: endpoint + auth + display name per source. Auth is
19+// flattened (kind enum + nullable connection_id) so the
20+// `usagesForConnection` query is one indexed SELECT.
21+// - graphql_source_header / graphql_source_query_param: one row per
22+// header/param entry. `kind` discriminates literal text from a
23+// secret reference; `secret_id` is indexed so `usagesForSecret`
24+// reads the index directly. PK is `(scope_id, id)` where id is a
25+// JSON tuple `[source_id,name]` so user-provided separators cannot
26+// collide.
27+// - graphql_operation: per-tool OperationBinding blob. Operation
28+// bindings don't reference secrets/connections, so they stay as
29+// JSON — that's a legit JSON case (the binding shape is plugin-
30+// internal opaque data).
2031 // ---------------------------------------------------------------------------
2132
2233 export const graphqlSchema = defineSchema({
@@ -26,9 +37,46 @@ export const graphqlSchema = defineSchema({ · 3 unmodified lines
2637 scope_id: { type: "string", required: true, index: true },
2738 name: { type: "string", required: true },
2839 endpoint: { type: "string", required: true },
29− headers: { type: "json", required: false },
30− query_params: { type: "json", required: false },
31− auth: { type: "json", required: false },
40+ auth_kind: {
41+ type: ["none", "oauth2"],
42+ required: true,
43+ defaultValue: "none",
44+ },
45+ auth_connection_id: {
46+ type: "string",
47+ required: false,
48+ index: true,
49+ },
50+ },
51+ },
52+ graphql_source_header: {
53+ fields: {
54+ id: { type: "string", required: true },
55+ scope_id: { type: "string", required: true, index: true },
56+ source_id: { type: "string", required: true, index: true },
57+ name: { type: "string", required: true },
58+ kind: {
59+ type: ["text", "secret"],
60+ required: true,
61+ },
62+ text_value: { type: "string", required: false },
63+ secret_id: { type: "string", required: false, index: true },
64+ secret_prefix: { type: "string", required: false },
65+ },
66+ },
67+ graphql_source_query_param: {
68+ fields: {
69+ id: { type: "string", required: true },
70+ scope_id: { type: "string", required: true, index: true },
71+ source_id: { type: "string", required: true, index: true },
72+ name: { type: "string", required: true },
73+ kind: {
74+ type: ["text", "secret"],
75+ required: true,
76+ },
77+ text_value: { type: "string", required: false },
78+ secret_id: { type: "string", required: false, index: true },
79+ secret_prefix: { type: "string", required: false },
3280 },
3381 },
3482 graphql_operation: {
@@ -98,29 +146,69 @@ const encodeBinding = (binding: OperationBinding): BindingJson => ({ · 63 unmodified lines
98146 const toJsonRecord = (value: unknown): Record<string, unknown> =>
99147 value as Record<string, unknown>;
100148
101−const decodeHeaders = (value: unknown): Record<string, HeaderValue> => {
102− if (value == null) return {};
103− if (typeof value === "string")
104− return JSON.parse(value) as Record<string, HeaderValue>;
105− return value as Record<string, HeaderValue>;
149+// Header / query-param rows: collapse the flat columns back into a
150+// `SecretBackedValue` map keyed by header name. `kind` discriminates the
151+// shape; `secret_prefix` is optional and only populated when present in
152+// the original config.
153+const rowsToValueMap = (
154+ rows: readonly Record<string, unknown>[],
155+): Record<string, HeaderValue> => {
156+ const out: Record<string, HeaderValue> = {};
157+ for (const row of rows) {
158+ const name = row.name as string;
159+ if (row.kind === "secret" && typeof row.secret_id === "string") {
160+ const prefix = row.secret_prefix as string | undefined | null;
161+ out[name] = prefix
162+ ? { secretId: row.secret_id, prefix }
163+ : { secretId: row.secret_id };
164+ } else if (row.kind === "text" && typeof row.text_value === "string") {
165+ out[name] = row.text_value;
166+ }
167+ }
168+ return out;
106169 };
107170
108−const decodeQueryParams = (value: unknown): Record<string, QueryParamValue> => {
109− if (value == null) return {};
110− if (typeof value === "string")
111− return JSON.parse(value) as Record<string, QueryParamValue>;
112− return value as Record<string, QueryParamValue>;
171+// Encode one entry of a SecretBackedValue map into a child row. Used by
172+// the writer for both `graphql_source_header` and
173+// `graphql_source_query_param`. Returns a `Record<string, unknown>` so
174+// the result is structurally assignable to the typed adapter's
175+// `RowInput` shape (which has its own index signature).
176+const valueToChildRow = (
177+ sourceId: string,
178+ scope: string,
179+ name: string,
180+ value: HeaderValue,
181+): Record<string, unknown> => {
182+ const id = JSON.stringify([sourceId, name]);
183+ if (typeof value === "string") {
184+ return {
185+ id,
186+ scope_id: scope,
187+ source_id: sourceId,
188+ name,
189+ kind: "text",
190+ text_value: value,
191+ };
192+ }
193+ return {
194+ id,
195+ scope_id: scope,
196+ source_id: sourceId,
197+ name,
198+ kind: "secret",
199+ secret_id: value.secretId,
200+ secret_prefix: value.prefix,
201+ };
113202 };
114203
115−const decodeAuth = (value: unknown): GraphqlSourceAuth => {
116− if (value == null) return { kind: "none" };
117− const parsed =
118− typeof value === "string"
119− ? (JSON.parse(value) as GraphqlSourceAuth)
120− : (value as GraphqlSourceAuth);
121− return parsed?.kind === "oauth2" && typeof parsed.connectionId === "string"
122− ? { kind: "oauth2", connectionId: parsed.connectionId }
123− : { kind: "none" };
204+const rowToAuth = (row: Record<string, unknown>): GraphqlSourceAuth => {
205+ if (
206+ row.auth_kind === "oauth2" &&
207+ typeof row.auth_connection_id === "string"
208+ ) {
209+ return { kind: "oauth2", connectionId: row.auth_connection_id };
210+ }
211+ return { kind: "none" };
124212 };
125213
126214 // ---------------------------------------------------------------------------
@@ -138,6 +226,15 @@ const decodeAuth = (value: unknown): GraphqlSourceAuth => { · 11 unmodified lines
138226 // `path.scopeId` for HTTP, `toolRow.scope_id` / `input.scope` for
139227 // invokeTool/lifecycle) so every keyed mutation targets exactly one
140228 // row.
229+/** Flat row shape returned by the usage-lookup helpers. Mirrors the new
230+ * child-table columns so callers can build a `Usage` without
231+ * re-decoding. */
232+export interface ChildUsageRow {
233+ readonly source_id: string;
234+ readonly scope_id: string;
235+ readonly name: string;
236+}
237+
141238 export interface GraphqlStore {
142239 readonly upsertSource: (
143240 input: StoredGraphqlSource,
@@ -180,6 +277,34 @@ export interface GraphqlStore { · 36 unmodified lines
180277 namespace: string,
181278 scope: string,
182279 ) => Effect.Effect<void, StorageFailure>;
280+
281+ // ---------------------------------------------------------------------
282+ // Usage lookups — power `usagesForSecret` / `usagesForConnection`.
283+ // Each is one indexed SELECT against the new normalized columns.
284+ // ---------------------------------------------------------------------
285+
286+ /** Header rows that reference the given secret id, across every scope
287+ * visible to the executor. */
288+ readonly findHeaderRowsBySecret: (
289+ secretId: string,
290+ ) => Effect.Effect<readonly ChildUsageRow[], StorageFailure>;
291+
292+ /** Query-param rows that reference the given secret id. */
293+ readonly findQueryParamRowsBySecret: (
294+ secretId: string,
295+ ) => Effect.Effect<readonly ChildUsageRow[], StorageFailure>;
296+
297+ /** Source rows whose oauth2 auth points at the given connection id. */
298+ readonly findSourcesByConnection: (
299+ connectionId: string,
300+ ) => Effect.Effect<readonly StoredGraphqlSource[], StorageFailure>;
301+
302+ /** Resolve the display name for one or more `(scope_id, source_id)`
303+ * pairs in a single round trip. Returned map is keyed by
304+ * `${scope_id}:${source_id}`; missing entries are simply absent. */
305+ readonly lookupSourceNames: (
306+ keys: readonly string[],
307+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>;
183308 }
184309
185310 // ---------------------------------------------------------------------------
@@ -189,15 +314,46 @@ export interface GraphqlStore { · 3 unmodified lines
189314 export const makeDefaultGraphqlStore = ({
190315 adapter: db,
191316 }: StorageDeps<GraphqlSchema>): GraphqlStore => {
192− const rowToSource = (row: Record<string, unknown>): StoredGraphqlSource => ({
193− namespace: row.id as string,
194− scope: row.scope_id as string,
195− name: row.name as string,
196− endpoint: row.endpoint as string,
197− headers: decodeHeaders(row.headers),
198− queryParams: decodeQueryParams(row.query_params),
199− auth: decodeAuth(row.auth),
200− });
317+ const loadHeaders = (sourceId: string, scope: string) =>
318+ db
319+ .findMany({
320+ model: "graphql_source_header",
321+ where: [
322+ { field: "source_id", value: sourceId },
323+ { field: "scope_id", value: scope },
324+ ],
325+ })
326+ .pipe(Effect.map(rowsToValueMap));
327+
328+ const loadQueryParams = (sourceId: string, scope: string) =>
329+ db
330+ .findMany({
331+ model: "graphql_source_query_param",
332+ where: [
333+ { field: "source_id", value: sourceId },
334+ { field: "scope_id", value: scope },
335+ ],
336+ })
337+ .pipe(Effect.map(rowsToValueMap));
338+
339+ const rowToSourceWithChildren = (
340+ row: Record<string, unknown>,
341+ ): Effect.Effect<StoredGraphqlSource, StorageFailure> =>
342+ Effect.gen(function* () {
343+ const sourceId = row.id as string;
344+ const scope = row.scope_id as string;
345+ const headers = yield* loadHeaders(sourceId, scope);
346+ const queryParams = yield* loadQueryParams(sourceId, scope);
347+ return {
348+ namespace: sourceId,
349+ scope,
350+ name: row.name as string,
351+ endpoint: row.endpoint as string,
352+ headers,
353+ queryParams,
354+ auth: rowToAuth(row),
355+ };
356+ });
201357
202358 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({
203359 toolId: row.id as string,
@@ -205,6 +361,34 @@ export const makeDefaultGraphqlStore = ({ · 1 unmodified line
205361 binding: decodeBinding(row.binding),
206362 });
207363
364+ // Replace child rows for a source by deleting then bulk-inserting. Used
365+ // by both upsertSource (full rewrite) and updateSourceMeta (partial
366+ // patch when headers/queryParams is supplied).
367+ const replaceChildren = (
368+ model: "graphql_source_header" | "graphql_source_query_param",
369+ sourceId: string,
370+ scope: string,
371+ values: Record<string, HeaderValue>,
372+ ) =>
373+ Effect.gen(function* () {
374+ yield* db.deleteMany({
375+ model,
376+ where: [
377+ { field: "source_id", value: sourceId },
378+ { field: "scope_id", value: scope },
379+ ],
380+ });
381+ const entries = Object.entries(values);
382+ if (entries.length === 0) return;
383+ yield* db.createMany({
384+ model,
385+ data: entries.map(([name, value]) =>
386+ valueToChildRow(sourceId, scope, name, value),
387+ ),
388+ forceAllowId: true,
389+ });
390+ });
391+
208392 const deleteSource = (namespace: string, scope: string) =>
209393 Effect.gen(function* () {
210394 yield* db.deleteMany({
@@ -214,6 +398,20 @@ export const makeDefaultGraphqlStore = ({ · 3 unmodified lines
214398 { field: "scope_id", value: scope },
215399 ],
216400 });
401+ yield* db.deleteMany({
402+ model: "graphql_source_header",
403+ where: [
404+ { field: "source_id", value: namespace },
405+ { field: "scope_id", value: scope },
406+ ],
407+ });
408+ yield* db.deleteMany({
409+ model: "graphql_source_query_param",
410+ where: [
411+ { field: "source_id", value: namespace },
412+ { field: "scope_id", value: scope },
413+ ],
414+ });
217415 yield* db.delete({
218416 model: "graphql_source",
219417 where: [
@@ -234,12 +432,24 @@ export const makeDefaultGraphqlStore = ({ · 14 unmodified lines
234432 scope_id: input.scope,
235433 name: input.name,
236434 endpoint: input.endpoint,
237− headers: input.headers,
238− query_params: input.queryParams,
239− auth: toJsonRecord(input.auth),
435+ auth_kind: input.auth.kind,
436+ auth_connection_id:
437+ input.auth.kind === "oauth2" ? input.auth.connectionId : undefined,
240438 },
241439 forceAllowId: true,
242440 });
441+ yield* replaceChildren(
442+ "graphql_source_header",
443+ input.namespace,
444+ input.scope,
445+ input.headers,
446+ );
447+ yield* replaceChildren(
448+ "graphql_source_query_param",
449+ input.namespace,
450+ input.scope,
451+ input.queryParams,
452+ );
243453 if (operations.length > 0) {
244454 yield* db.createMany({
245455 model: "graphql_operation",
@@ -267,41 +477,59 @@ export const makeDefaultGraphqlStore = ({ · 21 unmodified lines
267477 const update: Record<string, unknown> = {};
268478 if (patch.name !== undefined) update.name = patch.name;
269479 if (patch.endpoint !== undefined) update.endpoint = patch.endpoint;
480+ if (patch.auth !== undefined) {
481+ update.auth_kind = patch.auth.kind;
482+ update.auth_connection_id =
483+ patch.auth.kind === "oauth2" ? patch.auth.connectionId : null;
484+ }
485+ if (Object.keys(update).length > 0) {
486+ yield* db.update({
487+ model: "graphql_source",
488+ where: [
489+ { field: "id", value: namespace },
490+ { field: "scope_id", value: scope },
491+ ],
492+ update,
493+ });
494+ }
270495 if (patch.headers !== undefined) {
271− update.headers = patch.headers;
496+ yield* replaceChildren(
497+ "graphql_source_header",
498+ namespace,
499+ scope,
500+ patch.headers,
501+ );
272502 }
273503 if (patch.queryParams !== undefined) {
274− update.query_params = patch.queryParams;
504+ yield* replaceChildren(
505+ "graphql_source_query_param",
506+ namespace,
507+ scope,
508+ patch.queryParams,
509+ );
275510 }
276− if (patch.auth !== undefined) {
277− update.auth = toJsonRecord(patch.auth);
278− }
279− if (Object.keys(update).length === 0) return;
280− yield* db.update({
281− model: "graphql_source",
282− where: [
283− { field: "id", value: namespace },
284− { field: "scope_id", value: scope },
285− ],
286− update,
287− });
288511 }),
289512
290513 getSource: (namespace, scope) =>
291− db
292− .findOne({
514+ Effect.gen(function* () {
515+ const row = yield* db.findOne({
293516 model: "graphql_source",
294517 where: [
295518 { field: "id", value: namespace },
296519 { field: "scope_id", value: scope },
297520 ],
298− })
299− .pipe(Effect.map((row) => (row ? rowToSource(row) : null))),
521+ });
522+ if (!row) return null;
523+ return yield* rowToSourceWithChildren(row);
524+ }),
300525
301526 listSources: () =>
302− db
303− .findMany({ model: "graphql_source" })
304− .pipe(Effect.map((rows) => rows.map(rowToSource))),
527+ Effect.gen(function* () {
528+ const rows = yield* db.findMany({ model: "graphql_source" });
529+ return yield* Effect.forEach(rows, rowToSourceWithChildren, {
530+ concurrency: "unbounded",
531+ });
532+ }),
305533
306534 getOperationByToolId: (toolId, scope) =>
307535 db
@@ -326,5 +554,83 @@ export const makeDefaultGraphqlStore = ({ · 18 unmodified lines
326554 .pipe(Effect.map((rows) => rows.map(rowToOperation))),
327555
328556 removeSource: (namespace, scope) => deleteSource(namespace, scope),
557+
558+ findHeaderRowsBySecret: (secretId) =>
559+ db
560+ .findMany({
561+ model: "graphql_source_header",
562+ where: [{ field: "secret_id", value: secretId }],
563+ })
564+ .pipe(
565+ Effect.map((rows) =>
566+ rows.map(
567+ (r): ChildUsageRow => ({
568+ source_id: r.source_id as string,
569+ scope_id: r.scope_id as string,
570+ name: r.name as string,
571+ }),
572+ ),
573+ ),
574+ ),
575+
576+ findQueryParamRowsBySecret: (secretId) =>
577+ db
578+ .findMany({
579+ model: "graphql_source_query_param",
580+ where: [{ field: "secret_id", value: secretId }],
581+ })
582+ .pipe(
583+ Effect.map((rows) =>
584+ rows.map(
585+ (r): ChildUsageRow => ({
586+ source_id: r.source_id as string,
587+ scope_id: r.scope_id as string,
588+ name: r.name as string,
589+ }),
590+ ),
591+ ),
592+ ),
593+
594+ findSourcesByConnection: (connectionId) =>
595+ Effect.gen(function* () {
596+ const rows = yield* db.findMany({
597+ model: "graphql_source",
598+ where: [
599+ { field: "auth_connection_id", value: connectionId },
600+ ],
601+ });
602+ // Skip the children load — usage callers only need the parent
603+ // row's name + scope. Synthesize a minimal StoredGraphqlSource
604+ // shape with empty headers/params so the type matches without
605+ // a wasted child fetch.
606+ return rows.map(
607+ (row): StoredGraphqlSource => ({
608+ namespace: row.id as string,
609+ scope: row.scope_id as string,
610+ name: row.name as string,
611+ endpoint: row.endpoint as string,
612+ headers: {},
613+ queryParams: {},
614+ auth: rowToAuth(row),
615+ }),
616+ );
617+ }),
618+
619+ lookupSourceNames: (keys) =>
620+ Effect.gen(function* () {
621+ if (keys.length === 0) return new Map<string, string>();
622+ // Pull every source the executor's scope walk surfaces, then
623+ // index by composite key. Cheaper than per-key findOne and the
624+ // graphql source table is small in practice (one row per
625+ // endpoint).
626+ const rows = yield* db.findMany({ model: "graphql_source" });
627+ const requested = new Set(keys);
628+ const out = new Map<string, string>();
629+ for (const r of rows) {
630+ const key = `${r.scope_id as string}:${r.id as string}`;
631+ if (requested.has(key)) out.set(key, r.name as string);
632+ }
633+ return out;
634+ }),
329635 };
330636 };
packages/plugins/mcp/src/sdk/binding-store.ts+481−7
@@ -1,7 +1,19 @@
11 // ---------------------------------------------------------------------------
2−// MCP plugin storage — two tables (mcp_source, mcp_binding). OAuth
3−// session storage lives at the core level in `oauth2_session` and is
4−// owned by `ctx.oauth`.
2+// MCP plugin storage — four tables:
3+// - mcp_source: per-source structural data (transport, endpoint,
4+// stdio command/args/env, etc.) plus the auth flattened into
5+// columns so secret/connection refs are queryable. The non-ref
6+// structural data still lives in `config` as JSON because it's
7+// plugin-private and varies by transport (`remote` vs `stdio`
8+// have different shapes).
9+// - mcp_source_header / mcp_source_query_param: child tables for
10+// remote sources' headers and query_params SecretBackedMap entries.
11+// - mcp_binding: per-tool McpToolBinding (toolId/toolName/description/
12+// input+output schemas/annotations). Stays JSON: it carries no
13+// refs, and `inputSchema` / `outputSchema` are arbitrary
14+// user-supplied JSON Schemas — a legitimate JSON case.
15+// OAuth session storage lives at the core level in `oauth2_session`
16+// and is owned by `ctx.oauth`.
517 // ---------------------------------------------------------------------------
618
719 import { Effect, Schema } from "effect";
@@ -12,7 +24,12 @@ import { · 4 unmodified lines
1224 type StorageFailure,
1325 } from "@executor-js/sdk/core";
1426
15−import { McpToolBinding, McpStoredSourceData } from "./types";
27+import {
28+ McpToolBinding,
29+ McpStoredSourceData,
30+ type McpConnectionAuth,
31+ type SecretBackedValue,
32+} from "./types";
1633
1734 // ---------------------------------------------------------------------------
1835 // Schema
@@ -24,10 +41,61 @@ export const mcpSchema = defineSchema({ · 5 unmodified lines
2441 id: { type: "string", required: true },
2542 scope_id: { type: "string", required: true, index: true },
2643 name: { type: "string", required: true },
44+ // Plugin-private structural data minus the ref-bearing fields
45+ // (auth, headers, queryParams). For remote sources: transport,
46+ // endpoint, remoteTransport. For stdio: transport, command,
47+ // args, env, cwd.
2748 config: { type: "json", required: true },
49+ // Flattened McpConnectionAuth. Exactly one of the kind-tagged
50+ // groups is populated for non-`none` auths.
51+ auth_kind: {
52+ type: ["none", "header", "oauth2"],
53+ required: true,
54+ defaultValue: "none",
55+ },
56+ // Header-auth fields.
57+ auth_header_name: { type: "string", required: false },
58+ auth_secret_id: { type: "string", required: false, index: true },
59+ auth_secret_prefix: { type: "string", required: false },
60+ // OAuth2 auth fields.
61+ auth_connection_id: { type: "string", required: false, index: true },
62+ auth_client_id_secret_id: {
63+ type: "string",
64+ required: false,
65+ index: true,
66+ },
67+ auth_client_secret_secret_id: {
68+ type: "string",
69+ required: false,
70+ index: true,
71+ },
2872 created_at: { type: "date", required: true },
2973 },
3074 },
75+ mcp_source_header: {
76+ fields: {
77+ id: { type: "string", required: true },
78+ scope_id: { type: "string", required: true, index: true },
79+ source_id: { type: "string", required: true, index: true },
80+ name: { type: "string", required: true },
81+ kind: { type: ["text", "secret"], required: true },
82+ text_value: { type: "string", required: false },
83+ secret_id: { type: "string", required: false, index: true },
84+ secret_prefix: { type: "string", required: false },
85+ },
86+ },
87+ mcp_source_query_param: {
88+ fields: {
89+ id: { type: "string", required: true },
90+ scope_id: { type: "string", required: true, index: true },
91+ source_id: { type: "string", required: true, index: true },
92+ name: { type: "string", required: true },
93+ kind: { type: ["text", "secret"], required: true },
94+ text_value: { type: "string", required: false },
95+ secret_id: { type: "string", required: false, index: true },
96+ secret_prefix: { type: "string", required: false },
97+ },
98+ },
3199 mcp_binding: {
32100 fields: {
33101 id: { type: "string", required: true },
@@ -61,6 +129,126 @@ const coerceJson = (value: unknown): unknown => { · 27 unmodified lines
61129 }
62130 };
63131
132+// --- auth column packing/unpacking ------------------------------------------
133+
134+interface AuthColumns {
135+ readonly auth_kind: "none" | "header" | "oauth2";
136+ readonly auth_header_name?: string;
137+ readonly auth_secret_id?: string;
138+ readonly auth_secret_prefix?: string;
139+ readonly auth_connection_id?: string;
140+ readonly auth_client_id_secret_id?: string;
141+ readonly auth_client_secret_secret_id?: string;
142+}
143+
144+const authToColumns = (auth: McpConnectionAuth): AuthColumns => {
145+ if (auth.kind === "header") {
146+ return {
147+ auth_kind: "header",
148+ auth_header_name: auth.headerName,
149+ auth_secret_id: auth.secretId,
150+ auth_secret_prefix: auth.prefix,
151+ };
152+ }
153+ if (auth.kind === "oauth2") {
154+ return {
155+ auth_kind: "oauth2",
156+ auth_connection_id: auth.connectionId,
157+ auth_client_id_secret_id: auth.clientIdSecretId,
158+ auth_client_secret_secret_id: auth.clientSecretSecretId ?? undefined,
159+ };
160+ }
161+ return { auth_kind: "none" };
162+};
163+
164+const columnsToAuth = (row: Record<string, unknown>): McpConnectionAuth => {
165+ const kind = row.auth_kind as string;
166+ if (kind === "header" && typeof row.auth_secret_id === "string") {
167+ const prefix = row.auth_secret_prefix as string | null | undefined;
168+ return {
169+ kind: "header",
170+ headerName: (row.auth_header_name as string | null) ?? "",
171+ secretId: row.auth_secret_id,
172+ ...(prefix ? { prefix } : {}),
173+ };
174+ }
175+ if (kind === "oauth2" && typeof row.auth_connection_id === "string") {
176+ const cid = row.auth_client_id_secret_id as string | null | undefined;
177+ const csec = row.auth_client_secret_secret_id as string | null | undefined;
178+ return {
179+ kind: "oauth2",
180+ connectionId: row.auth_connection_id,
181+ ...(cid ? { clientIdSecretId: cid } : {}),
182+ ...(csec !== undefined && csec !== null
183+ ? { clientSecretSecretId: csec }
184+ : {}),
185+ };
186+ }
187+ return { kind: "none" };
188+};
189+
190+// --- SecretBackedMap <-> child rows (mcp_source_header / query_param) -------
191+
192+interface SecretBackedRow {
193+ readonly id: string;
194+ readonly scope_id: string;
195+ readonly source_id: string;
196+ readonly name: string;
197+ readonly kind: "text" | "secret";
198+ readonly text_value?: string;
199+ readonly secret_id?: string;
200+ readonly secret_prefix?: string;
201+ readonly [k: string]: unknown;
202+}
203+
204+const valueMapToRows = (
205+ sourceId: string,
206+ scope: string,
207+ values: Record<string, SecretBackedValue> | undefined,
208+): readonly SecretBackedRow[] => {
209+ if (!values) return [];
210+ return Object.entries(values).map(([name, value]) => {
211+ const id = JSON.stringify([sourceId, name]);
212+ if (typeof value === "string") {
213+ return {
214+ id,
215+ scope_id: scope,
216+ source_id: sourceId,
217+ name,
218+ kind: "text",
219+ text_value: value,
220+ };
221+ }
222+ return {
223+ id,
224+ scope_id: scope,
225+ source_id: sourceId,
226+ name,
227+ kind: "secret",
228+ secret_id: value.secretId,
229+ secret_prefix: value.prefix,
230+ };
231+ });
232+};
233+
234+const rowsToValueMap = (
235+ rows: readonly Record<string, unknown>[],
236+): Record<string, SecretBackedValue> => {
237+ const out: Record<string, SecretBackedValue> = {};
238+ for (const row of rows) {
239+ const name = row.name as string;
240+ if (row.kind === "secret" && typeof row.secret_id === "string") {
241+ const prefix = row.secret_prefix as string | undefined | null;
242+ out[name] = prefix
243+ ? { secretId: row.secret_id, prefix }
244+ : { secretId: row.secret_id };
245+ } else if (row.kind === "text" && typeof row.text_value === "string") {
246+ out[name] = row.text_value;
247+ }
248+ }
249+ return out;
250+};
251+
64252 // ---------------------------------------------------------------------------
65253 // Stored source (decoded) — what callers see
66254 // ---------------------------------------------------------------------------
@@ -136,6 +324,55 @@ export interface McpBindingStore { · 69 unmodified lines
136324 namespace: string,
137325 scope: string,
138326 ) => Effect.Effect<void, StorageFailure>;
327+
328+ // ---------------------------------------------------------------------
329+ // Usage lookups — back `usagesForSecret` / `usagesForConnection`.
330+ // ---------------------------------------------------------------------
331+
332+ /** Source rows whose flattened auth columns reference the given
333+ * secret id. The `slot` field on each result tags which column
334+ * matched so the caller can produce a precise Usage.slot. */
335+ readonly findSourcesBySecret: (
336+ secretId: string,
337+ ) => Effect.Effect<
338+ readonly {
339+ readonly namespace: string;
340+ readonly scope_id: string;
341+ readonly name: string;
342+ readonly slot: string;
343+ }[],
344+ StorageFailure
345+ >;
346+
347+ /** Source rows whose oauth2 auth points at the given connection id. */
348+ readonly findSourcesByConnection: (
349+ connectionId: string,
350+ ) => Effect.Effect<
351+ readonly {
352+ readonly namespace: string;
353+ readonly scope_id: string;
354+ readonly name: string;
355+ readonly slot: string;
356+ }[],
357+ StorageFailure
358+ >;
359+
360+ /** Header / query_param child rows that reference the given secret id. */
361+ readonly findChildRowsBySecret: (secretId: string) => Effect.Effect<
362+ readonly {
363+ readonly kind: "header" | "query_param";
364+ readonly source_id: string;
365+ readonly scope_id: string;
366+ readonly name: string;
367+ }[],
368+ StorageFailure
369+ >;
370+
371+ /** Resolve display names for `(scope_id, source_id)` pairs in one
372+ * round trip. Keys: `${scope_id}:${source_id}`. */
373+ readonly lookupSourceNames: (
374+ keys: readonly string[],
375+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>;
139376 }
140377
141378 // ---------------------------------------------------------------------------
@@ -217,7 +454,7 @@ export const makeMcpStore = ({ · 75 unmodified lines
217454 namespace: row.id,
218455 scope: row.scope_id,
219456 name: row.name,
220− config: decodeSourceData(coerceJson(row.config)),
457+ config: yield* hydrateSourceData(row, namespace, scope),
221458 };
222459 }),
223460
@@ -231,12 +468,14 @@ export const makeMcpStore = ({ · 7 unmodified lines
231468 ],
232469 });
233470 if (!row) return null;
234− return decodeSourceData(coerceJson(row.config));
471+ return yield* hydrateSourceData(row, namespace, scope);
235472 }),
236473
237474 putSource: (source) =>
238475 Effect.gen(function* () {
239476 const now = new Date();
477+ // Drop the source row and its child rows; recreate. Two-step
478+ // matches the existing put-overwrites-existing semantic.
240479 yield* db.delete({
241480 model: "mcp_source",
242481 where: [
@@ -244,17 +483,67 @@ export const makeMcpStore = ({ · 1 unmodified line
244483 { field: "scope_id", value: source.scope },
245484 ],
246485 });
486+ yield* deleteSourceChildren(source.namespace, source.scope);
487+
488+ const auth: McpConnectionAuth =
489+ source.config.transport === "remote"
490+ ? source.config.auth
491+ : { kind: "none" };
492+ const authCols = authToColumns(auth);
493+ const headers =
494+ source.config.transport === "remote"
495+ ? source.config.headers
496+ : undefined;
497+ const queryParams =
498+ source.config.transport === "remote"
499+ ? source.config.queryParams
500+ : undefined;
501+
502+ // The encoded config keeps every plugin-private field but
503+ // strips auth/headers/queryParams — those moved to columns/
504+ // child tables. We round-trip through encodeSourceData so the
505+ // remaining fields stay in the same JSON shape decode expects.
506+ const encodedConfig = stripExtractedFields(
507+ encodeSourceData(source.config) as Record<string, unknown>,
508+ );
509+
247510 yield* db.create({
248511 model: "mcp_source",
249512 data: {
250513 id: source.namespace,
251514 scope_id: source.scope,
252515 name: source.name,
253− config: encodeSourceData(source.config),
516+ config: encodedConfig,
254517 created_at: now,
518+ ...authCols,
255519 },
256520 forceAllowId: true,
257521 });
522+
523+ const headerRows = valueMapToRows(
524+ source.namespace,
525+ source.scope,
526+ headers,
527+ );
528+ if (headerRows.length > 0) {
529+ yield* db.createMany({
530+ model: "mcp_source_header",
531+ data: headerRows,
532+ forceAllowId: true,
533+ });
534+ }
535+ const paramRows = valueMapToRows(
536+ source.namespace,
537+ source.scope,
538+ queryParams,
539+ );
540+ if (paramRows.length > 0) {
541+ yield* db.createMany({
542+ model: "mcp_source_query_param",
543+ data: paramRows,
544+ forceAllowId: true,
545+ });
546+ }
258547 }),
259548
260549 removeSource: (namespace, scope) =>
@@ -266,6 +555,7 @@ export const makeMcpStore = ({ · 5 unmodified lines
266555 { field: "scope_id", value: scope },
267556 ],
268557 });
558+ yield* deleteSourceChildren(namespace, scope);
269559 yield* db.delete({
270560 model: "mcp_source",
271561 where: [
@@ -274,5 +564,189 @@ export const makeMcpStore = ({ · 2 unmodified lines
274564 ],
275565 });
276566 }),
567+
568+ findSourcesBySecret: (secretId) =>
569+ Effect.gen(function* () {
570+ // Three places a secret id can land on an mcp_source row: the
571+ // header-auth secret, and the two oauth2 client_*_secret_id
572+ // columns. Run all three lookups in parallel and dedupe by
573+ // (scope_id, id).
574+ const [byHeader, byClientId, byClientSecret] = yield* Effect.all(
575+ [
576+ db.findMany({
577+ model: "mcp_source",
578+ where: [{ field: "auth_secret_id", value: secretId }],
579+ }),
580+ db.findMany({
581+ model: "mcp_source",
582+ where: [
583+ { field: "auth_client_id_secret_id", value: secretId },
584+ ],
585+ }),
586+ db.findMany({
587+ model: "mcp_source",
588+ where: [
589+ { field: "auth_client_secret_secret_id", value: secretId },
590+ ],
591+ }),
592+ ],
593+ { concurrency: "unbounded" },
594+ );
595+ const dedup = new Map<string, Record<string, unknown>>();
596+ for (const r of [...byHeader, ...byClientId, ...byClientSecret]) {
597+ dedup.set(`${r.scope_id}:${r.id}`, r);
598+ }
599+ return [...dedup.values()].map((row) => ({
600+ namespace: row.id as string,
601+ scope_id: row.scope_id as string,
602+ name: row.name as string,
603+ slot:
604+ (byHeader as readonly Record<string, unknown>[]).includes(row)
605+ ? "auth.header"
606+ : (byClientId as readonly Record<string, unknown>[]).includes(
607+ row,
608+ )
609+ ? "auth.oauth2.client_id"
610+ : "auth.oauth2.client_secret",
611+ }));
612+ }),
613+
614+ findSourcesByConnection: (connectionId) =>
615+ db
616+ .findMany({
617+ model: "mcp_source",
618+ where: [{ field: "auth_connection_id", value: connectionId }],
619+ })
620+ .pipe(
621+ Effect.map((rows) =>
622+ rows.map((r) => ({
623+ namespace: r.id as string,
624+ scope_id: r.scope_id as string,
625+ name: r.name as string,
626+ slot: "auth.oauth2.connection",
627+ })),
628+ ),
629+ ),
630+
631+ findChildRowsBySecret: (secretId) =>
632+ Effect.gen(function* () {
633+ const [headers, params] = yield* Effect.all(
634+ [
635+ db.findMany({
636+ model: "mcp_source_header",
637+ where: [{ field: "secret_id", value: secretId }],
638+ }),
639+ db.findMany({
640+ model: "mcp_source_query_param",
641+ where: [{ field: "secret_id", value: secretId }],
642+ }),
643+ ],
644+ { concurrency: "unbounded" },
645+ );
646+ return [
647+ ...headers.map((r) => ({
648+ kind: "header" as const,
649+ source_id: r.source_id as string,
650+ scope_id: r.scope_id as string,
651+ name: r.name as string,
652+ })),
653+ ...params.map((r) => ({
654+ kind: "query_param" as const,
655+ source_id: r.source_id as string,
656+ scope_id: r.scope_id as string,
657+ name: r.name as string,
658+ })),
659+ ];
660+ }),
661+
662+ lookupSourceNames: (keys) =>
663+ Effect.gen(function* () {
664+ if (keys.length === 0) return new Map<string, string>();
665+ const rows = yield* db.findMany({ model: "mcp_source" });
666+ const requested = new Set(keys);
667+ const out = new Map<string, string>();
668+ for (const r of rows) {
669+ const key = `${r.scope_id as string}:${r.id as string}`;
670+ if (requested.has(key)) out.set(key, r.name as string);
671+ }
672+ return out;
673+ }),
277674 };
675+
676+ // ---------------------------------------------------------------------
677+ // Private helpers — depend on `db` so they live inside the closure.
678+ // ---------------------------------------------------------------------
679+
680+ function deleteSourceChildren(namespace: string, scope: string) {
681+ return Effect.gen(function* () {
682+ for (const model of [
683+ "mcp_source_header",
684+ "mcp_source_query_param",
685+ ] as const) {
686+ yield* db.deleteMany({
687+ model,
688+ where: [
689+ { field: "source_id", value: namespace },
690+ { field: "scope_id", value: scope },
691+ ],
692+ });
693+ }
694+ });
695+ }
696+
697+ function hydrateSourceData(
698+ row: Record<string, unknown>,
699+ namespace: string,
700+ scope: string,
701+ ): Effect.Effect<McpStoredSourceData, StorageFailure> {
702+ return Effect.gen(function* () {
703+ // The stored JSON has auth/headers/queryParams stripped (those
704+ // moved to columns / child tables). We must rehydrate the full
705+ // shape BEFORE handing it to the schema decoder, because
706+ // `McpRemoteSourceData.auth` is required.
707+ const partial = coerceJson(row.config) as Record<string, unknown>;
708+ if (partial.transport !== "remote") {
709+ // stdio sources have no extracted fields — decode as-is.
710+ return decodeSourceData(partial);
711+ }
712+ const headerRows = yield* db.findMany({
713+ model: "mcp_source_header",
714+ where: [
715+ { field: "source_id", value: namespace },
716+ { field: "scope_id", value: scope },
717+ ],
718+ });
719+ const paramRows = yield* db.findMany({
720+ model: "mcp_source_query_param",
721+ where: [
722+ { field: "source_id", value: namespace },
723+ { field: "scope_id", value: scope },
724+ ],
725+ });
726+ const headers = rowsToValueMap(headerRows);
727+ const queryParams = rowsToValueMap(paramRows);
728+ const reassembled = {
729+ ...partial,
730+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
731+ ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
732+ auth: columnsToAuth(row),
733+ };
734+ return decodeSourceData(reassembled);
735+ });
736+ }
737+};
738+
739+// Strip auth/headers/queryParams from the encoded source-data shape.
740+// Keeps the remaining structural fields (transport, endpoint, etc.) in
741+// the JSON config column. Per-transport: only the remote variant has
742+// these fields, so this is a no-op for stdio.
743+const stripExtractedFields = (
744+ encoded: Record<string, unknown>,
745+): Record<string, unknown> => {
746+ if (encoded.transport !== "remote") return encoded;
747+ const { auth, headers, queryParams, ...rest } = encoded;
748+ void auth;
749+ void headers;
750+ void queryParams;
751+ return rest;
278752 };
packages/plugins/mcp/src/sdk/plugin.test.ts+76−0
@@ -635,6 +635,82 @@ describe("mcpPlugin", () => { · 634 unmodified lines
635635 );
636636 }),
637637 );
638+
639+ // -------------------------------------------------------------------------
640+ // Usage tracking — refs land on auth_* columns + child tables and the
641+ // plugin's `usagesForSecret` / `usagesForConnection` should surface
642+ // every one. addSource against an unreachable endpoint still persists
643+ // the source row so the assertion runs without needing a live server.
644+ // -------------------------------------------------------------------------
645+
646+ it.effect("usagesForSecret aggregates header-auth + headers child rows", () =>
647+ Effect.gen(function* () {
648+ const executor = yield* createExecutor(
649+ makeTestConfig({ plugins: [mcpPlugin()] as const }),
650+ );
651+
652+ yield* executor.mcp
653+ .addSource({
654+ transport: "remote",
655+ scope: "test-scope",
656+ name: "header-auth",
657+ endpoint: "http://127.0.0.1:1/mcp",
658+ namespace: "header_auth_source",
659+ auth: {
660+ kind: "header",
661+ headerName: "X-API-Key",
662+ secretId: "shared-key",
663+ },
664+ headers: { "X-Trace": { secretId: "shared-key" } },
665+ queryParams: { ping: { secretId: "other-secret" } },
666+ })
667+ .pipe(Effect.result);
668+
669+ const usages = yield* executor.secrets.usages(
670+ SecretId.make("shared-key"),
671+ );
672+ expect(usages.length).toBe(2);
673+ const slots = usages.map((u) => u.slot).sort();
674+ expect(slots).toEqual(["auth.header", "header:X-Trace"]);
675+ expect(usages.every((u) => u.pluginId === "mcp")).toBe(true);
676+
677+ const otherUsages = yield* executor.secrets.usages(
678+ SecretId.make("other-secret"),
679+ );
680+ expect(otherUsages.length).toBe(1);
681+ expect(otherUsages[0].slot).toBe("query_param:ping");
682+ }),
683+ );
684+
685+ it.effect("usagesForConnection finds oauth2-bound mcp sources", () =>
686+ Effect.gen(function* () {
687+ const executor = yield* createExecutor(
688+ makeTestConfig({ plugins: [mcpPlugin()] as const }),
689+ );
690+
691+ yield* executor.mcp
692+ .addSource({
693+ transport: "remote",
694+ scope: "test-scope",
695+ name: "oauth-source",
696+ endpoint: "http://127.0.0.1:1/mcp",
697+ namespace: "oauth_ref",
698+ auth: { kind: "oauth2", connectionId: "conn-xyz" },
699+ })
700+ .pipe(Effect.result);
701+
702+ const usages = yield* executor.connections.usages(
703+ ConnectionId.make("conn-xyz"),
704+ );
705+ expect(usages.length).toBe(1);
706+ expect(usages[0]).toMatchObject({
707+ pluginId: "mcp",
708+ ownerKind: "mcp-source",
709+ ownerId: "oauth_ref",
710+ slot: "auth.oauth2.connection",
711+ });
712+ }),
713+ );
638714 });
639715
640716 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/plugin.ts+67−0
@@ -6,7 +6,9 @@ import { McpGroup } from "../api/group"; · 5 unmodified lines
66 import { McpExtensionService, McpHandlers } from "../api/handlers";
77
88 import {
9+ ScopeId,
910 SourceDetectionResult,
11+ Usage,
1012 definePlugin,
1113 resolveSecretBackedMap as resolveSharedSecretBackedMap,
1214 type PluginCtx,
@@ -993,6 +995,71 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 980 unmodified lines
993995 yield* ctx.storage.removeSource(sourceId, scope);
994996 }),
995997
998+ // Aggregate usages across the four places mcp can hold a secret ref:
999+ // header-auth secret, oauth2 client_id_secret, oauth2 client_secret,
1000+ // and the per-entry rows in mcp_source_header / mcp_source_query_param.
1001+ usagesForSecret: ({ ctx, args }) =>
1002+ Effect.gen(function* () {
1003+ const sources = yield* ctx.storage.findSourcesBySecret(args.secretId);
1004+ const childRows = yield* ctx.storage.findChildRowsBySecret(
1005+ args.secretId,
1006+ );
1007+
1008+ const sourceKeys = new Set<string>();
1009+ for (const s of sources) {
1010+ sourceKeys.add(`${s.scope_id}:${s.namespace}`);
1011+ }
1012+ for (const r of childRows) {
1013+ sourceKeys.add(`${r.scope_id}:${r.source_id}`);
1014+ }
1015+ const names = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
1016+
1017+ const out: Usage[] = [];
1018+ for (const s of sources) {
1019+ out.push(
1020+ new Usage({
1021+ pluginId: "mcp",
1022+ scopeId: ScopeId.make(s.scope_id),
1023+ ownerKind: "mcp-source",
1024+ ownerId: s.namespace,
1025+ ownerName: names.get(`${s.scope_id}:${s.namespace}`) ?? s.name,
1026+ slot: s.slot,
1027+ }),
1028+ );
1029+ }
1030+ for (const r of childRows) {
1031+ out.push(
1032+ new Usage({
1033+ pluginId: "mcp",
1034+ scopeId: ScopeId.make(r.scope_id),
1035+ ownerKind: `mcp-source-${r.kind.replace(/_/g, "-")}`,
1036+ ownerId: r.source_id,
1037+ ownerName: names.get(`${r.scope_id}:${r.source_id}`) ?? null,
1038+ slot: `${r.kind}:${r.name}`,
1039+ }),
1040+ );
1041+ }
1042+ return out;
1043+ }),
1044+
1045+ usagesForConnection: ({ ctx, args }) =>
1046+ Effect.gen(function* () {
1047+ const sources = yield* ctx.storage.findSourcesByConnection(
1048+ args.connectionId,
1049+ );
1050+ return sources.map(
1051+ (s) =>
1052+ new Usage({
1053+ pluginId: "mcp",
1054+ scopeId: ScopeId.make(s.scope_id),
1055+ ownerKind: "mcp-source",
1056+ ownerId: s.namespace,
1057+ ownerName: s.name,
1058+ slot: s.slot,
1059+ }),
1060+ );
1061+ }),
1062+
9961063 refreshSource: () => Effect.void,
9971064
9981065 // Connection refresh for oauth2-minted sources is owned by the
packages/plugins/openapi/src/sdk/plugin.test.ts+105−1
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020
2121 const TEST_SCOPE = "test-scope";
2222 import { openApiPlugin } from "./plugin";
23−import { OAuth2Auth } from "./types";
23+import { OAuth2Auth, OpenApiSourceBindingInput } from "./types";
2424
2525 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
2626
@@ -847,4 +847,108 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 820 unmodified lines
847847 expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true);
848848 }),
849849 );
850+
851+ // -------------------------------------------------------------------------
852+ // Usage tracking — once openapi normalizes its bindings, query_params,
853+ // and specFetchCredentials, `usagesForSecret` and `usagesForConnection`
854+ // should surface every reference and `secrets.remove` should RESTRICT.
855+ // -------------------------------------------------------------------------
856+
857+ it.effect("usagesForSecret aggregates bindings and child rows", () =>
858+ Effect.gen(function* () {
859+ const executor = yield* createExecutor(
860+ makeTestConfig({
861+ plugins: [memorySecretsPlugin(), openApiPlugin()] as const,
862+ }),
863+ );
864+
865+ yield* executor.secrets.set(
866+ new SetSecretInput({
867+ id: SecretId.make("api-key"),
868+ scope: ScopeId.make(TEST_SCOPE),
869+ name: "API Key",
870+ value: "abc123",
871+ provider: "memory",
872+ }),
873+ );
874+
875+ // Add a source whose query_params reference the secret directly.
876+ yield* executor.openapi.addSpec({
877+ spec: specJson,
878+ scope: TEST_SCOPE,
879+ namespace: "with_secret",
880+ baseUrl: "http://example.com",
881+ queryParams: { token: { secretId: "api-key" } },
882+ });
883+
884+ // Configure a slot binding pointing at the same secret.
885+ yield* executor.openapi.setSourceBinding(
886+ new OpenApiSourceBindingInput({
887+ sourceId: "with_secret",
888+ sourceScope: ScopeId.make(TEST_SCOPE),
889+ scope: ScopeId.make(TEST_SCOPE),
890+ slot: "header:authorization",
891+ value: { kind: "secret", secretId: SecretId.make("api-key") },
892+ }),
893+ );
894+
895+ const usages = yield* executor.secrets.usages(SecretId.make("api-key"));
896+ expect(usages.length).toBe(2);
897+ const slots = usages.map((u) => u.slot).sort();
898+ expect(slots).toEqual([
899+ "binding:header:authorization",
900+ "query_param:token",
901+ ]);
902+ expect(usages.every((u) => u.pluginId === "openapi")).toBe(true);
903+ }),
904+ );
905+
906+ it.effect("secrets.remove refuses while an openapi binding still uses it", () =>
907+ Effect.gen(function* () {
908+ const executor = yield* createExecutor(
909+ makeTestConfig({
910+ plugins: [memorySecretsPlugin(), openApiPlugin()] as const,
911+ }),
912+ );
913+ yield* executor.secrets.set(
914+ new SetSecretInput({
915+ id: SecretId.make("locked"),
916+ scope: ScopeId.make(TEST_SCOPE),
917+ name: "Locked",
918+ value: "v",
919+ provider: "memory",
920+ }),
921+ );
922+
923+ yield* executor.openapi.addSpec({
924+ spec: specJson,
925+ scope: TEST_SCOPE,
926+ namespace: "ref",
927+ baseUrl: "http://example.com",
928+ });
929+ yield* executor.openapi.setSourceBinding(
930+ new OpenApiSourceBindingInput({
931+ sourceId: "ref",
932+ sourceScope: ScopeId.make(TEST_SCOPE),
933+ scope: ScopeId.make(TEST_SCOPE),
934+ slot: "header:authorization",
935+ value: { kind: "secret", secretId: SecretId.make("locked") },
936+ }),
937+ );
938+
939+ const failure = yield* executor.secrets
940+ .remove(SecretId.make("locked"))
941+ .pipe(Effect.flip);
942+ expect((failure as { _tag: string })._tag).toBe("SecretInUseError");
943+
944+ // Detach the binding, then remove succeeds.
945+ yield* executor.openapi.removeSourceBinding(
946+ "ref",
947+ ScopeId.make(TEST_SCOPE),
948+ "header:authorization",
949+ ScopeId.make(TEST_SCOPE),
950+ );
951+ yield* executor.secrets.remove(SecretId.make("locked"));
952+ }),
953+ );
850954 });
packages/plugins/openapi/src/sdk/plugin.ts+77−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 ScopeId,
1111 SecretId,
1212 SourceDetectionResult,
13+ Usage,
1314 definePlugin,
1415 resolveSecretBackedMap,
1516 type PluginCtx,
@@ -1030,6 +1031,82 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1014 unmodified lines
10301031
10311032 removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope),
10321033
1034+ // Aggregate usages across the four places openapi can hold a direct
1035+ // secret ref:
1036+ // - openapi_source_binding.secret_id (kind=secret slot bindings)
1037+ // - openapi_source_query_param
1038+ // - openapi_source_spec_fetch_header / spec_fetch_query_param
1039+ // Each is one indexed SELECT in the store; the merge plus a single
1040+ // source-name JOIN happens here.
1041+ usagesForSecret: ({ ctx, args }) =>
1042+ Effect.gen(function* () {
1043+ const bindings = yield* ctx.storage.findBindingsBySecret(args.secretId);
1044+ const childRows = yield* ctx.storage.findChildRowsBySecret(
1045+ args.secretId,
1046+ );
1047+
1048+ const sourceKeys = new Set<string>();
1049+ for (const b of bindings) {
1050+ sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`);
1051+ }
1052+ for (const r of childRows) {
1053+ sourceKeys.add(`${r.scope_id}:${r.source_id}`);
1054+ }
1055+ const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
1056+
1057+ const out: Usage[] = [];
1058+ for (const b of bindings) {
1059+ out.push(
1060+ new Usage({
1061+ pluginId: "openapi",
1062+ scopeId: ScopeId.make(b.scopeId),
1063+ ownerKind: "openapi-source-binding",
1064+ ownerId: b.sourceId,
1065+ ownerName:
1066+ sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null,
1067+ slot: `binding:${b.slot}`,
1068+ }),
1069+ );
1070+ }
1071+ for (const r of childRows) {
1072+ out.push(
1073+ new Usage({
1074+ pluginId: "openapi",
1075+ scopeId: ScopeId.make(r.scope_id),
1076+ ownerKind: `openapi-source-${r.kind.replace(/_/g, "-")}`,
1077+ ownerId: r.source_id,
1078+ ownerName: sources.get(`${r.scope_id}:${r.source_id}`) ?? null,
1079+ slot: `${r.kind}:${r.name}`,
1080+ }),
1081+ );
1082+ }
1083+ return out;
1084+ }),
1085+
1086+ usagesForConnection: ({ ctx, args }) =>
1087+ Effect.gen(function* () {
1088+ const bindings = yield* ctx.storage.findBindingsByConnection(
1089+ args.connectionId,
1090+ );
1091+ const sourceKeys = new Set<string>();
1092+ for (const b of bindings) {
1093+ sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`);
1094+ }
1095+ const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
1096+ return bindings.map(
1097+ (b) =>
1098+ new Usage({
1099+ pluginId: "openapi",
1100+ scopeId: ScopeId.make(b.scopeId),
1101+ ownerKind: "openapi-source-binding",
1102+ ownerId: b.sourceId,
1103+ ownerName:
1104+ sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null,
1105+ slot: `binding:${b.slot}`,
1106+ }),
1107+ );
1108+ }),
1109+
10331110 // Re-fetch the spec from its origin URL (captured at addSpec time)
10341111 // and replay the same parse → extract → upsertSource → register
10351112 // path used by addSpec. Sources without a stored URL surface a
packages/plugins/openapi/src/sdk/store.ts+417−65
@@ -1,8 +1,10 @@
11 import { Effect, Schema } from "effect";
22
33 import {
4+ ConnectionId,
45 defineSchema,
56 ScopeId,
7+ SecretId,
68 StorageError,
79 type StorageDeps,
810 type StorageFailure,
@@ -27,6 +29,15 @@ import { · 18 unmodified lines
2729 // - openapi_source_binding: credential bindings for shared sources
2830 // ---------------------------------------------------------------------------
2931
32+// Each of the secret-backed child tables (`openapi_source_query_param`,
33+// `openapi_source_spec_fetch_header`,
34+// `openapi_source_spec_fetch_query_param`) shares the same column shape:
35+// id/scope_id/source_id/name plus a `kind` enum that discriminates a
36+// literal text value from a secret reference (with optional prefix).
37+// The fields are inlined per-table because `defineSchema`'s type
38+// narrowing relies on the literal types staying on the original
39+// declaration site.
40+
3041 export const openapiSchema = defineSchema({
3142 openapi_source: {
3243 fields: {
@@ -40,10 +51,14 @@ export const openapiSchema = defineSchema({ · 7 unmodified lines
4051 // is the address re-fetched on `refreshSource`.
4152 source_url: { type: "string", required: false },
4253 base_url: { type: "string", required: false },
54+ // `headers` and `oauth2` stay JSON: these carry slot names, not
55+ // direct secret/connection ids. The secrets/connections that
56+ // actually power them live one level of indirection deeper, in
57+ // `openapi_source_binding` rows keyed by slot — and those ARE
58+ // normalized below. Headers and oauth2 are plugin-private
59+ // structural data, not cross-cutting refs.
4360 headers: { type: "json", required: false },
44− query_params: { type: "json", required: false },
4561 oauth2: { type: "json", required: false },
46− invocation_config: { type: "json", required: true },
4762 },
4863 },
4964 openapi_operation: {
@@ -66,11 +81,54 @@ export const openapiSchema = defineSchema({ · 16 unmodified lines
6681 // bindings when a shared source is removed.
6782 target_scope_id: { type: "string", required: true, index: true },
6883 slot: { type: "string", required: true, index: true },
69− value: { type: "json", required: true },
84+ // Discriminated union, flattened. Exactly one of secret_id /
85+ // connection_id / text_value is populated based on `kind`.
86+ // `secret_id` and `connection_id` are indexed so usages queries
87+ // are one-hop SELECTs.
88+ kind: { type: ["secret", "connection", "text"], required: true },
89+ secret_id: { type: "string", required: false, index: true },
90+ connection_id: { type: "string", required: false, index: true },
91+ text_value: { type: "string", required: false },
7092 created_at: { type: "date", required: true },
7193 updated_at: { type: "date", required: true },
7294 },
7395 },
96+ openapi_source_query_param: {
97+ fields: {
98+ id: { type: "string", required: true },
99+ scope_id: { type: "string", required: true, index: true },
100+ source_id: { type: "string", required: true, index: true },
101+ name: { type: "string", required: true },
102+ kind: { type: ["text", "secret"], required: true },
103+ text_value: { type: "string", required: false },
104+ secret_id: { type: "string", required: false, index: true },
105+ secret_prefix: { type: "string", required: false },
106+ },
107+ },
108+ openapi_source_spec_fetch_header: {
109+ fields: {
110+ id: { type: "string", required: true },
111+ scope_id: { type: "string", required: true, index: true },
112+ source_id: { type: "string", required: true, index: true },
113+ name: { type: "string", required: true },
114+ kind: { type: ["text", "secret"], required: true },
115+ text_value: { type: "string", required: false },
116+ secret_id: { type: "string", required: false, index: true },
117+ secret_prefix: { type: "string", required: false },
118+ },
119+ },
120+ openapi_source_spec_fetch_query_param: {
121+ fields: {
122+ id: { type: "string", required: true },
123+ scope_id: { type: "string", required: true, index: true },
124+ source_id: { type: "string", required: true, index: true },
125+ name: { type: "string", required: true },
126+ kind: { type: ["text", "secret"], required: true },
127+ text_value: { type: "string", required: false },
128+ secret_id: { type: "string", required: false, index: true },
129+ secret_prefix: { type: "string", required: false },
130+ },
131+ },
74132 });
75133
76134 export type OpenapiSchema = typeof openapiSchema;
@@ -166,16 +224,70 @@ const decodeBinding = Schema.decodeUnknownSync(OperationBinding); · 89 unmodified lines
166224
167225 const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);
168226 const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);
169−const encodeSourceBindingValue = Schema.encodeSync(OpenApiSourceBindingValue);
170−const decodeSourceBindingValue = Schema.decodeUnknownSync(
171− OpenApiSourceBindingValue,
172−);
173227
174−const asJsonObject = (value: unknown): Record<string, unknown> => {
175− if (value == null) return {};
176− if (typeof value === "string")
177− return JSON.parse(value) as Record<string, unknown>;
178− return value as Record<string, unknown>;
228+interface ChildRow {
229+ readonly id: string;
230+ readonly scope_id: string;
231+ readonly source_id: string;
232+ readonly name: string;
233+ readonly kind: "text" | "secret";
234+ readonly text_value?: string;
235+ readonly secret_id?: string;
236+ readonly secret_prefix?: string;
237+ // Index signature to satisfy adapter's `RowInput` shape (the typed
238+ // adapter exposes its row shape with one).
239+ readonly [k: string]: unknown;
240+}
241+
242+// Collapse a SecretBackedValue map into the flat child-table column
243+// shape used by openapi_source_query_param and the two
244+// openapi_source_spec_fetch_* tables. Returns one record per entry.
245+const valueMapToChildRows = (
246+ sourceId: string,
247+ scope: string,
248+ values: Record<string, HeaderValue> | undefined,
249+): readonly ChildRow[] => {
250+ if (!values) return [];
251+ return Object.entries(values).map(([name, value]) => {
252+ const id = JSON.stringify([sourceId, name]);
253+ if (typeof value === "string") {
254+ return {
255+ id,
256+ scope_id: scope,
257+ source_id: sourceId,
258+ name,
259+ kind: "text",
260+ text_value: value,
261+ };
262+ }
263+ return {
264+ id,
265+ scope_id: scope,
266+ source_id: sourceId,
267+ name,
268+ kind: "secret",
269+ secret_id: value.secretId,
270+ secret_prefix: value.prefix,
271+ };
272+ });
273+};
274+
275+const childRowsToValueMap = (
276+ rows: readonly Record<string, unknown>[],
277+): Record<string, HeaderValue> => {
278+ const out: Record<string, HeaderValue> = {};
279+ for (const row of rows) {
280+ const name = row.name as string;
281+ if (row.kind === "secret" && typeof row.secret_id === "string") {
282+ const prefix = row.secret_prefix as string | undefined | null;
283+ out[name] = prefix
284+ ? { secretId: row.secret_id, prefix }
285+ : { secretId: row.secret_id };
286+ } else if (row.kind === "text" && typeof row.text_value === "string") {
287+ out[name] = row.text_value;
288+ }
289+ }
290+ return out;
179291 };
180292
181293 const toJsonRecord = (value: unknown): Record<string, unknown> =>
@@ -367,6 +479,44 @@ export interface OpenapiStore { · 185 unmodified lines
367479 slot: string,
368480 scope: string,
369481 ) => Effect.Effect<void, StorageFailure>;
482+
483+ // ---------------------------------------------------------------------
484+ // Usage lookups — back `usagesForSecret` / `usagesForConnection`.
485+ // Each is one indexed SELECT against the new normalized columns.
486+ // ---------------------------------------------------------------------
487+
488+ /** Source-binding rows that point at the given secret id. */
489+ readonly findBindingsBySecret: (
490+ secretId: string,
491+ ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>;
492+
493+ /** Source-binding rows that point at the given connection id. */
494+ readonly findBindingsByConnection: (
495+ connectionId: string,
496+ ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>;
497+
498+ /** Child rows from query_params / specFetch tables that reference the
499+ * given secret id, tagged with the table they came from so the
500+ * caller can produce a readable `slot` like
501+ * `query_param:foo` or `spec_fetch_header:Authorization`. */
502+ readonly findChildRowsBySecret: (secretId: string) => Effect.Effect<
503+ readonly {
504+ readonly kind:
505+ | "query_param"
506+ | "spec_fetch_header"
507+ | "spec_fetch_query_param";
508+ readonly source_id: string;
509+ readonly scope_id: string;
510+ readonly name: string;
511+ }[],
512+ StorageFailure
513+ >;
514+
515+ /** Resolve display names for one or more `(scope_id, source_id)` pairs
516+ * in a single round trip, keyed by `${scope_id}:${source_id}`. */
517+ readonly lookupSourceNames: (
518+ keys: readonly string[],
519+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>;
370520 }
371521
372522 // ---------------------------------------------------------------------------
@@ -400,6 +550,25 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
400550 encodeSyntheticRowIdPart(scopeId),
401551 ].join("::");
402552
553+ const rowToSourceBindingValue = (
554+ row: Record<string, unknown>,
555+ ): OpenApiSourceBindingValue => {
556+ const kind = row.kind as string;
557+ if (kind === "secret" && typeof row.secret_id === "string") {
558+ return { kind: "secret", secretId: SecretId.make(row.secret_id) };
559+ }
560+ if (kind === "connection" && typeof row.connection_id === "string") {
561+ return {
562+ kind: "connection",
563+ connectionId: ConnectionId.make(row.connection_id),
564+ };
565+ }
566+ // text fallback covers both well-formed text rows and any
567+ // partial/null row that survived a malformed write — `text_value`
568+ // defaults to "" so the type stays satisfied without a throw.
569+ return { kind: "text", text: (row.text_value as string | null) ?? "" };
570+ };
571+
403572 const rowToSourceBinding = (
404573 row: Record<string, unknown>,
405574 ): OpenApiSourceBindingRef =>
@@ -408,7 +577,7 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
408577 sourceScopeId: ScopeId.make(row.source_scope_id as string),
409578 scopeId: ScopeId.make(row.target_scope_id as string),
410579 slot: row.slot as string,
411− value: decodeSourceBindingValue(asJsonObject(row.value)),
580+ value: rowToSourceBindingValue(row),
412581 createdAt:
413582 row.created_at instanceof Date
414583 ? row.created_at
@@ -419,6 +588,18 @@ export const makeDefaultOpenapiStore = ({ · 4 unmodified lines
419588 : new Date(row.updated_at as string),
420589 });
421590
591+ const sourceBindingValueColumns = (
592+ value: OpenApiSourceBindingValue,
593+ ): { kind: string; secret_id?: string; connection_id?: string; text_value?: string } => {
594+ if (value.kind === "secret") {
595+ return { kind: "secret", secret_id: value.secretId };
596+ }
597+ if (value.kind === "connection") {
598+ return { kind: "connection", connection_id: value.connectionId };
599+ }
600+ return { kind: "text", text_value: value.text };
601+ };
602+
422603 const validateBindingScopes = (params: {
423604 readonly sourceScope: string;
424605 readonly targetScope: string;
@@ -485,39 +666,88 @@ export const makeDefaultOpenapiStore = ({ · 60 unmodified lines
485666 return source;
486667 });
487668
488− const rowToSource = (row: Record<string, unknown>): StoredSource => {
489− const normalizedHeaders = normalizeStoredHeaders(row.headers);
490− const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);
491− const invocationConfig = asJsonObject(row.invocation_config);
492− return {
493− namespace: row.id as string,
494− scope: row.scope_id as string,
495− name: row.name as string,
496− config: {
497− spec: row.spec as string,
498− sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
499− baseUrl: (row.base_url as string | null | undefined) ?? undefined,
500− headers: normalizedHeaders.headers,
501− queryParams: decodeHeaders(row.query_params),
502− specFetchCredentials: invocationConfig.specFetchCredentials as
503− | OpenApiSpecFetchCredentials
504− | undefined,
505− oauth2: normalizedOAuth2.oauth2,
506− },
507− legacy:
508− Object.keys(normalizedHeaders.legacy).length > 0 ||
509− normalizedOAuth2.legacy
510− ? {
511− ...(Object.keys(normalizedHeaders.legacy).length > 0
512− ? { headers: normalizedHeaders.legacy }
669+ const loadChildValueMap = (
670+ model:
671+ | "openapi_source_query_param"
672+ | "openapi_source_spec_fetch_header"
673+ | "openapi_source_spec_fetch_query_param",
674+ sourceId: string,
675+ scope: string,
676+ ) =>
677+ adapter
678+ .findMany({
679+ model,
680+ where: [
681+ { field: "source_id", value: sourceId },
682+ { field: "scope_id", value: scope },
683+ ],
684+ })
685+ .pipe(Effect.map(childRowsToValueMap));
686+
687+ const rowToSource = (
688+ row: Record<string, unknown>,
689+ ): Effect.Effect<StoredSource, StorageFailure> =>
690+ Effect.gen(function* () {
691+ const sourceId = row.id as string;
692+ const scope = row.scope_id as string;
693+ const normalizedHeaders = normalizeStoredHeaders(row.headers);
694+ const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);
695+
696+ const queryParams = yield* loadChildValueMap(
697+ "openapi_source_query_param",
698+ sourceId,
699+ scope,
700+ );
701+ const specFetchHeaders = yield* loadChildValueMap(
702+ "openapi_source_spec_fetch_header",
703+ sourceId,
704+ scope,
705+ );
706+ const specFetchQueryParams = yield* loadChildValueMap(
707+ "openapi_source_spec_fetch_query_param",
708+ sourceId,
709+ scope,
710+ );
711+ const specFetchCredentials: OpenApiSpecFetchCredentials | undefined =
712+ Object.keys(specFetchHeaders).length === 0 &&
713+ Object.keys(specFetchQueryParams).length === 0
714+ ? undefined
715+ : {
716+ ...(Object.keys(specFetchHeaders).length > 0
717+ ? { headers: specFetchHeaders }
513718 : {}),
514− ...(normalizedOAuth2.legacy
515− ? { oauth2: normalizedOAuth2.legacy }
719+ ...(Object.keys(specFetchQueryParams).length > 0
720+ ? { queryParams: specFetchQueryParams }
516721 : {}),
517− }
518− : undefined,
519− };
520− };
722+ };
723+
724+ return {
725+ namespace: sourceId,
726+ scope,
727+ name: row.name as string,
728+ config: {
729+ spec: row.spec as string,
730+ sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
731+ baseUrl: (row.base_url as string | null | undefined) ?? undefined,
732+ headers: normalizedHeaders.headers,
733+ queryParams,
734+ specFetchCredentials,
735+ oauth2: normalizedOAuth2.oauth2,
736+ },
737+ legacy:
738+ Object.keys(normalizedHeaders.legacy).length > 0 ||
739+ normalizedOAuth2.legacy
740+ ? {
741+ ...(Object.keys(normalizedHeaders.legacy).length > 0
742+ ? { headers: normalizedHeaders.legacy }
743+ : {}),
744+ ...(normalizedOAuth2.legacy
745+ ? { oauth2: normalizedOAuth2.legacy }
746+ : {}),
747+ }
748+ : undefined,
749+ };
750+ });
521751
522752 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({
523753 toolId: row.id as string,
@@ -527,6 +757,35 @@ export const makeDefaultOpenapiStore = ({ · 3 unmodified lines
527757 ),
528758 });
529759
760+ // Replace the rows of one child table for a source: delete then bulk
761+ // insert. Single helper so upsertSource and updateSourceMeta both
762+ // funnel through the same write path.
763+ const replaceChildRows = (
764+ model:
765+ | "openapi_source_query_param"
766+ | "openapi_source_spec_fetch_header"
767+ | "openapi_source_spec_fetch_query_param",
768+ sourceId: string,
769+ scope: string,
770+ values: Record<string, HeaderValue> | undefined,
771+ ) =>
772+ Effect.gen(function* () {
773+ yield* adapter.deleteMany({
774+ model,
775+ where: [
776+ { field: "source_id", value: sourceId },
777+ { field: "scope_id", value: scope },
778+ ],
779+ });
780+ const rows = valueMapToChildRows(sourceId, scope, values);
781+ if (rows.length === 0) return;
782+ yield* adapter.createMany({
783+ model,
784+ data: rows,
785+ forceAllowId: true,
786+ });
787+ });
788+
530789 const deleteSource = (
531790 namespace: string,
532791 scope: string,
@@ -540,6 +799,20 @@ export const makeDefaultOpenapiStore = ({ · 7 unmodified lines
540799 { field: "scope_id", value: scope },
541800 ],
542801 });
802+ // Drop every child table's rows for this source/scope.
803+ for (const model of [
804+ "openapi_source_query_param",
805+ "openapi_source_spec_fetch_header",
806+ "openapi_source_spec_fetch_query_param",
807+ ] as const) {
808+ yield* adapter.deleteMany({
809+ model,
810+ where: [
811+ { field: "source_id", value: namespace },
812+ { field: "scope_id", value: scope },
813+ ],
814+ });
815+ }
543816 yield* adapter.delete({
544817 model: "openapi_source",
545818 where: [
@@ -587,18 +860,30 @@ export const makeDefaultOpenapiStore = ({ · 41 unmodified lines
587860 ],
588861 ),
589862 ) as Record<string, unknown>,
590− query_params: input.config.queryParams,
591863 oauth2: input.config.oauth2
592864 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))
593865 : undefined,
594− invocation_config: {
595− ...(input.config.specFetchCredentials
596− ? { specFetchCredentials: input.config.specFetchCredentials }
597− : {}),
598− },
599866 },
600867 forceAllowId: true,
601868 });
869+ yield* replaceChildRows(
870+ "openapi_source_query_param",
871+ input.namespace,
872+ input.scope,
873+ input.config.queryParams,
874+ );
875+ yield* replaceChildRows(
876+ "openapi_source_spec_fetch_header",
877+ input.namespace,
878+ input.scope,
879+ input.config.specFetchCredentials?.headers,
880+ );
881+ yield* replaceChildRows(
882+ "openapi_source_spec_fetch_query_param",
883+ input.namespace,
884+ input.scope,
885+ input.config.specFetchCredentials?.queryParams,
886+ );
602887 if (operations.length > 0) {
603888 yield* adapter.createMany({
604889 model: "openapi_operation",
@@ -623,7 +908,7 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
623908 ],
624909 });
625910 if (!existingRow) return;
626− const existing = rowToSource(existingRow);
911+ const existing = yield* rowToSource(existingRow);
627912
628913 const nextName = patch.name?.trim() || existing.name;
629914 const nextBaseUrl =
@@ -632,10 +917,6 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
632917 patch.headers !== undefined
633918 ? patch.headers
634919 : (existing.config.headers ?? {});
635− const nextQueryParams =
636− patch.queryParams !== undefined
637− ? patch.queryParams
638− : (existing.config.queryParams ?? {});
639920 const nextOAuth2 =
640921 patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;
641922
@@ -660,30 +941,41 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
660941 },
661942 ]),
662943 ) as Record<string, unknown>,
663− query_params: nextQueryParams,
664944 oauth2: nextOAuth2
665945 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2))
666946 : undefined,
667− invocation_config: asJsonObject(existingRow.invocation_config),
668947 },
669948 });
949+ if (patch.queryParams !== undefined) {
950+ yield* replaceChildRows(
951+ "openapi_source_query_param",
952+ namespace,
953+ scope,
954+ patch.queryParams,
955+ );
956+ }
670957 }),
671958
672959 getSource: (namespace, scope) =>
673− adapter
674− .findOne({
960+ Effect.gen(function* () {
961+ const row = yield* adapter.findOne({
675962 model: "openapi_source",
676963 where: [
677964 { field: "id", value: namespace },
678965 { field: "scope_id", value: scope },
679966 ],
680− })
681− .pipe(Effect.map((row) => (row ? rowToSource(row) : null))),
967+ });
968+ if (!row) return null;
969+ return yield* rowToSource(row);
970+ }),
682971
683972 listSources: () =>
684− adapter
685− .findMany({ model: "openapi_source" })
686− .pipe(Effect.map((rows) => rows.map(rowToSource))),
973+ Effect.gen(function* () {
974+ const rows = yield* adapter.findMany({ model: "openapi_source" });
975+ return yield* Effect.forEach(rows, rowToSource, {
976+ concurrency: "unbounded",
977+ });
978+ }),
687979
688980 getOperationByToolId: (toolId, scope) =>
689981 adapter
@@ -779,6 +1071,7 @@ export const makeDefaultOpenapiStore = ({ · 89 unmodified lines
7791071 input.scope as string,
7801072 );
7811073 const now = new Date();
1074+ const valueColumns = sourceBindingValueColumns(input.value);
7821075 yield* adapter.delete({
7831076 model: "openapi_source_binding",
7841077 where: [{ field: "id", value: id }],
@@ -791,7 +1084,7 @@ export const makeDefaultOpenapiStore = ({ · 6 unmodified lines
7911084 source_scope_id: input.sourceScope as string,
7921085 target_scope_id: input.scope as string,
7931086 slot: input.slot,
794− value: toJsonRecord(encodeSourceBindingValue(input.value)),
1087+ ...valueColumns,
7951088 created_at: now,
7961089 updated_at: now,
7971090 },
@@ -825,5 +1118,64 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
8251118 ],
8261119 });
8271120 }),
1121+
1122+ findBindingsBySecret: (secretId) =>
1123+ adapter
1124+ .findMany({
1125+ model: "openapi_source_binding",
1126+ where: [{ field: "secret_id", value: secretId }],
1127+ })
1128+ .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))),
1129+
1130+ findBindingsByConnection: (connectionId) =>
1131+ adapter
1132+ .findMany({
1133+ model: "openapi_source_binding",
1134+ where: [{ field: "connection_id", value: connectionId }],
1135+ })
1136+ .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))),
1137+
1138+ findChildRowsBySecret: (secretId) =>
1139+ Effect.gen(function* () {
1140+ const tables = [
1141+ { model: "openapi_source_query_param" as const, kind: "query_param" as const },
1142+ { model: "openapi_source_spec_fetch_header" as const, kind: "spec_fetch_header" as const },
1143+ { model: "openapi_source_spec_fetch_query_param" as const, kind: "spec_fetch_query_param" as const },
1144+ ];
1145+ const perTable = yield* Effect.forEach(
1146+ tables,
1147+ (t) =>
1148+ adapter
1149+ .findMany({
1150+ model: t.model,
1151+ where: [{ field: "secret_id", value: secretId }],
1152+ })
1153+ .pipe(
1154+ Effect.map((rows) =>
1155+ rows.map((r) => ({
1156+ kind: t.kind,
1157+ source_id: r.source_id as string,
1158+ scope_id: r.scope_id as string,
1159+ name: r.name as string,
1160+ })),
1161+ ),
1162+ ),
1163+ { concurrency: "unbounded" },
1164+ );
1165+ return perTable.flat();
1166+ }),
1167+
1168+ lookupSourceNames: (keys) =>
1169+ Effect.gen(function* () {
1170+ if (keys.length === 0) return new Map<string, string>();
1171+ const rows = yield* adapter.findMany({ model: "openapi_source" });
1172+ const requested = new Set(keys);
1173+ const out = new Map<string, string>();
1174+ for (const r of rows) {
1175+ const key = `${r.scope_id as string}:${r.id as string}`;
1176+ if (requested.has(key)) out.set(key, r.name as string);
1177+ }
1178+ return out;
1179+ }),
8281180 };
8291181 };
packages/react/src/api/atoms.tsx+35−1
@@ -1,4 +1,10 @@
1−import { PolicyId, type ScopeId, type ToolId, type SecretId } from "@executor-js/sdk";
1+import {
2+ PolicyId,
3+ type ConnectionId,
4+ type ScopeId,
5+ type SecretId,
6+ type ToolId,
7+} from "@executor-js/sdk";
28 import * as Atom from "effect/unstable/reactivity/Atom";
39 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
410
@@ -72,6 +78,34 @@ export const connectionsAtom = (scopeId: ScopeId) => · 67 unmodified lines
7278 reactivityKeys: [ReactivityKey.connections],
7379 });
7480
81+export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) =>
82+ ExecutorApiClient.query("secrets", "usages", {
83+ params: { scopeId, secretId },
84+ timeToLive: "30 seconds",
85+ // Refresh whenever any source / connection / secret changes — adding
86+ // an oauth source pulls in a new connection-secret link and we want
87+ // the usage list to reflect it.
88+ reactivityKeys: [
89+ ReactivityKey.secrets,
90+ ReactivityKey.sources,
91+ ReactivityKey.connections,
92+ ],
93+ });
94+
95+export const connectionUsagesAtom = (
96+ scopeId: ScopeId,
97+ connectionId: ConnectionId,
98+) =>
99+ ExecutorApiClient.query("connections", "usages", {
100+ params: { scopeId, connectionId },
101+ timeToLive: "30 seconds",
102+ reactivityKeys: [
103+ ReactivityKey.connections,
104+ ReactivityKey.sources,
105+ ReactivityKey.secrets,
106+ ],
107+ });
108+
75109 export const policiesAtom = (scopeId: ScopeId) =>
76110 ExecutorApiClient.query("policies", "list", {
77111 params: { scopeId },
packages/react/src/pages/connections.tsx+45−3
@@ -1,9 +1,10 @@
1−import { useAtomSet } from "@effect/atom-react";
1+import { Suspense } from "react";
2+import { useAtomValue, useAtomSet } from "@effect/atom-react";
23 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3−import { ConnectionId } from "@executor-js/sdk";
4+import { ConnectionId, type ScopeId } from "@executor-js/sdk";
45 import { toast } from "sonner";
56
6−import { removeConnection } from "../api/atoms";
7+import { connectionUsagesAtom, removeConnection } from "../api/atoms";
78 import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic";
89 import { connectionWriteKeys } from "../api/reactivity-keys";
910 import { useScope, useScopeStack } from "../hooks/use-scope";
@@ -50,11 +51,45 @@ const connectionScopeLabel = ( · 40 unmodified lines
5051 return "Scoped";
5152 };
5253
54+// ---------------------------------------------------------------------------
55+// Used-by footer — same shape as the secrets page. Returns null when a
56+// connection isn't referenced anywhere so newly-created connections
57+// don't get a stray "Used by 0" line before any source binds to them.
58+// ---------------------------------------------------------------------------
59+
60+function ConnectionUsageFooter(props: {
61+ scopeId: ScopeId;
62+ connectionId: ConnectionId;
63+}) {
64+ const usages = useAtomValue(
65+ connectionUsagesAtom(props.scopeId, props.connectionId),
66+ );
67+ return AsyncResult.match(usages, {
68+ onInitial: () => null,
69+ onFailure: () => null,
70+ onSuccess: ({ value }) => {
71+ if (value.length === 0) return null;
72+ const labels = value
73+ .map((u) => u.ownerName ?? u.ownerId)
74+ .filter((s, i, a) => a.indexOf(s) === i);
75+ const visible = labels.slice(0, 3);
76+ const hidden = labels.length - visible.length;
77+ return (
78+ <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
79+ Used by {visible.join(", ")}
80+ {hidden > 0 ? ` +${hidden} more` : ""}
81+ </CardStackEntryDescription>
82+ );
83+ },
84+ });
85+}
86+
5387 // ---------------------------------------------------------------------------
5488 // Connection row
5589 // ---------------------------------------------------------------------------
5690
5791 function ConnectionRow(props: {
92+ scopeId: ScopeId;
5893 connection: {
5994 id: string;
6095 scopeId: string;
@@ -80,6 +115,12 @@ function ConnectionRow(props: { · 19 unmodified lines
80115 <CardStackEntryDescription className="text-xs text-muted-foreground">
81116 {displayProvider(connection.provider)}
82117 </CardStackEntryDescription>
118+ <Suspense fallback={null}>
119+ <ConnectionUsageFooter
120+ scopeId={props.scopeId}
121+ connectionId={ConnectionId.make(connection.id)}
122+ />
123+ </Suspense>
83124 </CardStackEntryContent>
84125 <CardStackEntryActions>
85126 <Badge variant="outline">{scopeLabel}</Badge>
@@ -185,6 +226,7 @@ export function ConnectionsPage() { · 99 unmodified lines
185226 }) => (
186227 <ConnectionRow
187228 key={c.id}
229+ scopeId={scopeId}
188230 connection={{
189231 id: c.id,
190232 scopeId: c.scopeId,
packages/react/src/pages/secrets.tsx+42−2
@@ -1,10 +1,10 @@
11 import { useMemo, useState, Suspense } from "react";
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4−import { secretsAtom, removeSecret } from "../api/atoms";
4+import { secretsAtom, secretUsagesAtom, removeSecret } from "../api/atoms";
55 import { secretWriteKeys } from "../api/reactivity-keys";
66 import { useSecretProviderPlugins } from "@executor-js/sdk/client";
7−import { SecretId } from "@executor-js/sdk";
7+import { SecretId, type ScopeId } from "@executor-js/sdk";
88 import { SecretForm } from "../plugins/secret-form";
99 import { useScope } from "../hooks/use-scope";
1010 import {
@@ -122,11 +122,44 @@ function AddSecretDialogContent(props: { · 111 unmodified lines
122122 );
123123 }
124124
125+// ---------------------------------------------------------------------------
126+// Used-by footer — fetched per-secret. Keeps the list compact: shows the
127+// count plus the first few owner names, with a "+N more" tail. Empty
128+// state collapses to nothing so secrets that aren't referenced anywhere
129+// don't get a noisy "Used by 0" line.
130+// ---------------------------------------------------------------------------
131+
132+function SecretUsageFooter(props: {
133+ scopeId: ScopeId;
134+ secretId: SecretId;
135+}) {
136+ const usages = useAtomValue(secretUsagesAtom(props.scopeId, props.secretId));
137+ return AsyncResult.match(usages, {
138+ onInitial: () => null,
139+ onFailure: () => null,
140+ onSuccess: ({ value }) => {
141+ if (value.length === 0) return null;
142+ const labels = value
143+ .map((u) => u.ownerName ?? u.ownerId)
144+ .filter((s, i, a) => a.indexOf(s) === i);
145+ const visible = labels.slice(0, 3);
146+ const hidden = labels.length - visible.length;
147+ return (
148+ <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
149+ Used by {visible.join(", ")}
150+ {hidden > 0 ? ` +${hidden} more` : ""}
151+ </CardStackEntryDescription>
152+ );
153+ },
154+ });
155+}
156+
125157 // ---------------------------------------------------------------------------
126158 // Secret row
127159 // ---------------------------------------------------------------------------
128160
129161 function SecretRow(props: {
162+ scopeId: ScopeId;
130163 showProvider: boolean;
131164 secret: { id: string; name: string; provider?: string };
132165 onRemove: () => void;
@@ -147,6 +180,12 @@ function SecretRow(props: { · 14 unmodified lines
147180 {secret.id}
148181 </span>
149182 </CardStackEntryTitle>
183+ <Suspense fallback={null}>
184+ <SecretUsageFooter
185+ scopeId={props.scopeId}
186+ secretId={SecretId.make(secret.id)}
187+ />
188+ </Suspense>
150189 </CardStackEntryContent>
151190 <CardStackEntryActions>
152191 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}
@@ -306,6 +345,7 @@ export function SecretsPage(props: { · 153 unmodified lines
306345 }) => (
307346 <SecretRow
308347 key={s.id}
348+ scopeId={scopeId}
309349 showProvider={showProviderInfo}
310350 secret={{
311351 id: s.id,