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

Showing the changes to packages/plugins/openapi/src (3 of 41 files). Show all

Unified · Split

3 files
  1. packages/plugins/openapi/src/sdk/plugin.test.ts +105 −1
  2. packages/plugins/openapi/src/sdk/plugin.ts +77 −0
  3. packages/plugins/openapi/src/sdk/store.ts +417 −65
packages/plugins/openapi/src/sdk/plugin.test.ts+105−1
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020
2121 const TEST_SCOPE = "test-scope";
2222 import { openApiPlugin } from "./plugin";
23−import { OAuth2Auth } from "./types";
23+import { OAuth2Auth, OpenApiSourceBindingInput } from "./types";
2424
2525 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
2626
@@ -847,4 +847,108 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 820 unmodified lines
847847 expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true);
848848 }),
849849 );
850+
851+ // -------------------------------------------------------------------------
852+ // Usage tracking — once openapi normalizes its bindings, query_params,
853+ // and specFetchCredentials, `usagesForSecret` and `usagesForConnection`
854+ // should surface every reference and `secrets.remove` should RESTRICT.
855+ // -------------------------------------------------------------------------
856+
857+ it.effect("usagesForSecret aggregates bindings and child rows", () =>
858+ Effect.gen(function* () {
859+ const executor = yield* createExecutor(
860+ makeTestConfig({
861+ plugins: [memorySecretsPlugin(), openApiPlugin()] as const,
862+ }),
863+ );
864+
865+ yield* executor.secrets.set(
866+ new SetSecretInput({
867+ id: SecretId.make("api-key"),
868+ scope: ScopeId.make(TEST_SCOPE),
869+ name: "API Key",
870+ value: "abc123",
871+ provider: "memory",
872+ }),
873+ );
874+
875+ // Add a source whose query_params reference the secret directly.
876+ yield* executor.openapi.addSpec({
877+ spec: specJson,
878+ scope: TEST_SCOPE,
879+ namespace: "with_secret",
880+ baseUrl: "http://example.com",
881+ queryParams: { token: { secretId: "api-key" } },
882+ });
883+
884+ // Configure a slot binding pointing at the same secret.
885+ yield* executor.openapi.setSourceBinding(
886+ new OpenApiSourceBindingInput({
887+ sourceId: "with_secret",
888+ sourceScope: ScopeId.make(TEST_SCOPE),
889+ scope: ScopeId.make(TEST_SCOPE),
890+ slot: "header:authorization",
891+ value: { kind: "secret", secretId: SecretId.make("api-key") },
892+ }),
893+ );
894+
895+ const usages = yield* executor.secrets.usages(SecretId.make("api-key"));
896+ expect(usages.length).toBe(2);
897+ const slots = usages.map((u) => u.slot).sort();
898+ expect(slots).toEqual([
899+ "binding:header:authorization",
900+ "query_param:token",
901+ ]);
902+ expect(usages.every((u) => u.pluginId === "openapi")).toBe(true);
903+ }),
904+ );
905+
906+ it.effect("secrets.remove refuses while an openapi binding still uses it", () =>
907+ Effect.gen(function* () {
908+ const executor = yield* createExecutor(
909+ makeTestConfig({
910+ plugins: [memorySecretsPlugin(), openApiPlugin()] as const,
911+ }),
912+ );
913+ yield* executor.secrets.set(
914+ new SetSecretInput({
915+ id: SecretId.make("locked"),
916+ scope: ScopeId.make(TEST_SCOPE),
917+ name: "Locked",
918+ value: "v",
919+ provider: "memory",
920+ }),
921+ );
922+
923+ yield* executor.openapi.addSpec({
924+ spec: specJson,
925+ scope: TEST_SCOPE,
926+ namespace: "ref",
927+ baseUrl: "http://example.com",
928+ });
929+ yield* executor.openapi.setSourceBinding(
930+ new OpenApiSourceBindingInput({
931+ sourceId: "ref",
932+ sourceScope: ScopeId.make(TEST_SCOPE),
933+ scope: ScopeId.make(TEST_SCOPE),
934+ slot: "header:authorization",
935+ value: { kind: "secret", secretId: SecretId.make("locked") },
936+ }),
937+ );
938+
939+ const failure = yield* executor.secrets
940+ .remove(SecretId.make("locked"))
941+ .pipe(Effect.flip);
942+ expect((failure as { _tag: string })._tag).toBe("SecretInUseError");
943+
944+ // Detach the binding, then remove succeeds.
945+ yield* executor.openapi.removeSourceBinding(
946+ "ref",
947+ ScopeId.make(TEST_SCOPE),
948+ "header:authorization",
949+ ScopeId.make(TEST_SCOPE),
950+ );
951+ yield* executor.secrets.remove(SecretId.make("locked"));
952+ }),
953+ );
850954 });
packages/plugins/openapi/src/sdk/plugin.ts+77−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 ScopeId,
1111 SecretId,
1212 SourceDetectionResult,
13+ Usage,
1314 definePlugin,
1415 resolveSecretBackedMap,
1516 type PluginCtx,
@@ -1030,6 +1031,82 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1014 unmodified lines
10301031
10311032 removeSource: ({ ctx, sourceId, scope }) => ctx.storage.removeSource(sourceId, scope),
10321033
1034+ // Aggregate usages across the four places openapi can hold a direct
1035+ // secret ref:
1036+ // - openapi_source_binding.secret_id (kind=secret slot bindings)
1037+ // - openapi_source_query_param
1038+ // - openapi_source_spec_fetch_header / spec_fetch_query_param
1039+ // Each is one indexed SELECT in the store; the merge plus a single
1040+ // source-name JOIN happens here.
1041+ usagesForSecret: ({ ctx, args }) =>
1042+ Effect.gen(function* () {
1043+ const bindings = yield* ctx.storage.findBindingsBySecret(args.secretId);
1044+ const childRows = yield* ctx.storage.findChildRowsBySecret(
1045+ args.secretId,
1046+ );
1047+
1048+ const sourceKeys = new Set<string>();
1049+ for (const b of bindings) {
1050+ sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`);
1051+ }
1052+ for (const r of childRows) {
1053+ sourceKeys.add(`${r.scope_id}:${r.source_id}`);
1054+ }
1055+ const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
1056+
1057+ const out: Usage[] = [];
1058+ for (const b of bindings) {
1059+ out.push(
1060+ new Usage({
1061+ pluginId: "openapi",
1062+ scopeId: ScopeId.make(b.scopeId),
1063+ ownerKind: "openapi-source-binding",
1064+ ownerId: b.sourceId,
1065+ ownerName:
1066+ sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null,
1067+ slot: `binding:${b.slot}`,
1068+ }),
1069+ );
1070+ }
1071+ for (const r of childRows) {
1072+ out.push(
1073+ new Usage({
1074+ pluginId: "openapi",
1075+ scopeId: ScopeId.make(r.scope_id),
1076+ ownerKind: `openapi-source-${r.kind.replace(/_/g, "-")}`,
1077+ ownerId: r.source_id,
1078+ ownerName: sources.get(`${r.scope_id}:${r.source_id}`) ?? null,
1079+ slot: `${r.kind}:${r.name}`,
1080+ }),
1081+ );
1082+ }
1083+ return out;
1084+ }),
1085+
1086+ usagesForConnection: ({ ctx, args }) =>
1087+ Effect.gen(function* () {
1088+ const bindings = yield* ctx.storage.findBindingsByConnection(
1089+ args.connectionId,
1090+ );
1091+ const sourceKeys = new Set<string>();
1092+ for (const b of bindings) {
1093+ sourceKeys.add(`${b.sourceScopeId}:${b.sourceId}`);
1094+ }
1095+ const sources = yield* ctx.storage.lookupSourceNames([...sourceKeys]);
1096+ return bindings.map(
1097+ (b) =>
1098+ new Usage({
1099+ pluginId: "openapi",
1100+ scopeId: ScopeId.make(b.scopeId),
1101+ ownerKind: "openapi-source-binding",
1102+ ownerId: b.sourceId,
1103+ ownerName:
1104+ sources.get(`${b.sourceScopeId}:${b.sourceId}`) ?? null,
1105+ slot: `binding:${b.slot}`,
1106+ }),
1107+ );
1108+ }),
1109+
10331110 // Re-fetch the spec from its origin URL (captured at addSpec time)
10341111 // and replay the same parse → extract → upsertSource → register
10351112 // path used by addSpec. Sources without a stored URL surface a
packages/plugins/openapi/src/sdk/store.ts+417−65
@@ -1,8 +1,10 @@
11 import { Effect, Schema } from "effect";
22
33 import {
4+ ConnectionId,
45 defineSchema,
56 ScopeId,
7+ SecretId,
68 StorageError,
79 type StorageDeps,
810 type StorageFailure,
@@ -27,6 +29,15 @@ import { · 18 unmodified lines
2729 // - openapi_source_binding: credential bindings for shared sources
2830 // ---------------------------------------------------------------------------
2931
32+// Each of the secret-backed child tables (`openapi_source_query_param`,
33+// `openapi_source_spec_fetch_header`,
34+// `openapi_source_spec_fetch_query_param`) shares the same column shape:
35+// id/scope_id/source_id/name plus a `kind` enum that discriminates a
36+// literal text value from a secret reference (with optional prefix).
37+// The fields are inlined per-table because `defineSchema`'s type
38+// narrowing relies on the literal types staying on the original
39+// declaration site.
40+
3041 export const openapiSchema = defineSchema({
3142 openapi_source: {
3243 fields: {
@@ -40,10 +51,14 @@ export const openapiSchema = defineSchema({ · 7 unmodified lines
4051 // is the address re-fetched on `refreshSource`.
4152 source_url: { type: "string", required: false },
4253 base_url: { type: "string", required: false },
54+ // `headers` and `oauth2` stay JSON: these carry slot names, not
55+ // direct secret/connection ids. The secrets/connections that
56+ // actually power them live one level of indirection deeper, in
57+ // `openapi_source_binding` rows keyed by slot — and those ARE
58+ // normalized below. Headers and oauth2 are plugin-private
59+ // structural data, not cross-cutting refs.
4360 headers: { type: "json", required: false },
44− query_params: { type: "json", required: false },
4561 oauth2: { type: "json", required: false },
46− invocation_config: { type: "json", required: true },
4762 },
4863 },
4964 openapi_operation: {
@@ -66,11 +81,54 @@ export const openapiSchema = defineSchema({ · 16 unmodified lines
6681 // bindings when a shared source is removed.
6782 target_scope_id: { type: "string", required: true, index: true },
6883 slot: { type: "string", required: true, index: true },
69− value: { type: "json", required: true },
84+ // Discriminated union, flattened. Exactly one of secret_id /
85+ // connection_id / text_value is populated based on `kind`.
86+ // `secret_id` and `connection_id` are indexed so usages queries
87+ // are one-hop SELECTs.
88+ kind: { type: ["secret", "connection", "text"], required: true },
89+ secret_id: { type: "string", required: false, index: true },
90+ connection_id: { type: "string", required: false, index: true },
91+ text_value: { type: "string", required: false },
7092 created_at: { type: "date", required: true },
7193 updated_at: { type: "date", required: true },
7294 },
7395 },
96+ openapi_source_query_param: {
97+ fields: {
98+ id: { type: "string", required: true },
99+ scope_id: { type: "string", required: true, index: true },
100+ source_id: { type: "string", required: true, index: true },
101+ name: { type: "string", required: true },
102+ kind: { type: ["text", "secret"], required: true },
103+ text_value: { type: "string", required: false },
104+ secret_id: { type: "string", required: false, index: true },
105+ secret_prefix: { type: "string", required: false },
106+ },
107+ },
108+ openapi_source_spec_fetch_header: {
109+ fields: {
110+ id: { type: "string", required: true },
111+ scope_id: { type: "string", required: true, index: true },
112+ source_id: { type: "string", required: true, index: true },
113+ name: { type: "string", required: true },
114+ kind: { type: ["text", "secret"], required: true },
115+ text_value: { type: "string", required: false },
116+ secret_id: { type: "string", required: false, index: true },
117+ secret_prefix: { type: "string", required: false },
118+ },
119+ },
120+ openapi_source_spec_fetch_query_param: {
121+ fields: {
122+ id: { type: "string", required: true },
123+ scope_id: { type: "string", required: true, index: true },
124+ source_id: { type: "string", required: true, index: true },
125+ name: { type: "string", required: true },
126+ kind: { type: ["text", "secret"], required: true },
127+ text_value: { type: "string", required: false },
128+ secret_id: { type: "string", required: false, index: true },
129+ secret_prefix: { type: "string", required: false },
130+ },
131+ },
74132 });
75133
76134 export type OpenapiSchema = typeof openapiSchema;
@@ -166,16 +224,70 @@ const decodeBinding = Schema.decodeUnknownSync(OperationBinding); · 89 unmodified lines
166224
167225 const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);
168226 const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);
169−const encodeSourceBindingValue = Schema.encodeSync(OpenApiSourceBindingValue);
170−const decodeSourceBindingValue = Schema.decodeUnknownSync(
171− OpenApiSourceBindingValue,
172−);
173227
174−const asJsonObject = (value: unknown): Record<string, unknown> => {
175− if (value == null) return {};
176− if (typeof value === "string")
177− return JSON.parse(value) as Record<string, unknown>;
178− return value as Record<string, unknown>;
228+interface ChildRow {
229+ readonly id: string;
230+ readonly scope_id: string;
231+ readonly source_id: string;
232+ readonly name: string;
233+ readonly kind: "text" | "secret";
234+ readonly text_value?: string;
235+ readonly secret_id?: string;
236+ readonly secret_prefix?: string;
237+ // Index signature to satisfy adapter's `RowInput` shape (the typed
238+ // adapter exposes its row shape with one).
239+ readonly [k: string]: unknown;
240+}
241+
242+// Collapse a SecretBackedValue map into the flat child-table column
243+// shape used by openapi_source_query_param and the two
244+// openapi_source_spec_fetch_* tables. Returns one record per entry.
245+const valueMapToChildRows = (
246+ sourceId: string,
247+ scope: string,
248+ values: Record<string, HeaderValue> | undefined,
249+): readonly ChildRow[] => {
250+ if (!values) return [];
251+ return Object.entries(values).map(([name, value]) => {
252+ const id = JSON.stringify([sourceId, name]);
253+ if (typeof value === "string") {
254+ return {
255+ id,
256+ scope_id: scope,
257+ source_id: sourceId,
258+ name,
259+ kind: "text",
260+ text_value: value,
261+ };
262+ }
263+ return {
264+ id,
265+ scope_id: scope,
266+ source_id: sourceId,
267+ name,
268+ kind: "secret",
269+ secret_id: value.secretId,
270+ secret_prefix: value.prefix,
271+ };
272+ });
273+};
274+
275+const childRowsToValueMap = (
276+ rows: readonly Record<string, unknown>[],
277+): Record<string, HeaderValue> => {
278+ const out: Record<string, HeaderValue> = {};
279+ for (const row of rows) {
280+ const name = row.name as string;
281+ if (row.kind === "secret" && typeof row.secret_id === "string") {
282+ const prefix = row.secret_prefix as string | undefined | null;
283+ out[name] = prefix
284+ ? { secretId: row.secret_id, prefix }
285+ : { secretId: row.secret_id };
286+ } else if (row.kind === "text" && typeof row.text_value === "string") {
287+ out[name] = row.text_value;
288+ }
289+ }
290+ return out;
179291 };
180292
181293 const toJsonRecord = (value: unknown): Record<string, unknown> =>
@@ -367,6 +479,44 @@ export interface OpenapiStore { · 185 unmodified lines
367479 slot: string,
368480 scope: string,
369481 ) => Effect.Effect<void, StorageFailure>;
482+
483+ // ---------------------------------------------------------------------
484+ // Usage lookups — back `usagesForSecret` / `usagesForConnection`.
485+ // Each is one indexed SELECT against the new normalized columns.
486+ // ---------------------------------------------------------------------
487+
488+ /** Source-binding rows that point at the given secret id. */
489+ readonly findBindingsBySecret: (
490+ secretId: string,
491+ ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>;
492+
493+ /** Source-binding rows that point at the given connection id. */
494+ readonly findBindingsByConnection: (
495+ connectionId: string,
496+ ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>;
497+
498+ /** Child rows from query_params / specFetch tables that reference the
499+ * given secret id, tagged with the table they came from so the
500+ * caller can produce a readable `slot` like
501+ * `query_param:foo` or `spec_fetch_header:Authorization`. */
502+ readonly findChildRowsBySecret: (secretId: string) => Effect.Effect<
503+ readonly {
504+ readonly kind:
505+ | "query_param"
506+ | "spec_fetch_header"
507+ | "spec_fetch_query_param";
508+ readonly source_id: string;
509+ readonly scope_id: string;
510+ readonly name: string;
511+ }[],
512+ StorageFailure
513+ >;
514+
515+ /** Resolve display names for one or more `(scope_id, source_id)` pairs
516+ * in a single round trip, keyed by `${scope_id}:${source_id}`. */
517+ readonly lookupSourceNames: (
518+ keys: readonly string[],
519+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageFailure>;
370520 }
371521
372522 // ---------------------------------------------------------------------------
@@ -400,6 +550,25 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
400550 encodeSyntheticRowIdPart(scopeId),
401551 ].join("::");
402552
553+ const rowToSourceBindingValue = (
554+ row: Record<string, unknown>,
555+ ): OpenApiSourceBindingValue => {
556+ const kind = row.kind as string;
557+ if (kind === "secret" && typeof row.secret_id === "string") {
558+ return { kind: "secret", secretId: SecretId.make(row.secret_id) };
559+ }
560+ if (kind === "connection" && typeof row.connection_id === "string") {
561+ return {
562+ kind: "connection",
563+ connectionId: ConnectionId.make(row.connection_id),
564+ };
565+ }
566+ // text fallback covers both well-formed text rows and any
567+ // partial/null row that survived a malformed write — `text_value`
568+ // defaults to "" so the type stays satisfied without a throw.
569+ return { kind: "text", text: (row.text_value as string | null) ?? "" };
570+ };
571+
403572 const rowToSourceBinding = (
404573 row: Record<string, unknown>,
405574 ): OpenApiSourceBindingRef =>
@@ -408,7 +577,7 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
408577 sourceScopeId: ScopeId.make(row.source_scope_id as string),
409578 scopeId: ScopeId.make(row.target_scope_id as string),
410579 slot: row.slot as string,
411− value: decodeSourceBindingValue(asJsonObject(row.value)),
580+ value: rowToSourceBindingValue(row),
412581 createdAt:
413582 row.created_at instanceof Date
414583 ? row.created_at
@@ -419,6 +588,18 @@ export const makeDefaultOpenapiStore = ({ · 4 unmodified lines
419588 : new Date(row.updated_at as string),
420589 });
421590
591+ const sourceBindingValueColumns = (
592+ value: OpenApiSourceBindingValue,
593+ ): { kind: string; secret_id?: string; connection_id?: string; text_value?: string } => {
594+ if (value.kind === "secret") {
595+ return { kind: "secret", secret_id: value.secretId };
596+ }
597+ if (value.kind === "connection") {
598+ return { kind: "connection", connection_id: value.connectionId };
599+ }
600+ return { kind: "text", text_value: value.text };
601+ };
602+
422603 const validateBindingScopes = (params: {
423604 readonly sourceScope: string;
424605 readonly targetScope: string;
@@ -485,39 +666,88 @@ export const makeDefaultOpenapiStore = ({ · 60 unmodified lines
485666 return source;
486667 });
487668
488− const rowToSource = (row: Record<string, unknown>): StoredSource => {
489− const normalizedHeaders = normalizeStoredHeaders(row.headers);
490− const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);
491− const invocationConfig = asJsonObject(row.invocation_config);
492− return {
493− namespace: row.id as string,
494− scope: row.scope_id as string,
495− name: row.name as string,
496− config: {
497− spec: row.spec as string,
498− sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
499− baseUrl: (row.base_url as string | null | undefined) ?? undefined,
500− headers: normalizedHeaders.headers,
501− queryParams: decodeHeaders(row.query_params),
502− specFetchCredentials: invocationConfig.specFetchCredentials as
503− | OpenApiSpecFetchCredentials
504− | undefined,
505− oauth2: normalizedOAuth2.oauth2,
506− },
507− legacy:
508− Object.keys(normalizedHeaders.legacy).length > 0 ||
509− normalizedOAuth2.legacy
510− ? {
511− ...(Object.keys(normalizedHeaders.legacy).length > 0
512− ? { headers: normalizedHeaders.legacy }
669+ const loadChildValueMap = (
670+ model:
671+ | "openapi_source_query_param"
672+ | "openapi_source_spec_fetch_header"
673+ | "openapi_source_spec_fetch_query_param",
674+ sourceId: string,
675+ scope: string,
676+ ) =>
677+ adapter
678+ .findMany({
679+ model,
680+ where: [
681+ { field: "source_id", value: sourceId },
682+ { field: "scope_id", value: scope },
683+ ],
684+ })
685+ .pipe(Effect.map(childRowsToValueMap));
686+
687+ const rowToSource = (
688+ row: Record<string, unknown>,
689+ ): Effect.Effect<StoredSource, StorageFailure> =>
690+ Effect.gen(function* () {
691+ const sourceId = row.id as string;
692+ const scope = row.scope_id as string;
693+ const normalizedHeaders = normalizeStoredHeaders(row.headers);
694+ const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);
695+
696+ const queryParams = yield* loadChildValueMap(
697+ "openapi_source_query_param",
698+ sourceId,
699+ scope,
700+ );
701+ const specFetchHeaders = yield* loadChildValueMap(
702+ "openapi_source_spec_fetch_header",
703+ sourceId,
704+ scope,
705+ );
706+ const specFetchQueryParams = yield* loadChildValueMap(
707+ "openapi_source_spec_fetch_query_param",
708+ sourceId,
709+ scope,
710+ );
711+ const specFetchCredentials: OpenApiSpecFetchCredentials | undefined =
712+ Object.keys(specFetchHeaders).length === 0 &&
713+ Object.keys(specFetchQueryParams).length === 0
714+ ? undefined
715+ : {
716+ ...(Object.keys(specFetchHeaders).length > 0
717+ ? { headers: specFetchHeaders }
513718 : {}),
514− ...(normalizedOAuth2.legacy
515− ? { oauth2: normalizedOAuth2.legacy }
719+ ...(Object.keys(specFetchQueryParams).length > 0
720+ ? { queryParams: specFetchQueryParams }
516721 : {}),
517− }
518− : undefined,
519− };
520− };
722+ };
723+
724+ return {
725+ namespace: sourceId,
726+ scope,
727+ name: row.name as string,
728+ config: {
729+ spec: row.spec as string,
730+ sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
731+ baseUrl: (row.base_url as string | null | undefined) ?? undefined,
732+ headers: normalizedHeaders.headers,
733+ queryParams,
734+ specFetchCredentials,
735+ oauth2: normalizedOAuth2.oauth2,
736+ },
737+ legacy:
738+ Object.keys(normalizedHeaders.legacy).length > 0 ||
739+ normalizedOAuth2.legacy
740+ ? {
741+ ...(Object.keys(normalizedHeaders.legacy).length > 0
742+ ? { headers: normalizedHeaders.legacy }
743+ : {}),
744+ ...(normalizedOAuth2.legacy
745+ ? { oauth2: normalizedOAuth2.legacy }
746+ : {}),
747+ }
748+ : undefined,
749+ };
750+ });
521751
522752 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({
523753 toolId: row.id as string,
@@ -527,6 +757,35 @@ export const makeDefaultOpenapiStore = ({ · 3 unmodified lines
527757 ),
528758 });
529759
760+ // Replace the rows of one child table for a source: delete then bulk
761+ // insert. Single helper so upsertSource and updateSourceMeta both
762+ // funnel through the same write path.
763+ const replaceChildRows = (
764+ model:
765+ | "openapi_source_query_param"
766+ | "openapi_source_spec_fetch_header"
767+ | "openapi_source_spec_fetch_query_param",
768+ sourceId: string,
769+ scope: string,
770+ values: Record<string, HeaderValue> | undefined,
771+ ) =>
772+ Effect.gen(function* () {
773+ yield* adapter.deleteMany({
774+ model,
775+ where: [
776+ { field: "source_id", value: sourceId },
777+ { field: "scope_id", value: scope },
778+ ],
779+ });
780+ const rows = valueMapToChildRows(sourceId, scope, values);
781+ if (rows.length === 0) return;
782+ yield* adapter.createMany({
783+ model,
784+ data: rows,
785+ forceAllowId: true,
786+ });
787+ });
788+
530789 const deleteSource = (
531790 namespace: string,
532791 scope: string,
@@ -540,6 +799,20 @@ export const makeDefaultOpenapiStore = ({ · 7 unmodified lines
540799 { field: "scope_id", value: scope },
541800 ],
542801 });
802+ // Drop every child table's rows for this source/scope.
803+ for (const model of [
804+ "openapi_source_query_param",
805+ "openapi_source_spec_fetch_header",
806+ "openapi_source_spec_fetch_query_param",
807+ ] as const) {
808+ yield* adapter.deleteMany({
809+ model,
810+ where: [
811+ { field: "source_id", value: namespace },
812+ { field: "scope_id", value: scope },
813+ ],
814+ });
815+ }
543816 yield* adapter.delete({
544817 model: "openapi_source",
545818 where: [
@@ -587,18 +860,30 @@ export const makeDefaultOpenapiStore = ({ · 41 unmodified lines
587860 ],
588861 ),
589862 ) as Record<string, unknown>,
590− query_params: input.config.queryParams,
591863 oauth2: input.config.oauth2
592864 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))
593865 : undefined,
594− invocation_config: {
595− ...(input.config.specFetchCredentials
596− ? { specFetchCredentials: input.config.specFetchCredentials }
597− : {}),
598− },
599866 },
600867 forceAllowId: true,
601868 });
869+ yield* replaceChildRows(
870+ "openapi_source_query_param",
871+ input.namespace,
872+ input.scope,
873+ input.config.queryParams,
874+ );
875+ yield* replaceChildRows(
876+ "openapi_source_spec_fetch_header",
877+ input.namespace,
878+ input.scope,
879+ input.config.specFetchCredentials?.headers,
880+ );
881+ yield* replaceChildRows(
882+ "openapi_source_spec_fetch_query_param",
883+ input.namespace,
884+ input.scope,
885+ input.config.specFetchCredentials?.queryParams,
886+ );
602887 if (operations.length > 0) {
603888 yield* adapter.createMany({
604889 model: "openapi_operation",
@@ -623,7 +908,7 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
623908 ],
624909 });
625910 if (!existingRow) return;
626− const existing = rowToSource(existingRow);
911+ const existing = yield* rowToSource(existingRow);
627912
628913 const nextName = patch.name?.trim() || existing.name;
629914 const nextBaseUrl =
@@ -632,10 +917,6 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
632917 patch.headers !== undefined
633918 ? patch.headers
634919 : (existing.config.headers ?? {});
635− const nextQueryParams =
636− patch.queryParams !== undefined
637− ? patch.queryParams
638− : (existing.config.queryParams ?? {});
639920 const nextOAuth2 =
640921 patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;
641922
@@ -660,30 +941,41 @@ export const makeDefaultOpenapiStore = ({ · 18 unmodified lines
660941 },
661942 ]),
662943 ) as Record<string, unknown>,
663− query_params: nextQueryParams,
664944 oauth2: nextOAuth2
665945 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2))
666946 : undefined,
667− invocation_config: asJsonObject(existingRow.invocation_config),
668947 },
669948 });
949+ if (patch.queryParams !== undefined) {
950+ yield* replaceChildRows(
951+ "openapi_source_query_param",
952+ namespace,
953+ scope,
954+ patch.queryParams,
955+ );
956+ }
670957 }),
671958
672959 getSource: (namespace, scope) =>
673− adapter
674− .findOne({
960+ Effect.gen(function* () {
961+ const row = yield* adapter.findOne({
675962 model: "openapi_source",
676963 where: [
677964 { field: "id", value: namespace },
678965 { field: "scope_id", value: scope },
679966 ],
680− })
681− .pipe(Effect.map((row) => (row ? rowToSource(row) : null))),
967+ });
968+ if (!row) return null;
969+ return yield* rowToSource(row);
970+ }),
682971
683972 listSources: () =>
684− adapter
685− .findMany({ model: "openapi_source" })
686− .pipe(Effect.map((rows) => rows.map(rowToSource))),
973+ Effect.gen(function* () {
974+ const rows = yield* adapter.findMany({ model: "openapi_source" });
975+ return yield* Effect.forEach(rows, rowToSource, {
976+ concurrency: "unbounded",
977+ });
978+ }),
687979
688980 getOperationByToolId: (toolId, scope) =>
689981 adapter
@@ -779,6 +1071,7 @@ export const makeDefaultOpenapiStore = ({ · 89 unmodified lines
7791071 input.scope as string,
7801072 );
7811073 const now = new Date();
1074+ const valueColumns = sourceBindingValueColumns(input.value);
7821075 yield* adapter.delete({
7831076 model: "openapi_source_binding",
7841077 where: [{ field: "id", value: id }],
@@ -791,7 +1084,7 @@ export const makeDefaultOpenapiStore = ({ · 6 unmodified lines
7911084 source_scope_id: input.sourceScope as string,
7921085 target_scope_id: input.scope as string,
7931086 slot: input.slot,
794− value: toJsonRecord(encodeSourceBindingValue(input.value)),
1087+ ...valueColumns,
7951088 created_at: now,
7961089 updated_at: now,
7971090 },
@@ -825,5 +1118,64 @@ export const makeDefaultOpenapiStore = ({ · 27 unmodified lines
8251118 ],
8261119 });
8271120 }),
1121+
1122+ findBindingsBySecret: (secretId) =>
1123+ adapter
1124+ .findMany({
1125+ model: "openapi_source_binding",
1126+ where: [{ field: "secret_id", value: secretId }],
1127+ })
1128+ .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))),
1129+
1130+ findBindingsByConnection: (connectionId) =>
1131+ adapter
1132+ .findMany({
1133+ model: "openapi_source_binding",
1134+ where: [{ field: "connection_id", value: connectionId }],
1135+ })
1136+ .pipe(Effect.map((rows) => rows.map(rowToSourceBinding))),
1137+
1138+ findChildRowsBySecret: (secretId) =>
1139+ Effect.gen(function* () {
1140+ const tables = [
1141+ { model: "openapi_source_query_param" as const, kind: "query_param" as const },
1142+ { model: "openapi_source_spec_fetch_header" as const, kind: "spec_fetch_header" as const },
1143+ { model: "openapi_source_spec_fetch_query_param" as const, kind: "spec_fetch_query_param" as const },
1144+ ];
1145+ const perTable = yield* Effect.forEach(
1146+ tables,
1147+ (t) =>
1148+ adapter
1149+ .findMany({
1150+ model: t.model,
1151+ where: [{ field: "secret_id", value: secretId }],
1152+ })
1153+ .pipe(
1154+ Effect.map((rows) =>
1155+ rows.map((r) => ({
1156+ kind: t.kind,
1157+ source_id: r.source_id as string,
1158+ scope_id: r.scope_id as string,
1159+ name: r.name as string,
1160+ })),
1161+ ),
1162+ ),
1163+ { concurrency: "unbounded" },
1164+ );
1165+ return perTable.flat();
1166+ }),
1167+
1168+ lookupSourceNames: (keys) =>
1169+ Effect.gen(function* () {
1170+ if (keys.length === 0) return new Map<string, string>();
1171+ const rows = yield* adapter.findMany({ model: "openapi_source" });
1172+ const requested = new Set(keys);
1173+ const out = new Map<string, string>();
1174+ for (const r of rows) {
1175+ const key = `${r.scope_id as string}:${r.id as string}`;
1176+ if (requested.has(key)) out.set(key, r.name as string);
1177+ }
1178+ return out;
1179+ }),
8281180 };
8291181 };