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
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};