Unified · Split
| @@ -56,7 +56,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 55 unmodified lines | |||
| 56 | }); | 56 | }); |
| 57 | return { | 57 | return { |
| 58 | toolCount: result.toolCount, | 58 | toolCount: result.toolCount, |
| 59 | namespace: payload.namespace ?? "graphql", | 59 | namespace: result.namespace, |
| 60 | }; | 60 | }; |
| 61 | })), | 61 | })), |
| 62 | ) | 62 | ) |
| @@ -98,6 +98,7 @@ describe("graphqlPlugin", () => { · 97 unmodified lines | |||
| 98 | namespace: "test_api", | 98 | namespace: "test_api", |
| 99 | }); | 99 | }); |
| 100 | expect(result.toolCount).toBe(2); | 100 | expect(result.toolCount).toBe(2); |
| 101 | expect(result.namespace).toBe("test_api"); | ||
| 101 | 102 | ||
| 102 | const tools = yield* executor.tools.list(); | 103 | const tools = yield* executor.tools.list(); |
| 103 | const ids = tools.map((t) => t.id); | 104 | const ids = tools.map((t) => t.id); |
| @@ -249,7 +250,7 @@ describe("graphqlPlugin", () => { · 145 unmodified lines | |||
| 249 | introspectionJson, | 250 | introspectionJson, |
| 250 | namespace: "via_static", | 251 | namespace: "via_static", |
| 251 | }); | 252 | }); |
| 252 | expect(result).toEqual({ toolCount: 2 }); | 253 | expect(result).toEqual({ toolCount: 2, namespace: "via_static" }); |
| 253 | 254 | ||
| 254 | const tools = yield* executor.tools.list(); | 255 | const tools = yield* executor.tools.list(); |
| 255 | expect( | 256 | expect( |
| @@ -95,7 +95,10 @@ export interface GraphqlPluginExtension { · 94 unmodified lines | |||
| 95 | /** Add a GraphQL endpoint and register its operations as tools */ | 95 | /** Add a GraphQL endpoint and register its operations as tools */ |
| 96 | readonly addSource: ( | 96 | readonly addSource: ( |
| 97 | config: GraphqlSourceConfig, | 97 | config: GraphqlSourceConfig, |
| 98 | ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>; | 98 | ) => Effect.Effect< |
| 99 | { readonly toolCount: number; readonly namespace: string }, | ||
| 100 | GraphqlExtensionFailure | ||
| 101 | >; | ||
| 99 | 102 | ||
| 100 | /** Remove all tools from a previously added GraphQL source by namespace. | 103 | /** Remove all tools from a previously added GraphQL source by namespace. |
| 101 | * `scope` pins the cleanup to the exact row — without it a shadowed | 104 | * `scope` pins the cleanup to the exact row — without it a shadowed |
| @@ -411,7 +414,6 @@ export const graphqlPlugin = definePlugin( · 309 unmodified lines | |||
| 411 | ) | 414 | ) |
| 412 | : Effect.void, | 415 | : Effect.void, |
| 413 | ), | 416 | ), |
| 414 | Effect.map(({ toolCount }) => ({ toolCount })), | ||
| 415 | ), | 417 | ), |
| 416 | 418 | ||
| 417 | removeSource: (namespace, scope) => | 419 | removeSource: (namespace, scope) => |
| @@ -606,4 +606,176 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 605 unmodified lines | |||
| 606 | ); | 606 | ); |
| 607 | }), | 607 | }), |
| 608 | ); | 608 | ); |
| 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 | ); | ||
| 609 | }); | 781 | }); |
| @@ -458,9 +458,50 @@ const canonicalizeOAuth2 = ( · 457 unmodified lines | |||
| 458 | }; | 458 | }; |
| 459 | }; | 459 | }; |
| 460 | 460 | ||
| 461 | interface EffectiveSourceConfig { | ||
| 462 | readonly config: SourceConfig; | ||
| 463 | readonly headersSource: StoredSource; | ||
| 464 | readonly oauth2Source: StoredSource; | ||
| 465 | } | ||
| 466 | |||
| 461 | const resolveEffectiveSourceConfig = ( | 467 | const resolveEffectiveSourceConfig = ( |
| 468 | ctx: PluginCtx<OpenapiStore>, | ||
| 462 | base: StoredSource, | 469 | 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 | }); | ||
| 464 | 505 | ||
| 465 | const resolveConfiguredHeaders = ( | 506 | const resolveConfiguredHeaders = ( |
| 466 | ctx: PluginCtx<OpenapiStore>, | 507 | ctx: PluginCtx<OpenapiStore>, |
| @@ -741,8 +782,9 @@ export const openApiPlugin = definePlugin( · 274 unmodified lines | |||
| 741 | Effect.gen(function* () { | 782 | Effect.gen(function* () { |
| 742 | const existing = yield* ctx.storage.getSource(sourceId, scope); | 783 | const existing = yield* ctx.storage.getSource(sourceId, scope); |
| 743 | if (!existing) return; | 784 | if (!existing) return; |
| 744 | const resolvedConfig = yield* resolveEffectiveSourceConfig(existing); | 785 | const effective = yield* resolveEffectiveSourceConfig(ctx, existing); |
| 745 | const sourceUrl = existing.config.sourceUrl; | 786 | const resolvedConfig = effective.config; |
| 787 | const sourceUrl = resolvedConfig.sourceUrl; | ||
| 746 | if (!sourceUrl) return; | 788 | if (!sourceUrl) return; |
| 747 | const specText = yield* resolveSpecText(sourceUrl).pipe( | 789 | const specText = yield* resolveSpecText(sourceUrl).pipe( |
| 748 | Effect.provide(httpClientLayer), | 790 | Effect.provide(httpClientLayer), |
| @@ -754,8 +796,8 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines | |||
| 754 | name: existing.name, | 796 | name: existing.name, |
| 755 | baseUrl: resolvedConfig.baseUrl, | 797 | baseUrl: resolvedConfig.baseUrl, |
| 756 | namespace: existing.namespace, | 798 | namespace: existing.namespace, |
| 757 | headers: resolvedConfig.headers, | 799 | headers: existing.config.headers, |
| 758 | oauth2: resolvedConfig.oauth2, | 800 | oauth2: existing.config.oauth2, |
| 759 | }); | 801 | }); |
| 760 | }); | 802 | }); |
| 761 | 803 | ||
| @@ -819,10 +861,10 @@ export const openApiPlugin = definePlugin( · 57 unmodified lines | |||
| 819 | Effect.gen(function* () { | 861 | Effect.gen(function* () { |
| 820 | const source = yield* ctx.storage.getSource(namespace, scope); | 862 | const source = yield* ctx.storage.getSource(namespace, scope); |
| 821 | if (!source) return null; | 863 | if (!source) return null; |
| 822 | const config = yield* resolveEffectiveSourceConfig(source); | 864 | const effective = yield* resolveEffectiveSourceConfig(ctx, source); |
| 823 | return { | 865 | return { |
| 824 | ...source, | 866 | ...source, |
| 825 | config, | 867 | config: effective.config, |
| 826 | }; | 868 | }; |
| 827 | }), | 869 | }), |
| 828 | 870 | ||
| @@ -1246,12 +1288,13 @@ export const openApiPlugin = definePlugin( · 417 unmodified lines | |||
| 1246 | ); | 1288 | ); |
| 1247 | } | 1289 | } |
| 1248 | 1290 | ||
| 1249 | const config = yield* resolveEffectiveSourceConfig(source); | 1291 | const effective = yield* resolveEffectiveSourceConfig(ctx, source); |
| 1292 | const config = effective.config; | ||
| 1250 | const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, { | 1293 | const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, { |
| 1251 | sourceId: op.sourceId, | 1294 | sourceId: op.sourceId, |
| 1252 | sourceScope: toolScope, | 1295 | sourceScope: effective.headersSource.scope, |
| 1253 | headers: config.headers ?? {}, | 1296 | headers: config.headers ?? {}, |
| 1254 | legacyHeaders: source.legacy?.headers, | 1297 | legacyHeaders: effective.headersSource.legacy?.headers, |
| 1255 | }).pipe( | 1298 | }).pipe( |
| 1256 | Effect.mapError((err) => new Error(err.message)), | 1299 | Effect.mapError((err) => new Error(err.message)), |
| 1257 | ); | 1300 | ); |
| @@ -1263,9 +1306,9 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines | |||
| 1263 | if (config.oauth2) { | 1306 | if (config.oauth2) { |
| 1264 | const connectionId = yield* resolveOAuthConnectionId(ctx, { | 1307 | const connectionId = yield* resolveOAuthConnectionId(ctx, { |
| 1265 | sourceId: op.sourceId, | 1308 | sourceId: op.sourceId, |
| 1266 | sourceScope: toolScope, | 1309 | sourceScope: effective.oauth2Source.scope, |
| 1267 | oauth2: config.oauth2, | 1310 | oauth2: config.oauth2, |
| 1268 | legacyOAuth2: source.legacy?.oauth2, | 1311 | legacyOAuth2: effective.oauth2Source.legacy?.oauth2, |
| 1269 | }); | 1312 | }); |
| 1270 | if (!connectionId) { | 1313 | if (!connectionId) { |
| 1271 | return yield* Effect.fail( | 1314 | return yield* Effect.fail( |