Unified · Split
| @@ -56,7 +56,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 55 unmodified lines | ||
| 56 | 56 | }); |
| 57 | 57 | return { |
| 58 | 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 | 98 | namespace: "test_api", |
| 99 | 99 | }); |
| 100 | 100 | expect(result.toolCount).toBe(2); |
| 101 | + expect(result.namespace).toBe("test_api"); | |
| 101 | 102 | |
| 102 | 103 | const tools = yield* executor.tools.list(); |
| 103 | 104 | const ids = tools.map((t) => t.id); |
| @@ -249,7 +250,7 @@ describe("graphqlPlugin", () => { · 145 unmodified lines | ||
| 249 | 250 | introspectionJson, |
| 250 | 251 | namespace: "via_static", |
| 251 | 252 | }); |
| 252 | − expect(result).toEqual({ toolCount: 2 }); | |
| 253 | + expect(result).toEqual({ toolCount: 2, namespace: "via_static" }); | |
| 253 | 254 | |
| 254 | 255 | const tools = yield* executor.tools.list(); |
| 255 | 256 | expect( |
| @@ -95,7 +95,10 @@ export interface GraphqlPluginExtension { · 94 unmodified lines | ||
| 95 | 95 | /** Add a GraphQL endpoint and register its operations as tools */ |
| 96 | 96 | readonly addSource: ( |
| 97 | 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 | 103 | /** Remove all tools from a previously added GraphQL source by namespace. |
| 101 | 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 | 415 | : Effect.void, |
| 413 | 416 | ), |
| 414 | − Effect.map(({ toolCount }) => ({ toolCount })), | |
| 415 | 417 | ), |
| 416 | 418 | |
| 417 | 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 | 467 | const resolveEffectiveSourceConfig = ( |
| 468 | + ctx: PluginCtx<OpenapiStore>, | |
| 462 | 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 | 506 | const resolveConfiguredHeaders = ( |
| 466 | 507 | ctx: PluginCtx<OpenapiStore>, |
| @@ -741,8 +782,9 @@ export const openApiPlugin = definePlugin( · 274 unmodified lines | ||
| 741 | 782 | Effect.gen(function* () { |
| 742 | 783 | const existing = yield* ctx.storage.getSource(sourceId, scope); |
| 743 | 784 | 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; | |
| 746 | 788 | if (!sourceUrl) return; |
| 747 | 789 | const specText = yield* resolveSpecText(sourceUrl).pipe( |
| 748 | 790 | Effect.provide(httpClientLayer), |
| @@ -754,8 +796,8 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines | ||
| 754 | 796 | name: existing.name, |
| 755 | 797 | baseUrl: resolvedConfig.baseUrl, |
| 756 | 798 | namespace: existing.namespace, |
| 757 | − headers: resolvedConfig.headers, | |
| 758 | − oauth2: resolvedConfig.oauth2, | |
| 799 | + headers: existing.config.headers, | |
| 800 | + oauth2: existing.config.oauth2, | |
| 759 | 801 | }); |
| 760 | 802 | }); |
| 761 | 803 | |
| @@ -819,10 +861,10 @@ export const openApiPlugin = definePlugin( · 57 unmodified lines | ||
| 819 | 861 | Effect.gen(function* () { |
| 820 | 862 | const source = yield* ctx.storage.getSource(namespace, scope); |
| 821 | 863 | if (!source) return null; |
| 822 | − const config = yield* resolveEffectiveSourceConfig(source); | |
| 864 | + const effective = yield* resolveEffectiveSourceConfig(ctx, source); | |
| 823 | 865 | return { |
| 824 | 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 | 1293 | const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, { |
| 1251 | 1294 | sourceId: op.sourceId, |
| 1252 | − sourceScope: toolScope, | |
| 1295 | + sourceScope: effective.headersSource.scope, | |
| 1253 | 1296 | headers: config.headers ?? {}, |
| 1254 | − legacyHeaders: source.legacy?.headers, | |
| 1297 | + legacyHeaders: effective.headersSource.legacy?.headers, | |
| 1255 | 1298 | }).pipe( |
| 1256 | 1299 | Effect.mapError((err) => new Error(err.message)), |
| 1257 | 1300 | ); |
| @@ -1263,9 +1306,9 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines | ||
| 1263 | 1306 | if (config.oauth2) { |
| 1264 | 1307 | const connectionId = yield* resolveOAuthConnectionId(ctx, { |
| 1265 | 1308 | sourceId: op.sourceId, |
| 1266 | − sourceScope: toolScope, | |
| 1309 | + sourceScope: effective.oauth2Source.scope, | |
| 1267 | 1310 | oauth2: config.oauth2, |
| 1268 | − legacyOAuth2: source.legacy?.oauth2, | |
| 1311 | + legacyOAuth2: effective.oauth2Source.legacy?.oauth2, | |
| 1269 | 1312 | }); |
| 1270 | 1313 | if (!connectionId) { |
| 1271 | 1314 | return yield* Effect.fail( |