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
15CREATE 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
27CREATE INDEX "graphql_source_header_scope_id_idx" ON "graphql_source_header" USING btree ("scope_id");--> statement-breakpoint
28CREATE INDEX "graphql_source_header_source_id_idx" ON "graphql_source_header" USING btree ("source_id");--> statement-breakpoint
29CREATE INDEX "graphql_source_header_secret_id_idx" ON "graphql_source_header" USING btree ("secret_id");--> statement-breakpoint
30
31CREATE 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
43CREATE INDEX "graphql_source_query_param_scope_id_idx" ON "graphql_source_query_param" USING btree ("scope_id");--> statement-breakpoint
44CREATE INDEX "graphql_source_query_param_source_id_idx" ON "graphql_source_query_param" USING btree ("source_id");--> statement-breakpoint
45CREATE 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.
49ALTER TABLE "graphql_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
50ALTER TABLE "graphql_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint
51CREATE 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".
56UPDATE "graphql_source"
57SET
58 "auth_kind" = COALESCE("auth"->>'kind', 'none'),
59 "auth_connection_id" = "auth"->>'connectionId'
60WHERE "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.
66INSERT INTO "graphql_source_header"
67 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
68SELECT
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
80FROM "graphql_source" s, jsonb_each(s."headers") h
81WHERE s."headers" IS NOT NULL
82ON CONFLICT DO NOTHING;--> statement-breakpoint
83
84-- Same for query_params.
85INSERT INTO "graphql_source_query_param"
86 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
87SELECT
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
99FROM "graphql_source" s, jsonb_each(s."query_params") q
100WHERE s."query_params" IS NOT NULL
101ON CONFLICT DO NOTHING;--> statement-breakpoint
102
103ALTER TABLE "graphql_source" DROP COLUMN "headers";--> statement-breakpoint
104ALTER TABLE "graphql_source" DROP COLUMN "query_params";--> statement-breakpoint
105ALTER 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
117CREATE 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
129CREATE INDEX "openapi_source_query_param_scope_id_idx" ON "openapi_source_query_param" USING btree ("scope_id");--> statement-breakpoint
130CREATE INDEX "openapi_source_query_param_source_id_idx" ON "openapi_source_query_param" USING btree ("source_id");--> statement-breakpoint
131CREATE INDEX "openapi_source_query_param_secret_id_idx" ON "openapi_source_query_param" USING btree ("secret_id");--> statement-breakpoint
132
133CREATE 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
145CREATE INDEX "openapi_source_spec_fetch_header_scope_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("scope_id");--> statement-breakpoint
146CREATE INDEX "openapi_source_spec_fetch_header_source_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("source_id");--> statement-breakpoint
147CREATE INDEX "openapi_source_spec_fetch_header_secret_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("secret_id");--> statement-breakpoint
148
149CREATE 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
161CREATE INDEX "openapi_source_spec_fetch_query_param_scope_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("scope_id");--> statement-breakpoint
162CREATE INDEX "openapi_source_spec_fetch_query_param_source_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("source_id");--> statement-breakpoint
163CREATE 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.
168ALTER TABLE "openapi_source_binding" ADD COLUMN "kind" text DEFAULT 'text' NOT NULL;--> statement-breakpoint
169ALTER TABLE "openapi_source_binding" ADD COLUMN "secret_id" text;--> statement-breakpoint
170ALTER TABLE "openapi_source_binding" ADD COLUMN "connection_id" text;--> statement-breakpoint
171ALTER TABLE "openapi_source_binding" ADD COLUMN "text_value" text;--> statement-breakpoint
172CREATE INDEX "openapi_source_binding_secret_id_idx" ON "openapi_source_binding" USING btree ("secret_id");--> statement-breakpoint
173CREATE INDEX "openapi_source_binding_connection_id_idx" ON "openapi_source_binding" USING btree ("connection_id");--> statement-breakpoint
174
175UPDATE "openapi_source_binding"
176SET
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
181WHERE "value" IS NOT NULL;--> statement-breakpoint
182
183INSERT INTO "openapi_source_query_param"
184 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
185SELECT
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
197FROM "openapi_source" s, jsonb_each(s."query_params") q
198WHERE s."query_params" IS NOT NULL
199ON CONFLICT DO NOTHING;--> statement-breakpoint
200
201INSERT INTO "openapi_source_spec_fetch_header"
202 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
203SELECT
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
215FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'headers') h
216WHERE s."invocation_config"->'specFetchCredentials'->'headers' IS NOT NULL
217ON CONFLICT DO NOTHING;--> statement-breakpoint
218
219INSERT INTO "openapi_source_spec_fetch_query_param"
220 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
221SELECT
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
233FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'queryParams') q
234WHERE s."invocation_config"->'specFetchCredentials'->'queryParams' IS NOT NULL
235ON 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.
242UPDATE "openapi_source"
243SET "oauth2" = "invocation_config"->'oauth2'
244WHERE "oauth2" IS NULL
245 AND "invocation_config"->'oauth2' IS NOT NULL;--> statement-breakpoint
246
247ALTER TABLE "openapi_source_binding" DROP COLUMN "value";--> statement-breakpoint
248ALTER TABLE "openapi_source" DROP COLUMN "query_params";--> statement-breakpoint
249ALTER 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
262CREATE 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
274CREATE INDEX "mcp_source_header_scope_id_idx" ON "mcp_source_header" USING btree ("scope_id");--> statement-breakpoint
275CREATE INDEX "mcp_source_header_source_id_idx" ON "mcp_source_header" USING btree ("source_id");--> statement-breakpoint
276CREATE INDEX "mcp_source_header_secret_id_idx" ON "mcp_source_header" USING btree ("secret_id");--> statement-breakpoint
277
278CREATE 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
290CREATE INDEX "mcp_source_query_param_scope_id_idx" ON "mcp_source_query_param" USING btree ("scope_id");--> statement-breakpoint
291CREATE INDEX "mcp_source_query_param_source_id_idx" ON "mcp_source_query_param" USING btree ("source_id");--> statement-breakpoint
292CREATE INDEX "mcp_source_query_param_secret_id_idx" ON "mcp_source_query_param" USING btree ("secret_id");--> statement-breakpoint
293
294ALTER TABLE "mcp_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
295ALTER TABLE "mcp_source" ADD COLUMN "auth_header_name" text;--> statement-breakpoint
296ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_id" text;--> statement-breakpoint
297ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_prefix" text;--> statement-breakpoint
298ALTER TABLE "mcp_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint
299ALTER TABLE "mcp_source" ADD COLUMN "auth_client_id_secret_id" text;--> statement-breakpoint
300ALTER TABLE "mcp_source" ADD COLUMN "auth_client_secret_secret_id" text;--> statement-breakpoint
301CREATE INDEX "mcp_source_auth_secret_id_idx" ON "mcp_source" USING btree ("auth_secret_id");--> statement-breakpoint
302CREATE INDEX "mcp_source_auth_connection_id_idx" ON "mcp_source" USING btree ("auth_connection_id");--> statement-breakpoint
303CREATE INDEX "mcp_source_auth_client_id_secret_id_idx" ON "mcp_source" USING btree ("auth_client_id_secret_id");--> statement-breakpoint
304CREATE 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.
310UPDATE "mcp_source"
311SET
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}'
319WHERE "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
331INSERT INTO "mcp_source_header"
332 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
333SELECT
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
345FROM "mcp_source" s, jsonb_each(s."config"->'headers') h
346WHERE s."config"->'headers' IS NOT NULL
347ON CONFLICT DO NOTHING;--> statement-breakpoint
348
349INSERT INTO "mcp_source_query_param"
350 ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix")
351SELECT
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
363FROM "mcp_source" s, jsonb_each(s."config"->'queryParams') q
364WHERE s."config"->'queryParams' IS NOT NULL
365ON 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).
371UPDATE "mcp_source"
372SET "config" = "config" - 'headers' - 'queryParams'
373WHERE "config" IS NOT NULL;--> statement-breakpoint
374
375UPDATE "mcp_source"
376SET "config" = "config" - 'auth'
377WHERE "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
57 "when": 1777567556847,57 "when": 1777567556847,
58 "tag": "0007_military_young_avengers",58 "tag": "0007_military_young_avengers",
59 "breakpoints": true59 "breakpoints": true
60 },
61 {
62 "idx": 8,
63 "version": "7",
64 "when": 1778004191000,
65 "tag": "0008_normalize_plugin_secret_refs",
66 "breakpoints": true
60 }67 }
61 ]68 ]
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
122 source_url: text('source_url'),122 source_url: text('source_url'),
123 base_url: text('base_url'),123 base_url: text('base_url'),
124 headers: jsonb('headers'),124 headers: jsonb('headers'),
125 query_params: jsonb('query_params'),125 oauth2: jsonb('oauth2')
126 oauth2: jsonb('oauth2'),
127 invocation_config: jsonb('invocation_config').notNull()
128}, (table) => [126}, (table) => [
129 primaryKey({ columns: [table.scope_id, table.id] }),127 primaryKey({ columns: [table.scope_id, table.id] }),
130 index("openapi_source_scope_id_idx").on(table.scope_id),128 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
147 source_scope_id: text('source_scope_id').notNull(),145 source_scope_id: text('source_scope_id').notNull(),
148 target_scope_id: text('target_scope_id').notNull(),146 target_scope_id: text('target_scope_id').notNull(),
149 slot: text('slot').notNull(),147 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'),
151 created_at: timestamp('created_at').notNull(),152 created_at: timestamp('created_at').notNull(),
152 updated_at: timestamp('updated_at').notNull()153 updated_at: timestamp('updated_at').notNull()
153}, (table) => [154}, (table) => [
@@ -155,6 +156,56 @@ export const openapi_source_binding = pgTable("openapi_source_binding", { · 1 unmodified line
155 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),156 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
156 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),157 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
157 index("openapi_source_binding_slot_idx").on(table.slot),158 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
163export 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
179export 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
195export 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),
158]);209]);
159 210
160export const mcp_source = pgTable("mcp_source", {211export const mcp_source = pgTable("mcp_source", {
@@ -162,10 +213,53 @@ export const mcp_source = pgTable("mcp_source", { · 1 unmodified line
162 scope_id: text('scope_id').notNull(),213 scope_id: text('scope_id').notNull(),
163 name: text('name').notNull(),214 name: text('name').notNull(),
164 config: jsonb('config').notNull(),215 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'),
165 created_at: timestamp('created_at').notNull()223 created_at: timestamp('created_at').notNull()
166}, (table) => [224}, (table) => [
167 primaryKey({ columns: [table.scope_id, table.id] }),225 primaryKey({ columns: [table.scope_id, table.id] }),
168 index("mcp_source_scope_id_idx").on(table.scope_id),226 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
233export 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
249export 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),
169]);263]);
170 264
171export const mcp_binding = pgTable("mcp_binding", {265export const mcp_binding = pgTable("mcp_binding", {
@@ -185,12 +279,44 @@ export const graphql_source = pgTable("graphql_source", { · 13 unmodified lines
185 scope_id: text('scope_id').notNull(),279 scope_id: text('scope_id').notNull(),
186 name: text('name').notNull(),280 name: text('name').notNull(),
187 endpoint: text('endpoint').notNull(),281 endpoint: text('endpoint').notNull(),
188 headers: jsonb('headers'),282 auth_kind: text('auth_kind', { enum: ['none', 'oauth2'] }).default("none").notNull(),
189 query_params: jsonb('query_params'),283 auth_connection_id: text('auth_connection_id')
190 auth: jsonb('auth')
191}, (table) => [284}, (table) => [
192 primaryKey({ columns: [table.scope_id, table.id] }),285 primaryKey({ columns: [table.scope_id, table.id] }),
193 index("graphql_source_scope_id_idx").on(table.scope_id),286 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
290export 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
306export 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),
194]);320]);
195 321
196export const graphql_operation = pgTable("graphql_operation", {322export 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
39CREATE 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
51CREATE INDEX `graphql_source_header_scope_id_idx` ON `graphql_source_header` (`scope_id`);--> statement-breakpoint
52CREATE INDEX `graphql_source_header_source_id_idx` ON `graphql_source_header` (`source_id`);--> statement-breakpoint
53CREATE INDEX `graphql_source_header_secret_id_idx` ON `graphql_source_header` (`secret_id`);--> statement-breakpoint
54
55CREATE 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
67CREATE INDEX `graphql_source_query_param_scope_id_idx` ON `graphql_source_query_param` (`scope_id`);--> statement-breakpoint
68CREATE INDEX `graphql_source_query_param_source_id_idx` ON `graphql_source_query_param` (`source_id`);--> statement-breakpoint
69CREATE 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.
73ALTER TABLE `graphql_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
74ALTER TABLE `graphql_source` ADD `auth_connection_id` text;--> statement-breakpoint
75CREATE 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".
80UPDATE `graphql_source`
81SET
82 `auth_kind` = COALESCE(json_extract(`auth`, '$.kind'), 'none'),
83 `auth_connection_id` = json_extract(`auth`, '$.connectionId')
84WHERE `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.
90INSERT OR IGNORE INTO `graphql_source_header`
91 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
92SELECT
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
104FROM `graphql_source` s, json_each(s.`headers`) h
105WHERE s.`headers` IS NOT NULL;--> statement-breakpoint
106
107-- Same for query_params.
108INSERT OR IGNORE INTO `graphql_source_query_param`
109 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
110SELECT
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
122FROM `graphql_source` s, json_each(s.`query_params`) q
123WHERE 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.
127ALTER TABLE `graphql_source` DROP COLUMN `headers`;--> statement-breakpoint
128ALTER TABLE `graphql_source` DROP COLUMN `query_params`;--> statement-breakpoint
129ALTER 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
155CREATE 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
167CREATE INDEX `openapi_source_query_param_scope_id_idx` ON `openapi_source_query_param` (`scope_id`);--> statement-breakpoint
168CREATE INDEX `openapi_source_query_param_source_id_idx` ON `openapi_source_query_param` (`source_id`);--> statement-breakpoint
169CREATE INDEX `openapi_source_query_param_secret_id_idx` ON `openapi_source_query_param` (`secret_id`);--> statement-breakpoint
170
171CREATE 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
183CREATE INDEX `openapi_source_spec_fetch_header_scope_id_idx` ON `openapi_source_spec_fetch_header` (`scope_id`);--> statement-breakpoint
184CREATE INDEX `openapi_source_spec_fetch_header_source_id_idx` ON `openapi_source_spec_fetch_header` (`source_id`);--> statement-breakpoint
185CREATE INDEX `openapi_source_spec_fetch_header_secret_id_idx` ON `openapi_source_spec_fetch_header` (`secret_id`);--> statement-breakpoint
186
187CREATE 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
199CREATE INDEX `openapi_source_spec_fetch_query_param_scope_id_idx` ON `openapi_source_spec_fetch_query_param` (`scope_id`);--> statement-breakpoint
200CREATE INDEX `openapi_source_spec_fetch_query_param_source_id_idx` ON `openapi_source_spec_fetch_query_param` (`source_id`);--> statement-breakpoint
201CREATE 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.
206ALTER TABLE `openapi_source_binding` ADD `kind` text DEFAULT 'text' NOT NULL;--> statement-breakpoint
207ALTER TABLE `openapi_source_binding` ADD `secret_id` text;--> statement-breakpoint
208ALTER TABLE `openapi_source_binding` ADD `connection_id` text;--> statement-breakpoint
209ALTER TABLE `openapi_source_binding` ADD `text_value` text;--> statement-breakpoint
210CREATE INDEX `openapi_source_binding_secret_id_idx` ON `openapi_source_binding` (`secret_id`);--> statement-breakpoint
211CREATE 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.
220UPDATE `openapi_source_binding`
221SET
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
226WHERE `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.
232INSERT OR IGNORE INTO `openapi_source_query_param`
233 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
234SELECT
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
246FROM `openapi_source` s, json_each(s.`query_params`) q
247WHERE 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.
252INSERT OR IGNORE INTO `openapi_source_spec_fetch_header`
253 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
254SELECT
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
266FROM `openapi_source` s, json_each(json_extract(s.`invocation_config`, '$.specFetchCredentials.headers')) h
267WHERE json_extract(s.`invocation_config`, '$.specFetchCredentials.headers') IS NOT NULL;--> statement-breakpoint
268
269INSERT OR IGNORE INTO `openapi_source_spec_fetch_query_param`
270 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
271SELECT
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
283FROM `openapi_source` s, json_each(json_extract(s.`invocation_config`, '$.specFetchCredentials.queryParams')) q
284WHERE 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.
291UPDATE `openapi_source`
292SET `oauth2` = json_extract(`invocation_config`, '$.oauth2')
293WHERE `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.
297ALTER TABLE `openapi_source_binding` DROP COLUMN `value`;--> statement-breakpoint
298ALTER TABLE `openapi_source` DROP COLUMN `query_params`;--> statement-breakpoint
299ALTER 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
327CREATE 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
339CREATE INDEX `mcp_source_header_scope_id_idx` ON `mcp_source_header` (`scope_id`);--> statement-breakpoint
340CREATE INDEX `mcp_source_header_source_id_idx` ON `mcp_source_header` (`source_id`);--> statement-breakpoint
341CREATE INDEX `mcp_source_header_secret_id_idx` ON `mcp_source_header` (`secret_id`);--> statement-breakpoint
342
343CREATE 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
355CREATE INDEX `mcp_source_query_param_scope_id_idx` ON `mcp_source_query_param` (`scope_id`);--> statement-breakpoint
356CREATE INDEX `mcp_source_query_param_source_id_idx` ON `mcp_source_query_param` (`source_id`);--> statement-breakpoint
357CREATE 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.
361ALTER TABLE `mcp_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
362ALTER TABLE `mcp_source` ADD `auth_header_name` text;--> statement-breakpoint
363ALTER TABLE `mcp_source` ADD `auth_secret_id` text;--> statement-breakpoint
364ALTER TABLE `mcp_source` ADD `auth_secret_prefix` text;--> statement-breakpoint
365ALTER TABLE `mcp_source` ADD `auth_connection_id` text;--> statement-breakpoint
366ALTER TABLE `mcp_source` ADD `auth_client_id_secret_id` text;--> statement-breakpoint
367ALTER TABLE `mcp_source` ADD `auth_client_secret_secret_id` text;--> statement-breakpoint
368CREATE INDEX `mcp_source_auth_secret_id_idx` ON `mcp_source` (`auth_secret_id`);--> statement-breakpoint
369CREATE INDEX `mcp_source_auth_connection_id_idx` ON `mcp_source` (`auth_connection_id`);--> statement-breakpoint
370CREATE INDEX `mcp_source_auth_client_id_secret_id_idx` ON `mcp_source` (`auth_client_id_secret_id`);--> statement-breakpoint
371CREATE 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.
383UPDATE `mcp_source`
384SET
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')
392WHERE `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.
409INSERT OR IGNORE INTO `mcp_source_header`
410 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
411SELECT
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
423FROM `mcp_source` s, json_each(json_extract(s.`config`, '$.headers')) h
424WHERE json_extract(s.`config`, '$.headers') IS NOT NULL;--> statement-breakpoint
425
426INSERT OR IGNORE INTO `mcp_source_query_param`
427 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
428SELECT
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
440FROM `mcp_source` s, json_each(json_extract(s.`config`, '$.queryParams')) q
441WHERE 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.
450UPDATE `mcp_source`
451SET `config` = json_remove(`config`, '$.headers', '$.queryParams')
452WHERE `config` IS NOT NULL;--> statement-breakpoint
453
454UPDATE `mcp_source`
455SET `config` = json_remove(`config`, '$.auth')
456WHERE `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
490CREATE 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
502CREATE INDEX `google_discovery_source_credential_header_scope_id_idx` ON `google_discovery_source_credential_header` (`scope_id`);--> statement-breakpoint
503CREATE INDEX `google_discovery_source_credential_header_source_id_idx` ON `google_discovery_source_credential_header` (`source_id`);--> statement-breakpoint
504CREATE INDEX `google_discovery_source_credential_header_secret_id_idx` ON `google_discovery_source_credential_header` (`secret_id`);--> statement-breakpoint
505
506CREATE 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
518CREATE INDEX `google_discovery_source_credential_query_param_scope_id_idx` ON `google_discovery_source_credential_query_param` (`scope_id`);--> statement-breakpoint
519CREATE INDEX `google_discovery_source_credential_query_param_source_id_idx` ON `google_discovery_source_credential_query_param` (`source_id`);--> statement-breakpoint
520CREATE INDEX `google_discovery_source_credential_query_param_secret_id_idx` ON `google_discovery_source_credential_query_param` (`secret_id`);--> statement-breakpoint
521
522ALTER TABLE `google_discovery_source` ADD `auth_kind` text DEFAULT 'none' NOT NULL;--> statement-breakpoint
523ALTER TABLE `google_discovery_source` ADD `auth_connection_id` text;--> statement-breakpoint
524ALTER TABLE `google_discovery_source` ADD `auth_client_id_secret_id` text;--> statement-breakpoint
525ALTER TABLE `google_discovery_source` ADD `auth_client_secret_secret_id` text;--> statement-breakpoint
526ALTER TABLE `google_discovery_source` ADD `auth_scopes` text;--> statement-breakpoint
527CREATE INDEX `google_discovery_source_auth_connection_id_idx` ON `google_discovery_source` (`auth_connection_id`);--> statement-breakpoint
528CREATE INDEX `google_discovery_source_auth_client_id_secret_id_idx` ON `google_discovery_source` (`auth_client_id_secret_id`);--> statement-breakpoint
529CREATE 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.
532UPDATE `google_discovery_source`
533SET
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')
539WHERE `config` IS NOT NULL;--> statement-breakpoint
540
541-- Backfill credential header / query_param child rows.
542INSERT OR IGNORE INTO `google_discovery_source_credential_header`
543 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
544SELECT
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
556FROM `google_discovery_source` s, json_each(json_extract(s.`config`, '$.credentials.headers')) h
557WHERE json_extract(s.`config`, '$.credentials.headers') IS NOT NULL;--> statement-breakpoint
558
559INSERT OR IGNORE INTO `google_discovery_source_credential_query_param`
560 (`scope_id`, `id`, `source_id`, `name`, `kind`, `text_value`, `secret_id`, `secret_prefix`)
561SELECT
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
573FROM `google_discovery_source` s, json_each(json_extract(s.`config`, '$.credentials.queryParams')) q
574WHERE json_extract(s.`config`, '$.credentials.queryParams') IS NOT NULL;--> statement-breakpoint
575
576-- Strip the extracted fields from the legacy config JSON.
577UPDATE `google_discovery_source`
578SET `config` = json_remove(`config`, '$.auth', '$.credentials')
579WHERE `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
50 "when": 1777850000001,50 "when": 1777850000001,
51 "tag": "0006_neat_terror",51 "tag": "0006_neat_terror",
52 "breakpoints": true52 "breakpoints": true
53 },
54 {
55 "idx": 7,
56 "version": "6",
57 "when": 1778100000000,
58 "tag": "0007_normalize_plugin_secret_refs",
59 "breakpoints": true
53 }60 }
54 ]61 ]
55}62}
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
6import { Database } from "bun:sqlite";
7
8export 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.
100export const STAMP_BEFORE = 1777850000001;
101
102export 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
122 source_url: text('source_url'),122 source_url: text('source_url'),
123 base_url: text('base_url'),123 base_url: text('base_url'),
124 headers: text('headers', { mode: "json" }),124 headers: text('headers', { mode: "json" }),
125 query_params: text('query_params', { mode: "json" }),125 oauth2: text('oauth2', { mode: "json" })
126 oauth2: text('oauth2', { mode: "json" }),
127 invocation_config: text('invocation_config', { mode: "json" }).notNull()
128}, (table) => [126}, (table) => [
129 primaryKey({ columns: [table.scope_id, table.id] }),127 primaryKey({ columns: [table.scope_id, table.id] }),
130 index("openapi_source_scope_id_idx").on(table.scope_id),128 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
147 source_scope_id: text('source_scope_id').notNull(),145 source_scope_id: text('source_scope_id').notNull(),
148 target_scope_id: text('target_scope_id').notNull(),146 target_scope_id: text('target_scope_id').notNull(),
149 slot: text('slot').notNull(),147 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'),
151 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),152 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
152 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()153 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
153}, (table) => [154}, (table) => [
@@ -155,6 +156,56 @@ export const openapi_source_binding = sqliteTable("openapi_source_binding", { · 1 unmodified line
155 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),156 index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
156 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),157 index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
157 index("openapi_source_binding_slot_idx").on(table.slot),158 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
163export 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
179export 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
195export 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),
158]);209]);
159 210
160export const mcp_source = sqliteTable("mcp_source", {211export const mcp_source = sqliteTable("mcp_source", {
@@ -162,10 +213,53 @@ export const mcp_source = sqliteTable("mcp_source", { · 1 unmodified line
162 scope_id: text('scope_id').notNull(),213 scope_id: text('scope_id').notNull(),
163 name: text('name').notNull(),214 name: text('name').notNull(),
164 config: text('config', { mode: "json" }).notNull(),215 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'),
165 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()223 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull()
166}, (table) => [224}, (table) => [
167 primaryKey({ columns: [table.scope_id, table.id] }),225 primaryKey({ columns: [table.scope_id, table.id] }),
168 index("mcp_source_scope_id_idx").on(table.scope_id),226 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
233export 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
249export 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),
169]);263]);
170 264
171export const mcp_binding = sqliteTable("mcp_binding", {265export const mcp_binding = sqliteTable("mcp_binding", {
@@ -185,11 +279,51 @@ export const google_discovery_source = sqliteTable("google_discovery_source", { · 13 unmodified lines
185 scope_id: text('scope_id').notNull(),279 scope_id: text('scope_id').notNull(),
186 name: text('name').notNull(),280 name: text('name').notNull(),
187 config: text('config', { mode: "json" }).notNull(),281 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" }),
188 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),287 created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
189 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()288 updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
190}, (table) => [289}, (table) => [
191 primaryKey({ columns: [table.scope_id, table.id] }),290 primaryKey({ columns: [table.scope_id, table.id] }),
192 index("google_discovery_source_scope_id_idx").on(table.scope_id),291 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
297export 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
313export 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),
193]);327]);
194 328
195export const google_discovery_binding = sqliteTable("google_discovery_binding", {329export const google_discovery_binding = sqliteTable("google_discovery_binding", {
@@ -209,12 +343,44 @@ export const graphql_source = sqliteTable("graphql_source", { · 13 unmodified lines
209 scope_id: text('scope_id').notNull(),343 scope_id: text('scope_id').notNull(),
210 name: text('name').notNull(),344 name: text('name').notNull(),
211 endpoint: text('endpoint').notNull(),345 endpoint: text('endpoint').notNull(),
212 headers: text('headers', { mode: "json" }),346 auth_kind: text({ enum: ['none', 'oauth2'] }).default("none").notNull(),
213 query_params: text('query_params', { mode: "json" }),347 auth_connection_id: text('auth_connection_id')
214 auth: text('auth', { mode: "json" })
215}, (table) => [348}, (table) => [
216 primaryKey({ columns: [table.scope_id, table.id] }),349 primaryKey({ columns: [table.scope_id, table.id] }),
217 index("graphql_source_scope_id_idx").on(table.scope_id),350 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
354export 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
370export 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),
218]);384]);
219 385
220export const graphql_operation = sqliteTable("graphql_operation", {386export const graphql_operation = sqliteTable("graphql_operation", {
@@ -228,11 +394,3 @@ export const graphql_operation = sqliteTable("graphql_operation", { · 7 unmodified lines
228 index("graphql_operation_source_id_idx").on(table.source_id),394 index("graphql_operation_source_id_idx").on(table.source_id),
229]);395]);
230 396
231export 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
86 refresh_token_secret_id: "refresh-token",86 refresh_token_secret_id: "refresh-token",
87 });87 });
88 88
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.
89 const source = db92 const source = db
90 .prepare("SELECT config FROM mcp_source WHERE scope_id = ? AND id = ?")93 .prepare(
91 .get("scope-1", "remote-mcp") as { readonly config: string };94 "SELECT config, auth_kind, auth_connection_id FROM mcp_source WHERE scope_id = ? AND id = ?",
92 expect(JSON.parse(source.config).auth).toEqual({95 )
93 kind: "oauth2",96 .get("scope-1", "remote-mcp") as {
94 connectionId: "mcp-oauth2-remote-mcp",97 readonly config: string;
95 });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");
96 104
97 const ownedSecrets = db105 const ownedSecrets = db
98 .prepare(106 .prepare(
@@ -112,4 +120,86 @@ describe("migrateLegacyConnections", () => { · 13 unmodified lines
112 120
113 db.close();121 db.close();
114 });122 });
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 });
115});205});
apps/local/src/server/migrate-connections.ts+70−17
@@ -250,16 +250,32 @@ type OpenApiRow = { · 249 unmodified lines
250 250
251const migrateOpenApi = async (sqlite: Database): Promise<void> => {251const migrateOpenApi = async (sqlite: Database): Promise<void> => {
252 if (!tableExists(sqlite, "openapi_source")) return;252 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";
253 const rows = sqlite267 const rows = sqlite
254 .prepare(268 .prepare(`SELECT ${selectCols} FROM openapi_source`)
255 "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source",
256 )
257 .all() as ReadonlyArray<OpenApiRow>;269 .all() as ReadonlyArray<OpenApiRow>;
258 if (rows.length === 0) return;270 if (rows.length === 0) return;
259 271
260 const updateSource = sqlite.prepare(272 const updateSource = hasInvocationConfig
261 "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",273 ? sqlite.prepare(
262 );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 );
263 279
264 for (const row of rows) {280 for (const row of rows) {
265 let invocation: Record<string, unknown> = {};281 let invocation: Record<string, unknown> = {};
@@ -340,13 +356,21 @@ const migrateOpenApi = async (sqlite: Database): Promise<void> => { · 74 unmodified lines
340 });356 });
341 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);357 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
342 if (err) throw new Error(err);358 if (err) throw new Error(err);
343 const nextInvocation = { ...invocation, oauth2: oauth2Pointer };359 if (hasInvocationConfig) {
344 updateSource.run(360 const nextInvocation = { ...invocation, oauth2: oauth2Pointer };
345 JSON.stringify(oauth2Pointer),361 updateSource.run(
346 JSON.stringify(nextInvocation),362 JSON.stringify(oauth2Pointer),
347 row.scope_id,363 JSON.stringify(nextInvocation),
348 row.id,364 row.scope_id,
349 );365 row.id,
366 );
367 } else {
368 updateSource.run(
369 JSON.stringify(oauth2Pointer),
370 row.scope_id,
371 row.id,
372 );
373 }
350 });374 });
351 try {375 try {
352 txn();376 txn();
@@ -407,9 +431,23 @@ const migrateMcp = (sqlite: Database): void => { · 54 unmodified lines
407 .all() as ReadonlyArray<McpRow>;431 .all() as ReadonlyArray<McpRow>;
408 if (rows.length === 0) return;432 if (rows.length === 0) return;
409 433
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(
411 "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?",444 "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?",
412 );445 );
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;
413 451
414 for (const row of rows) {452 for (const row of rows) {
415 let config: Record<string, unknown> = {};453 let config: Record<string, unknown> = {};
@@ -447,8 +485,14 @@ const migrateMcp = (sqlite: Database): void => { · 31 unmodified lines
447 resourceMetadataUrl: legacy.resourceMetadataUrl,485 resourceMetadataUrl: legacy.resourceMetadataUrl,
448 resourceMetadata: null,486 resourceMetadata: null,
449 };487 };
450 const authPointer = { kind: "oauth2" as const, connectionId };488 // Strip auth from config — post-0009 the canonical home is the
451 const nextConfig = { ...config, auth: authPointer };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 } };
452 496
453 const secretIds = [legacy.accessTokenSecretId];497 const secretIds = [legacy.accessTokenSecretId];
454 const secretNames = [`Connection ${connectionId} access token`];498 const secretNames = [`Connection ${connectionId} access token`];
@@ -471,7 +515,16 @@ const migrateMcp = (sqlite: Database): void => { · 16 unmodified lines
471 });515 });
472 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);516 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
473 if (err) throw new Error(err);517 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 }
475 });528 });
476 try {529 try {
477 txn();530 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
7import { describe, expect, it } from "@effect/vitest";
8import { Database } from "bun:sqlite";
9import { mkdtempSync, rmSync } from "node:fs";
10import { join } from "node:path";
11import { tmpdir } from "node:os";
12import { drizzle } from "drizzle-orm/bun-sqlite";
13import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14
15import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16
17const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18
19describe("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
7import { describe, expect, it } from "@effect/vitest";
8import { Database } from "bun:sqlite";
9import { mkdtempSync, rmSync } from "node:fs";
10import { join } from "node:path";
11import { tmpdir } from "node:os";
12import { drizzle } from "drizzle-orm/bun-sqlite";
13import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14
15import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16
17const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18
19describe("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
7import { describe, expect, it } from "@effect/vitest";
8import { Database } from "bun:sqlite";
9import { mkdtempSync, rmSync } from "node:fs";
10import { join } from "node:path";
11import { tmpdir } from "node:os";
12import { drizzle } from "drizzle-orm/bun-sqlite";
13import { migrate } from "drizzle-orm/bun-sqlite/migrator";
14
15import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
16
17const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
18
19describe("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
8import { describe, expect, it } from "@effect/vitest";
9import { Database } from "bun:sqlite";
10import { mkdtempSync, rmSync } from "node:fs";
11import { join } from "node:path";
12import { tmpdir } from "node:os";
13import { drizzle } from "drizzle-orm/bun-sqlite";
14import { migrate } from "drizzle-orm/bun-sqlite/migrator";
15
16import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema";
17
18const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
19
20describe("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
1939 1939
1940 "@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=="],1940 "@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=="],
1941 1941
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=="],
1943 1943
1944 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],1944 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
1945 1945
packages/core/api/src/connections/api.ts+16−1
@@ -3,7 +3,9 @@ import { Schema } from "effect"; · 2 unmodified lines
3 3
4import {4import {
5 ConnectionId,5 ConnectionId,
6 ConnectionInUseError,
6 ScopeId,7 ScopeId,
8 Usage,
7} from "@executor-js/sdk";9} from "@executor-js/sdk";
8 10
9import { InternalError } from "../observability";11import { InternalError } from "../observability";
@@ -34,6 +36,8 @@ const ConnectionRefResponse = Schema.Struct({ · 24 unmodified lines
34// Group36// Group
35// ---------------------------------------------------------------------------37// ---------------------------------------------------------------------------
36 38
39const ConnectionInUse = ConnectionInUseError.annotate({ httpApiStatus: 409 });
40
37export const ConnectionsApi = HttpApiGroup.make("connections")41export const ConnectionsApi = HttpApiGroup.make("connections")
38 .add(42 .add(
39 HttpApiEndpoint.get("list", "/scopes/:scopeId/connections", {43 HttpApiEndpoint.get("list", "/scopes/:scopeId/connections", {
@@ -46,6 +50,17 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 6 unmodified lines
46 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/connections/:connectionId", {50 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/connections/:connectionId", {
47 params: ConnectionParams,51 params: ConnectionParams,
48 success: Schema.Struct({ removed: Schema.Boolean }),52 success: Schema.Struct({ removed: Schema.Boolean }),
49 error: InternalError,53 error: [InternalError, ConnectionInUse],
50 }),54 }),
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 ),
51 );66 );
packages/core/api/src/handlers/connections.ts+8−0
@@ -40,5 +40,13 @@ export const ConnectionsHandlers = HttpApiBuilder.group( · 39 unmodified lines
40 return { removed: true };40 return { removed: true };
41 }),41 }),
42 ),42 ),
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 ),
43 ),51 ),
44);52);
packages/core/api/src/handlers/secrets.ts+6−0
@@ -51,5 +51,11 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 50 unmodified lines
51 yield* executor.secrets.remove(path.secretId);51 yield* executor.secrets.remove(path.secretId);
52 return { removed: true };52 return { removed: true };
53 })),53 })),
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 })),
54 ),60 ),
55);61);
packages/core/api/src/secrets/api.ts+11−1
@@ -3,9 +3,11 @@ import { Schema } from "effect"; · 2 unmodified lines
3import {3import {
4 ScopeId,4 ScopeId,
5 SecretId,5 SecretId,
6 SecretInUseError,
6 SecretNotFoundError,7 SecretNotFoundError,
7 SecretOwnedByConnectionError,8 SecretOwnedByConnectionError,
8 SecretResolutionError,9 SecretResolutionError,
10 Usage,
9} from "@executor-js/sdk";11} from "@executor-js/sdk";
10 12
11import { InternalError } from "../observability";13import { InternalError } from "../observability";
@@ -52,6 +54,7 @@ const SecretResolution = SecretResolutionError.annotate( · 40 unmodified lines
52const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate(54const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate(
53 { httpApiStatus: 409 },55 { httpApiStatus: 409 },
54);56);
57const SecretInUse = SecretInUseError.annotate({ httpApiStatus: 409 });
55 58
56// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
57// Group60// Group
@@ -84,6 +87,13 @@ export const SecretsApi = HttpApiGroup.make("secrets") · 26 unmodified lines
84 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", {87 HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", {
85 params: SecretParams,88 params: SecretParams,
86 success: Schema.Struct({ removed: Schema.Boolean }),89 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,
88 }),98 }),
89 );99 );
packages/core/execution/src/promise.ts+2−0
@@ -101,6 +101,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ · 100 unmodified lines
101 set: (input) => fromPromise(() => pe.secrets.set(input)),101 set: (input) => fromPromise(() => pe.secrets.set(input)),
102 remove: (id) => fromPromise(() => pe.secrets.remove(id)),102 remove: (id) => fromPromise(() => pe.secrets.remove(id)),
103 list: () => fromPromise(() => pe.secrets.list()),103 list: () => fromPromise(() => pe.secrets.list()),
104 usages: (id) => fromPromise(() => pe.secrets.usages(id)),
104 providers: () => fromPromise(() => pe.secrets.providers()),105 providers: () => fromPromise(() => pe.secrets.providers()),
105 },106 },
106 connections: {107 connections: {
@@ -111,6 +112,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ · 4 unmodified lines
111 setIdentityLabel: (id, label) => fromPromise(() => pe.connections.setIdentityLabel(id, label)),112 setIdentityLabel: (id, label) => fromPromise(() => pe.connections.setIdentityLabel(id, label)),
112 accessToken: (id) => fromPromise(() => pe.connections.accessToken(id)),113 accessToken: (id) => fromPromise(() => pe.connections.accessToken(id)),
113 remove: (id) => fromPromise(() => pe.connections.remove(id)),114 remove: (id) => fromPromise(() => pe.connections.remove(id)),
115 usages: (id) => fromPromise(() => pe.connections.usages(id)),
114 providers: () => fromPromise(() => pe.connections.providers()),116 providers: () => fromPromise(() => pe.connections.providers()),
115 },117 },
116 oauth: {118 oauth: {
packages/core/sdk/src/errors.ts+28−1
@@ -98,6 +98,21 @@ export class SecretOwnedByConnectionError extends Schema.TaggedErrorClass<Secret · 97 unmodified lines
98 },98 },
99) {}99) {}
100 100
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)`. */
108export class SecretInUseError extends Schema.TaggedErrorClass<SecretInUseError>()(
109 "SecretInUseError",
110 {
111 secretId: SecretId,
112 usageCount: Schema.Number,
113 },
114) {}
115
101// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
102// Connections117// Connections
103// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
@@ -141,6 +156,16 @@ export class ConnectionReauthRequiredError extends Schema.TaggedErrorClass<Conne · 37 unmodified lines
141 },156 },
142) {}157) {}
143 158
159/** Raised when `connections.remove(id)` is called on a connection that's
160 * still referenced by sources / bindings. Mirrors `SecretInUseError`. */
161export class ConnectionInUseError extends Schema.TaggedErrorClass<ConnectionInUseError>()(
162 "ConnectionInUseError",
163 {
164 connectionId: ConnectionId,
165 usageCount: Schema.Number,
166 },
167) {}
168
144// ---------------------------------------------------------------------------169// ---------------------------------------------------------------------------
145// Union type for convenience in signatures.170// Union type for convenience in signatures.
146// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
@@ -156,7 +181,9 @@ export type ExecutorError = · 9 unmodified lines
156 | SecretNotFoundError181 | SecretNotFoundError
157 | SecretResolutionError182 | SecretResolutionError
158 | SecretOwnedByConnectionError183 | SecretOwnedByConnectionError
184 | SecretInUseError
159 | ConnectionNotFoundError185 | ConnectionNotFoundError
160 | ConnectionProviderNotRegisteredError186 | ConnectionProviderNotRegisteredError
161 | ConnectionRefreshNotSupportedError187 | ConnectionRefreshNotSupportedError
162 | ConnectionReauthRequiredError;188 | ConnectionReauthRequiredError
189 | ConnectionInUseError;
packages/core/sdk/src/executor.ts+191−6
@@ -44,12 +44,14 @@ import { · 43 unmodified lines
44 type ElicitationRequest,44 type ElicitationRequest,
45} from "./elicitation";45} from "./elicitation";
46import {46import {
47 ConnectionInUseError,
47 ConnectionNotFoundError,48 ConnectionNotFoundError,
48 ConnectionProviderNotRegisteredError,49 ConnectionProviderNotRegisteredError,
49 ConnectionReauthRequiredError,50 ConnectionReauthRequiredError,
50 ConnectionRefreshNotSupportedError,51 ConnectionRefreshNotSupportedError,
51 NoHandlerError,52 NoHandlerError,
52 PluginNotLoadedError,53 PluginNotLoadedError,
54 SecretInUseError,
53 SecretOwnedByConnectionError,55 SecretOwnedByConnectionError,
54 SourceRemovalNotAllowedError,56 SourceRemovalNotAllowedError,
55 ToolBlockedError,57 ToolBlockedError,
@@ -84,6 +86,7 @@ import { · 28 unmodified lines
84 SetSecretInput,86 SetSecretInput,
85 type SecretProvider,87 type SecretProvider,
86} from "./secrets";88} from "./secrets";
89import { Usage } from "./usages";
87import {90import {
88 ToolSchema,91 ToolSchema,
89 type Source,92 type Source,
@@ -211,11 +214,22 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 121 unmodified lines
211 ) => Effect.Effect<SecretRef, StorageFailure>;214 ) => Effect.Effect<SecretRef, StorageFailure>;
212 /** Delete a bare (non-connection-owned) secret. Connection-owned215 /** Delete a bare (non-connection-owned) secret. Connection-owned
213 * secrets are rejected with `SecretOwnedByConnectionError` — use216 * 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. */
215 readonly remove: (220 readonly remove: (
216 id: string,221 id: string,
217 ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;222 ) => Effect.Effect<
223 void,
224 SecretOwnedByConnectionError | SecretInUseError | StorageFailure
225 >;
218 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;226 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>;
219 readonly providers: () => Effect.Effect<readonly string[]>;233 readonly providers: () => Effect.Effect<readonly string[]>;
220 };234 };
221 235
@@ -251,7 +265,16 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 29 unmodified lines
251 | ConnectionRefreshError265 | ConnectionRefreshError
252 | StorageFailure266 | StorageFailure
253 >;267 >;
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>;
255 readonly providers: () => Effect.Effect<readonly string[]>;278 readonly providers: () => Effect.Effect<readonly string[]>;
256 };279 };
257 280
@@ -945,9 +968,129 @@ export const createExecutor = < · 687 unmodified lines
945 });968 });
946 });969 });
947 970
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
948 const secretsRemove = (1088 const secretsRemove = (
949 id: string,1089 id: string,
950 ): Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure> =>1090 ): Effect.Effect<
1091 void,
1092 SecretOwnedByConnectionError | SecretInUseError | StorageFailure
1093 > =>
951 Effect.gen(function* () {1094 Effect.gen(function* () {
952 // Remove is shadowing-aware: drop only the innermost-scope row.1095 // Remove is shadowing-aware: drop only the innermost-scope row.
953 // Removing a user-scope override on a secret that also has an1096 // Removing a user-scope override on a secret that also has an
@@ -973,6 +1116,27 @@ export const createExecutor = < · 19 unmodified lines
973 }),1116 }),
974 );1117 );
975 }1118 }
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 }
976 const targetScope = (target?.scope_id as string | undefined) ??1140 const targetScope = (target?.scope_id as string | undefined) ??
977 scopeIds[0]!;1141 scopeIds[0]!;
978 1142
@@ -1488,10 +1652,29 @@ export const createExecutor = < · 509 unmodified lines
1488 1652
1489 const connectionsRemove = (1653 const connectionsRemove = (
1490 id: string,1654 id: string,
1491 ): Effect.Effect<void, StorageFailure> =>1655 ): Effect.Effect<void, ConnectionInUseError | StorageFailure> =>
1492 Effect.gen(function* () {1656 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[]);
1494 if (!row) return;1662 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 }
1495 const scope = row.scope_id as string;1678 const scope = row.scope_id as string;
1496 yield* adapter.transaction(() =>1679 yield* adapter.transaction(() =>
1497 Effect.gen(function* () {1680 Effect.gen(function* () {
@@ -2800,6 +2983,7 @@ export const createExecutor = < · 1302 unmodified lines
2800 set: secretsSet,2983 set: secretsSet,
2801 remove: secretsRemove,2984 remove: secretsRemove,
2802 list: secretsList,2985 list: secretsList,
2986 usages: secretsUsages,
2803 providers: () =>2987 providers: () =>
2804 Effect.sync(2988 Effect.sync(
2805 () => Array.from(secretProviders.keys()) as readonly string[],2989 () => Array.from(secretProviders.keys()) as readonly string[],
@@ -2813,6 +2997,7 @@ export const createExecutor = < · 7 unmodified lines
2813 setIdentityLabel: connectionsSetIdentityLabel,2997 setIdentityLabel: connectionsSetIdentityLabel,
2814 accessToken: connectionsAccessToken,2998 accessToken: connectionsAccessToken,
2815 remove: connectionsRemove,2999 remove: connectionsRemove,
3000 usages: connectionsUsages,
2816 providers: () =>3001 providers: () =>
2817 Effect.sync(3002 Effect.sync(
2818 () =>3003 () =>
packages/core/sdk/src/index.ts+9−0
@@ -55,10 +55,12 @@ export { · 54 unmodified lines
55 SecretNotFoundError,55 SecretNotFoundError,
56 SecretResolutionError,56 SecretResolutionError,
57 SecretOwnedByConnectionError,57 SecretOwnedByConnectionError,
58 SecretInUseError,
58 ConnectionNotFoundError,59 ConnectionNotFoundError,
59 ConnectionProviderNotRegisteredError,60 ConnectionProviderNotRegisteredError,
60 ConnectionRefreshNotSupportedError,61 ConnectionRefreshNotSupportedError,
61 ConnectionReauthRequiredError,62 ConnectionReauthRequiredError,
63 ConnectionInUseError,
62 type ExecutorError,64 type ExecutorError,
63} from "./errors";65} from "./errors";
64 66
@@ -118,6 +120,13 @@ export { · 53 unmodified lines
118 type ResolveSecretBackedMapOptions,120 type ResolveSecretBackedMapOptions,
119} from "./secret-backed-value";121} from "./secret-backed-value";
120 122
123// Usage tracking — secret/connection refs across plugins
124export {
125 Usage,
126 type UsagesForSecretInput,
127 type UsagesForConnectionInput,
128} from "./usages";
129
121// Connections130// Connections
122export {131export {
123 ConnectionRef,132 ConnectionRef,
packages/core/sdk/src/plugin.ts+36−3
@@ -29,15 +29,18 @@ import type { · 28 unmodified lines
29 ElicitationResponse,29 ElicitationResponse,
30} from "./elicitation";30} from "./elicitation";
31import type {31import type {
32 ConnectionInUseError,
32 ConnectionNotFoundError,33 ConnectionNotFoundError,
33 ConnectionProviderNotRegisteredError,34 ConnectionProviderNotRegisteredError,
34 ConnectionReauthRequiredError,35 ConnectionReauthRequiredError,
35 ConnectionRefreshNotSupportedError,36 ConnectionRefreshNotSupportedError,
37 SecretInUseError,
36 SecretOwnedByConnectionError,38 SecretOwnedByConnectionError,
37} from "./errors";39} from "./errors";
38import type { OAuthService } from "./oauth";40import type { OAuthService } from "./oauth";
39import type { Scope } from "./scope";41import type { Scope } from "./scope";
40import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";42import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets";
43import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./usages";
41 44
42// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
43// StorageDeps — backing passed to a plugin's `storage` factory. The only46// StorageDeps — backing passed to a plugin's `storage` factory. The only
@@ -160,10 +163,15 @@ export interface PluginCtx<TStore = unknown> { · 116 unmodified lines
160 /** Delete a secret from its pinned provider and the core table.163 /** Delete a secret from its pinned provider and the core table.
161 * Rejects with `SecretOwnedByConnectionError` if the row is owned164 * Rejects with `SecretOwnedByConnectionError` if the row is owned
162 * by a connection — callers must go through `connections.remove`165 * 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. */
164 readonly remove: (169 readonly remove: (
165 id: string,170 id: string,
166 ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>;171 ) => Effect.Effect<
172 void,
173 SecretOwnedByConnectionError | SecretInUseError | StorageFailure
174 >;
167 };175 };
168 176
169 /** Connections — product-level sign-in state. Owns backing secret177 /** Connections — product-level sign-in state. Owns backing secret
@@ -206,7 +214,12 @@ export interface PluginCtx<TStore = unknown> { · 36 unmodified lines
206 | ConnectionRefreshError214 | ConnectionRefreshError
207 | StorageFailure215 | StorageFailure
208 >;216 >;
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>;
210 };223 };
211 224
212 /** Shared OAuth service. Plugins use this to probe/start/complete OAuth225 /** Shared OAuth service. Plugins use this to probe/start/complete OAuth
@@ -460,6 +473,26 @@ export interface PluginSpec< · 247 unmodified lines
460 readonly toolRows: readonly ToolRow[];473 readonly toolRows: readonly ToolRow[];
461 }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;474 }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
462 475
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
463 /** Called when `executor.sources.remove(id)` targets a source owned496 /** Called when `executor.sources.remove(id)` targets a source owned
464 * by this plugin. Plugin-side cleanup only; the executor deletes497 * by this plugin. Plugin-side cleanup only; the executor deletes
465 * the core source/tool rows after this callback returns, inside498 * the core source/tool rows after this callback returns, inside
packages/core/sdk/src/usages.ts+42−0
@@ -0,0 +1,42 @@
1import { Schema } from "effect";
2
3import { 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
27export 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
36export interface UsagesForSecretInput {
37 readonly secretId: SecretId;
38}
39
40export 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
21 type StorageFailure,21 type StorageFailure,
22} from "@executor-js/sdk/core";22} from "@executor-js/sdk/core";
23 23
24import { GoogleDiscoveryMethodBinding, GoogleDiscoveryStoredSourceData } from "./types";24import {
25 GoogleDiscoveryMethodBinding,
26 GoogleDiscoveryStoredSourceData,
27 type GoogleDiscoveryAuth,
28 type GoogleDiscoveryCredentialValue,
29 type GoogleDiscoveryFetchCredentials,
30} from "./types";
25 31
26// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
27// OAuth session TTL33// OAuth session TTL
@@ -39,11 +45,58 @@ export const googleDiscoverySchema = defineSchema({ · 11 unmodified lines
39 id: { type: "string", required: true },45 id: { type: "string", required: true },
40 scope_id: { type: "string", required: true, index: true },46 scope_id: { type: "string", required: true, index: true },
41 name: { type: "string", required: true },47 name: { type: "string", required: true },
48 // Plugin-private structural config minus auth/credentials —
49 // discoveryUrl, service, version, rootUrl, servicePath. These
50 // never carry refs.
42 config: { type: "json", required: true },51 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 },
43 created_at: { type: "date", required: true },72 created_at: { type: "date", required: true },
44 updated_at: { type: "date", required: true },73 updated_at: { type: "date", required: true },
45 },74 },
46 },75 },
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 },
47 google_discovery_binding: {100 google_discovery_binding: {
48 fields: {101 fields: {
49 id: { type: "string", required: true },102 id: { type: "string", required: true },
@@ -93,6 +146,112 @@ const decodeJson = (value: unknown): unknown => { · 43 unmodified lines
93 }146 }
94};147};
95 148
149// --- auth column packing/unpacking ------------------------------------------
150
151interface 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
161const 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
174const 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
195interface 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
207const 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
237const 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
96// ---------------------------------------------------------------------------255// ---------------------------------------------------------------------------
97// Store interface256// Store interface
98// ---------------------------------------------------------------------------257// ---------------------------------------------------------------------------
@@ -154,6 +313,52 @@ export interface GoogleDiscoveryStore { · 55 unmodified lines
154 sourceId: string,313 sourceId: string,
155 scope: string,314 scope: string,
156 ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>;315 ) => 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>;
157}362}
158 363
159// ---------------------------------------------------------------------------364// ---------------------------------------------------------------------------
@@ -245,6 +450,8 @@ export const makeGoogleDiscoveryStore = ( · 85 unmodified lines
245 putSource: (source) =>450 putSource: (source) =>
246 Effect.gen(function* () {451 Effect.gen(function* () {
247 const now = new Date();452 const now = new Date();
453 // Wipe the source row + every child row before recreating —
454 // matches putSource's "fully replace" semantic.
248 yield* db.delete({455 yield* db.delete({
249 model: "google_discovery_source",456 model: "google_discovery_source",
250 where: [457 where: [
@@ -252,18 +459,29 @@ export const makeGoogleDiscoveryStore = ( · 1 unmodified line
252 { field: "scope_id", value: source.scope },459 { field: "scope_id", value: source.scope },
253 ],460 ],
254 });461 });
462 yield* deleteSourceChildren(source.namespace, source.scope);
463
464 const encoded = stripExtractedFields(
465 encodeStoredSourceData(source.config) as Record<string, unknown>,
466 );
255 yield* db.create({467 yield* db.create({
256 model: "google_discovery_source",468 model: "google_discovery_source",
257 data: {469 data: {
258 id: source.namespace,470 id: source.namespace,
259 scope_id: source.scope,471 scope_id: source.scope,
260 name: source.name,472 name: source.name,
261 config: toJsonRecord(encodeStoredSourceData(source.config)),473 config: toJsonRecord(encoded),
262 created_at: now,474 created_at: now,
263 updated_at: now,475 updated_at: now,
476 ...authToColumns(source.config.auth),
264 },477 },
265 forceAllowId: true,478 forceAllowId: true,
266 });479 });
480 yield* writeCredentialRows(
481 source.namespace,
482 source.scope,
483 source.config.credentials,
484 );
267 }),485 }),
268 486
269 updateSourceMeta: (sourceId, scope, update) =>487 updateSourceMeta: (sourceId, scope, update) =>
@@ -276,12 +494,7 @@ export const makeGoogleDiscoveryStore = ( · 6 unmodified lines
276 ],494 ],
277 });495 });
278 if (!row) return;496 if (!row) return;
279 const config = decodeStoredSourceData(decodeJson(row.config));497 const auth = update.auth ?? columnsToAuth(row);
280 const nextConfig = new GoogleDiscoveryStoredSourceData({
281 ...config,
282 name: update.name ?? config.name,
283 auth: update.auth ?? config.auth,
284 });
285 yield* db.update({498 yield* db.update({
286 model: "google_discovery_source",499 model: "google_discovery_source",
287 where: [500 where: [
@@ -290,22 +503,23 @@ export const makeGoogleDiscoveryStore = ( · 2 unmodified lines
290 ],503 ],
291 update: {504 update: {
292 name: update.name ?? (row.name as string),505 name: update.name ?? (row.name as string),
293 config: toJsonRecord(encodeStoredSourceData(nextConfig)),
294 updated_at: new Date(),506 updated_at: new Date(),
507 ...authToColumns(auth),
295 },508 },
296 });509 });
297 }),510 }),
298 511
299 removeSource: (sourceId, scope) =>512 removeSource: (sourceId, scope) =>
300 db513 Effect.gen(function* () {
301 .delete({514 yield* deleteSourceChildren(sourceId, scope);
515 yield* db.delete({
302 model: "google_discovery_source",516 model: "google_discovery_source",
303 where: [517 where: [
304 { field: "id", value: sourceId },518 { field: "id", value: sourceId },
305 { field: "scope_id", value: scope },519 { field: "scope_id", value: scope },
306 ],520 ],
307 })521 });
308 .pipe(Effect.asVoid),522 }),
309 523
310 getSource: (sourceId, scope) =>524 getSource: (sourceId, scope) =>
311 Effect.gen(function* () {525 Effect.gen(function* () {
@@ -321,7 +535,7 @@ export const makeGoogleDiscoveryStore = ( · 9 unmodified lines
321 namespace: row.id as string,535 namespace: row.id as string,
322 scope: row.scope_id as string,536 scope: row.scope_id as string,
323 name: row.name as string,537 name: row.name as string,
324 config: decodeStoredSourceData(decodeJson(row.config)),538 config: yield* hydrateStoredSourceData(row, sourceId, scope),
325 };539 };
326 }),540 }),
327 541
@@ -335,7 +549,219 @@ export const makeGoogleDiscoveryStore = ( · 7 unmodified lines
335 ],549 ],
336 });550 });
337 if (!row) return null;551 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;
339 }),658 }),
340 };659 };
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.
760const 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;
341};767};
packages/plugins/google-discovery/src/sdk/plugin.test.ts+78−0
@@ -625,4 +625,82 @@ describe("Google Discovery plugin", () => { · 624 unmodified lines
625 }625 }
626 }),626 }),
627 );627 );
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 );
628});706});
packages/plugins/google-discovery/src/sdk/plugin.ts+67−0
@@ -1,7 +1,9 @@
1import { Effect, Option } from "effect";1import { Effect, Option } from "effect";
2 2
3import {3import {
4 ScopeId,
4 SourceDetectionResult,5 SourceDetectionResult,
6 Usage,
5 definePlugin,7 definePlugin,
6 resolveSecretBackedMap,8 resolveSecretBackedMap,
7 type PluginCtx,9 type PluginCtx,
@@ -435,6 +437,71 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 427 unmodified lines
435 yield* typedCtx.storage.removeSource(sourceId, scope);437 yield* typedCtx.storage.removeSource(sourceId, scope);
436 }),438 }),
437 439
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
438 detect: ({ url }) =>505 detect: ({ url }) =>
439 Effect.gen(function* () {506 Effect.gen(function* () {
440 const trimmed = url.trim();507 const trimmed = url.trim();
packages/plugins/graphql/src/sdk/plugin.test.ts+123−0
@@ -507,4 +507,127 @@ describe("graphqlPlugin", () => { · 506 unmodified lines
507 expect(orgView?.endpoint).toBe("http://org.example.com/graphql");507 expect(orgView?.endpoint).toBe("http://org.example.com/graphql");
508 }),508 }),
509 );509 );
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 );
510});633});
packages/plugins/graphql/src/sdk/plugin.ts+78−0
@@ -7,7 +7,9 @@ import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; · 6 unmodified lines
7 7
8import {8import {
9 definePlugin,9 definePlugin,
10 ScopeId,
10 SourceDetectionResult,11 SourceDetectionResult,
12 Usage,
11 type StorageFailure,13 type StorageFailure,
12 type ToolAnnotations,14 type ToolAnnotations,
13 type ToolRow,15 type ToolRow,
@@ -632,6 +634,82 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 618 unmodified lines
632 removeSource: ({ ctx, sourceId, scope }) =>634 removeSource: ({ ctx, sourceId, scope }) =>
633 ctx.storage.removeSource(sourceId, scope),635 ctx.storage.removeSource(sourceId, scope),
634 636
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
635 detect: ({ url }) =>713 detect: ({ url }) =>
636 Effect.gen(function* () {714 Effect.gen(function* () {
637 const trimmed = url.trim();715 const trimmed = url.trim();
packages/plugins/graphql/src/sdk/store.ts+364−58
@@ -14,9 +14,20 @@ import { · 13 unmodified lines
14} from "./types";14} from "./types";
15 15
16// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
17// Schema — two tables:17// Schema — four tables:
18// - graphql_source: endpoint + headers + display name per source18// - graphql_source: endpoint + auth + display name per source. Auth is
19// - graphql_operation: per-tool OperationBinding blob keyed by tool id19// 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).
20// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
21 32
22export const graphqlSchema = defineSchema({33export const graphqlSchema = defineSchema({
@@ -26,9 +37,46 @@ export const graphqlSchema = defineSchema({ · 3 unmodified lines
26 scope_id: { type: "string", required: true, index: true },37 scope_id: { type: "string", required: true, index: true },
27 name: { type: "string", required: true },38 name: { type: "string", required: true },
28 endpoint: { type: "string", required: true },39 endpoint: { type: "string", required: true },
29 headers: { type: "json", required: false },40 auth_kind: {
30 query_params: { type: "json", required: false },41 type: ["none", "oauth2"],
31 auth: { type: "json", required: false },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 },
32 },80 },
33 },81 },
34 graphql_operation: {82 graphql_operation: {
@@ -98,29 +146,69 @@ const encodeBinding = (binding: OperationBinding): BindingJson => ({ · 63 unmodified lines
98const toJsonRecord = (value: unknown): Record<string, unknown> =>146const toJsonRecord = (value: unknown): Record<string, unknown> =>
99 value as Record<string, unknown>;147 value as Record<string, unknown>;
100 148
101const decodeHeaders = (value: unknown): Record<string, HeaderValue> => {149// Header / query-param rows: collapse the flat columns back into a
102 if (value == null) return {};150// `SecretBackedValue` map keyed by header name. `kind` discriminates the
103 if (typeof value === "string")151// shape; `secret_prefix` is optional and only populated when present in
104 return JSON.parse(value) as Record<string, HeaderValue>;152// the original config.
105 return value as Record<string, HeaderValue>;153const 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;
106};169};
107 170
108const decodeQueryParams = (value: unknown): Record<string, QueryParamValue> => {171// Encode one entry of a SecretBackedValue map into a child row. Used by
109 if (value == null) return {};172// the writer for both `graphql_source_header` and
110 if (typeof value === "string")173// `graphql_source_query_param`. Returns a `Record<string, unknown>` so
111 return JSON.parse(value) as Record<string, QueryParamValue>;174// the result is structurally assignable to the typed adapter's
112 return value as Record<string, QueryParamValue>;175// `RowInput` shape (which has its own index signature).
176const 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 };
113};202};
114 203
115const decodeAuth = (value: unknown): GraphqlSourceAuth => {204const rowToAuth = (row: Record<string, unknown>): GraphqlSourceAuth => {
116 if (value == null) return { kind: "none" };205 if (
117 const parsed =206 row.auth_kind === "oauth2" &&
118 typeof value === "string"207 typeof row.auth_connection_id === "string"
119 ? (JSON.parse(value) as GraphqlSourceAuth)208 ) {
120 : (value as GraphqlSourceAuth);209 return { kind: "oauth2", connectionId: row.auth_connection_id };
121 return parsed?.kind === "oauth2" && typeof parsed.connectionId === "string"210 }
122 ? { kind: "oauth2", connectionId: parsed.connectionId }211 return { kind: "none" };
123 : { kind: "none" };
124};212};
125 213
126// ---------------------------------------------------------------------------214// ---------------------------------------------------------------------------
@@ -138,6 +226,15 @@ const decodeAuth = (value: unknown): GraphqlSourceAuth => { · 11 unmodified lines
138// `path.scopeId` for HTTP, `toolRow.scope_id` / `input.scope` for226// `path.scopeId` for HTTP, `toolRow.scope_id` / `input.scope` for
139// invokeTool/lifecycle) so every keyed mutation targets exactly one227// invokeTool/lifecycle) so every keyed mutation targets exactly one
140// row.228// 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. */
232export interface ChildUsageRow {
233 readonly source_id: string;
234 readonly scope_id: string;
235 readonly name: string;
236}
237
141export interface GraphqlStore {238export interface GraphqlStore {
142 readonly upsertSource: (239 readonly upsertSource: (
143 input: StoredGraphqlSource,240 input: StoredGraphqlSource,
@@ -180,6 +277,34 @@ export interface GraphqlStore { · 36 unmodified lines
180 namespace: string,277 namespace: string,
181 scope: string,278 scope: string,
182 ) => Effect.Effect<void, StorageFailure>;279 ) => 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>;
183}308}
184 309
185// ---------------------------------------------------------------------------310// ---------------------------------------------------------------------------
@@ -189,15 +314,46 @@ export interface GraphqlStore { · 3 unmodified lines
189export const makeDefaultGraphqlStore = ({314export const makeDefaultGraphqlStore = ({
190 adapter: db,315 adapter: db,
191}: StorageDeps<GraphqlSchema>): GraphqlStore => {316}: StorageDeps<GraphqlSchema>): GraphqlStore => {
192 const rowToSource = (row: Record<string, unknown>): StoredGraphqlSource => ({317 const loadHeaders = (sourceId: string, scope: string) =>
193 namespace: row.id as string,318 db
194 scope: row.scope_id as string,319 .findMany({
195 name: row.name as string,320 model: "graphql_source_header",
196 endpoint: row.endpoint as string,321 where: [
197 headers: decodeHeaders(row.headers),322 { field: "source_id", value: sourceId },
198 queryParams: decodeQueryParams(row.query_params),323 { field: "scope_id", value: scope },
199 auth: decodeAuth(row.auth),324 ],
200 });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 });
201 357
202 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({358 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({
203 toolId: row.id as string,359 toolId: row.id as string,
@@ -205,6 +361,34 @@ export const makeDefaultGraphqlStore = ({ · 1 unmodified line
205 binding: decodeBinding(row.binding),361 binding: decodeBinding(row.binding),
206 });362 });
207 363
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
208 const deleteSource = (namespace: string, scope: string) =>392 const deleteSource = (namespace: string, scope: string) =>
209 Effect.gen(function* () {393 Effect.gen(function* () {
210 yield* db.deleteMany({394 yield* db.deleteMany({
@@ -214,6 +398,20 @@ export const makeDefaultGraphqlStore = ({ · 3 unmodified lines
214 { field: "scope_id", value: scope },398 { field: "scope_id", value: scope },
215 ],399 ],
216 });400 });
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 });
217 yield* db.delete({415 yield* db.delete({
218 model: "graphql_source",416 model: "graphql_source",
219 where: [417 where: [
@@ -234,12 +432,24 @@ export const makeDefaultGraphqlStore = ({ · 14 unmodified lines
234 scope_id: input.scope,432 scope_id: input.scope,
235 name: input.name,433 name: input.name,
236 endpoint: input.endpoint,434 endpoint: input.endpoint,
237 headers: input.headers,435 auth_kind: input.auth.kind,
238 query_params: input.queryParams,436 auth_connection_id:
239 auth: toJsonRecord(input.auth),437 input.auth.kind === "oauth2" ? input.auth.connectionId : undefined,
240 },438 },
241 forceAllowId: true,439 forceAllowId: true,
242 });440 });
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 );
243 if (operations.length > 0) {453 if (operations.length > 0) {
244 yield* db.createMany({454 yield* db.createMany({
245 model: "graphql_operation",455 model: "graphql_operation",
@@ -267,41 +477,59 @@ export const makeDefaultGraphqlStore = ({ · 21 unmodified lines
267 const update: Record<string, unknown> = {};477 const update: Record<string, unknown> = {};
268 if (patch.name !== undefined) update.name = patch.name;478 if (patch.name !== undefined) update.name = patch.name;
269 if (patch.endpoint !== undefined) update.endpoint = patch.endpoint;479 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 }
270 if (patch.headers !== undefined) {495 if (patch.headers !== undefined) {
271 update.headers = patch.headers;496 yield* replaceChildren(
497 "graphql_source_header",
498 namespace,
499 scope,
500 patch.headers,
501 );
272 }502 }
273 if (patch.queryParams !== undefined) {503 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 );
275 }510 }
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 });
288 }),511 }),
289 512
290 getSource: (namespace, scope) =>513 getSource: (namespace, scope) =>
291 db514 Effect.gen(function* () {
292 .findOne({515 const row = yield* db.findOne({
293 model: "graphql_source",516 model: "graphql_source",
294 where: [517 where: [
295 { field: "id", value: namespace },518 { field: "id", value: namespace },
296 { field: "scope_id", value: scope },519 { field: "scope_id", value: scope },
297 ],520 ],
298 })521 });
299 .pipe(Effect.map((row) => (row ? rowToSource(row) : null))),522 if (!row) return null;
523 return yield* rowToSourceWithChildren(row);
524 }),
300 525
301 listSources: () =>526 listSources: () =>
302 db527 Effect.gen(function* () {
303 .findMany({ model: "graphql_source" })528 const rows = yield* db.findMany({ model: "graphql_source" });
304 .pipe(Effect.map((rows) => rows.map(rowToSource))),529 return yield* Effect.forEach(rows, rowToSourceWithChildren, {
530 concurrency: "unbounded",
531 });
532 }),
305 533
306 getOperationByToolId: (toolId, scope) =>534 getOperationByToolId: (toolId, scope) =>
307 db535 db
@@ -326,5 +554,83 @@ export const makeDefaultGraphqlStore = ({ · 18 unmodified lines
326 .pipe(Effect.map((rows) => rows.map(rowToOperation))),554 .pipe(Effect.map((rows) => rows.map(rowToOperation))),
327 555
328 removeSource: (namespace, scope) => deleteSource(namespace, scope),556 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 }),
329 };635 };
330};636};
packages/plugins/mcp/src/sdk/binding-store.ts+481−7
@@ -1,7 +1,19 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// MCP plugin storage — two tables (mcp_source, mcp_binding). OAuth2// MCP plugin storage — four tables:
3// session storage lives at the core level in `oauth2_session` and is3// - mcp_source: per-source structural data (transport, endpoint,
4// owned by `ctx.oauth`.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`.
5// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
6 18
7import { Effect, Schema } from "effect";19import { Effect, Schema } from "effect";
@@ -12,7 +24,12 @@ import { · 4 unmodified lines
12 type StorageFailure,24 type StorageFailure,
13} from "@executor-js/sdk/core";25} from "@executor-js/sdk/core";
14 26
15import { McpToolBinding, McpStoredSourceData } from "./types";27import {
28 McpToolBinding,
29 McpStoredSourceData,
30 type McpConnectionAuth,
31 type SecretBackedValue,
32} from "./types";
16 33
17// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
18// Schema35// Schema
@@ -24,10 +41,61 @@ export const mcpSchema = defineSchema({ · 5 unmodified lines
24 id: { type: "string", required: true },41 id: { type: "string", required: true },
25 scope_id: { type: "string", required: true, index: true },42 scope_id: { type: "string", required: true, index: true },
26 name: { type: "string", required: true },43 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.
27 config: { type: "json", required: true },48 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 },
28 created_at: { type: "date", required: true },72 created_at: { type: "date", required: true },
29 },73 },
30 },74 },
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 },
31 mcp_binding: {99 mcp_binding: {
32 fields: {100 fields: {
33 id: { type: "string", required: true },101 id: { type: "string", required: true },
@@ -61,6 +129,126 @@ const coerceJson = (value: unknown): unknown => { · 27 unmodified lines
61 }129 }
62};130};
63 131
132// --- auth column packing/unpacking ------------------------------------------
133
134interface 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
144const 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
164const 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
192interface 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
204const 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
234const 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
64// ---------------------------------------------------------------------------252// ---------------------------------------------------------------------------
65// Stored source (decoded) — what callers see253// Stored source (decoded) — what callers see
66// ---------------------------------------------------------------------------254// ---------------------------------------------------------------------------
@@ -136,6 +324,55 @@ export interface McpBindingStore { · 69 unmodified lines
136 namespace: string,324 namespace: string,
137 scope: string,325 scope: string,
138 ) => Effect.Effect<void, StorageFailure>;326 ) => 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>;
139}376}
140 377
141// ---------------------------------------------------------------------------378// ---------------------------------------------------------------------------
@@ -217,7 +454,7 @@ export const makeMcpStore = ({ · 75 unmodified lines
217 namespace: row.id,454 namespace: row.id,
218 scope: row.scope_id,455 scope: row.scope_id,
219 name: row.name,456 name: row.name,
220 config: decodeSourceData(coerceJson(row.config)),457 config: yield* hydrateSourceData(row, namespace, scope),
221 };458 };
222 }),459 }),
223 460
@@ -231,12 +468,14 @@ export const makeMcpStore = ({ · 7 unmodified lines
231 ],468 ],
232 });469 });
233 if (!row) return null;470 if (!row) return null;
234 return decodeSourceData(coerceJson(row.config));471 return yield* hydrateSourceData(row, namespace, scope);
235 }),472 }),
236 473
237 putSource: (source) =>474 putSource: (source) =>
238 Effect.gen(function* () {475 Effect.gen(function* () {
239 const now = new Date();476 const now = new Date();
477 // Drop the source row and its child rows; recreate. Two-step
478 // matches the existing put-overwrites-existing semantic.
240 yield* db.delete({479 yield* db.delete({
241 model: "mcp_source",480 model: "mcp_source",
242 where: [481 where: [
@@ -244,17 +483,67 @@ export const makeMcpStore = ({ · 1 unmodified line
244 { field: "scope_id", value: source.scope },483 { field: "scope_id", value: source.scope },
245 ],484 ],
246 });485 });
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
247 yield* db.create({510 yield* db.create({
248 model: "mcp_source",511 model: "mcp_source",
249 data: {512 data: {
250 id: source.namespace,513 id: source.namespace,
251 scope_id: source.scope,514 scope_id: source.scope,
252 name: source.name,515 name: source.name,
253 config: encodeSourceData(source.config),516 config: encodedConfig,
254 created_at: now,517 created_at: now,
518 ...authCols,
255 },519 },
256 forceAllowId: true,520 forceAllowId: true,
257 });521 });
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 }
258 }),547 }),
259 548
260 removeSource: (namespace, scope) =>549 removeSource: (namespace, scope) =>
@@ -266,6 +555,7 @@ export const makeMcpStore = ({ · 5 unmodified lines
266 { field: "scope_id", value: scope },555 { field: "scope_id", value: scope },
267 ],556 ],
268 });557 });
558 yield* deleteSourceChildren(namespace, scope);
269 yield* db.delete({559 yield* db.delete({
270 model: "mcp_source",560 model: "mcp_source",
271 where: [561 where: [
@@ -274,5 +564,189 @@ export const makeMcpStore = ({ · 2 unmodified lines
274 ],564 ],
275 });565 });
276 }),566 }),
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 }),
277 };674 };
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.
743const 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;
278};752};
packages/plugins/mcp/src/sdk/plugin.test.ts+76−0
@@ -635,6 +635,82 @@ describe("mcpPlugin", () => { · 634 unmodified lines
635 );635 );
636 }),636 }),
637 );637 );
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 );
638});714});
639 715
640// ---------------------------------------------------------------------------716// ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/plugin.ts+67−0
@@ -6,7 +6,9 @@ import { McpGroup } from "../api/group"; · 5 unmodified lines
6import { McpExtensionService, McpHandlers } from "../api/handlers";6import { McpExtensionService, McpHandlers } from "../api/handlers";
7 7
8import {8import {
9 ScopeId,
9 SourceDetectionResult,10 SourceDetectionResult,
11 Usage,
10 definePlugin,12 definePlugin,
11 resolveSecretBackedMap as resolveSharedSecretBackedMap,13 resolveSecretBackedMap as resolveSharedSecretBackedMap,
12 type PluginCtx,14 type PluginCtx,
@@ -993,6 +995,71 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 980 unmodified lines
993 yield* ctx.storage.removeSource(sourceId, scope);995 yield* ctx.storage.removeSource(sourceId, scope);
994 }),996 }),
995 997
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
996 refreshSource: () => Effect.void,1063 refreshSource: () => Effect.void,
997 1064
998 // Connection refresh for oauth2-minted sources is owned by the1065 // 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
20 20
21const TEST_SCOPE = "test-scope";21const TEST_SCOPE = "test-scope";
22import { openApiPlugin } from "./plugin";22import { openApiPlugin } from "./plugin";
23import { OAuth2Auth } from "./types";23import { OAuth2Auth, OpenApiSourceBindingInput } from "./types";
24 24
25const autoApprove: InvokeOptions = { onElicitation: "accept-all" };25const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
26 26
@@ -847,4 +847,108 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 820 unmodified lines
847 expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true);847 expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true);
848 }),848 }),
849 );849 );
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 );
850});954});
packages/plugins/openapi/src/sdk/plugin.ts+77−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 ScopeId,10 ScopeId,
11 SecretId,11 SecretId,
12 SourceDetectionResult,12 SourceDetectionResult,
13 Usage,
13 definePlugin,14 definePlugin,
14 resolveSecretBackedMap,15 resolveSecretBackedMap,
15 type PluginCtx,16 type PluginCtx,
@@ -1030,6 +1031,82 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1014 unmodified lines
1030 1031
1031 removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope),1032 removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope),
1032 1033
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
1033 // Re-fetch the spec from its origin URL (captured at addSpec time)1110 // Re-fetch the spec from its origin URL (captured at addSpec time)
1034 // and replay the same parse → extract → upsertSource → register1111 // and replay the same parse → extract → upsertSource → register
1035 // path used by addSpec. Sources without a stored URL surface a1112 // path used by addSpec. Sources without a stored URL surface a
packages/plugins/openapi/src/sdk/store.ts+417−65
@@ -1,8 +1,10 @@
1import { Effect, Schema } from "effect";1import { Effect, Schema } from "effect";
2 2
3import {3import {
4 ConnectionId,
4 defineSchema,5 defineSchema,
5 ScopeId,6 ScopeId,
7 SecretId,
6 StorageError,8 StorageError,
7 type StorageDeps,9 type StorageDeps,
8 type StorageFailure,10 type StorageFailure,
@@ -27,6 +29,15 @@ import { · 18 unmodified lines
27// - openapi_source_binding: credential bindings for shared sources29// - openapi_source_binding: credential bindings for shared sources
28// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
29 31
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
30export const openapiSchema = defineSchema({41export const openapiSchema = defineSchema({
31 openapi_source: {42 openapi_source: {
32 fields: {43 fields: {
@@ -40,10 +51,14 @@ export const openapiSchema = defineSchema({ · 7 unmodified lines
40 // is the address re-fetched on `refreshSource`.51 // is the address re-fetched on `refreshSource`.
41 source_url: { type: "string", required: false },52 source_url: { type: "string", required: false },
42 base_url: { type: "string", required: false },53 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.
43 headers: { type: "json", required: false },60 headers: { type: "json", required: false },
44 query_params: { type: "json", required: false },
45 oauth2: { type: "json", required: false },61 oauth2: { type: "json", required: false },
46 invocation_config: { type: "json", required: true },
47 },62 },
48 },63 },
49 openapi_operation: {64 openapi_operation: {
@@ -66,11 +81,54 @@ export const openapiSchema = defineSchema({ · 16 unmodified lines
66 // bindings when a shared source is removed.81 // bindings when a shared source is removed.
67 target_scope_id: { type: "string", required: true, index: true },82 target_scope_id: { type: "string", required: true, index: true },
68 slot: { type: "string", required: true, index: true },83 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 },
70 created_at: { type: "date", required: true },92 created_at: { type: "date", required: true },
71 updated_at: { type: "date", required: true },93 updated_at: { type: "date", required: true },
72 },94 },
73 },95 },
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 },
74});132});
75 133
76export type OpenapiSchema = typeof openapiSchema;134export type OpenapiSchema = typeof openapiSchema;
@@ -166,16 +224,70 @@ const decodeBinding = Schema.decodeUnknownSync(OperationBinding); · 89 unmodified lines
166 224
167const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);225const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);
168const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);226const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);
169const encodeSourceBindingValue = Schema.encodeSync(OpenApiSourceBindingValue);
170const decodeSourceBindingValue = Schema.decodeUnknownSync(
171 OpenApiSourceBindingValue,
172);
173 227
174const asJsonObject = (value: unknown): Record<string, unknown> => {228interface ChildRow {
175 if (value == null) return {};229 readonly id: string;
176 if (typeof value === "string")230 readonly scope_id: string;
177 return JSON.parse(value) as Record<string, unknown>;231 readonly source_id: string;
178 return value as Record<string, unknown>;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.
245const 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
275const 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;
179};291};
180 292
181const toJsonRecord = (value: unknown): Record<string, unknown> =>293const toJsonRecord = (value: unknown): Record<string, unknown> =>
@@ -367,6 +479,44 @@ export interface OpenapiStore { · 185 unmodified lines
367 slot: string,479 slot: string,
368 scope: string,480 scope: string,
369 ) => Effect.Effect<void, StorageFailure>;481 ) => 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>;
370}520}
371 521
372// ---------------------------------------------------------------------------522// ---------------------------------------------------------------------------
@@ -400,6 +550,25 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
400 encodeSyntheticRowIdPart(scopeId),550 encodeSyntheticRowIdPart(scopeId),
401 ].join("::");551 ].join("::");
402 552
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
403 const rowToSourceBinding = (572 const rowToSourceBinding = (
404 row: Record<string, unknown>,573 row: Record<string, unknown>,
405 ): OpenApiSourceBindingRef =>574 ): OpenApiSourceBindingRef =>
@@ -408,7 +577,7 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
408 sourceScopeId: ScopeId.make(row.source_scope_id as string),577 sourceScopeId: ScopeId.make(row.source_scope_id as string),
409 scopeId: ScopeId.make(row.target_scope_id as string),578 scopeId: ScopeId.make(row.target_scope_id as string),
410 slot: row.slot as string,579 slot: row.slot as string,
411 value: decodeSourceBindingValue(asJsonObject(row.value)),580 value: rowToSourceBindingValue(row),
412 createdAt:581 createdAt:
413 row.created_at instanceof Date582 row.created_at instanceof Date
414 ? row.created_at583 ? row.created_at
@@ -419,6 +588,18 @@ export const makeDefaultOpenapiStore = ({ · 4 unmodified lines
419 : new Date(row.updated_at as string),588 : new Date(row.updated_at as string),
420 });589 });
421 590
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
422 const validateBindingScopes = (params: {603 const validateBindingScopes = (params: {
423 readonly sourceScope: string;604 readonly sourceScope: string;
424 readonly targetScope: string;605 readonly targetScope: string;
@@ -485,39 +666,88 @@ export const makeDefaultOpenapiStore = ({ · 60 unmodified lines
485 return source;666 return source;
486 });667 });
487 668
488 const rowToSource = (row: Record<string, unknown>): StoredSource => {669 const loadChildValueMap = (
489 const normalizedHeaders = normalizeStoredHeaders(row.headers);670 model:
490 const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);671 | "openapi_source_query_param"
491 const invocationConfig = asJsonObject(row.invocation_config);672 | "openapi_source_spec_fetch_header"
492 return {673 | "openapi_source_spec_fetch_query_param",
493 namespace: row.id as string,674 sourceId: string,
494 scope: row.scope_id as string,675 scope: string,
495 name: row.name as string,676 ) =>
496 config: {677 adapter
497 spec: row.spec as string,678 .findMany({
498 sourceUrl: (row.source_url as string | null | undefined) ?? undefined,679 model,
499 baseUrl: (row.base_url as string | null | undefined) ?? undefined,680 where: [
500 headers: normalizedHeaders.headers,681 { field: "source_id", value: sourceId },
501 queryParams: decodeHeaders(row.query_params),682 { field: "scope_id", value: scope },
502 specFetchCredentials: invocationConfig.specFetchCredentials as683 ],
503 | OpenApiSpecFetchCredentials684 })
504 | undefined,685 .pipe(Effect.map(childRowsToValueMap));
505 oauth2: normalizedOAuth2.oauth2,686
506 },687 const rowToSource = (
507 legacy:688 row: Record<string, unknown>,
508 Object.keys(normalizedHeaders.legacy).length > 0 ||689 ): Effect.Effect<StoredSource, StorageFailure> =>
509 normalizedOAuth2.legacy690 Effect.gen(function* () {
510 ? {691 const sourceId = row.id as string;
511 ...(Object.keys(normalizedHeaders.legacy).length > 0692 const scope = row.scope_id as string;
512 ? { headers: normalizedHeaders.legacy }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 }
513 : {}),718 : {}),
514 ...(normalizedOAuth2.legacy719 ...(Object.keys(specFetchQueryParams).length > 0
515 ? { oauth2: normalizedOAuth2.legacy }720 ? { queryParams: specFetchQueryParams }
516 : {}),721 : {}),
517 }722 };
518 : undefined,723
519 };724 return {
520 };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 });
521 751
522 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({752 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({
523 toolId: row.id as string,753 toolId: row.id as string,
@@ -527,6 +757,35 @@ export const makeDefaultOpenapiStore = ({ · 3 unmodified lines
527 ),757 ),
528 });758 });
529 759
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
530 const deleteSource = (789 const deleteSource = (
531 namespace: string,790 namespace: string,
532 scope: string,791 scope: string,
@@ -540,6 +799,20 @@ export const makeDefaultOpenapiStore = ({ · 7 unmodified lines
540 { field: "scope_id", value: scope },799 { field: "scope_id", value: scope },
541 ],800 ],
542 });801 });
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 }
543 yield* adapter.delete({816 yield* adapter.delete({
544 model: "openapi_source",817 model: "openapi_source",
545 where: [818 where: [
@@ -587,18 +860,30 @@ export const makeDefaultOpenapiStore = ({ · 41 unmodified lines
587 ],860 ],
588 ),861 ),
589 ) as Record<string, unknown>,862 ) as Record<string, unknown>,
590 query_params: input.config.queryParams,
591 oauth2: input.config.oauth2863 oauth2: input.config.oauth2
592 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))864 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))
593 : undefined,865 : undefined,
594 invocation_config: {
595 ...(input.config.specFetchCredentials
596 ? { specFetchCredentials: input.config.specFetchCredentials }
597 : {}),
598 },
599 },866 },
600 forceAllowId: true,867 forceAllowId: true,
601 });868 });
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 );
602 if (operations.length > 0) {887 if (operations.length > 0) {
603 yield* adapter.createMany({888 yield* adapter.createMany({
604 model: "openapi_operation",889 model: "openapi_operation",
@@ -623,7 +908,7 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
623 ],908 ],
624 });909 });
625 if (!existingRow) return;910 if (!existingRow) return;
626 const existing = rowToSource(existingRow);911 const existing = yield* rowToSource(existingRow);
627 912
628 const nextName = patch.name?.trim() || existing.name;913 const nextName = patch.name?.trim() || existing.name;
629 const nextBaseUrl =914 const nextBaseUrl =
@@ -632,10 +917,6 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
632 patch.headers !== undefined917 patch.headers !== undefined
633 ? patch.headers918 ? patch.headers
634 : (existing.config.headers ?? {});919 : (existing.config.headers ?? {});
635 const nextQueryParams =
636 patch.queryParams !== undefined
637 ? patch.queryParams
638 : (existing.config.queryParams ?? {});
639 const nextOAuth2 =920 const nextOAuth2 =
640 patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;921 patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;
641 922
@@ -660,30 +941,41 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
660 },941 },
661 ]),942 ]),
662 ) as Record<string, unknown>,943 ) as Record<string, unknown>,
663 query_params: nextQueryParams,
664 oauth2: nextOAuth2944 oauth2: nextOAuth2
665 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2))945 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2))
666 : undefined,946 : undefined,
667 invocation_config: asJsonObject(existingRow.invocation_config),
668 },947 },
669 });948 });
949 if (patch.queryParams !== undefined) {
950 yield* replaceChildRows(
951 "openapi_source_query_param",
952 namespace,
953 scope,
954 patch.queryParams,
955 );
956 }
670 }),957 }),
671 958
672 getSource: (namespace, scope) =>959 getSource: (namespace, scope) =>
673 adapter960 Effect.gen(function* () {
674 .findOne({961 const row = yield* adapter.findOne({
675 model: "openapi_source",962 model: "openapi_source",
676 where: [963 where: [
677 { field: "id", value: namespace },964 { field: "id", value: namespace },
678 { field: "scope_id", value: scope },965 { field: "scope_id", value: scope },
679 ],966 ],
680 })967 });
681 .pipe(Effect.map((row) => (row ? rowToSource(row) : null))),968 if (!row) return null;
969 return yield* rowToSource(row);
970 }),
682 971
683 listSources: () =>972 listSources: () =>
684 adapter973 Effect.gen(function* () {
685 .findMany({ model: "openapi_source" })974 const rows = yield* adapter.findMany({ model: "openapi_source" });
686 .pipe(Effect.map((rows) => rows.map(rowToSource))),975 return yield* Effect.forEach(rows, rowToSource, {
976 concurrency: "unbounded",
977 });
978 }),
687 979
688 getOperationByToolId: (toolId, scope) =>980 getOperationByToolId: (toolId, scope) =>
689 adapter981 adapter
@@ -779,6 +1071,7 @@ export const makeDefaultOpenapiStore = ({ · 89 unmodified lines
779 input.scope as string,1071 input.scope as string,
780 );1072 );
781 const now = new Date();1073 const now = new Date();
1074 const valueColumns = sourceBindingValueColumns(input.value);
782 yield* adapter.delete({1075 yield* adapter.delete({
783 model: "openapi_source_binding",1076 model: "openapi_source_binding",
784 where: [{ field: "id", value: id }],1077 where: [{ field: "id", value: id }],
@@ -791,7 +1084,7 @@ export const makeDefaultOpenapiStore = ({ · 6 unmodified lines
791 source_scope_id: input.sourceScope as string,1084 source_scope_id: input.sourceScope as string,
792 target_scope_id: input.scope as string,1085 target_scope_id: input.scope as string,
793 slot: input.slot,1086 slot: input.slot,
794 value: toJsonRecord(encodeSourceBindingValue(input.value)),1087 ...valueColumns,
795 created_at: now,1088 created_at: now,
796 updated_at: now,1089 updated_at: now,
797 },1090 },
@@ -825,5 +1118,64 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
825 ],1118 ],
826 });1119 });
827 }),1120 }),
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 }),
828 };1180 };
829};1181};
packages/react/src/api/atoms.tsx+35−1
@@ -1,4 +1,10 @@
1import { PolicyId, type ScopeId, type ToolId, type SecretId } from "@executor-js/sdk";1import {
2 PolicyId,
3 type ConnectionId,
4 type ScopeId,
5 type SecretId,
6 type ToolId,
7} from "@executor-js/sdk";
2import * as Atom from "effect/unstable/reactivity/Atom";8import * as Atom from "effect/unstable/reactivity/Atom";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";9import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4 10
@@ -72,6 +78,34 @@ export const connectionsAtom = (scopeId: ScopeId) => · 67 unmodified lines
72 reactivityKeys: [ReactivityKey.connections],78 reactivityKeys: [ReactivityKey.connections],
73 });79 });
74 80
81export 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
95export 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
75export const policiesAtom = (scopeId: ScopeId) =>109export const policiesAtom = (scopeId: ScopeId) =>
76 ExecutorApiClient.query("policies", "list", {110 ExecutorApiClient.query("policies", "list", {
77 params: { scopeId },111 params: { scopeId },
packages/react/src/pages/connections.tsx+45−3
@@ -1,9 +1,10 @@
1import { useAtomSet } from "@effect/atom-react";1import { Suspense } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { ConnectionId } from "@executor-js/sdk";4import { ConnectionId, type ScopeId } from "@executor-js/sdk";
4import { toast } from "sonner";5import { toast } from "sonner";
5 6
6import { removeConnection } from "../api/atoms";7import { connectionUsagesAtom, removeConnection } from "../api/atoms";
7import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic";8import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic";
8import { connectionWriteKeys } from "../api/reactivity-keys";9import { connectionWriteKeys } from "../api/reactivity-keys";
9import { useScope, useScopeStack } from "../hooks/use-scope";10import { useScope, useScopeStack } from "../hooks/use-scope";
@@ -50,11 +51,45 @@ const connectionScopeLabel = ( · 40 unmodified lines
50 return "Scoped";51 return "Scoped";
51};52};
52 53
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
60function 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
53// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
54// Connection row88// Connection row
55// ---------------------------------------------------------------------------89// ---------------------------------------------------------------------------
56 90
57function ConnectionRow(props: {91function ConnectionRow(props: {
92 scopeId: ScopeId;
58 connection: {93 connection: {
59 id: string;94 id: string;
60 scopeId: string;95 scopeId: string;
@@ -80,6 +115,12 @@ function ConnectionRow(props: { · 19 unmodified lines
80 <CardStackEntryDescription className="text-xs text-muted-foreground">115 <CardStackEntryDescription className="text-xs text-muted-foreground">
81 {displayProvider(connection.provider)}116 {displayProvider(connection.provider)}
82 </CardStackEntryDescription>117 </CardStackEntryDescription>
118 <Suspense fallback={null}>
119 <ConnectionUsageFooter
120 scopeId={props.scopeId}
121 connectionId={ConnectionId.make(connection.id)}
122 />
123 </Suspense>
83 </CardStackEntryContent>124 </CardStackEntryContent>
84 <CardStackEntryActions>125 <CardStackEntryActions>
85 <Badge variant="outline">{scopeLabel}</Badge>126 <Badge variant="outline">{scopeLabel}</Badge>
@@ -185,6 +226,7 @@ export function ConnectionsPage() { · 99 unmodified lines
185 }) => (226 }) => (
186 <ConnectionRow227 <ConnectionRow
187 key={c.id}228 key={c.id}
229 scopeId={scopeId}
188 connection={{230 connection={{
189 id: c.id,231 id: c.id,
190 scopeId: c.scopeId,232 scopeId: c.scopeId,
packages/react/src/pages/secrets.tsx+42−2
@@ -1,10 +1,10 @@
1import { useMemo, useState, Suspense } from "react";1import { useMemo, useState, Suspense } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { secretsAtom, removeSecret } from "../api/atoms";4import { secretsAtom, secretUsagesAtom, removeSecret } from "../api/atoms";
5import { secretWriteKeys } from "../api/reactivity-keys";5import { secretWriteKeys } from "../api/reactivity-keys";
6import { useSecretProviderPlugins } from "@executor-js/sdk/client";6import { useSecretProviderPlugins } from "@executor-js/sdk/client";
7import { SecretId } from "@executor-js/sdk";7import { SecretId, type ScopeId } from "@executor-js/sdk";
8import { SecretForm } from "../plugins/secret-form";8import { SecretForm } from "../plugins/secret-form";
9import { useScope } from "../hooks/use-scope";9import { useScope } from "../hooks/use-scope";
10import {10import {
@@ -122,11 +122,44 @@ function AddSecretDialogContent(props: { · 111 unmodified lines
122 );122 );
123}123}
124 124
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
132function 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
125// ---------------------------------------------------------------------------157// ---------------------------------------------------------------------------
126// Secret row158// Secret row
127// ---------------------------------------------------------------------------159// ---------------------------------------------------------------------------
128 160
129function SecretRow(props: {161function SecretRow(props: {
162 scopeId: ScopeId;
130 showProvider: boolean;163 showProvider: boolean;
131 secret: { id: string; name: string; provider?: string };164 secret: { id: string; name: string; provider?: string };
132 onRemove: () => void;165 onRemove: () => void;
@@ -147,6 +180,12 @@ function SecretRow(props: { · 14 unmodified lines
147 {secret.id}180 {secret.id}
148 </span>181 </span>
149 </CardStackEntryTitle>182 </CardStackEntryTitle>
183 <Suspense fallback={null}>
184 <SecretUsageFooter
185 scopeId={props.scopeId}
186 secretId={SecretId.make(secret.id)}
187 />
188 </Suspense>
150 </CardStackEntryContent>189 </CardStackEntryContent>
151 <CardStackEntryActions>190 <CardStackEntryActions>
152 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}191 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}
@@ -306,6 +345,7 @@ export function SecretsPage(props: { · 153 unmodified lines
306 }) => (345 }) => (
307 <SecretRow346 <SecretRow
308 key={s.id}347 key={s.id}
348 scopeId={scopeId}
309 showProvider={showProviderInfo}349 showProvider={showProviderInfo}
310 secret={{350 secret={{
311 id: s.id,351 id: s.id,