History

Fix scoped source config inheritance (#392)

Rhys Sullivan committed 6 months ago

Commit ab0feed1 parent f1259355 files · +234 −16Browse files

Unified · Split

5 files
  1. packages/plugins/graphql/src/api/handlers.ts +1 −1
  2. packages/plugins/graphql/src/sdk/plugin.test.ts +2 −1
  3. packages/plugins/graphql/src/sdk/plugin.ts +4 −2
  4. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +172 −0
  5. packages/plugins/openapi/src/sdk/plugin.ts +55 −12
packages/plugins/graphql/src/api/handlers.ts+1−1
@@ -56,7 +56,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 55 unmodified lines
5656 });
5757 return {
5858 toolCount: result.toolCount,
59− namespace: payload.namespace ?? "graphql",
59+ namespace: result.namespace,
6060 };
6161 })),
6262 )
packages/plugins/graphql/src/sdk/plugin.test.ts+2−1
@@ -98,6 +98,7 @@ describe("graphqlPlugin", () => { · 97 unmodified lines
9898 namespace: "test_api",
9999 });
100100 expect(result.toolCount).toBe(2);
101+ expect(result.namespace).toBe("test_api");
101102
102103 const tools = yield* executor.tools.list();
103104 const ids = tools.map((t) => t.id);
@@ -249,7 +250,7 @@ describe("graphqlPlugin", () => { · 145 unmodified lines
249250 introspectionJson,
250251 namespace: "via_static",
251252 });
252− expect(result).toEqual({ toolCount: 2 });
253+ expect(result).toEqual({ toolCount: 2, namespace: "via_static" });
253254
254255 const tools = yield* executor.tools.list();
255256 expect(
packages/plugins/graphql/src/sdk/plugin.ts+4−2
@@ -95,7 +95,10 @@ export interface GraphqlPluginExtension { · 94 unmodified lines
9595 /** Add a GraphQL endpoint and register its operations as tools */
9696 readonly addSource: (
9797 config: GraphqlSourceConfig,
98− ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>;
98+ ) => Effect.Effect<
99+ { readonly toolCount: number; readonly namespace: string },
100+ GraphqlExtensionFailure
101+ >;
99102
100103 /** Remove all tools from a previously added GraphQL source by namespace.
101104 * `scope` pins the cleanup to the exact row — without it a shadowed
@@ -411,7 +414,6 @@ export const graphqlPlugin = definePlugin( · 309 unmodified lines
411414 )
412415 : Effect.void,
413416 ),
414− Effect.map(({ toolCount }) => ({ toolCount })),
415417 ),
416418
417419 removeSource: (namespace, scope) =>
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+172−0
@@ -606,4 +606,176 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 605 unmodified lines
606606 );
607607 }),
608608 );
609+
610+ it.effect("user-scope source shadows inherit the org baseUrl", () =>
611+ Effect.gen(function* () {
612+ const memorySecretsPlugin = definePlugin(() => ({
613+ id: "memory-secrets" as const,
614+ storage: () => ({}),
615+ secretProviders: [],
616+ }));
617+
618+ const httpClient = yield* HttpClient.HttpClient;
619+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
620+ const plugins = [
621+ openApiPlugin({ httpClientLayer: clientLayer }),
622+ memorySecretsPlugin(),
623+ ] as const;
624+
625+ const schema = collectSchemas(plugins);
626+ const adapter = makeMemoryAdapter({ schema });
627+ const blobs = makeInMemoryBlobStore();
628+ const now = new Date();
629+ const orgScope = new Scope({
630+ id: ScopeId.make("org"),
631+ name: "acme-org",
632+ createdAt: now,
633+ });
634+ const aliceScope = new Scope({
635+ id: ScopeId.make("user-alice"),
636+ name: "alice",
637+ createdAt: now,
638+ });
639+
640+ const adminExec = yield* createExecutor({
641+ scopes: [orgScope],
642+ adapter,
643+ blobs,
644+ plugins,
645+ });
646+ const aliceExec = yield* createExecutor({
647+ scopes: [aliceScope, orgScope],
648+ adapter,
649+ blobs,
650+ plugins,
651+ });
652+
653+ yield* adminExec.openapi.addSpec({
654+ spec: specJson,
655+ scope: orgScope.id as string,
656+ namespace: "vercel",
657+ baseUrl: "https://api.vercel.example",
658+ });
659+
660+ yield* aliceExec.openapi.addSpec({
661+ spec: specJson,
662+ scope: aliceScope.id as string,
663+ namespace: "vercel",
664+ });
665+
666+ const source = yield* aliceExec.openapi.getSource(
667+ "vercel",
668+ aliceScope.id as string,
669+ );
670+ expect(source?.scope).toBe(aliceScope.id);
671+ expect(source?.config.baseUrl).toBe("https://api.vercel.example");
672+ }),
673+ );
674+
675+ it.effect("user-scope source shadows resolve inherited org bindings at the org source scope", () =>
676+ Effect.gen(function* () {
677+ const secretStore = new Map<string, string>();
678+ const key = (scope: string, id: string) => `${scope}\u0000${id}`;
679+ const memoryProvider: SecretProvider = {
680+ key: "memory",
681+ writable: true,
682+ get: (id, scope) =>
683+ Effect.sync(() => secretStore.get(key(scope, id)) ?? null),
684+ set: (id, value, scope) =>
685+ Effect.sync(() => {
686+ secretStore.set(key(scope, id), value);
687+ }),
688+ delete: (id, scope) =>
689+ Effect.sync(() => secretStore.delete(key(scope, id))),
690+ };
691+ const memorySecretsPlugin = definePlugin(() => ({
692+ id: "memory-secrets" as const,
693+ storage: () => ({}),
694+ secretProviders: [memoryProvider],
695+ }));
696+
697+ const httpClient = yield* HttpClient.HttpClient;
698+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
699+ const plugins = [
700+ openApiPlugin({ httpClientLayer: clientLayer }),
701+ memorySecretsPlugin(),
702+ ] as const;
703+
704+ const schema = collectSchemas(plugins);
705+ const adapter = makeMemoryAdapter({ schema });
706+ const blobs = makeInMemoryBlobStore();
707+ const now = new Date();
708+ const orgScope = new Scope({
709+ id: ScopeId.make("org"),
710+ name: "acme-org",
711+ createdAt: now,
712+ });
713+ const aliceScope = new Scope({
714+ id: ScopeId.make("user-alice"),
715+ name: "alice",
716+ createdAt: now,
717+ });
718+
719+ const adminExec = yield* createExecutor({
720+ scopes: [orgScope],
721+ adapter,
722+ blobs,
723+ plugins,
724+ });
725+ const aliceExec = yield* createExecutor({
726+ scopes: [aliceScope, orgScope],
727+ adapter,
728+ blobs,
729+ plugins,
730+ });
731+
732+ yield* adminExec.openapi.addSpec({
733+ spec: specJson,
734+ scope: orgScope.id as string,
735+ namespace: "vercel",
736+ baseUrl: "",
737+ headers: {
738+ Authorization: new ConfiguredHeaderBinding({
739+ kind: "binding",
740+ slot: "auth:token",
741+ prefix: "Bearer ",
742+ }),
743+ },
744+ });
745+ yield* aliceExec.openapi.addSpec({
746+ spec: specJson,
747+ scope: aliceScope.id as string,
748+ namespace: "vercel",
749+ });
750+
751+ yield* aliceExec.secrets.set(
752+ new SetSecretInput({
753+ id: SecretId.make("alice_vercel_pat"),
754+ scope: aliceScope.id,
755+ name: "Alice Vercel PAT",
756+ value: "alice-token",
757+ }),
758+ );
759+ yield* aliceExec.openapi.setSourceBinding(
760+ new OpenApiSourceBindingInput({
761+ sourceId: "vercel",
762+ sourceScope: orgScope.id,
763+ scope: aliceScope.id,
764+ slot: "auth:token",
765+ value: {
766+ kind: "secret",
767+ secretId: SecretId.make("alice_vercel_pat"),
768+ },
769+ }),
770+ );
771+
772+ const result = (yield* aliceExec.tools.invoke(
773+ "vercel.projects.list",
774+ {},
775+ autoApprove,
776+ )) as { data: { authorization?: string } | null; error: unknown };
777+ expect(result.error).toBeNull();
778+ expect(result.data?.authorization).toBe("Bearer alice-token");
779+ }),
780+ );
609781 });
packages/plugins/openapi/src/sdk/plugin.ts+55−12
@@ -458,9 +458,50 @@ const canonicalizeOAuth2 = ( · 457 unmodified lines
458458 };
459459 };
460460
461+interface EffectiveSourceConfig {
462+ readonly config: SourceConfig;
463+ readonly headersSource: StoredSource;
464+ readonly oauth2Source: StoredSource;
465+}
466+
461467 const resolveEffectiveSourceConfig = (
468+ ctx: PluginCtx<OpenapiStore>,
462469 base: StoredSource,
463−): Effect.Effect<SourceConfig, never> => Effect.succeed(base.config);
470+): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>
471+ Effect.gen(function* () {
472+ const rank = new Map(ctx.scopes.map((scope, index) => [scope.id as string, index] as const));
473+ const baseRank = rank.get(base.scope) ?? Infinity;
474+ const fallback = (yield* ctx.storage.listSources())
475+ .filter((source) => source.namespace === base.namespace)
476+ .filter((source) => (rank.get(source.scope) ?? Infinity) > baseRank)
477+ .sort(
478+ (a, b) =>
479+ (rank.get(a.scope) ?? Infinity) -
480+ (rank.get(b.scope) ?? Infinity),
481+ )[0];
482+
483+ if (!fallback) {
484+ return {
485+ config: base.config,
486+ headersSource: base,
487+ oauth2Source: base,
488+ };
489+ }
490+
491+ const hasBaseHeaders = Object.keys(base.config.headers ?? {}).length > 0;
492+ return {
493+ config: {
494+ ...base.config,
495+ sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,
496+ baseUrl: base.config.baseUrl || fallback.config.baseUrl,
497+ namespace: base.config.namespace ?? fallback.config.namespace,
498+ headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,
499+ oauth2: base.config.oauth2 ?? fallback.config.oauth2,
500+ },
501+ headersSource: hasBaseHeaders ? base : fallback,
502+ oauth2Source: base.config.oauth2 ? base : fallback,
503+ };
504+ });
464505
465506 const resolveConfiguredHeaders = (
466507 ctx: PluginCtx<OpenapiStore>,
@@ -741,8 +782,9 @@ export const openApiPlugin = definePlugin( · 274 unmodified lines
741782 Effect.gen(function* () {
742783 const existing = yield* ctx.storage.getSource(sourceId, scope);
743784 if (!existing) return;
744− const resolvedConfig = yield* resolveEffectiveSourceConfig(existing);
745− const sourceUrl = existing.config.sourceUrl;
785+ const effective = yield* resolveEffectiveSourceConfig(ctx, existing);
786+ const resolvedConfig = effective.config;
787+ const sourceUrl = resolvedConfig.sourceUrl;
746788 if (!sourceUrl) return;
747789 const specText = yield* resolveSpecText(sourceUrl).pipe(
748790 Effect.provide(httpClientLayer),
@@ -754,8 +796,8 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines
754796 name: existing.name,
755797 baseUrl: resolvedConfig.baseUrl,
756798 namespace: existing.namespace,
757− headers: resolvedConfig.headers,
758− oauth2: resolvedConfig.oauth2,
799+ headers: existing.config.headers,
800+ oauth2: existing.config.oauth2,
759801 });
760802 });
761803
@@ -819,10 +861,10 @@ export const openApiPlugin = definePlugin( · 57 unmodified lines
819861 Effect.gen(function* () {
820862 const source = yield* ctx.storage.getSource(namespace, scope);
821863 if (!source) return null;
822− const config = yield* resolveEffectiveSourceConfig(source);
864+ const effective = yield* resolveEffectiveSourceConfig(ctx, source);
823865 return {
824866 ...source,
825− config,
867+ config: effective.config,
826868 };
827869 }),
828870
@@ -1246,12 +1288,13 @@ export const openApiPlugin = definePlugin( · 417 unmodified lines
12461288 );
12471289 }
12481290
1249− const config = yield* resolveEffectiveSourceConfig(source);
1291+ const effective = yield* resolveEffectiveSourceConfig(ctx, source);
1292+ const config = effective.config;
12501293 const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, {
12511294 sourceId: op.sourceId,
1252− sourceScope: toolScope,
1295+ sourceScope: effective.headersSource.scope,
12531296 headers: config.headers ?? {},
1254− legacyHeaders: source.legacy?.headers,
1297+ legacyHeaders: effective.headersSource.legacy?.headers,
12551298 }).pipe(
12561299 Effect.mapError((err) => new Error(err.message)),
12571300 );
@@ -1263,9 +1306,9 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines
12631306 if (config.oauth2) {
12641307 const connectionId = yield* resolveOAuthConnectionId(ctx, {
12651308 sourceId: op.sourceId,
1266− sourceScope: toolScope,
1309+ sourceScope: effective.oauth2Source.scope,
12671310 oauth2: config.oauth2,
1268− legacyOAuth2: source.legacy?.oauth2,
1311+ legacyOAuth2: effective.oauth2Source.legacy?.oauth2,
12691312 });
12701313 if (!connectionId) {
12711314 return yield* Effect.fail(