History

Unify auth methods across OpenAPI, GraphQL, and MCP on a shared placements model (#943)

* Add @executor-js/http-auth: shared placements-based auth-method model One vocabulary for the HTTP protocol plugins (openapi/graphql/mcp): an apikey method carries N header/query placements, each bound to its own credential variable (or sharing one), plus literal placements for static values. Includes the renderer, required-variable extraction, slug normalization, and catalog projection helpers. Core gains the carrier-agnostic getValues seam on ResolveToolsInput (multi-input analog of getValue) and the catalog wire schema now carries placement variable/literal so multi-input methods survive the HTTP boundary. Removes the dead http-source and graphql-greenfield packages (zero consumers; they forked this exact logic). * MCP: adopt the shared placements auth model McpAuthMethod becomes none | apikey(placements) | oauth2 — one method can mix header and query placements, each rendered from its own credential input (or sharing one). buildConnectorInput renders via the shared renderer over credential.values; invokeTool fails explicitly with connection_value_missing instead of dialing unauthenticated; discovery keeps its tolerant skip. The addServer 'auth' shorthand stays as input sugar. Pre-canonical stored shapes (singular auth, retired header/query kinds, openapi-shaped v1-migration templates) are rewritten by the new one-off migrate-config module — runtime decodes only the canonical shape. Slugs and variable names are preserved verbatim (connection.template / item_ids contracts). The react codec collapses onto the new shared-auth-method-codec in packages/react, deleting the first-named-placement truncation; the edit screen diffs placements deeply. Wire tests pin the extreme case: one method = bearer header + team-id query param, asserted on what the server received. * OpenAPI: adopt the shared placements auth model The apiKey arm of Authentication becomes the shared ApiKeyAuthMethod (placements bound to per-input variables) — the variable()-templated header/query records are retired. The oauth arm keeps its core shape (type: "oauth", stored endpoints + scopes). renderAuthTemplate and requiredTemplateVariables now sit on the shared renderer; the catalog projection and react codec collapse onto the shared helpers. Stored legacy templates are rewritten by the one-off migrate-config module; equivalence tests pin that migrated templates render byte-identically to the legacy renderer (multi-variable Datadog-style, literal-only values, prefix extraction). Slugs and variable names are preserved verbatim. * Wire the one-off auth-config migration + unified-auth e2e scenarios The migration planner (and a shared SQLite runner) live in http-auth; each app routes by plugin_id to the plugins' migrate functions: local runs it at boot after the v1->v2 migration (desktop inherits), selfhost at boot beside its defensive column adds, cloud as an out-of-band script (db:migrate-auth:prod) run before deploying canonical-only code. e2e: scenarios move to canonical wire shapes and gain the extreme case — one MCP source declaring [oauth][bearer header + team-id query (two inputs)][bearer header][query token], one connection per method, every invocation made through the real MCP execute surface and asserted against what a recording MCP server received. The selfhost browser suite gains the 2-input connect modal scenario (one field per input, both rendered together on the wire). * GraphQL: adopt the shared placements auth model GraphqlAuthMethod becomes none | apikey(placements) | oauth2(header?, prefix?) — one method can mix header and query placements, each rendered from its own credential input. The local renderer is deleted: apikey renders through the shared renderer, oauth2 through oauthBearerPlacement (custom header/prefix overrides preserved). Introspection, discovery, and invoke run on credential.values; a missing input fails explicitly, naming the variable. Pre-canonical stored shapes (retired {kind:'apiKey', in, name} entries, openapi-shaped v1-migration templates, the singular auth field — incl. the v1 output that wrote BOTH auth:{oauth2} and an apiKey array) are rewritten by the one-off migrate-config module. The react codec collapses onto shared-auth-method-codec, deleting the one-template-per-placement workaround. Wire tests pin the extreme case (bearer header + team-id query on one method) against a recording GraphQL server, on both introspection and invocation. * Sweep remaining consumers onto the canonical auth shapes Examples (docs quickstart + regenerated snippet, promise-sdk, all-plugins), the kernel dynamic-worker integration test, and the local/cloud auth-tool-failure suites move off the retired variable()-templated apiKey shape. Boundary lint suppressions for the migration runner/script; changelog stub for the new http-auth package. * e2e: make the sandbox tool-result assertion shape-agnostic * Changeset + repo notes for the unified auth model * Fold http-auth into @executor-js/sdk as the /http-auth subpath Carrier-agnosticism is a composition property, not a location one: core never imports the module, so it doesn't need its own package. The vocabulary ships as @executor-js/sdk/http-auth (browser-safe subpath, own tsup entry); the standalone package, its workspace entry, deps, and fixed-group/changeset references are gone. * Accept request-shaped auth authoring on every plugin Auth inputs take two dialects, normalized to the one stored model: the canonical placements shape, and the request-shaped template that reads like the request it produces — { type: "apiKey", headers: { Authorization: ["Bearer ", variable("token")] }, queryParams: { team_id: [variable("team_id")] } } variable() is exported from each plugin. Authoring is strict where the migration is tolerant: a template value renders at most ONE variable, as the FINAL part — exotic interleavings are rejected at decode with a pointed message instead of silently flattened (only the one-off migration flattens, for data that already exists). The dialect is accepted by addServer/addIntegration/addSpec/configure on all three plugins (typed clients included — the docs quickstart and the auth-tool-failure suites author this way again), expanded before merge so stored configs stay canonical. Wire tests pin that an authored template renders identically to its canonical twin. * One apikey authoring dialect: request-shaped in, placements out Auth INPUTS accept exactly one apikey shape — the request-shaped template (headers/queryParams records with variable() slots) — expanded to canonical placements at the boundary. Stored configs and the catalog keep reading as placements; apiKeyAuthTemplateFromMethod serializes them back, so every read-modify-write surface (edit screen replace, custom- method remove, add flows) round-trips through one serializer instead of the wire accepting two dialects. Published schemas now teach a single authoring shape; canonical placement inputs are rejected with a schema error pointing at the dialect. * One discriminator: OpenAPI oauth templates re-key to kind:"oauth2" Every auth method is now kind-keyed — the core OAuthAuthentication shape drops its legacy type:"oauth" spelling, so the Authentication union narrows on .kind alone and the '"kind" in template' guards are gone. The one-off config migration rewrites stored type:"oauth" entries (slug and endpoints verbatim); oauth INPUTS are wire-typed (plain slug) and branded in the normalize pass, letting the HTTP handlers pass payloads through untouched. * Migration script: TLS for direct connections + post-migration decode audit The script connects to PlanetScale directly (no Hyperdrive), which requires TLS. The plan now also audits the SIMULATED final state of every protocol-plugin row — rewritten or untouched — against the new runtime's canonical decoders and refuses to write if any row would read as 'no usable config' after deploy. Verified against prod: 343 rows, 302 rewrite, audit clean.

Rhys Sullivan committed 5 months ago

Commit c92bbda1 parent 7212406122 files · +4629 −5854Browse files

Unified · Split

122 files
  1. .changeset/unified-auth-methods.md +14 −0
  2. AGENTS.md +4 −1
  3. apps/cloud/package.json +2 −0
  4. apps/cloud/scripts/migrate-auth-configs.ts +101 −0
  5. apps/host-selfhost/src/app.ts +8 −0
  6. apps/host-selfhost/src/db/auth-config-migration.ts +17 −0
  7. apps/local/src/db/auth-config-migration.ts +17 −0
  8. apps/local/src/executor.ts +11 −0
  9. bun.lock +0 −55
  10. docs/snippets/sdk/quickstart/add-integration.mdx +1 −1
  11. e2e/scenarios/auth-methods.test.ts +226 −20
  12. e2e/selfhost/auth-methods-ui.test.ts +83 −0
  13. e2e/selfhost/mcp-multi-auth.test.ts +4 −1
  14. e2e/selfhost/oauth-app-modal.test.ts +1 −1
  15. examples/all-plugins/src/main.ts +1 −1
  16. examples/docs-sdk-quickstart/src/main.ts +1 −1
  17. examples/promise-sdk/src/main.ts +1 −1
  18. packages/core/api/src/integrations/api.ts +6 −0
  19. packages/core/sdk/package.json +8 −1
  20. packages/core/sdk/src/executor.ts +30 −11
  21. packages/core/sdk/src/http-auth/auth-method.test.ts +223 −0
  22. packages/core/sdk/src/http-auth/auth-method.ts +188 −0
  23. packages/core/sdk/src/http-auth/authoring.test.ts +97 −0
  24. packages/core/sdk/src/http-auth/authoring.ts +148 −0
  25. packages/core/sdk/src/http-auth/index.ts +43 −0
  26. packages/core/sdk/src/http-auth/legacy.ts +82 −0
  27. packages/core/sdk/src/http-auth/migrate.test.ts +80 −0
  28. packages/core/sdk/src/http-auth/migrate.ts +99 −0
  29. packages/core/sdk/src/integration.ts +4 −0
  30. packages/core/sdk/src/oauth-client.ts +3 −2
  31. packages/core/sdk/src/plugin.ts +4 −0
  32. packages/core/sdk/tsup.config.ts +1 −0
  33. packages/kernel/runtime-dynamic-worker/src/integration.test.ts +3 −3
  34. packages/plugins/graphql-greenfield/CHANGELOG.md +0 −33
  35. packages/plugins/graphql-greenfield/package.json +0 −56
  36. packages/plugins/graphql-greenfield/src/api/group.ts +0 −83
  37. packages/plugins/graphql-greenfield/src/api/handlers.ts +0 −70
  38. packages/plugins/graphql-greenfield/src/api/index.ts +0 −20
  39. packages/plugins/graphql-greenfield/src/promise.ts +0 −7
  40. packages/plugins/graphql-greenfield/src/sdk/errors.ts +0 −40
  41. packages/plugins/graphql-greenfield/src/sdk/extract.ts +0 −252
  42. packages/plugins/graphql-greenfield/src/sdk/index.ts +0 −31
  43. packages/plugins/graphql-greenfield/src/sdk/introspect.ts +0 −343
  44. packages/plugins/graphql-greenfield/src/sdk/invoke.ts +0 −147
  45. packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts +0 −512
  46. packages/plugins/graphql-greenfield/src/sdk/plugin.ts +0 −730
  47. packages/plugins/graphql-greenfield/src/sdk/presets.ts +0 −55
  48. packages/plugins/graphql-greenfield/src/sdk/types.ts +0 −147
  49. packages/plugins/graphql-greenfield/src/testing/index.ts +0 −258
  50. packages/plugins/graphql-greenfield/tsconfig.json +0 −26
  51. packages/plugins/graphql-greenfield/tsup.config.ts +0 −14
  52. packages/plugins/graphql-greenfield/vitest.config.ts +0 −13
  53. packages/plugins/graphql/src/api/group.ts +8 −7
  54. packages/plugins/graphql/src/api/handlers.test.ts +52 −19
  55. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +15 −22
  56. packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx +19 −15
  57. packages/plugins/graphql/src/react/auth-method-config.test.ts +164 −71
  58. packages/plugins/graphql/src/react/auth-method-config.ts +71 −70
  59. packages/plugins/graphql/src/react/defaults.test.ts +1 −22
  60. packages/plugins/graphql/src/react/defaults.ts +0 −20
  61. packages/plugins/graphql/src/sdk/configure.test.ts +31 −24
  62. packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts +57 −8
  63. packages/plugins/graphql/src/sdk/index.ts +9 −4
  64. packages/plugins/graphql/src/sdk/migrate-config.test.ts +202 −0
  65. packages/plugins/graphql/src/sdk/migrate-config.ts +148 −0
  66. packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts +206 −0
  67. packages/plugins/graphql/src/sdk/plugin.test.ts +3 −5
  68. packages/plugins/graphql/src/sdk/plugin.ts +107 −96
  69. packages/plugins/graphql/src/sdk/types.ts +101 −32
  70. packages/plugins/http-source/CHANGELOG.md +0 −33
  71. packages/plugins/http-source/package.json +0 −79
  72. packages/plugins/http-source/src/index.ts +0 −1
  73. packages/plugins/http-source/src/react/index.ts +0 −25
  74. packages/plugins/http-source/src/sdk/configure.test.ts +0 −223
  75. packages/plugins/http-source/src/sdk/errors.ts +0 −18
  76. packages/plugins/http-source/src/sdk/index.ts +0 −33
  77. packages/plugins/http-source/src/sdk/plugin.test.ts +0 −210
  78. packages/plugins/http-source/src/sdk/plugin.ts +0 −404
  79. packages/plugins/http-source/src/sdk/request.test.ts +0 −142
  80. packages/plugins/http-source/src/sdk/request.ts +0 −134
  81. packages/plugins/http-source/src/sdk/template.test.ts +0 −113
  82. packages/plugins/http-source/src/sdk/template.ts +0 −106
  83. packages/plugins/http-source/src/sdk/types.ts +0 −97
  84. packages/plugins/http-source/tsconfig.json +0 −23
  85. packages/plugins/http-source/tsup.config.ts +0 −14
  86. packages/plugins/http-source/vitest.config.ts +0 −7
  87. packages/plugins/mcp/src/api/group.ts +2 −2
  88. packages/plugins/mcp/src/react/AddMcpSource.tsx +2 −2
  89. packages/plugins/mcp/src/react/EditMcpSource.tsx +36 −11
  90. packages/plugins/mcp/src/react/McpAccountsPanel.tsx +6 −2
  91. packages/plugins/mcp/src/react/auth-method-config.test.ts +103 −32
  92. packages/plugins/mcp/src/react/auth-method-config.ts +47 −79
  93. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +63 −46
  94. packages/plugins/mcp/src/sdk/index.ts +8 −1
  95. packages/plugins/mcp/src/sdk/migrate-config.test.ts +165 −0
  96. packages/plugins/mcp/src/sdk/migrate-config.ts +185 −0
  97. packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts +228 −0
  98. packages/plugins/mcp/src/sdk/plugin.test.ts +8 −3
  99. packages/plugins/mcp/src/sdk/plugin.ts +58 −34
  100. packages/plugins/mcp/src/sdk/types.ts +110 −83
  101. packages/plugins/openapi/src/api/group.ts +15 −24
  102. packages/plugins/openapi/src/api/handlers.ts +2 −14
  103. packages/plugins/openapi/src/promise.ts +6 −9
  104. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +18 −20
  105. packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx +6 −2
  106. packages/plugins/openapi/src/react/auth-method-config.test.ts +90 −83
  107. packages/plugins/openapi/src/react/auth-method-config.ts +45 −158
  108. packages/plugins/openapi/src/sdk/config.ts +21 −65
  109. packages/plugins/openapi/src/sdk/configure.test.ts +72 −27
  110. packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +6 −7
  111. packages/plugins/openapi/src/sdk/google-bundle.test.ts +2 −2
  112. packages/plugins/openapi/src/sdk/google-discovery.test.ts +5 −5
  113. packages/plugins/openapi/src/sdk/google-discovery.ts +1 −1
  114. packages/plugins/openapi/src/sdk/index.ts +7 −3
  115. packages/plugins/openapi/src/sdk/migrate-config.test.ts +225 −0
  116. packages/plugins/openapi/src/sdk/migrate-config.ts +70 −0
  117. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +3 −3
  118. packages/plugins/openapi/src/sdk/plugin.test.ts +5 −5
  119. packages/plugins/openapi/src/sdk/plugin.ts +29 −87
  120. packages/plugins/openapi/src/sdk/types.ts +45 −30
  121. packages/react/src/lib/auth-placements.tsx +6 −1
  122. packages/react/src/lib/shared-auth-method-codec.ts +224 −0
.changeset/unified-auth-methods.md+14−0
@@ -0,0 +1,14 @@
1+---
2+"executor": minor
3+"@executor-js/react": minor
4+---
5+
6+**One auth model across OpenAPI, GraphQL, and MCP**
7+
8+- Every protocol plugin now stores the same placements-based auth methods (the new `@executor-js/sdk/http-auth` vocabulary): an API-key method carries any mix of header and query placements, each rendered from its own credential input — so one source can declare OAuth, a bearer-header-plus-team-id-query method, a plain bearer, and a query token side by side, and one connection can carry several values (e.g. both Datadog keys).
9+- MCP and GraphQL gain what only OpenAPI could do before: multi-placement methods, query-parameter credentials (servers like ui.sh's `?token=`), and multi-input connections. Rendering, catalog projection, slug normalization, and the React method editor/codec are shared instead of triplicated; the connect modal collects one value per input.
10+- Invoking with an unresolvable credential input now fails with `connection_value_missing` (naming the missing inputs) instead of silently dialing unauthenticated.
11+- Stored integration configs are rewritten to the canonical shape by a one-off migration: local and self-host run it automatically at startup; cloud operators run `bun run db:migrate-auth:prod` before deploying. Connection bindings and stored credential values are preserved exactly.
12+- Authoring: apikey methods are authored in ONE request-shaped dialect on every plugin — it reads like the request it produces: `{ type: "apiKey", headers: { Authorization: ["Bearer ", variable("token")] }, queryParams: { team_id: [variable("team_id")] } }` (`variable()` is exported from each plugin; a plain-string value is a static literal). Inputs normalize to the canonical placements model, which is what stored configs and the catalog read as. Authoring is strict where the renderer is: a value carries at most one variable, as the final part.
13+- Every method is keyed by `kind` — OpenAPI's oauth templates re-key from the retired `type: "oauth"` spelling to `kind: "oauth2"` (matching MCP/GraphQL); the one-off migration rewrites stored entries.
14+- Breaking (wire): the retired single-placement inputs (`headerName` on MCP, `in`/`name` on GraphQL), raw canonical-placement inputs, and `type: "oauth"` oauth inputs are rejected. The `mcp.addServer` singular `auth` shorthand still works.
AGENTS.md+4−1
@@ -47,7 +47,10 @@ before using it. · 46 unmodified lines
4747 ## Package Roles
4848
4949 - `packages/core/sdk`: executor core contracts, plugin wiring, scopes, sources,
50− secrets, policies, and test fixtures.
50+ secrets, policies, and test fixtures. The `@executor-js/sdk/http-auth`
51+ subpath carries the shared placements-based auth-method vocabulary the HTTP
52+ protocol plugins compose (core itself never imports it — composition, not
53+ location, keeps core carrier-agnostic).
5154 - `packages/core/storage-*`: storage adapters and storage test support.
5255 - `packages/plugins/*`: protocol and provider plugins. Plugin-specific
5356 runtime, React, API, and testing helpers should live with the owning plugin.
apps/cloud/package.json+2−0
@@ -13,6 +13,8 @@ · 12 unmodified lines
1313 "db:studio": "drizzle-kit studio",
1414 "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio",
1515 "db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
16+ "db:migrate-auth:prod": "op run --env-file=.env.production -- bun run scripts/migrate-auth-configs.ts",
17+ "db:migrate-auth:dev": "op run --env-file=.env.op -- bun run scripts/migrate-auth-configs.ts",
1618 "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
1719 "build": "node scripts/build.mjs",
1820 "preview": "vite preview",
apps/cloud/scripts/migrate-auth-configs.ts+101−0
@@ -0,0 +1,101 @@
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: out-of-band migration script over a raw postgres connection */
2+// ---------------------------------------------------------------------------
3+// One-off data migration: rewrite pre-canonical integration auth configs
4+// into the shared placements model. Run OUT-OF-BAND against the database
5+// BEFORE deploying code that only decodes the canonical shape (the same
6+// rule as schema migrations — Workers never migrate at request time):
7+//
8+// bun run db:migrate-auth:prod # op run --env-file=.env.production
9+// bun run db:migrate-auth:dev # against the local PGlite dev db
10+//
11+// Idempotent — canonical rows plan zero updates, so re-running is safe.
12+// Pass --dry-run to print the plan without writing.
13+// ---------------------------------------------------------------------------
14+
15+import postgres from "postgres";
16+import { Option } from "effect";
17+import { planAuthConfigMigration, type AuthConfigMigrationRow } from "@executor-js/sdk/http-auth";
18+import {
19+ decodeGraphqlIntegrationConfigOption,
20+ migrateGraphqlAuthConfig,
21+} from "@executor-js/plugin-graphql";
22+import { migrateMcpAuthConfig, parseMcpIntegrationConfig } from "@executor-js/plugin-mcp";
23+import {
24+ decodeOpenApiIntegrationConfig,
25+ migrateOpenApiAuthConfig,
26+} from "@executor-js/plugin-openapi";
27+
28+const transforms = {
29+ mcp: migrateMcpAuthConfig,
30+ graphql: migrateGraphqlAuthConfig,
31+ openapi: migrateOpenApiAuthConfig,
32+};
33+
34+// Whether a config decodes under the NEW runtime (the canonical shapes).
35+// Used by the post-migration audit: every row the new code will read must
36+// decode, whether the migration rewrote it or left it untouched.
37+const decodesCanonically: Record<string, (config: unknown) => boolean> = {
38+ mcp: (config) => parseMcpIntegrationConfig(config) !== null,
39+ graphql: (config) => Option.isSome(decodeGraphqlIntegrationConfigOption(config)),
40+ openapi: (config) => decodeOpenApiIntegrationConfig(config) !== null,
41+};
42+
43+const connectionString = process.env.DATABASE_URL;
44+if (!connectionString) {
45+ console.error("DATABASE_URL is not set");
46+ process.exit(1);
47+}
48+const dryRun = process.argv.includes("--dry-run");
49+
50+// Direct (non-Hyperdrive) connection — PlanetScale requires TLS.
51+const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
52+
53+try {
54+ const rows = await sql<{ row_id: string; plugin_id: string; config: unknown }[]>`
55+ SELECT row_id, plugin_id, config FROM integration
56+ `;
57+
58+ const inputs: AuthConfigMigrationRow[] = rows.map((row) => ({
59+ rowId: row.row_id,
60+ pluginId: row.plugin_id,
61+ // postgres-js parses json columns; tolerate text just in case.
62+ config: typeof row.config === "string" ? JSON.parse(row.config) : row.config,
63+ }));
64+
65+ const updates = planAuthConfigMigration(inputs, transforms);
66+ console.log(`${rows.length} integration row(s), ${updates.length} to rewrite`);
67+
68+ // Post-migration audit: simulate the final state of every row this plugin
69+ // set owns and check it decodes under the new runtime. A row that neither
70+ // rewrites nor decodes would read as "no usable config" after deploy —
71+ // surface those BEFORE writing anything.
72+ const planned = new Map(updates.map((update) => [update.rowId, update.config]));
73+ const undecodable = inputs.filter((row) => {
74+ const decodes = decodesCanonically[row.pluginId];
75+ if (!decodes) return false; // not a protocol-plugin row (no auth config)
76+ return !decodes(planned.get(row.rowId) ?? row.config);
77+ });
78+ if (undecodable.length > 0) {
79+ console.error(`${undecodable.length} row(s) would NOT decode after migration:`);
80+ for (const row of undecodable) console.error(` ${row.pluginId} ${row.rowId}`);
81+ process.exit(1);
82+ }
83+ console.log("audit: every row decodes canonically after migration");
84+
85+ if (dryRun) {
86+ for (const update of updates) console.log(` would rewrite ${update.rowId}`);
87+ } else if (updates.length > 0) {
88+ await sql.begin(async (tx) => {
89+ for (const update of updates) {
90+ await tx`
91+ UPDATE integration
92+ SET config = ${tx.json(update.config as never)}
93+ WHERE row_id = ${update.rowId}
94+ `;
95+ }
96+ });
97+ console.log(`rewrote ${updates.length} row(s)`);
98+ }
99+} finally {
100+ await sql.end();
101+}
apps/host-selfhost/src/app.ts+8−0
@@ -4,7 +4,10 @@ import { Layer } from "effect"; · 3 unmodified lines
44
55 import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
66
7+import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
8+
79 import { resolveAuthProviders } from "./auth";
10+import { authConfigTransforms } from "./db/auth-config-migration";
811 import { makeSelfHostAdminApiLayer } from "./admin/handlers";
912 import { makeSelfHostSystemApiLayer } from "./system/handlers";
1013 import { selfHostAccountMiddleware } from "./account";
@@ -54,6 +57,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 43 unmodified lines
5457 version: SELF_HOST_SCHEMA_VERSION,
5558 });
5659
60+ // One-off data migration: rewrite pre-canonical integration auth configs
61+ // into the shared placements model. Idempotent — a no-op once every row is
62+ // canonical (same defensive-at-boot pattern as the column adds above).
63+ await runSqliteAuthConfigMigration(dbHandle.client, authConfigTransforms);
64+
5765 // ---- auth providers ---------------------------------------------------
5866 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
5967 // API + MCP OAuth seam, all over the shared libSQL handle.
apps/host-selfhost/src/db/auth-config-migration.ts+17−0
@@ -0,0 +1,17 @@
1+// ---------------------------------------------------------------------------
2+// The per-plugin auth-config transforms the one-off boot migration runs over
3+// the `integration` table (see `runSqliteAuthConfigMigration`). Each plugin
4+// owns the rewrite of its own legacy config shapes into the canonical
5+// placements model; this map only routes by `plugin_id`.
6+// ---------------------------------------------------------------------------
7+
8+import type { AuthConfigTransform } from "@executor-js/sdk/http-auth";
9+import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql";
10+import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp";
11+import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi";
12+
13+export const authConfigTransforms: Record<string, AuthConfigTransform> = {
14+ mcp: migrateMcpAuthConfig,
15+ graphql: migrateGraphqlAuthConfig,
16+ openapi: migrateOpenApiAuthConfig,
17+};
apps/local/src/db/auth-config-migration.ts+17−0
@@ -0,0 +1,17 @@
1+// ---------------------------------------------------------------------------
2+// The per-plugin auth-config transforms the one-off boot migration runs over
3+// the `integration` table (see `runSqliteAuthConfigMigration`). Each plugin
4+// owns the rewrite of its own legacy config shapes into the canonical
5+// placements model; this map only routes by `plugin_id`.
6+// ---------------------------------------------------------------------------
7+
8+import type { AuthConfigTransform } from "@executor-js/sdk/http-auth";
9+import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql";
10+import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp";
11+import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi";
12+
13+export const authConfigTransforms: Record<string, AuthConfigTransform> = {
14+ mcp: migrateMcpAuthConfig,
15+ graphql: migrateGraphqlAuthConfig,
16+ openapi: migrateOpenApiAuthConfig,
17+};
apps/local/src/executor.ts+11−0
@@ -5,10 +5,12 @@ import { basename, join } from "node:path"; · 4 unmodified lines
55 import { createHash } from "node:crypto";
66
77 import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk";
8+import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
89 import { collectTables } from "@executor-js/api/server";
910 import { loadPluginsFromJsonc } from "@executor-js/config";
1011
1112 import executorConfig from "../executor.config";
13+import { authConfigTransforms } from "./db/auth-config-migration";
1214 import { createSqliteFumaDb } from "./db/sqlite-fumadb";
1315 import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";
1416
@@ -167,6 +169,15 @@ const createLocalExecutorLayer = () => { · 152 unmodified lines
167169 (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),
168170 );
169171
172+ // One-off data migration: rewrite pre-canonical integration auth
173+ // configs (incl. v1→v2 outputs) into the shared placements model.
174+ // Idempotent — a no-op once every row is canonical.
175+ yield* Effect.tryPromise({
176+ try: () => runSqliteAuthConfigMigration(sqlite.client, authConfigTransforms),
177+ catch: (cause) =>
178+ new LocalExecutorCreateError({ message: CREATE_SQLITE_ERROR_MESSAGE, cause }),
179+ });
180+
170181 // webBaseUrl is where the executor's web UI listens — same port as the
171182 // daemon API since the daemon serves both. Mirrors serve.ts's port
172183 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
bun.lock+0−55
@@ -846,57 +846,6 @@ · 845 unmodified lines
846846 "react",
847847 ],
848848 },
849− "packages/plugins/graphql-greenfield": {
850− "name": "@executor-js/plugin-graphql-greenfield",
851− "version": "0.0.4",
852− "dependencies": {
853− "@effect/platform-node": "catalog:",
854− "@executor-js/sdk": "workspace:*",
855− "effect": "catalog:",
856− "graphql": "^16.12.0",
857− "graphql-yoga": "^5.17.0",
858− },
859− "devDependencies": {
860− "@effect/vitest": "catalog:",
861− "@executor-js/api": "workspace:*",
862− "@types/node": "catalog:",
863− "bun-types": "catalog:",
864− "tsup": "catalog:",
865− "vitest": "catalog:",
866− },
867− "peerDependencies": {
868− "@executor-js/api": "workspace:*",
869− },
870− "optionalPeers": [
871− "@executor-js/api",
872− ],
873− },
874− "packages/plugins/http-source": {
875− "name": "@executor-js/plugin-http-source",
876− "version": "2.0.4",
877− "dependencies": {
878− "@executor-js/sdk": "workspace:*",
879− "effect": "catalog:",
880− },
881− "devDependencies": {
882− "@effect/vitest": "catalog:",
883− "@executor-js/react": "workspace:*",
884− "@types/node": "catalog:",
885− "@types/react": "catalog:",
886− "bun-types": "catalog:",
887− "react": "catalog:",
888− "tsup": "catalog:",
889− "vitest": "catalog:",
890− },
891− "peerDependencies": {
892− "@executor-js/react": "workspace:*",
893− "react": "catalog:",
894− },
895− "optionalPeers": [
896− "@executor-js/react",
897− "react",
898− ],
899− },
900849 "packages/plugins/keychain": {
901850 "name": "@executor-js/plugin-keychain",
902851 "version": "1.5.3",
@@ -1598,10 +1547,6 @@ · 695 unmodified lines
15981547
15991548 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],
16001549
1601− "@executor-js/plugin-graphql-greenfield": ["@executor-js/plugin-graphql-greenfield@workspace:packages/plugins/graphql-greenfield"],
1602−
1603− "@executor-js/plugin-http-source": ["@executor-js/plugin-http-source@workspace:packages/plugins/http-source"],
1604−
16051550 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],
16061551
16071552 "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"],
docs/snippets/sdk/quickstart/add-integration.mdx+1−1
@@ -4,7 +4,7 @@ Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end ma · 3 unmodified lines
44 _/}
55
66 ```ts
7−// An integration is the API surface. The apiKey template declares where a
7+// An integration is the API surface. The auth template declares where a
88 // connection's credential is placed on each request — here, an `X-API-Key`
99 // header. `variable("token")` is the slot the resolved credential renders into.
1010 await executor.openapi.addSpec({
e2e/scenarios/auth-methods.test.ts+226−20
@@ -1,26 +1,33 @@
11 // Cross-target: multi-method authentication — an integration declares SEVERAL
2−// auth methods and a connection picks one by template slug. Covers the model
3−// rework: MCP's slugged `authenticationTemplate` array, the merge-append
4−// configureAuth flow (a custom API key must never displace a detected OAuth
5−// method), declaring a method on a server that advertises none, and GraphQL's
6−// multi-method add. Entirely through the typed clients — no MCP server is
7−// dialed (registration and method configuration are catalog statements).
2+// auth methods and a connection picks one by template slug. Covers the unified
3+// placements model: one apikey method can mix header and query placements,
4+// each rendered from its own credential input; the merge-append configureAuth
5+// flow (a custom API key must never displace a detected OAuth method);
6+// declaring a method on a server that advertises none; GraphQL's multi-method
7+// add — and the EXTREME case end-to-end: a source declaring
8+// [oauth] [bearer header + team-id query] [bearer header] [query token],
9+// one connection per credential method, every invocation asserted against
10+// what a real recording MCP server received.
811 import { randomBytes } from "node:crypto";
912
1013 import { expect } from "@effect/vitest";
1114 import { Effect } from "effect";
1215 import { composePluginApi } from "@executor-js/api/server";
1316 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
17+import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
1418 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
15−import { IntegrationSlug } from "@executor-js/sdk/shared";
19+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20+
21+import { variable } from "@executor-js/sdk/http-auth";
1622
1723 import { scenario } from "../src/scenario";
1824
1925 const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const);
2026
21−const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
27+const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
2228
23−// Registration never dials the endpoint, so a closed local port is fine.
29+// Registration never dials the endpoint, so a closed local port is fine for
30+// the catalog-only scenarios.
2431 const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp";
2532
2633 scenario(
@@ -30,7 +37,7 @@ scenario( · 3 unmodified lines
3037 Effect.gen(function* () {
3138 const identity = yield* ctx.target.newIdentity();
3239 const client = yield* ctx.api.client(api, identity);
33− const slug = freshSlug("mcp-multiauth");
40+ const slug = freshSlug("mcp_multiauth");
3441
3542 yield* client.mcp.addServer({
3643 payload: {
@@ -40,7 +47,7 @@ scenario( · 3 unmodified lines
4047 slug,
4148 authenticationTemplate: [
4249 { kind: "oauth2" },
43− { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
50+ { type: "apiKey", headers: { "X-Api-Key": ["Bearer ", variable("token")] } },
4451 ],
4552 },
4653 });
@@ -50,7 +57,8 @@ scenario( · 3 unmodified lines
5057 params: { slug: IntegrationSlug.make(slug) },
5158 });
5259
53− // Both methods project into the catalog, slugged by kind, so the
60+ // Both methods project into the catalog (a slug-less single-header
61+ // apikey method gets the carrier-derived `header` slug), so the
5462 // connect flow can offer either and a connection binds one by slug.
5563 expect(
5664 integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })),
@@ -81,7 +89,7 @@ scenario( · 24 unmodified lines
8189 Effect.gen(function* () {
8290 const identity = yield* ctx.target.newIdentity();
8391 const client = yield* ctx.api.client(api, identity);
84− const slug = freshSlug("mcp-oauth-plus-key");
92+ const slug = freshSlug("mcp_oauth_plus_key");
8593
8694 // The add flow registered what the probe detected: OAuth only.
8795 yield* client.mcp.addServer({
@@ -99,13 +107,15 @@ scenario( · 11 unmodified lines
99107 const configured = yield* client.mcp.configureAuth({
100108 params: { slug: IntegrationSlug.make(slug) },
101109 payload: {
102− authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }],
110+ authenticationTemplate: [
111+ { type: "apiKey", headers: { "X-Api-Key": [variable("token")] } },
112+ ],
103113 },
104114 });
105115 expect(
106116 configured.authenticationTemplate.map((m) => m.kind),
107117 "the declared set now holds both methods",
108− ).toEqual(["oauth2", "header"]);
118+ ).toEqual(["oauth2", "apikey"]);
109119 expect(
110120 configured.authenticationTemplate[1]?.slug,
111121 "the custom method gets its own custom_ slug",
@@ -135,7 +145,7 @@ scenario( · 23 unmodified lines
135145 Effect.gen(function* () {
136146 const identity = yield* ctx.target.newIdentity();
137147 const client = yield* ctx.api.client(api, identity);
138− const slug = freshSlug("mcp-open-plus-key");
148+ const slug = freshSlug("mcp_open_plus_key");
139149
140150 // No declared auth — the server advertises nothing.
141151 yield* client.mcp.addServer({
@@ -155,7 +165,7 @@ scenario( · 13 unmodified lines
155165 params: { slug: IntegrationSlug.make(slug) },
156166 payload: {
157167 authenticationTemplate: [
158− { kind: "header", headerName: "Authorization", prefix: "Bearer " },
168+ { type: "apiKey", headers: { Authorization: ["Bearer ", variable("token")] } },
159169 ],
160170 },
161171 });
@@ -184,7 +194,7 @@ scenario( · 22 unmodified lines
184194 Effect.gen(function* () {
185195 const identity = yield* ctx.target.newIdentity();
186196 const client = yield* ctx.api.client(api, identity);
187− const slug = freshSlug("graphql-multiauth");
197+ const slug = freshSlug("graphql_multiauth");
188198
189199 yield* client.graphql.addIntegration({
190200 payload: {
@@ -192,8 +202,8 @@ scenario( · 1 unmodified line
192202 slug,
193203 name: "Multi-auth GraphQL",
194204 authenticationTemplate: [
195− { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" },
196− { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" },
205+ { slug: "apiKey", type: "apiKey", headers: { "X-Api-Key": [variable("token")] } },
206+ { slug: "apikey-2", type: "apiKey", queryParams: { api_key: [variable("token")] } },
197207 ],
198208 },
199209 });
@@ -222,3 +232,199 @@ scenario( · 22 unmodified lines
222232 );
223233 }),
224234 );
235+
236+// ---------------------------------------------------------------------------
237+// THE EXTREME CASE — one source, four methods, three live connections, every
238+// invocation asserted against what the server actually received:
239+// [oauth2] (declared; connect flow not exercised here)
240+// [bearer header + team-id query] (TWO inputs on ONE method)
241+// [bearer header] (single input)
242+// [auth-token query param] (single input, the ui.sh shape)
243+// ---------------------------------------------------------------------------
244+
245+scenario(
246+ "Auth methods · one source mixes oauth, a 2-input header+query method, a bearer header, and a query token",
247+ { needs: ["api", "mcp-oauth"] },
248+ (ctx) =>
249+ Effect.gen(function* () {
250+ const identity = yield* ctx.target.newIdentity();
251+ const client = yield* ctx.api.client(api, identity);
252+ const slug = freshSlug("mcp_extreme");
253+
254+ // A real MCP server that records every request it receives.
255+ const server = yield* serveMcpServer(() =>
256+ makeEchoMcpServer({
257+ name: "extreme-auth",
258+ toolName: "whoami",
259+ toolDescription: "Echoes a marker so the test can prove the invoke reached the server",
260+ inputName: "marker",
261+ text: (marker) => `ok:${marker}`,
262+ }),
263+ );
264+
265+ yield* client.mcp.addServer({
266+ payload: {
267+ transport: "remote",
268+ name: "Extreme auth MCP",
269+ endpoint: server.url,
270+ slug,
271+ authenticationTemplate: [
272+ { kind: "oauth2" },
273+ {
274+ slug: "token_and_team",
275+ type: "apiKey",
276+ headers: { Authorization: ["Bearer ", variable("api_token")] },
277+ queryParams: { team_id: [variable("team_id")] },
278+ },
279+ {
280+ slug: "bearer",
281+ type: "apiKey",
282+ headers: { Authorization: ["Bearer ", variable("token")] },
283+ },
284+ {
285+ slug: "query_token",
286+ type: "apiKey",
287+ queryParams: { auth_token: [variable("token")] },
288+ },
289+ ],
290+ },
291+ });
292+
293+ yield* Effect.gen(function* () {
294+ // All four methods project into the catalog, the 2-input method with
295+ // both placements and their distinct variables (what drives the two
296+ // credential fields in the connect modal).
297+ const integration = yield* client.integrations.get({
298+ params: { slug: IntegrationSlug.make(slug) },
299+ });
300+ expect(
301+ integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })),
302+ "the catalog lists all four methods",
303+ ).toEqual([
304+ { kind: "oauth", template: "oauth2" },
305+ { kind: "apikey", template: "token_and_team" },
306+ { kind: "apikey", template: "bearer" },
307+ { kind: "apikey", template: "query_token" },
308+ ]);
309+ expect(
310+ integration.authMethods[1]?.placements,
311+ "the 2-input method carries both placements with their variables",
312+ ).toEqual([
313+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
314+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
315+ ]);
316+
317+ // One connection per credential method. The 2-input method takes a
318+ // `values` map (one entry per placement variable).
319+ yield* client.connections.create({
320+ payload: {
321+ owner: "org",
322+ name: ConnectionName.make("mixed"),
323+ integration: IntegrationSlug.make(slug),
324+ template: AuthTemplateSlug.make("token_and_team"),
325+ values: { api_token: "tok_mixed", team_id: "team_42" },
326+ },
327+ });
328+ yield* client.connections.create({
329+ payload: {
330+ owner: "org",
331+ name: ConnectionName.make("bearer"),
332+ integration: IntegrationSlug.make(slug),
333+ template: AuthTemplateSlug.make("bearer"),
334+ value: "tok_bearer",
335+ },
336+ });
337+ yield* client.connections.create({
338+ payload: {
339+ owner: "org",
340+ name: ConnectionName.make("token"),
341+ integration: IntegrationSlug.make(slug),
342+ template: AuthTemplateSlug.make("query_token"),
343+ value: "tok_query",
344+ },
345+ });
346+
347+ // Discovery dialed the server per connection; every connection
348+ // produced its own addressed tool.
349+ const tools = yield* client.tools.list();
350+ const names = tools
351+ .filter((tool) => String(tool.integration) === slug)
352+ .map((tool) => String(tool.address));
353+ for (const conn of ["mixed", "bearer", "token"]) {
354+ expect(names, `connection "${conn}" resolved the server's tool`).toContain(
355+ `tools.${slug}.org.${conn}.whoami`,
356+ );
357+ }
358+
359+ // Invoke through the MCP surface (the real agent path: execute runs
360+ // sandbox code that calls the addressed tool), then assert the WIRE:
361+ // what the recording server received for each connection.
362+ const session = ctx.mcp.session(identity);
363+
364+ const invokeAndCapture = (conn: string, marker: string) =>
365+ Effect.gen(function* () {
366+ const before = (yield* server.requests).length;
367+ const result = yield* session.call("execute", {
368+ // Return the whole result — the assertion only needs the echoed
369+ // marker to prove the call reached the server.
370+ code: `return JSON.stringify(await tools.${slug}.org.${conn}.whoami({ marker: ${JSON.stringify(marker)} }));`,
371+ });
372+ expect(result.text, `the ${conn} invocation reached the server`).toContain(
373+ `ok:${marker}`,
374+ );
375+ return (yield* server.requests).slice(before);
376+ });
377+
378+ const mixedRequests = yield* invokeAndCapture("mixed", "m1");
379+ expect(
380+ mixedRequests.every((r) => r.authorization === "Bearer tok_mixed"),
381+ "the 2-input method rendered its bearer header",
382+ ).toBe(true);
383+ expect(
384+ mixedRequests.every((r) => r.url.includes("team_id=team_42")),
385+ "…and its team-id query param, from the second input",
386+ ).toBe(true);
387+
388+ const bearerRequests = yield* invokeAndCapture("bearer", "b1");
389+ expect(
390+ bearerRequests.every((r) => r.authorization === "Bearer tok_bearer"),
391+ "the bearer method rendered only its header",
392+ ).toBe(true);
393+ expect(
394+ bearerRequests.every(
395+ (r) => !r.url.includes("team_id=") && !r.url.includes("auth_token="),
396+ ),
397+ "…with no query credential bleeding in from sibling methods",
398+ ).toBe(true);
399+
400+ const queryRequests = yield* invokeAndCapture("token", "q1");
401+ expect(
402+ queryRequests.every((r) => r.url.includes("auth_token=tok_query")),
403+ "the query method rendered its token param",
404+ ).toBe(true);
405+ expect(
406+ queryRequests.every((r) => r.authorization === undefined),
407+ "…and no Authorization header",
408+ ).toBe(true);
409+ }).pipe(
410+ Effect.ensuring(
411+ Effect.gen(function* () {
412+ for (const name of ["mixed", "bearer", "token"]) {
413+ yield* client.connections
414+ .remove({
415+ params: {
416+ owner: "org",
417+ integration: IntegrationSlug.make(slug),
418+ name: ConnectionName.make(name),
419+ },
420+ })
421+ .pipe(Effect.ignore);
422+ }
423+ yield* client.mcp
424+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
425+ .pipe(Effect.ignore);
426+ }),
427+ ),
428+ );
429+ }),
430+);
e2e/selfhost/auth-methods-ui.test.ts+83−0
@@ -154,3 +154,86 @@ scenario( · 153 unmodified lines
154154 }),
155155 ),
156156 );
157+
158+scenario(
159+ "Auth methods · the connect modal collects one value per input of a 2-input method",
160+ { needs: ["browser", "api"] },
161+ (ctx) =>
162+ Effect.scoped(
163+ Effect.gen(function* () {
164+ // A method with TWO credential inputs: a bearer header rendered from
165+ // `api_token` and a team-id query param rendered from `team_id`. The
166+ // server requires both on every request, so the connect-time
167+ // discovery dial only succeeds when the modal collected both values.
168+ const apiToken = `e2e-two-input-${randomBytes(4).toString("hex")}`;
169+ const teamId = `team-${randomBytes(3).toString("hex")}`;
170+ const server = yield* serveMcpServer(() => makeGreetingMcpServer(), {
171+ auth: {
172+ validateAuthorization: (authorization) =>
173+ Effect.succeed(authorization === `Bearer ${apiToken}`),
174+ },
175+ });
176+
177+ const identity = yield* ctx.target.newIdentity();
178+ const client = yield* ctx.api.client(api, identity);
179+ const slug = `mcp-two-input-${randomBytes(3).toString("hex")}`;
180+
181+ yield* client.mcp.addServer({
182+ payload: {
183+ transport: "remote",
184+ name: "Two-input MCP",
185+ endpoint: server.endpoint,
186+ slug,
187+ authenticationTemplate: [
188+ {
189+ slug: "token_and_team",
190+ type: "apiKey",
191+ headers: { Authorization: ["Bearer ", { type: "variable", name: "api_token" }] },
192+ queryParams: { team_id: [{ type: "variable", name: "team_id" }] },
193+ },
194+ ],
195+ },
196+ });
197+
198+ yield* Effect.gen(function* () {
199+ yield* ctx.browser.session(identity, async ({ page, step }) => {
200+ await step("Open the integration's connect modal", async () => {
201+ await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
202+ await page.getByRole("button", { name: "Add connection" }).first().click();
203+ await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
204+ });
205+
206+ await step("The method renders one field per credential input", async () => {
207+ await page.getByPlaceholder("paste Authorization").waitFor();
208+ await page.getByPlaceholder("paste team_id").waitFor();
209+ });
210+
211+ await step("Fill both values and connect", async () => {
212+ await page.getByPlaceholder("paste Authorization").fill(apiToken);
213+ await page.getByPlaceholder("paste team_id").fill(teamId);
214+ await page.getByRole("button", { name: "Add connection" }).click();
215+ await page.getByText("Connection added").waitFor();
216+ });
217+ });
218+
219+ // Wire proof: the discovery dial rendered BOTH inputs — the bearer
220+ // header (the server rejects anything else) and the team-id query.
221+ const requests = yield* server.requests;
222+ expect(
223+ requests.some(
224+ (request) =>
225+ request.authorization === `Bearer ${apiToken}` &&
226+ request.url.includes(`team_id=${teamId}`),
227+ ),
228+ "the server saw the bearer header and the team-id query param together",
229+ ).toBe(true);
230+ }).pipe(
231+ Effect.ensuring(
232+ client.mcp
233+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
234+ .pipe(Effect.ignore),
235+ ),
236+ );
237+ }),
238+ ),
239+);
e2e/selfhost/mcp-multi-auth.test.ts+4−1
@@ -46,7 +46,10 @@ scenario( · 45 unmodified lines
4646 slug,
4747 authenticationTemplate: [
4848 { kind: "oauth2" },
49− { kind: "header", headerName: "Authorization", prefix: "Bearer " },
49+ {
50+ type: "apiKey",
51+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
52+ },
5053 ],
5154 },
5255 });
e2e/selfhost/oauth-app-modal.test.ts+1−1
@@ -63,7 +63,7 @@ scenario( · 62 unmodified lines
6363 authenticationTemplate: [
6464 {
6565 slug: "oauth",
66− type: "oauth",
66+ kind: "oauth2",
6767 authorizationUrl: "https://auth.example/authorize",
6868 tokenUrl: "https://auth.example/token",
6969 scopes: [],
examples/all-plugins/src/main.ts+1−1
@@ -235,7 +235,7 @@ const program = Effect.gen(function* () { · 234 unmodified lines
235235 baseUrl: "https://example.com/api",
236236 authenticationTemplate: [
237237 {
238− slug: AuthTemplateSlug.make("apiKey"),
238+ slug: "apiKey",
239239 type: "apiKey",
240240 headers: { "X-API-Key": [variable("token")] },
241241 },
examples/docs-sdk-quickstart/src/main.ts+1−1
@@ -99,7 +99,7 @@ const executor = await createExecutor({ · 98 unmodified lines
9999 // docs:end create-executor
100100
101101 // docs:start add-integration
102−// An integration is the API surface. The apiKey template declares where a
102+// An integration is the API surface. The auth template declares where a
103103 // connection's credential is placed on each request — here, an `X-API-Key`
104104 // header. `variable("token")` is the slot the resolved credential renders into.
105105 await executor.openapi.addSpec({
examples/promise-sdk/src/main.ts+1−1
@@ -130,7 +130,7 @@ await executor.connections.create({ · 129 unmodified lines
130130 // template: "bearer",
131131 // value: "sk_live_...",
132132 // });
133−void variable;
133+void variable; // referenced by the commented authoring example above
134134
135135 // ---------------------------------------------------------------------------
136136 // 4. GraphQL — introspect an endpoint as an integration, then connect.
packages/core/api/src/integrations/api.ts+6−0
@@ -34,6 +34,12 @@ const PlacementDescriptor = Schema.Struct({ · 33 unmodified lines
3434 carrier: Schema.Literals(["header", "query"]),
3535 name: Schema.String,
3636 prefix: Schema.String,
37+ /** Input variable this placement renders from (absent ⇒ `token`). Without
38+ * it the client cannot derive per-variable credential inputs for
39+ * multi-input methods. */
40+ variable: Schema.optional(Schema.String),
41+ /** Static value rendered verbatim (no credential input). */
42+ literal: Schema.optional(Schema.String),
3743 });
3844
3945 /** OAuth specifics — mirrors the SDK's `AuthMethodOAuthDescriptor`. */
packages/core/sdk/package.json+8−1
@@ -23,7 +23,8 @@ · 22 unmodified lines
2323 "./promise": "./src/promise.ts",
2424 "./client": "./src/client.ts",
2525 "./testing": "./src/testing.ts",
26− "./migration": "./src/migration-spec.ts"
26+ "./migration": "./src/migration-spec.ts",
27+ "./http-auth": "./src/http-auth/index.ts"
2728 },
2829 "publishConfig": {
2930 "access": "public",
@@ -69,6 +70,12 @@ · 39 unmodified lines
6970 "types": "./dist/migration-spec.d.ts",
7071 "default": "./dist/migration-spec.js"
7172 }
73+ },
74+ "./http-auth": {
75+ "import": {
76+ "types": "./dist/http-auth.d.ts",
77+ "default": "./dist/http-auth.js"
78+ }
7279 }
7380 }
7481 },
packages/core/sdk/src/executor.ts+30−11
@@ -1547,17 +1547,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1546 unmodified lines
15471547 Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null),
15481548 );
15491549
1550− const resolveConnectionValueByRef = (
1551− ref: ConnectionRef,
1552− ): Effect.Effect<string | null, StorageFailure> =>
1553− Effect.gen(function* () {
1554− const row = yield* findConnectionRow(ref);
1555− if (!row) return null;
1556− return yield* resolveConnectionValue(row);
1557− }).pipe(
1558− // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`)
1559− // is `StorageFailure`-typed; fold a reauth-required resolution failure
1560− // into a StorageError so the public surface stays stable.
1550+ // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`,
1551+ // `getValues`) is `StorageFailure`-typed; fold a reauth-required resolution
1552+ // failure into a StorageError so the public surface stays stable.
1553+ const foldResolutionFailure = <A>(
1554+ effect: Effect.Effect<A, StorageFailure | CredentialResolutionError>,
1555+ ): Effect.Effect<A, StorageFailure> =>
1556+ effect.pipe(
15611557 Effect.catchTag("CredentialResolutionError", (err) =>
15621558 Effect.fail(
15631559 new StorageError({
@@ -1569,6 +1565,28 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
15691565 ),
15701566 );
15711567
1568+ const resolveConnectionValueByRef = (
1569+ ref: ConnectionRef,
1570+ ): Effect.Effect<string | null, StorageFailure> =>
1571+ foldResolutionFailure(
1572+ Effect.gen(function* () {
1573+ const row = yield* findConnectionRow(ref);
1574+ if (!row) return null;
1575+ return yield* resolveConnectionValue(row);
1576+ }),
1577+ );
1578+
1579+ const resolveConnectionValuesByRef = (
1580+ ref: ConnectionRef,
1581+ ): Effect.Effect<Record<string, string | null>, StorageFailure> =>
1582+ foldResolutionFailure(
1583+ Effect.gen(function* () {
1584+ const row = yield* findConnectionRow(ref);
1585+ if (!row) return {};
1586+ return yield* resolveConnectionValues(row);
1587+ }),
1588+ );
1589+
15721590 // ------------------------------------------------------------------
15731591 // Integrations
15741592 // ------------------------------------------------------------------
@@ -1810,6 +1828,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 235 unmodified lines
18101828 connection: ref,
18111829 template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
18121830 getValue: () => resolveConnectionValueByRef(ref),
1831+ getValues: () => resolveConnectionValuesByRef(ref),
18131832 })
18141833 .pipe(
18151834 Effect.mapError((cause) =>
packages/core/sdk/src/http-auth/auth-method.test.ts+223−0
@@ -0,0 +1,223 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ type ApiKeyAuthMethod,
5+ type AuthPlacement,
6+ apiKeyMethodLabel,
7+ describeApiKeyAuthMethod,
8+ describeNoneAuthMethod,
9+ normalizeAuthMethodSlugs,
10+ oauthBearerPlacement,
11+ renderAuthPlacements,
12+ requiredPlacementVariables,
13+} from "./auth-method";
14+
15+describe("renderAuthPlacements", () => {
16+ it("renders a bearer header from the implicit token variable", () => {
17+ expect(
18+ renderAuthPlacements([{ carrier: "header", name: "Authorization", prefix: "Bearer " }], {
19+ token: "tok_1",
20+ }),
21+ ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} });
22+ });
23+
24+ it("renders a query param (the ui.sh '?token=' case)", () => {
25+ expect(renderAuthPlacements([{ carrier: "query", name: "token" }], { token: "tok_1" })).toEqual(
26+ { headers: {}, queryParams: { token: "tok_1" } },
27+ );
28+ });
29+
30+ it("mixes header and query placements in one method", () => {
31+ expect(
32+ renderAuthPlacements(
33+ [
34+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
35+ { carrier: "query", name: "team_id", variable: "team_id" },
36+ ],
37+ { api_token: "tok_1", team_id: "team_9" },
38+ ),
39+ ).toEqual({
40+ headers: { Authorization: "Bearer tok_1" },
41+ queryParams: { team_id: "team_9" },
42+ });
43+ });
44+
45+ it("renders distinct variables into their own placements (Datadog's two keys)", () => {
46+ expect(
47+ renderAuthPlacements(
48+ [
49+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
50+ { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
51+ ],
52+ { dd_api_key: "a", dd_application_key: "b" },
53+ ),
54+ ).toEqual({
55+ headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" },
56+ queryParams: {},
57+ });
58+ });
59+
60+ it("lets two placements share one variable (same value in two spots)", () => {
61+ expect(
62+ renderAuthPlacements(
63+ [
64+ { carrier: "header", name: "X-Token" },
65+ { carrier: "query", name: "token" },
66+ ],
67+ { token: "shared" },
68+ ),
69+ ).toEqual({ headers: { "X-Token": "shared" }, queryParams: { token: "shared" } });
70+ });
71+
72+ it("renders literal placements verbatim, with no credential", () => {
73+ expect(
74+ renderAuthPlacements(
75+ [
76+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
77+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
78+ ],
79+ { token: "tok_1" },
80+ ),
81+ ).toEqual({
82+ headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" },
83+ queryParams: {},
84+ });
85+ });
86+
87+ it("skips a credential placement whose variable resolved to nothing", () => {
88+ expect(
89+ renderAuthPlacements(
90+ [
91+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
92+ { carrier: "query", name: "team_id", variable: "team_id" },
93+ ],
94+ { token: "tok_1", team_id: null },
95+ ),
96+ ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} });
97+ });
98+
99+ it("renders nothing for an empty values map", () => {
100+ expect(
101+ renderAuthPlacements([{ carrier: "header", name: "Authorization", prefix: "Bearer " }], {}),
102+ ).toEqual({ headers: {}, queryParams: {} });
103+ });
104+});
105+
106+describe("requiredPlacementVariables", () => {
107+ it("dedupes shared variables and defaults absent ones to token", () => {
108+ const placements: readonly AuthPlacement[] = [
109+ { carrier: "header", name: "X-Token" },
110+ { carrier: "query", name: "token" },
111+ { carrier: "query", name: "team_id", variable: "team_id" },
112+ ];
113+ expect(requiredPlacementVariables(placements)).toEqual(["token", "team_id"]);
114+ });
115+
116+ it("excludes literal placements (they reference no credential)", () => {
117+ expect(
118+ requiredPlacementVariables([
119+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
120+ ]),
121+ ).toEqual([]);
122+ });
123+});
124+
125+describe("oauthBearerPlacement", () => {
126+ it("defaults to the conventional Authorization: Bearer header", () => {
127+ expect(renderAuthPlacements([oauthBearerPlacement()], { token: "at_1" })).toEqual({
128+ headers: { Authorization: "Bearer at_1" },
129+ queryParams: {},
130+ });
131+ });
132+
133+ it("honors a custom header and prefix (graphql oauth override)", () => {
134+ expect(
135+ renderAuthPlacements([oauthBearerPlacement("X-Access-Token", "")], { token: "at_1" }),
136+ ).toEqual({ headers: { "X-Access-Token": "at_1" }, queryParams: {} });
137+ });
138+});
139+
140+describe("normalizeAuthMethodSlugs", () => {
141+ type Input = { readonly slug?: string | undefined; readonly kind: string };
142+ const byKind = (method: Input) => method.kind;
143+
144+ it("keeps caller-provided slugs and defaults the rest", () => {
145+ expect(
146+ normalizeAuthMethodSlugs(
147+ [{ slug: "custom_abc", kind: "apikey" }, { kind: "none" }],
148+ byKind,
149+ ).map((m) => m.slug),
150+ ).toEqual(["custom_abc", "none"]);
151+ });
152+
153+ it("suffixes collisions deterministically", () => {
154+ expect(
155+ normalizeAuthMethodSlugs(
156+ [{ kind: "apikey" }, { kind: "apikey" }, { slug: "apikey", kind: "apikey" }],
157+ byKind,
158+ ).map((m) => m.slug),
159+ ).toEqual(["apikey", "apikey_2", "apikey_3"]);
160+ });
161+
162+ it("treats a blank slug as absent", () => {
163+ expect(normalizeAuthMethodSlugs([{ slug: " ", kind: "oauth2" }], byKind)[0]?.slug).toBe(
164+ "oauth2",
165+ );
166+ });
167+});
168+
169+describe("catalog projection", () => {
170+ it("projects an apikey method with placements, deriving the label", () => {
171+ const method: ApiKeyAuthMethod = {
172+ slug: "header",
173+ kind: "apikey",
174+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
175+ };
176+ expect(describeApiKeyAuthMethod(method)).toEqual({
177+ id: "header",
178+ label: "API key (X-Api-Key)",
179+ kind: "apikey",
180+ template: "header",
181+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
182+ });
183+ });
184+
185+ it("carries explicit variables and literals through to the descriptor", () => {
186+ const method: ApiKeyAuthMethod = {
187+ slug: "custom_x",
188+ kind: "apikey",
189+ label: "Token + team",
190+ placements: [
191+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
192+ { carrier: "query", name: "team_id", variable: "team_id" },
193+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
194+ ],
195+ };
196+ expect(describeApiKeyAuthMethod(method)).toEqual({
197+ id: "custom_x",
198+ label: "Token + team",
199+ kind: "apikey",
200+ template: "custom_x",
201+ placements: [
202+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
203+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
204+ { carrier: "header", name: "X-Api-Version", prefix: "", literal: "2023-01-01" },
205+ ],
206+ });
207+ });
208+
209+ it("falls back to the slug when no placement is named", () => {
210+ expect(apiKeyMethodLabel({ slug: "custom_y", kind: "apikey", placements: [] })).toBe(
211+ "API key (custom_y)",
212+ );
213+ });
214+
215+ it("projects a none method", () => {
216+ expect(describeNoneAuthMethod("none")).toEqual({
217+ id: "none",
218+ label: "No authentication",
219+ kind: "none",
220+ template: "none",
221+ });
222+ });
223+});
packages/core/sdk/src/http-auth/auth-method.ts+188−0
@@ -0,0 +1,188 @@
1+import { Schema } from "effect";
2+import type { AuthMethodDescriptor, AuthPlacementDescriptor } from "../integration";
3+
4+// ---------------------------------------------------------------------------
5+// The shared HTTP auth-method vocabulary for protocol plugins (openapi,
6+// graphql, mcp). An integration declares a LIST of auth methods; a connection
7+// binds one by its `slug` (`connection.template`) and supplies one credential
8+// value per `variable` the method's placements reference (D11: core resolves
9+// `values: Record<variable, string|null>`, the plugin renders them onto the
10+// request).
11+//
12+// Core itself never imports this module — core stays carrier-agnostic (a
13+// connection could be a CLI login or a DB URL). It ships as the
14+// `@executor-js/sdk/http-auth` subpath purely as a home: composition, not
15+// location, is what keeps headers/query params an HTTP-plugin concern.
16+//
17+// OAuth methods are NOT modeled here — their config genuinely differs per
18+// plugin (openapi stores endpoints+scopes, graphql an optional header
19+// override, mcp discovers everything at connect time). Each plugin's method
20+// union is `NoneAuthMethod | ApiKeyAuthMethod | <its own oauth variant>`.
21+// OAuth-refreshed connections resolve only the `token` input, so oauth
22+// values must never be mixed into a placements method.
23+// ---------------------------------------------------------------------------
24+
25+/** The canonical input variable of single-credential methods. A placement
26+ * with no `variable` renders from it, and core resolves a connection created
27+ * with a bare `{ value }` into `{ token: value }`. */
28+export const TOKEN_VARIABLE = "token";
29+
30+export const AuthCarrier = Schema.Literals(["header", "query"]);
31+export type AuthCarrier = typeof AuthCarrier.Type;
32+
33+/** One spot on the outbound request a method writes to.
34+ *
35+ * Credential placement: renders `prefix + values[variable ?? "token"]`.
36+ * Two placements naming the same variable share one credential input; a
37+ * placement naming its own variable gets its own input (e.g. Datadog's two
38+ * keys).
39+ *
40+ * Literal placement (`literal` set): renders the literal verbatim and
41+ * references no credential — static values a method carries alongside its
42+ * credential (e.g. a fixed version header). */
43+export const AuthPlacement = Schema.Struct({
44+ carrier: AuthCarrier,
45+ /** Header name (e.g. `Authorization`) or query-param name (e.g. `token`). */
46+ name: Schema.String,
47+ /** Literal prepended to the credential value, e.g. `Bearer `. */
48+ prefix: Schema.optional(Schema.String),
49+ /** The credential input this placement renders from. Absent ⇒ `token`. */
50+ variable: Schema.optional(Schema.String),
51+ /** Render this exact value instead of a credential. */
52+ literal: Schema.optional(Schema.String),
53+});
54+export type AuthPlacement = typeof AuthPlacement.Type;
55+
56+export const ApiKeyAuthMethod = Schema.Struct({
57+ slug: Schema.String,
58+ kind: Schema.Literal("apikey"),
59+ /** Display label; derived from the first placement when absent. */
60+ label: Schema.optional(Schema.String),
61+ placements: Schema.Array(AuthPlacement),
62+});
63+export type ApiKeyAuthMethod = typeof ApiKeyAuthMethod.Type;
64+
65+/** An open integration — connections carry no credential. */
66+export const NoneAuthMethod = Schema.Struct({
67+ slug: Schema.String,
68+ kind: Schema.Literal("none"),
69+});
70+export type NoneAuthMethod = typeof NoneAuthMethod.Type;
71+
72+// ---------------------------------------------------------------------------
73+// Rendering
74+// ---------------------------------------------------------------------------
75+
76+export interface RenderedAuthPlacements {
77+ readonly headers: Record<string, string>;
78+ readonly queryParams: Record<string, string>;
79+}
80+
81+const renderPlacementValue = (
82+ placement: AuthPlacement,
83+ values: Record<string, string | null>,
84+): string | null => {
85+ if (placement.literal !== undefined) return placement.literal;
86+ const value = values[placement.variable ?? TOKEN_VARIABLE];
87+ if (value == null) return null;
88+ return placement.prefix ? `${placement.prefix}${value}` : value;
89+};
90+
91+/** Render a method's placements with a connection's resolved values. Total:
92+ * a credential placement whose variable resolved to nothing is skipped —
93+ * callers own their missing-value policy (fail the invocation, dial
94+ * unauthenticated, …) and should gate on `requiredPlacementVariables`. */
95+export const renderAuthPlacements = (
96+ placements: readonly AuthPlacement[],
97+ values: Record<string, string | null>,
98+): RenderedAuthPlacements => {
99+ const headers: Record<string, string> = {};
100+ const queryParams: Record<string, string> = {};
101+ for (const placement of placements) {
102+ const rendered = renderPlacementValue(placement, values);
103+ if (rendered === null) continue;
104+ if (placement.carrier === "header") headers[placement.name] = rendered;
105+ else queryParams[placement.name] = rendered;
106+ }
107+ return { headers, queryParams };
108+};
109+
110+/** The distinct credential inputs a method's placements reference — what a
111+ * connection must supply. Literal placements reference none. */
112+export const requiredPlacementVariables = (
113+ placements: readonly AuthPlacement[],
114+): readonly string[] => {
115+ const names = new Set<string>();
116+ for (const placement of placements) {
117+ if (placement.literal !== undefined) continue;
118+ names.add(placement.variable ?? TOKEN_VARIABLE);
119+ }
120+ return [...names];
121+};
122+
123+/** The conventional rendering of an OAuth access token, as a placement — for
124+ * plugins whose oauth method applies the token as a plain (possibly
125+ * customized) header. The token is always the `token` input. */
126+export const oauthBearerPlacement = (header?: string, prefix?: string): AuthPlacement => ({
127+ carrier: "header",
128+ name: header ?? "Authorization",
129+ prefix: prefix ?? "Bearer ",
130+});
131+
132+// ---------------------------------------------------------------------------
133+// Slug normalization — methods are input without slugs from UIs/agents; every
134+// stored method needs a stable one (`connection.template` binds by it).
135+// ---------------------------------------------------------------------------
136+
137+/** Assign each method a stable slug: a caller-provided one wins, otherwise
138+ * `defaultSlugFor(method)`, suffixed `_2`, `_3`, … on collision. */
139+export const normalizeAuthMethodSlugs = <T extends { readonly slug?: string | undefined }>(
140+ methods: readonly T[],
141+ defaultSlugFor: (method: T) => string,
142+): readonly (T & { readonly slug: string })[] => {
143+ const taken = new Set<string>();
144+ return methods.map((method: T): T & { readonly slug: string } => {
145+ const requested = method.slug?.trim() || defaultSlugFor(method);
146+ let slug = requested;
147+ for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`;
148+ taken.add(slug);
149+ return { ...method, slug };
150+ });
151+};
152+
153+// ---------------------------------------------------------------------------
154+// Catalog projection — the plugin-agnostic `AuthMethodDescriptor` every
155+// plugin's `describeAuthMethods` emits (the descriptor TYPE stays in
156+// @executor-js/sdk: core carries it as opaque catalog metadata).
157+// ---------------------------------------------------------------------------
158+
159+export const apiKeyMethodLabel = (method: ApiKeyAuthMethod): string => {
160+ if (method.label !== undefined && method.label.trim().length > 0) return method.label;
161+ const first = method.placements.find(
162+ (placement: AuthPlacement) => placement.name.trim().length > 0,
163+ );
164+ return first ? `API key (${first.name})` : `API key (${method.slug})`;
165+};
166+
167+const describePlacement = (placement: AuthPlacement): AuthPlacementDescriptor => ({
168+ carrier: placement.carrier,
169+ name: placement.name,
170+ prefix: placement.prefix ?? "",
171+ ...(placement.variable !== undefined ? { variable: placement.variable } : {}),
172+ ...(placement.literal !== undefined ? { literal: placement.literal } : {}),
173+});
174+
175+export const describeApiKeyAuthMethod = (method: ApiKeyAuthMethod): AuthMethodDescriptor => ({
176+ id: method.slug,
177+ label: apiKeyMethodLabel(method),
178+ kind: "apikey",
179+ template: method.slug,
180+ placements: method.placements.map(describePlacement),
181+});
182+
183+export const describeNoneAuthMethod = (slug: string): AuthMethodDescriptor => ({
184+ id: slug,
185+ label: "No authentication",
186+ kind: "none",
187+ template: slug,
188+});
packages/core/sdk/src/http-auth/authoring.test.ts+97−0
@@ -0,0 +1,97 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Option, Schema } from "effect";
3+
4+import { ApiKeyAuthTemplate, apiKeyMethodFromAuthTemplate, variable } from "./authoring";
5+import { renderAuthPlacements } from "./auth-method";
6+
7+const decode = Schema.decodeUnknownOption(ApiKeyAuthTemplate);
8+
9+describe("request-shaped authoring", () => {
10+ it("a bearer header template expands to the canonical placement", () => {
11+ expect(
12+ apiKeyMethodFromAuthTemplate({
13+ slug: "bearer",
14+ type: "apiKey",
15+ headers: { Authorization: ["Bearer ", variable("token")] },
16+ }),
17+ ).toEqual({
18+ slug: "bearer",
19+ kind: "apikey",
20+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
21+ });
22+ });
23+
24+ it("headers and query params mix, each input keeping its variable", () => {
25+ const method = apiKeyMethodFromAuthTemplate({
26+ type: "apiKey",
27+ headers: { Authorization: ["Bearer ", variable("api_token")] },
28+ queryParams: { team_id: [variable("team_id")] },
29+ });
30+ expect(method.placements).toEqual([
31+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
32+ { carrier: "query", name: "team_id", variable: "team_id" },
33+ ]);
34+ // …and the expansion renders exactly like the template reads.
35+ expect(renderAuthPlacements(method.placements, { api_token: "tok", team_id: "t42" })).toEqual({
36+ headers: { Authorization: "Bearer tok" },
37+ queryParams: { team_id: "t42" },
38+ });
39+ });
40+
41+ it("a plain-string value is a static literal (no credential input)", () => {
42+ expect(
43+ apiKeyMethodFromAuthTemplate({
44+ type: "apiKey",
45+ headers: { "X-Api-Version": "2023-01-01", "X-Api-Key": [variable("token")] },
46+ }).placements,
47+ ).toEqual([
48+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
49+ { carrier: "header", name: "X-Api-Key" },
50+ ]);
51+ });
52+
53+ it("slug and label are optional and carried through", () => {
54+ const method = apiKeyMethodFromAuthTemplate({
55+ type: "apiKey",
56+ label: "Team key",
57+ queryParams: { token: [variable("token")] },
58+ });
59+ expect(method.slug).toBeUndefined();
60+ expect(method.label).toBe("Team key");
61+ });
62+
63+ it("rejects a variable that is not the final part (authoring is strict)", () => {
64+ expect(
65+ Option.isNone(
66+ decode({
67+ type: "apiKey",
68+ headers: { Authorization: [variable("token"), "-suffix"] },
69+ }),
70+ ),
71+ ).toBe(true);
72+ });
73+
74+ it("rejects two variables in one value", () => {
75+ expect(
76+ Option.isNone(
77+ decode({
78+ type: "apiKey",
79+ headers: { Authorization: [variable("a"), ":", variable("b")] },
80+ }),
81+ ),
82+ ).toBe(true);
83+ });
84+
85+ it("accepts the strict shapes it should", () => {
86+ expect(
87+ Option.isSome(
88+ decode({
89+ slug: "ok",
90+ type: "apiKey",
91+ headers: { A: ["Bearer ", variable("token")], B: "literal" },
92+ queryParams: { q: [variable("q")] },
93+ }),
94+ ),
95+ ).toBe(true);
96+ });
97+});
packages/core/sdk/src/http-auth/authoring.ts+148−0
@@ -0,0 +1,148 @@
1+// ---------------------------------------------------------------------------
2+// Request-shaped authoring — the second accepted INPUT dialect for apikey
3+// methods, alongside canonical placements. It reads like the HTTP request it
4+// produces:
5+//
6+// {
7+// slug: "bearer",
8+// type: "apiKey",
9+// headers: { Authorization: ["Bearer ", variable("token")] },
10+// queryParams: { team_id: [variable("team_id")] },
11+// }
12+//
13+// A record value is either a parts-array (`prefix literals… + one variable,
14+// last`) rendering a credential input, or a plain string rendering a static
15+// literal. Normalized to canonical placements at the boundary — STORED
16+// configs carry only the canonical shape.
17+//
18+// Authoring is strict where the migration is tolerant: a parts-array with a
19+// variable anywhere but last, or with more than one variable, is rejected at
20+// decode time with a pointed message (the renderer and UI honor exactly
21+// prefix+variable, so accepting more would silently lose information —
22+// `migrate-config` only flattens that way for pre-existing stored data).
23+// ---------------------------------------------------------------------------
24+
25+import { Schema } from "effect";
26+
27+import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method";
28+
29+export interface AuthTemplateVariable {
30+ readonly type: "variable";
31+ readonly name: string;
32+}
33+
34+/** Mark where a credential input renders inside a template value:
35+ * `["Bearer ", variable("token")]`. */
36+export const variable = (name: string): AuthTemplateVariable => ({ type: "variable", name });
37+
38+const VariablePart = Schema.Struct({
39+ type: Schema.Literal("variable"),
40+ name: Schema.String,
41+});
42+
43+const isVariablePart = (part: string | AuthTemplateVariable): part is AuthTemplateVariable =>
44+ typeof part !== "string";
45+
46+/** `string` (a static literal) or `prefix literals… + one variable, last`. */
47+export const AuthTemplateValue = Schema.Union([
48+ Schema.String,
49+ Schema.Array(Schema.Union([Schema.String, VariablePart])).check(
50+ Schema.makeFilter((parts) => {
51+ const variableIndexes = parts.flatMap((part, index) => (isVariablePart(part) ? [index] : []));
52+ if (
53+ variableIndexes.length > 1 ||
54+ (variableIndexes.length === 1 && variableIndexes[0] !== parts.length - 1)
55+ ) {
56+ return "a template value renders at most ONE variable, as the FINAL part — split extra variables/suffixes into separate header or query entries";
57+ }
58+ return undefined;
59+ }),
60+ ),
61+]);
62+export type AuthTemplateValue = typeof AuthTemplateValue.Type;
63+
64+/** The request-shaped apikey authoring dialect. `slug` optional (backfilled
65+ * like every other input); `label` optional. */
66+export const ApiKeyAuthTemplate = Schema.Struct({
67+ slug: Schema.optional(Schema.String),
68+ type: Schema.Literal("apiKey"),
69+ label: Schema.optional(Schema.String),
70+ headers: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)),
71+ queryParams: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)),
72+});
73+export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type;
74+
75+const placementFromValue = (
76+ carrier: "header" | "query",
77+ name: string,
78+ value: AuthTemplateValue,
79+): AuthPlacement => {
80+ if (typeof value === "string") return { carrier, name, literal: value };
81+ const variablePart = value.find(isVariablePart);
82+ if (variablePart === undefined) {
83+ return { carrier, name, literal: value.filter((p) => typeof p === "string").join("") };
84+ }
85+ const prefix = value.filter((part): part is string => typeof part === "string").join("");
86+ return {
87+ carrier,
88+ name,
89+ ...(prefix ? { prefix } : {}),
90+ ...(variablePart.name !== TOKEN_VARIABLE ? { variable: variablePart.name } : {}),
91+ };
92+};
93+
94+/** Normalize the request-shaped dialect into the canonical apikey method
95+ * (sans slug backfill — the caller's normalize pass owns slugs). */
96+export const apiKeyMethodFromAuthTemplate = (
97+ template: ApiKeyAuthTemplate,
98+): Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string } => {
99+ const placements: AuthPlacement[] = [];
100+ for (const [name, value] of Object.entries(template.headers ?? {})) {
101+ placements.push(placementFromValue("header", name, value));
102+ }
103+ for (const [name, value] of Object.entries(template.queryParams ?? {})) {
104+ placements.push(placementFromValue("query", name, value));
105+ }
106+ return {
107+ ...(template.slug !== undefined ? { slug: template.slug } : {}),
108+ kind: "apikey",
109+ ...(template.label !== undefined ? { label: template.label } : {}),
110+ placements,
111+ };
112+};
113+
114+/** Serialize a canonical method back into the request-shaped dialect — the
115+ * write side of read-modify-write flows (stored configs and the catalog
116+ * read as placements; auth INPUTS accept only this dialect). Same-named
117+ * same-carrier placements collapse, exactly as the renderer's header/query
118+ * records do. */
119+export const apiKeyAuthTemplateFromMethod = (method: {
120+ readonly slug?: string;
121+ readonly label?: string;
122+ readonly placements: readonly AuthPlacement[];
123+}): ApiKeyAuthTemplate => {
124+ const headers: Record<string, AuthTemplateValue> = {};
125+ const queryParams: Record<string, AuthTemplateValue> = {};
126+ for (const placement of method.placements) {
127+ const target = placement.carrier === "header" ? headers : queryParams;
128+ target[placement.name] =
129+ placement.literal !== undefined
130+ ? placement.literal
131+ : placement.prefix
132+ ? [placement.prefix, variable(placement.variable ?? TOKEN_VARIABLE)]
133+ : [variable(placement.variable ?? TOKEN_VARIABLE)];
134+ }
135+ return {
136+ ...(method.slug !== undefined ? { slug: method.slug } : {}),
137+ type: "apiKey",
138+ ...(method.label !== undefined ? { label: method.label } : {}),
139+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
140+ ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
141+ };
142+};
143+
144+/** Whether an input union member is the request-shaped dialect. */
145+export const isApiKeyAuthTemplate = (input: {
146+ readonly kind?: string;
147+ readonly type?: string;
148+}): input is ApiKeyAuthTemplate => input.type === "apiKey";
packages/core/sdk/src/http-auth/index.ts+43−0
@@ -0,0 +1,43 @@
1+export {
2+ ApiKeyAuthMethod,
3+ AuthCarrier,
4+ AuthPlacement,
5+ NoneAuthMethod,
6+ TOKEN_VARIABLE,
7+ apiKeyMethodLabel,
8+ describeApiKeyAuthMethod,
9+ describeNoneAuthMethod,
10+ normalizeAuthMethodSlugs,
11+ oauthBearerPlacement,
12+ renderAuthPlacements,
13+ requiredPlacementVariables,
14+ type RenderedAuthPlacements,
15+} from "./auth-method";
16+
17+// Request-shaped authoring dialect — accepted on every plugin's auth inputs,
18+// normalized to canonical placements at the boundary.
19+export {
20+ ApiKeyAuthTemplate,
21+ AuthTemplateValue,
22+ apiKeyAuthTemplateFromMethod,
23+ apiKeyMethodFromAuthTemplate,
24+ isApiKeyAuthTemplate,
25+ variable,
26+ type AuthTemplateVariable,
27+} from "./authoring";
28+
29+// Migration-only legacy vocabulary — runtime code must not use these.
30+export {
31+ LegacyApiKeyTemplate,
32+ LegacyTemplateValue,
33+ apiKeyMethodFromLegacyTemplate,
34+ decodeLegacyApiKeyTemplate,
35+} from "./legacy";
36+export {
37+ planAuthConfigMigration,
38+ runSqliteAuthConfigMigration,
39+ type AuthConfigMigrationRow,
40+ type AuthConfigMigrationUpdate,
41+ type AuthConfigTransform,
42+ type SqliteAuthConfigClient,
43+} from "./migrate";
packages/core/sdk/src/http-auth/legacy.ts+82−0
@@ -0,0 +1,82 @@
1+// ---------------------------------------------------------------------------
2+// FROZEN legacy shapes — input vocabulary for the one-off config migrations.
3+//
4+// Before the canonical placements model, the OpenAPI plugin (and the v1→v2
5+// migration, which wrote this shape into MCP and GraphQL configs too) stored
6+// apiKey methods as header/query records of TEMPLATE VALUES: a string literal
7+// or a part-array interleaving literals with `{ type: "variable", name }`
8+// references.
9+//
10+// Runtime code must not import this module — it exists so every plugin's
11+// `migrate-config` rewrites stored blobs the same way, and the rules below are
12+// the contract: variable names are preserved VERBATIM (they key the
13+// connections' stored `item_ids`), the placement prefix is the literal run
14+// before the first variable, a literal-only value becomes a `literal`
15+// placement, and anything after the first variable is dropped (matching what
16+// the descriptor projection has always shown).
17+// ---------------------------------------------------------------------------
18+
19+import { Schema } from "effect";
20+
21+import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method";
22+
23+const LegacyVariablePart = Schema.Struct({
24+ type: Schema.Literal("variable"),
25+ name: Schema.String,
26+});
27+
28+export const LegacyTemplateValue = Schema.Union([
29+ Schema.String,
30+ Schema.Array(Schema.Union([Schema.String, LegacyVariablePart])),
31+]);
32+export type LegacyTemplateValue = typeof LegacyTemplateValue.Type;
33+
34+/** The pre-canonical apiKey template shape (OpenAPI native + what the v1→v2
35+ * migration wrote into MCP/GraphQL configs). */
36+export const LegacyApiKeyTemplate = Schema.Struct({
37+ slug: Schema.String,
38+ type: Schema.Literal("apiKey"),
39+ headers: Schema.optional(Schema.Record(Schema.String, LegacyTemplateValue)),
40+ queryParams: Schema.optional(Schema.Record(Schema.String, LegacyTemplateValue)),
41+});
42+export type LegacyApiKeyTemplate = typeof LegacyApiKeyTemplate.Type;
43+
44+export const decodeLegacyApiKeyTemplate = Schema.decodeUnknownOption(LegacyApiKeyTemplate);
45+
46+const placementFromTemplateValue = (
47+ carrier: "header" | "query",
48+ name: string,
49+ value: LegacyTemplateValue,
50+): AuthPlacement => {
51+ if (typeof value === "string") return { carrier, name, literal: value };
52+ const literals: string[] = [];
53+ for (const part of value) {
54+ if (typeof part === "string") {
55+ literals.push(part);
56+ continue;
57+ }
58+ const prefix = literals.join("");
59+ return {
60+ carrier,
61+ name,
62+ ...(prefix ? { prefix } : {}),
63+ ...(part.name !== TOKEN_VARIABLE ? { variable: part.name } : {}),
64+ };
65+ }
66+ return { carrier, name, literal: literals.join("") };
67+};
68+
69+/** Rewrite a legacy apiKey template into the canonical placements method.
70+ * Slug and variable names are preserved verbatim. */
71+export const apiKeyMethodFromLegacyTemplate = (
72+ template: LegacyApiKeyTemplate,
73+): ApiKeyAuthMethod => {
74+ const placements: AuthPlacement[] = [];
75+ for (const [name, value] of Object.entries(template.headers ?? {})) {
76+ placements.push(placementFromTemplateValue("header", name, value));
77+ }
78+ for (const [name, value] of Object.entries(template.queryParams ?? {})) {
79+ placements.push(placementFromTemplateValue("query", name, value));
80+ }
81+ return { slug: template.slug, kind: "apikey", placements };
82+};
packages/core/sdk/src/http-auth/migrate.test.ts+80−0
@@ -0,0 +1,80 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ planAuthConfigMigration,
5+ runSqliteAuthConfigMigration,
6+ type AuthConfigTransform,
7+ type SqliteAuthConfigClient,
8+} from "./migrate";
9+
10+const transforms: Record<string, AuthConfigTransform> = {
11+ mcp: (config) => {
12+ const blob = config as { legacy?: boolean };
13+ return blob.legacy ? { migrated: true } : null;
14+ },
15+};
16+
17+describe("planAuthConfigMigration", () => {
18+ it("plans rewrites only for known plugins whose transform rewrites", () => {
19+ const updates = planAuthConfigMigration(
20+ [
21+ { rowId: "a", pluginId: "mcp", config: { legacy: true } },
22+ { rowId: "b", pluginId: "mcp", config: { legacy: false } },
23+ { rowId: "c", pluginId: "keychain", config: { legacy: true } },
24+ ],
25+ transforms,
26+ );
27+ expect(updates).toEqual([{ rowId: "a", config: { migrated: true } }]);
28+ });
29+});
30+
31+// A tiny scripted fake standing in for a libSQL client.
32+const makeFakeClient = (rows: Record<string, unknown>[]) => {
33+ const log: unknown[] = [];
34+ const client: SqliteAuthConfigClient = {
35+ execute: (stmt) => {
36+ log.push(stmt);
37+ if (typeof stmt === "string" && stmt.includes("sqlite_master")) {
38+ return Promise.resolve({ rows: [{ name: "integration" }] });
39+ }
40+ if (typeof stmt === "string" && stmt.startsWith("SELECT row_id")) {
41+ return Promise.resolve({ rows });
42+ }
43+ return Promise.resolve({ rows: [] });
44+ },
45+ };
46+ return { client, log };
47+};
48+
49+describe("runSqliteAuthConfigMigration", () => {
50+ it("rewrites legacy rows in a transaction and reports the count", async () => {
51+ const { client, log } = makeFakeClient([
52+ { row_id: "a", plugin_id: "mcp", config: JSON.stringify({ legacy: true }) },
53+ { row_id: "b", plugin_id: "mcp", config: JSON.stringify({ legacy: false }) },
54+ { row_id: "c", plugin_id: "mcp", config: "not json" },
55+ ]);
56+ const count = await runSqliteAuthConfigMigration(client, transforms);
57+ expect(count).toBe(1);
58+ expect(log).toContainEqual("BEGIN");
59+ expect(log).toContainEqual({
60+ sql: "UPDATE integration SET config = ? WHERE row_id = ?",
61+ args: [JSON.stringify({ migrated: true }), "a"],
62+ });
63+ expect(log).toContainEqual("COMMIT");
64+ });
65+
66+ it("plans nothing on a canonical database (idempotent re-run)", async () => {
67+ const { client, log } = makeFakeClient([
68+ { row_id: "a", plugin_id: "mcp", config: JSON.stringify({ legacy: false }) },
69+ ]);
70+ expect(await runSqliteAuthConfigMigration(client, transforms)).toBe(0);
71+ expect(log).not.toContainEqual("BEGIN");
72+ });
73+
74+ it("treats a database without the integration table as nothing to migrate", async () => {
75+ const client: SqliteAuthConfigClient = {
76+ execute: () => Promise.resolve({ rows: [] }),
77+ };
78+ expect(await runSqliteAuthConfigMigration(client, transforms)).toBe(0);
79+ });
80+});
packages/core/sdk/src/http-auth/migrate.ts+99−0
@@ -0,0 +1,99 @@
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: one-shot config migration drives a raw SQL client (JSON text columns, transaction + rollback) */
2+// ---------------------------------------------------------------------------
3+// One-off auth-config migration planner — the driver-agnostic core every app
4+// wires its own SQL around. Each protocol plugin exports a pure
5+// `migrate<Plugin>AuthConfig(config) → config | null` (null = leave the row
6+// untouched); this plans which integration rows to rewrite. Idempotent by
7+// construction: re-running over migrated rows plans zero updates.
8+// ---------------------------------------------------------------------------
9+
10+export type AuthConfigTransform = (config: unknown) => unknown | null;
11+
12+export interface AuthConfigMigrationRow {
13+ /** The integration row's primary key (`row_id`). */
14+ readonly rowId: string;
15+ readonly pluginId: string;
16+ readonly config: unknown;
17+}
18+
19+export interface AuthConfigMigrationUpdate {
20+ readonly rowId: string;
21+ readonly config: unknown;
22+}
23+
24+/** Plan the config rewrites for a set of integration rows. `transforms` maps
25+ * plugin ids (`mcp` / `graphql` / `openapi`) to their migration functions;
26+ * rows of other plugins, and rows whose transform returns null, are left
27+ * untouched. */
28+export const planAuthConfigMigration = (
29+ rows: readonly AuthConfigMigrationRow[],
30+ transforms: Record<string, AuthConfigTransform>,
31+): readonly AuthConfigMigrationUpdate[] => {
32+ const updates: AuthConfigMigrationUpdate[] = [];
33+ for (const row of rows) {
34+ const transform = transforms[row.pluginId];
35+ if (!transform) continue;
36+ const migrated = transform(row.config);
37+ if (migrated !== null) updates.push({ rowId: row.rowId, config: migrated });
38+ }
39+ return updates;
40+};
41+
42+// ---------------------------------------------------------------------------
43+// SQLite runner — shared by the libSQL-backed apps (local boot, selfhost
44+// boot). Structural client interface so this package stays dependency-free;
45+// `@libsql/client` satisfies it. Postgres apps (cloud) wire their own SQL
46+// around `planAuthConfigMigration`.
47+// ---------------------------------------------------------------------------
48+
49+export interface SqliteAuthConfigClient {
50+ execute(
51+ stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
52+ ): Promise<{ readonly rows: readonly Record<string, unknown>[] }>;
53+}
54+
55+/** Rewrite pre-canonical integration `config` blobs in a SQLite database.
56+ * Idempotent (canonical rows plan no updates); returns the number of rows
57+ * rewritten. The `integration` table may not exist yet on a fresh database —
58+ * that counts as nothing to migrate. */
59+export const runSqliteAuthConfigMigration = async (
60+ client: SqliteAuthConfigClient,
61+ transforms: Record<string, AuthConfigTransform>,
62+): Promise<number> => {
63+ const exists = await client.execute(
64+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'",
65+ );
66+ if (exists.rows.length === 0) return 0;
67+
68+ const result = await client.execute("SELECT row_id, plugin_id, config FROM integration");
69+ const rows: AuthConfigMigrationRow[] = [];
70+ for (const row of result.rows) {
71+ if (typeof row.row_id !== "string" || typeof row.plugin_id !== "string") continue;
72+ if (typeof row.config !== "string") continue;
73+ let config: unknown;
74+ try {
75+ config = JSON.parse(row.config);
76+ } catch {
77+ continue;
78+ }
79+ rows.push({ rowId: row.row_id, pluginId: row.plugin_id, config });
80+ }
81+
82+ const updates = planAuthConfigMigration(rows, transforms);
83+ if (updates.length === 0) return 0;
84+
85+ await client.execute("BEGIN");
86+ try {
87+ for (const update of updates) {
88+ await client.execute({
89+ sql: "UPDATE integration SET config = ? WHERE row_id = ?",
90+ args: [JSON.stringify(update.config), update.rowId],
91+ });
92+ }
93+ await client.execute("COMMIT");
94+ } catch (cause) {
95+ await client.execute("ROLLBACK");
96+ throw cause;
97+ }
98+ return updates.length;
99+};
packages/core/sdk/src/integration.ts+4−0
@@ -35,6 +35,10 @@ export interface AuthPlacementDescriptor { · 34 unmodified lines
3535 * methods; a distinct name per input for multi-input ones (e.g. Datadog).
3636 * Absent → treated as `token`. */
3737 readonly variable?: string;
38+ /** Set when the placement renders this exact value instead of a credential
39+ * (a static header/param the method carries). Such placements reference no
40+ * input variable. */
41+ readonly literal?: string;
3842 }
3943
4044 /** OAuth specifics for an `oauth` auth method. For probe-at-connect providers
packages/core/sdk/src/oauth-client.ts+3−2
@@ -25,10 +25,11 @@ import { · 24 unmodified lines
2525 export type OAuthGrant = "authorization_code" | "client_credentials";
2626
2727 /** Provider OAuth config an integration declares as one of its auth templates —
28− * what to request. (The flow itself runs off the self-contained OAuthClient.) */
28+ * what to request. (The flow itself runs off the self-contained OAuthClient.)
29+ * Keyed `kind: "oauth2"` like every auth method across the plugins. */
2930 export interface OAuthAuthentication {
3031 readonly slug: AuthTemplateSlug;
31− readonly type: "oauth";
32+ readonly kind: "oauth2";
3233 readonly authorizationUrl: string;
3334 readonly tokenUrl: string;
3435 readonly scopes: readonly string[];
packages/core/sdk/src/plugin.ts+4−0
@@ -201,6 +201,10 @@ export interface ResolveToolsInput { · 200 unmodified lines
201201 /** Lazily resolve the connection's credential value via its provider — only
202202 * the kinds that actually call out (mcp) pay for it. */
203203 readonly getValue: () => Effect.Effect<string | null, StorageFailure>;
204+ /** Lazily resolve every credential input (`variable → value`) — the
205+ * multi-input analog of `getValue`, for methods whose placements reference
206+ * more than one variable. Empty map when the connection isn't persisted. */
207+ readonly getValues: () => Effect.Effect<Record<string, string | null>, StorageFailure>;
204208 }
205209
206210 export interface ResolveToolsResult {
packages/core/sdk/tsup.config.ts+1−0
@@ -8,6 +8,7 @@ export default defineConfig({ · 7 unmodified lines
88 "host-internal": "src/host-internal.ts",
99 client: "src/client.ts",
1010 "migration-spec": "src/migration-spec.ts",
11+ "http-auth": "src/http-auth/index.ts",
1112 testing: "src/testing.ts",
1213 },
1314 format: ["esm"],
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+3−3
@@ -43,7 +43,7 @@ import { · 42 unmodified lines
4343 type FumaTables,
4444 } from "@executor-js/sdk";
4545 import { makeExecutorToolInvoker } from "@executor-js/execution";
46−import { openApiPlugin, variable, type Authentication } from "@executor-js/plugin-openapi";
46+import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi";
4747
4848 import { makeDynamicWorkerExecutor } from "./executor";
4949
@@ -85,8 +85,8 @@ const memoryProvider = (): CredentialProvider => { · 35 unmodified lines
8585 // Minimal apiKey template: the resolved connection value renders into an
8686 // `x-api-key` header. The body round-trip tests don't assert on auth, but a
8787 // template + connection are required for tools to exist per-connection.
88−const apiKeyTemplate: Authentication = {
89− slug: AuthTemplateSlug.make("apiKey"),
88+const apiKeyTemplate: AuthenticationInput = {
89+ slug: "apiKey",
9090 type: "apiKey",
9191 headers: { "x-api-key": [variable("token")] },
9292 };
packages/plugins/graphql-greenfield/CHANGELOG.md+0−33
@@ -1,33 +0,0 @@
1−# @executor-js/plugin-graphql-greenfield
2−
3−## 0.0.4
4−
5−### Patch Changes
6−
7−- Updated dependencies []:
8− - @executor-js/sdk@1.5.3
9− - @executor-js/api@1.4.25
10−
11−## 0.0.3
12−
13−### Patch Changes
14−
15−- Updated dependencies []:
16− - @executor-js/sdk@1.5.2
17− - @executor-js/api@1.4.24
18−
19−## 0.0.2
20−
21−### Patch Changes
22−
23−- Updated dependencies []:
24− - @executor-js/sdk@1.5.1
25− - @executor-js/api@1.4.23
26−
27−## 0.0.1
28−
29−### Patch Changes
30−
31−- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]:
32− - @executor-js/sdk@1.5.0
33− - @executor-js/api@1.4.22
packages/plugins/graphql-greenfield/package.json+0−56
@@ -1,56 +0,0 @@
1−{
2− "name": "@executor-js/plugin-graphql-greenfield",
3− "version": "0.0.4",
4− "private": true,
5− "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/graphql-greenfield",
6− "bugs": {
7− "url": "https://github.com/RhysSullivan/executor/issues"
8− },
9− "license": "MIT",
10− "repository": {
11− "type": "git",
12− "url": "git+https://github.com/RhysSullivan/executor.git",
13− "directory": "packages/plugins/graphql-greenfield"
14− },
15− "files": [
16− "dist"
17− ],
18− "type": "module",
19− "exports": {
20− ".": "./src/sdk/index.ts",
21− "./promise": "./src/promise.ts",
22− "./api": "./src/api/index.ts",
23− "./presets": "./src/sdk/presets.ts",
24− "./testing": "./src/testing/index.ts"
25− },
26− "scripts": {
27− "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
28− "typecheck": "tsgo --noEmit",
29− "test": "vitest run",
30− "test:watch": "vitest",
31− "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
32− },
33− "dependencies": {
34− "@effect/platform-node": "catalog:",
35− "@executor-js/sdk": "workspace:*",
36− "effect": "catalog:",
37− "graphql": "^16.12.0",
38− "graphql-yoga": "^5.17.0"
39− },
40− "devDependencies": {
41− "@effect/vitest": "catalog:",
42− "@executor-js/api": "workspace:*",
43− "@types/node": "catalog:",
44− "bun-types": "catalog:",
45− "tsup": "catalog:",
46− "vitest": "catalog:"
47− },
48− "peerDependencies": {
49− "@executor-js/api": "workspace:*"
50− },
51− "peerDependenciesMeta": {
52− "@executor-js/api": {
53− "optional": true
54− }
55− }
56−}
packages/plugins/graphql-greenfield/src/api/group.ts+0−83
@@ -1,83 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { Schema } from "effect";
3−import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared";
4−
5−import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6−import { AuthTemplate } from "../sdk/types";
7−
8−// ---------------------------------------------------------------------------
9−// Params
10−// ---------------------------------------------------------------------------
11−
12−const IntegrationParams = {
13− slug: Schema.String,
14−};
15−
16−// ---------------------------------------------------------------------------
17−// Payloads
18−// ---------------------------------------------------------------------------
19−
20−const AddIntegrationPayload = Schema.Struct({
21− slug: Schema.String,
22− endpoint: Schema.String,
23− description: Schema.optional(Schema.String),
24− introspectionJson: Schema.optional(Schema.String),
25− authentication: Schema.optional(Schema.Array(AuthTemplate)),
26− introspectionHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
27−});
28−
29−// ---------------------------------------------------------------------------
30−// Responses
31−// ---------------------------------------------------------------------------
32−
33−const AddIntegrationResponse = Schema.Struct({
34− slug: Schema.String,
35− toolCount: Schema.Number,
36−});
37−
38−const GetIntegrationResponse = Schema.NullOr(
39− Schema.Struct({
40− slug: Schema.String,
41− description: Schema.String,
42− kind: Schema.String,
43− canRemove: Schema.Boolean,
44− canRefresh: Schema.Boolean,
45− config: Schema.NullOr(Schema.Unknown),
46− }),
47−);
48−
49−// ---------------------------------------------------------------------------
50−// Errors with HTTP status
51−// ---------------------------------------------------------------------------
52−
53−const IntrospectionError = GraphqlIntrospectionError.annotate({ httpApiStatus: 400 });
54−const ExtractionError = GraphqlExtractionError.annotate({ httpApiStatus: 400 });
55−
56−const GraphqlErrors = [
57− InternalError,
58− IntrospectionError,
59− ExtractionError,
60− IntegrationAlreadyExistsError,
61−] as const;
62−
63−// ---------------------------------------------------------------------------
64−// Group — addIntegration / getIntegration over the v2 integration catalog.
65−// (v1's scope-keyed addSource/getSource is replaced by catalog-keyed routes;
66−// connections are managed through the core connections API, not here.)
67−// ---------------------------------------------------------------------------
68−
69−export const GraphqlGroup = HttpApiGroup.make("graphql-greenfield")
70− .add(
71− HttpApiEndpoint.post("addIntegration", "/graphql-greenfield/integrations", {
72− payload: AddIntegrationPayload,
73− success: AddIntegrationResponse,
74− error: GraphqlErrors,
75− }),
76− )
77− .add(
78− HttpApiEndpoint.get("getIntegration", "/graphql-greenfield/integrations/:slug", {
79− params: IntegrationParams,
80− success: GetIntegrationResponse,
81− error: GraphqlErrors,
82− }),
83− );
packages/plugins/graphql-greenfield/src/api/handlers.ts+0−70
@@ -1,70 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { Context, Effect } from "effect";
3−
4−import { addGroup, capture } from "@executor-js/api";
5−import type { GraphqlPluginExtension } from "../sdk/plugin";
6−import { GraphqlGroup } from "./group";
7−
8−// ---------------------------------------------------------------------------
9−// Service tag
10−//
11−// Holds the `Captured` shape — every method's `StorageFailure` channel has
12−// been swapped for `InternalError({ traceId })`. The host app provides an
13−// already-wrapped extension via
14−// `Layer.succeed(GraphqlExtensionService, withCapture(executor["graphql-greenfield"]))`.
15−// ---------------------------------------------------------------------------
16−
17−export class GraphqlExtensionService extends Context.Service<
18− GraphqlExtensionService,
19− GraphqlPluginExtension
20−>()("GraphqlGreenfieldExtensionService") {}
21−
22−// ---------------------------------------------------------------------------
23−// Composed API — core + graphql group
24−// ---------------------------------------------------------------------------
25−
26−const ExecutorApiWithGraphql = addGroup(GraphqlGroup);
27−
28−// ---------------------------------------------------------------------------
29−// Handlers
30−// ---------------------------------------------------------------------------
31−
32−export const GraphqlHandlers = HttpApiBuilder.group(
33− ExecutorApiWithGraphql,
34− "graphql-greenfield",
35− (handlers) =>
36− handlers
37− .handle("addIntegration", ({ payload }) =>
38− capture(
39− Effect.gen(function* () {
40− const ext = yield* GraphqlExtensionService;
41− const result = yield* ext.addIntegration({
42− slug: payload.slug,
43− endpoint: payload.endpoint,
44− description: payload.description,
45− introspectionJson: payload.introspectionJson,
46− authentication: payload.authentication,
47− introspectionHeaders: payload.introspectionHeaders,
48− });
49− return { slug: result.slug, toolCount: result.toolCount };
50− }),
51− ),
52− )
53− .handle("getIntegration", ({ params: path }) =>
54− capture(
55− Effect.gen(function* () {
56− const ext = yield* GraphqlExtensionService;
57− const integration = yield* ext.getIntegration(path.slug);
58− if (integration === null) return null;
59− return {
60− slug: String(integration.slug),
61− description: integration.description,
62− kind: integration.kind,
63− canRemove: integration.canRemove,
64− canRefresh: integration.canRefresh,
65− config: integration.config,
66− };
67− }),
68− ),
69− ),
70−);
packages/plugins/graphql-greenfield/src/api/index.ts+0−20
@@ -1,20 +0,0 @@
1−import { definePlugin } from "@executor-js/sdk";
2−
3−import { graphqlPlugin, type GraphqlPluginOptions } from "../sdk/plugin";
4−import { GraphqlGroup } from "./group";
5−import { GraphqlHandlers, GraphqlExtensionService } from "./handlers";
6−
7−export { GraphqlGroup } from "./group";
8−export { GraphqlHandlers, GraphqlExtensionService } from "./handlers";
9−
10−// HTTP-augmented variant of `graphqlPlugin`. The returned plugin carries the
11−// HTTP `routes`, `handlers`, and `extensionService` so a host can mount the
12−// GraphQL HTTP surface. Hosts that compose an `HttpApi` import this; SDK-only
13−// consumers stay on `@executor-js/plugin-graphql-greenfield` and never load
14−// `@executor-js/api`.
15−export const graphqlHttpPlugin = definePlugin((options?: GraphqlPluginOptions) => ({
16− ...graphqlPlugin(options),
17− routes: () => GraphqlGroup,
18− handlers: () => GraphqlHandlers,
19− extensionService: GraphqlExtensionService,
20−}));
packages/plugins/graphql-greenfield/src/promise.ts+0−7
@@ -1,7 +0,0 @@
1−export { graphqlPlugin } from "./sdk/plugin";
2−export type {
3− GraphqlPluginOptions,
4− GraphqlPluginExtension,
5− AddGraphqlIntegrationInput,
6− ConfigureGraphqlIntegrationInput,
7−} from "./sdk/plugin";
packages/plugins/graphql-greenfield/src/sdk/errors.ts+0−40
@@ -1,40 +0,0 @@
1−import { Data, Schema } from "effect";
2−import type { Option } from "effect";
3−import type { AuthToolFailureCode } from "@executor-js/sdk";
4−
5−export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIntrospectionError>()(
6− "GraphqlIntrospectionError",
7− {
8− message: Schema.String,
9− },
10−) {}
11−
12−export class GraphqlExtractionError extends Schema.TaggedErrorClass<GraphqlExtractionError>()(
13− "GraphqlExtractionError",
14− {
15− message: Schema.String,
16− },
17−) {}
18−
19−export class GraphqlInvocationError extends Data.TaggedError("GraphqlInvocationError")<{
20− readonly message: string;
21− readonly statusCode: Option.Option<number>;
22− readonly cause?: unknown;
23−}> {}
24−
25−/** Raised inside `invokeTool` when the connection's credential value could not
26− * be resolved (provider returned nothing / OAuth re-auth needed). v2 keys this
27− * by the connection (owner + integration + name) rather than v1's source/scope
28− * + credential-binding slot. */
29−export class GraphqlAuthRequiredError extends Data.TaggedError("GraphqlAuthRequiredError")<{
30− readonly code: AuthToolFailureCode;
31− readonly message: string;
32− readonly owner: "org" | "user";
33− readonly integration: string;
34− readonly connection: string;
35− readonly credentialKind: "secret" | "oauth" | "upstream";
36− readonly credentialLabel?: string;
37− readonly status?: number;
38− readonly details?: unknown;
39− readonly cause?: unknown;
40−}> {}
packages/plugins/graphql-greenfield/src/sdk/extract.ts+0−252
@@ -1,252 +0,0 @@
1−import { Effect, Match, Option } from "effect";
2−
3−import { GraphqlExtractionError } from "./errors";
4−import type {
5− IntrospectionResult,
6− IntrospectionSchema,
7− IntrospectionType,
8− IntrospectionTypeRef,
9− IntrospectionInputValue,
10−} from "./introspect";
11−import {
12− ExtractedField,
13− ExtractionResult,
14− GraphqlArgument,
15− type GraphqlOperationKind,
16−} from "./types";
17−
18−// ---------------------------------------------------------------------------
19−// Type ref helpers
20−// ---------------------------------------------------------------------------
21−
22−/** Unwrap NON_NULL / LIST wrappers to get the leaf type name */
23−const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
24− if (ref.name) return ref.name;
25− if (ref.ofType) return unwrapTypeName(ref.ofType);
26− return "Unknown";
27−};
28−
29−/** Check if a type ref is non-null (required) */
30−const isNonNull = (ref: IntrospectionTypeRef): boolean => ref.kind === "NON_NULL";
31−
32−// ---------------------------------------------------------------------------
33−// Build shared definitions from all INPUT_OBJECT and ENUM types
34−// ---------------------------------------------------------------------------
35−
36−const buildDefinitions = (
37− types: ReadonlyMap<string, IntrospectionType>,
38−): Record<string, unknown> => {
39− const defs: Record<string, unknown> = {};
40−
41− for (const [name, type] of types) {
42− // Skip internal types
43− if (name.startsWith("__")) continue;
44−
45− if (type.kind === "INPUT_OBJECT" && type.inputFields) {
46− const properties: Record<string, unknown> = {};
47− const required: string[] = [];
48−
49− for (const field of type.inputFields) {
50− const schema = typeRefToJsonSchema(field.type, types);
51− if (field.description) {
52− (schema as Record<string, unknown>).description = field.description;
53− }
54− properties[field.name] = schema;
55− if (isNonNull(field.type)) {
56− required.push(field.name);
57− }
58− }
59−
60− const def: Record<string, unknown> = { type: "object", properties };
61− if (required.length > 0) def.required = required;
62− if (type.description) def.description = type.description;
63− defs[name] = def;
64− }
65−
66− if (type.kind === "ENUM" && type.enumValues) {
67− defs[name] = {
68− type: "string",
69− enum: type.enumValues.map((v) => v.name),
70− ...(type.description ? { description: type.description } : {}),
71− };
72− }
73− }
74−
75− return defs;
76−};
77−
78−// ---------------------------------------------------------------------------
79−// Convert a type ref to JSON Schema using $ref for complex types
80−// ---------------------------------------------------------------------------
81−
82−const typeRefToJsonSchema = (
83− ref: IntrospectionTypeRef,
84− // oxlint-disable-next-line only-used-in-recursion
85− types: ReadonlyMap<string, IntrospectionType>,
86−): Record<string, unknown> =>
87− Match.value(ref.kind).pipe(
88− Match.when(
89− "NON_NULL",
90− (): Record<string, unknown> => (ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}),
91− ),
92− Match.when(
93− "LIST",
94− (): Record<string, unknown> => ({
95− type: "array",
96− items: ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {},
97− }),
98− ),
99− Match.when("SCALAR", (): Record<string, unknown> => scalarToJsonSchema(ref.name ?? "String")),
100− Match.when(
101− "ENUM",
102− (): Record<string, unknown> =>
103− ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "string" },
104− ),
105− Match.when(
106− "INPUT_OBJECT",
107− (): Record<string, unknown> =>
108− ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "object" },
109− ),
110− Match.whenOr(
111− "OBJECT",
112− "INTERFACE",
113− "UNION",
114− (): Record<string, unknown> => ({ type: "object" }),
115− ),
116− Match.option,
117− Option.getOrElse((): Record<string, unknown> => ({})),
118− );
119−
120−const scalarToJsonSchema = (name: string): Record<string, unknown> =>
121− Match.value(name).pipe(
122− Match.whenOr("String", "ID", (): Record<string, unknown> => ({ type: "string" })),
123− Match.when("Int", (): Record<string, unknown> => ({ type: "integer" })),
124− Match.when("Float", (): Record<string, unknown> => ({ type: "number" })),
125− Match.when("Boolean", (): Record<string, unknown> => ({ type: "boolean" })),
126− Match.option,
127− Option.getOrElse(
128− (): Record<string, unknown> => ({ type: "string", description: `Custom scalar: ${name}` }),
129− ),
130− );
131−
132−// ---------------------------------------------------------------------------
133−// Build input JSON Schema from field arguments
134−// ---------------------------------------------------------------------------
135−
136−const buildInputSchema = (
137− args: readonly IntrospectionInputValue[],
138− types: ReadonlyMap<string, IntrospectionType>,
139−): Record<string, unknown> | undefined => {
140− if (args.length === 0) return undefined;
141−
142− const properties: Record<string, unknown> = {};
143− const required: string[] = [];
144−
145− for (const arg of args) {
146− const schema = typeRefToJsonSchema(arg.type, types);
147− if (arg.description) {
148− (schema as Record<string, unknown>).description = arg.description;
149− }
150− properties[arg.name] = schema;
151− if (isNonNull(arg.type)) {
152− required.push(arg.name);
153− }
154− }
155−
156− const inputSchema: Record<string, unknown> = {
157− type: "object",
158− properties,
159− };
160− if (required.length > 0) inputSchema.required = required;
161− return inputSchema;
162−};
163−
164−/** Format a type ref back to GraphQL type notation (e.g. "[String!]!") */
165−const formatTypeRef = (ref: IntrospectionTypeRef): string =>
166− Match.value(ref.kind).pipe(
167− Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")),
168− Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")),
169− Match.option,
170− Option.getOrElse(() => ref.name ?? "Unknown"),
171− );
172−
173−// ---------------------------------------------------------------------------
174−// Extract fields from schema
175−// ---------------------------------------------------------------------------
176−
177−const extractFields = (
178− _schema: IntrospectionSchema,
179− kind: GraphqlOperationKind,
180− typeName: string | null | undefined,
181− types: ReadonlyMap<string, IntrospectionType>,
182−): ExtractedField[] => {
183− if (!typeName) return [];
184−
185− const type = types.get(typeName);
186− if (!type?.fields) return [];
187−
188− return type.fields
189− .filter((f) => !f.name.startsWith("__"))
190− .map((field) => {
191− const args = field.args.map((arg) =>
192− GraphqlArgument.make({
193− name: arg.name,
194− typeName: formatTypeRef(arg.type),
195− required: isNonNull(arg.type),
196− description: arg.description ? Option.some(arg.description) : Option.none(),
197− }),
198− );
199−
200− const inputSchema = buildInputSchema(field.args, types);
201−
202− return ExtractedField.make({
203− fieldName: field.name,
204− kind,
205− description: field.description ? Option.some(field.description) : Option.none(),
206− arguments: args,
207− inputSchema: inputSchema ? Option.some(inputSchema) : Option.none(),
208− returnTypeName: unwrapTypeName(field.type),
209− });
210− });
211−};
212−
213−// ---------------------------------------------------------------------------
214−// Public API
215−// ---------------------------------------------------------------------------
216−
217−export interface ExtractionOutput {
218− readonly result: ExtractionResult;
219− /** Shared JSON Schema definitions for INPUT_OBJECT and ENUM types.
220− * Tool input schemas use `$ref` pointers into these. */
221− readonly definitions: Record<string, unknown>;
222−}
223−
224−export const extract = (
225− introspection: IntrospectionResult,
226−): Effect.Effect<ExtractionOutput, GraphqlExtractionError> =>
227− Effect.try({
228− try: () => {
229− const schema = introspection.__schema;
230− const typeMap = new Map<string, IntrospectionType>();
231− for (const t of schema.types) {
232− typeMap.set(t.name, t);
233− }
234−
235− const definitions = buildDefinitions(typeMap);
236−
237− const queryFields = extractFields(schema, "query", schema.queryType?.name, typeMap);
238− const mutationFields = extractFields(schema, "mutation", schema.mutationType?.name, typeMap);
239−
240− return {
241− result: ExtractionResult.make({
242− schemaName: Option.none(),
243− fields: [...queryFields, ...mutationFields],
244− }),
245− definitions,
246− };
247− },
248− catch: () =>
249− new GraphqlExtractionError({
250− message: "Failed to extract GraphQL schema",
251− }),
252− });
packages/plugins/graphql-greenfield/src/sdk/index.ts+0−31
@@ -1,31 +0,0 @@
1−export { introspect, parseIntrospectionJson } from "./introspect";
2−export { extract, type ExtractionOutput } from "./extract";
3−export { invoke, invokeWithLayer, endpointForTelemetry } from "./invoke";
4−export {
5− graphqlPlugin,
6− type GraphqlPluginExtension,
7− type GraphqlPluginOptions,
8− type AddGraphqlIntegrationInput,
9− type ConfigureGraphqlIntegrationInput,
10−} from "./plugin";
11−
12−export {
13− GraphqlIntrospectionError,
14− GraphqlExtractionError,
15− GraphqlInvocationError,
16− GraphqlAuthRequiredError,
17−} from "./errors";
18−
19−export {
20− ApiKeyHeaderTemplate,
21− ApiKeyQueryTemplate,
22− AuthTemplate,
23− ExtractedField,
24− ExtractionResult,
25− GraphqlArgument,
26− GraphqlIntegrationConfig,
27− GraphqlOperationKind,
28− InvocationResult,
29− OAuthTemplate,
30− OperationBinding,
31−} from "./types";
packages/plugins/graphql-greenfield/src/sdk/introspect.ts+0−343
@@ -1,343 +0,0 @@
1−import { Effect, Option, Schema } from "effect";
2−import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3−
4−import { GraphqlIntrospectionError } from "./errors";
5−
6−// ---------------------------------------------------------------------------
7−// Introspection query — standard GraphQL introspection
8−// ---------------------------------------------------------------------------
9−
10−const INTROSPECTION_QUERY = `
11− query IntrospectionQuery {
12− __schema {
13− queryType { name }
14− mutationType { name }
15− types {
16− kind
17− name
18− description
19− fields(includeDeprecated: false) {
20− name
21− description
22− args {
23− name
24− description
25− type {
26− ...TypeRef
27− }
28− defaultValue
29− }
30− type {
31− ...TypeRef
32− }
33− }
34− inputFields {
35− name
36− description
37− type {
38− ...TypeRef
39− }
40− defaultValue
41− }
42− enumValues(includeDeprecated: false) {
43− name
44− description
45− }
46− }
47− }
48− }
49−
50− fragment TypeRef on __Type {
51− kind
52− name
53− ofType {
54− kind
55− name
56− ofType {
57− kind
58− name
59− ofType {
60− kind
61− name
62− ofType {
63− kind
64− name
65− ofType {
66− kind
67− name
68− ofType {
69− kind
70− name
71− }
72− }
73− }
74− }
75− }
76− }
77− }
78−`;
79−
80−// ---------------------------------------------------------------------------
81−// Introspection result types
82−// ---------------------------------------------------------------------------
83−
84−const IntrospectionTypeRefLeaf = Schema.Struct({
85− kind: Schema.String,
86− name: Schema.NullOr(Schema.String),
87− ofType: Schema.optional(Schema.Null),
88−});
89−
90−const IntrospectionTypeRef5 = Schema.Struct({
91− kind: Schema.String,
92− name: Schema.NullOr(Schema.String),
93− ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)),
94−});
95−
96−const IntrospectionTypeRef4 = Schema.Struct({
97− kind: Schema.String,
98− name: Schema.NullOr(Schema.String),
99− ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)),
100−});
101−
102−const IntrospectionTypeRef3 = Schema.Struct({
103− kind: Schema.String,
104− name: Schema.NullOr(Schema.String),
105− ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)),
106−});
107−
108−const IntrospectionTypeRef2 = Schema.Struct({
109− kind: Schema.String,
110− name: Schema.NullOr(Schema.String),
111− ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)),
112−});
113−
114−const IntrospectionTypeRefSchema = Schema.Struct({
115− kind: Schema.String,
116− name: Schema.NullOr(Schema.String),
117− ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef2)),
118−});
119−
120−const IntrospectionInputValueSchema = Schema.Struct({
121− name: Schema.String,
122− description: Schema.NullOr(Schema.String),
123− type: IntrospectionTypeRefSchema,
124− defaultValue: Schema.NullOr(Schema.String),
125−});
126−
127−const IntrospectionFieldSchema = Schema.Struct({
128− name: Schema.String,
129− description: Schema.NullOr(Schema.String),
130− args: Schema.Array(IntrospectionInputValueSchema),
131− type: IntrospectionTypeRefSchema,
132−});
133−
134−const IntrospectionTypeSchema = Schema.Struct({
135− kind: Schema.String,
136− name: Schema.String,
137− description: Schema.NullOr(Schema.String),
138− fields: Schema.NullOr(Schema.Array(IntrospectionFieldSchema)),
139− inputFields: Schema.NullOr(Schema.Array(IntrospectionInputValueSchema)),
140− enumValues: Schema.NullOr(
141− Schema.Array(
142− Schema.Struct({
143− name: Schema.String,
144− description: Schema.NullOr(Schema.String),
145− }),
146− ),
147− ),
148−});
149−
150−const IntrospectionResultSchema = Schema.Struct({
151− __schema: Schema.Struct({
152− queryType: Schema.NullOr(Schema.Struct({ name: Schema.String })),
153− mutationType: Schema.NullOr(Schema.Struct({ name: Schema.String })),
154− types: Schema.Array(IntrospectionTypeSchema),
155− }),
156−});
157−
158−const IntrospectionResponseSchema = Schema.Struct({
159− data: Schema.optional(IntrospectionResultSchema),
160− errors: Schema.optional(Schema.Array(Schema.Unknown)),
161−});
162−
163−const UpstreamErrorResponseSchema = Schema.Struct({
164− message: Schema.optional(Schema.String),
165− errors: Schema.optional(
166− Schema.Array(
167− Schema.Struct({
168− message: Schema.optional(Schema.String),
169− }),
170− ),
171− ),
172−});
173−
174−const IntrospectionJsonSchema = Schema.Union([
175− Schema.Struct({ data: IntrospectionResultSchema }),
176− IntrospectionResultSchema,
177−]);
178−const JsonTextSchema = Schema.fromJsonString(Schema.Unknown);
179−
180−const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema);
181−
182−export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type;
183−export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type;
184−export type IntrospectionField = typeof IntrospectionFieldSchema.Type;
185−export type IntrospectionEnumValue = NonNullable<
186− (typeof IntrospectionTypeSchema.Type)["enumValues"]
187−>[number];
188−export type IntrospectionType = typeof IntrospectionTypeSchema.Type;
189−export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"];
190−export type IntrospectionResult = typeof IntrospectionResultSchema.Type;
191−
192−const firstUpstreamErrorMessage = (value: unknown): string | null => {
193− const decoded = decodeUpstreamErrorResponse(value);
194− return Option.match(decoded, {
195− onNone: () => null,
196− onSome: (response) => {
197− if (response.message) return response.message;
198− for (const entry of response.errors ?? []) {
199− const message = entry.message;
200− if (message) return message;
201− }
202− return null;
203− },
204− });
205−};
206−
207−const redactUpstreamBody = (body: string): string =>
208− body
209− .replaceAll(
210− /("(?:access_token|refresh_token|id_token|client_secret|token|authorization)"\s*:\s*")[^"]*(")/gi,
211− "$1[redacted]$2",
212− )
213− .replaceAll(
214− /((?:access_token|refresh_token|id_token|client_secret|token|authorization)=)[^&\s]*/gi,
215− "$1[redacted]",
216− )
217− .replaceAll(
218− /((?:authorization|access-token|refresh-token|id-token|client-secret|token)\s*:\s*)(?:bearer\s+)?[^\s,;]+/gi,
219− "$1[redacted]",
220− );
221−
222−const upstreamTextMessage = (body: string): string | null => {
223− const text = redactUpstreamBody(body.replaceAll(/\s+/g, " ").trim());
224− if (text.length === 0) return null;
225− return text.length > 500 ? `${text.slice(0, 500)}...` : text;
226−};
227−
228−// ---------------------------------------------------------------------------
229−// Introspect a GraphQL endpoint
230−// ---------------------------------------------------------------------------
231−
232−export const introspect = Effect.fn("GraphQL.introspect")(function* (
233− endpoint: string,
234− headers?: Record<string, string>,
235− queryParams?: Record<string, string>,
236−) {
237− const client = yield* HttpClient.HttpClient;
238− const requestEndpoint =
239− queryParams && Object.keys(queryParams).length > 0
240− ? (() => {
241− const url = new URL(endpoint);
242− for (const [name, value] of Object.entries(queryParams)) {
243− url.searchParams.set(name, value);
244− }
245− return url.toString();
246− })()
247− : endpoint;
248−
249− let request = HttpClientRequest.post(requestEndpoint).pipe(
250− HttpClientRequest.setHeader("Content-Type", "application/json"),
251− HttpClientRequest.setHeader("Accept", "application/json"),
252− HttpClientRequest.setHeader("User-Agent", "executor-graphql"),
253− HttpClientRequest.bodyJsonUnsafe({
254− query: INTROSPECTION_QUERY,
255− }),
256− );
257−
258− if (headers) {
259− for (const [k, v] of Object.entries(headers)) {
260− request = HttpClientRequest.setHeader(request, k, v);
261− }
262− }
263−
264− const response = yield* client.execute(request).pipe(
265− Effect.tapCause((cause) => Effect.logError("graphql introspection request failed", cause)),
266− Effect.mapError(
267− () =>
268− new GraphqlIntrospectionError({
269− message: "Failed to reach GraphQL endpoint",
270− }),
271− ),
272− );
273−
274− if (response.status !== 200) {
275− const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")));
276− const raw = responseText
277− ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe(
278− Effect.catch(() => Effect.succeed(null)),
279− )
280− : null;
281− const upstreamMessage = upstreamTextMessage(
282− (raw === null ? null : firstUpstreamErrorMessage(raw)) ?? responseText,
283− );
284− return yield* new GraphqlIntrospectionError({
285− message: upstreamMessage
286− ? `Introspection failed with status ${response.status}: ${upstreamMessage}`
287− : `Introspection failed with status ${response.status}`,
288− });
289− }
290−
291− const raw = yield* response.json.pipe(
292− Effect.tapCause((cause) => Effect.logError("graphql introspection JSON parse failed", cause)),
293− Effect.mapError(
294− () =>
295− new GraphqlIntrospectionError({
296− message: `Failed to parse introspection response as JSON`,
297− }),
298− ),
299− );
300−
301− const json = yield* Schema.decodeUnknownEffect(IntrospectionResponseSchema)(raw).pipe(
302− Effect.mapError(
303− () =>
304− new GraphqlIntrospectionError({
305− message: "Introspection response has an invalid shape",
306− }),
307− ),
308− );
309−
310− if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
311− const upstreamMessage = firstUpstreamErrorMessage(json);
312− return yield* new GraphqlIntrospectionError({
313− message: upstreamMessage
314− ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`
315− : `Introspection returned ${json.errors.length} error(s)`,
316− });
317− }
318−
319− if (!json.data?.__schema) {
320− return yield* new GraphqlIntrospectionError({
321− message: "Introspection response missing __schema",
322− });
323− }
324−
325− return json.data;
326−});
327−
328−// ---------------------------------------------------------------------------
329−// Parse an introspection result from a JSON string (for offline/text input)
330−// ---------------------------------------------------------------------------
331−
332−export const parseIntrospectionJson = (
333− text: string,
334−): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> =>
335− Schema.decodeUnknownEffect(Schema.fromJsonString(IntrospectionJsonSchema))(text).pipe(
336− Effect.map((parsed) => ("data" in parsed ? parsed.data : parsed)),
337− Effect.mapError(
338− () =>
339− new GraphqlIntrospectionError({
340− message: "Failed to parse introspection JSON",
341− }),
342− ),
343− );
packages/plugins/graphql-greenfield/src/sdk/invoke.ts+0−147
@@ -1,147 +0,0 @@
1−import { Effect, Layer, Option } from "effect";
2−import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3−
4−import { GraphqlInvocationError } from "./errors";
5−import { type OperationBinding, InvocationResult } from "./types";
6−
7−// removed: v1 `resolveHeaders` (secret-backed header map via
8−// `resolveSecretBackedMap`). In v2 a connection resolves to a single credential
9−// value that the plugin renders through the integration's auth template at
10−// invoke time — there is no per-header secret map to resolve.
11−
12−const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, string>): string => {
13− if (Object.keys(queryParams).length === 0) return endpoint;
14− const url = new URL(endpoint);
15− for (const [name, value] of Object.entries(queryParams)) {
16− url.searchParams.set(name, value);
17− }
18− return url.toString();
19−};
20−
21−export const endpointForTelemetry = (endpoint: string): string => {
22− if (!URL.canParse(endpoint)) return endpoint;
23− const url = new URL(endpoint);
24− url.search = "";
25− url.hash = "";
26− return url.toString();
27−};
28−
29−// ---------------------------------------------------------------------------
30−// Response helpers
31−// ---------------------------------------------------------------------------
32−
33−const isJsonContentType = (ct: string | null | undefined): boolean => {
34− if (!ct) return false;
35− const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? "";
36− return (
37− normalized === "application/json" || normalized.includes("+json") || normalized.includes("json")
38− );
39−};
40−
41−// ---------------------------------------------------------------------------
42−// Public API — execute a GraphQL operation
43−// ---------------------------------------------------------------------------
44−
45−export const invoke = Effect.fn("GraphQL.invoke")(function* (
46− operation: OperationBinding,
47− args: Record<string, unknown>,
48− endpoint: string,
49− resolvedHeaders: Record<string, string>,
50− resolvedQueryParams: Record<string, string> = {},
51−) {
52− const client = yield* HttpClient.HttpClient;
53− const requestEndpoint = endpointWithQueryParams(endpoint, resolvedQueryParams);
54− const telemetryEndpoint = endpointForTelemetry(endpoint);
55−
56− yield* Effect.annotateCurrentSpan({
57− "http.method": "POST",
58− "http.url": telemetryEndpoint,
59− "plugin.graphql.endpoint": telemetryEndpoint,
60− "plugin.graphql.operation_kind": operation.kind,
61− "plugin.graphql.field_name": operation.fieldName,
62− "plugin.graphql.headers.resolved_count": Object.keys(resolvedHeaders).length,
63− "plugin.graphql.query_params.resolved_count": Object.keys(resolvedQueryParams).length,
64− });
65−
66− // Build the GraphQL request body
67− const variables: Record<string, unknown> = {};
68− for (const varName of operation.variableNames) {
69− if (args[varName] !== undefined) {
70− variables[varName] = args[varName];
71− }
72− }
73−
74− // Also pick up any variables from a "variables" container
75− if (typeof args.variables === "object" && args.variables !== null) {
76− Object.assign(variables, args.variables);
77− }
78−
79− let request = HttpClientRequest.post(requestEndpoint).pipe(
80− HttpClientRequest.setHeader("Content-Type", "application/json"),
81− HttpClientRequest.bodyJsonUnsafe({
82− query: operation.operationString,
83− variables: Object.keys(variables).length > 0 ? variables : undefined,
84− }),
85− );
86−
87− for (const [name, value] of Object.entries(resolvedHeaders)) {
88− request = HttpClientRequest.setHeader(request, name, value);
89− }
90−
91− const response = yield* client.execute(request).pipe(
92− Effect.mapError(
93− (err: unknown) =>
94− new GraphqlInvocationError({
95− message: "GraphQL request failed",
96− statusCode: Option.none(),
97− cause: err,
98− }),
99− ),
100− );
101−
102− const status = response.status;
103− const contentType = response.headers["content-type"] ?? null;
104−
105− const body: unknown = isJsonContentType(contentType)
106− ? yield* response.json.pipe(Effect.catch(() => response.text))
107− : yield* response.text;
108−
109− // GraphQL responses are always 200 with { data, errors }
110− const gqlBody = body as { data?: unknown; errors?: unknown[] } | null;
111− const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0;
112−
113− yield* Effect.annotateCurrentSpan({
114− "http.status_code": status,
115− "plugin.graphql.has_errors": hasErrors,
116− "plugin.graphql.error_count": hasErrors ? gqlBody!.errors!.length : 0,
117− });
118−
119− return InvocationResult.make({
120− status,
121− data: gqlBody?.data ?? null,
122− errors: hasErrors ? gqlBody!.errors : null,
123− });
124−});
125−
126−// ---------------------------------------------------------------------------
127−// Invoke a GraphQL operation with a provided HttpClient layer
128−// ---------------------------------------------------------------------------
129−
130−export const invokeWithLayer = (
131− operation: OperationBinding,
132− args: Record<string, unknown>,
133− endpoint: string,
134− resolvedHeaders: Record<string, string>,
135− resolvedQueryParams: Record<string, string>,
136− httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
137−) =>
138− invoke(operation, args, endpoint, resolvedHeaders, resolvedQueryParams).pipe(
139− Effect.provide(httpClientLayer),
140− Effect.withSpan("plugin.graphql.invoke", {
141− attributes: {
142− "plugin.graphql.endpoint": endpointForTelemetry(endpoint),
143− "plugin.graphql.operation_kind": operation.kind,
144− "plugin.graphql.field_name": operation.fieldName,
145− },
146− }),
147− );
packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts+0−512
@@ -1,512 +0,0 @@
1−import { describe, it, expect } from "@effect/vitest";
2−import { Effect } from "effect";
3−import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4−
5−import {
6− AuthTemplateSlug,
7− ConnectionName,
8− createExecutor,
9− IntegrationSlug,
10− ToolAddress,
11−} from "@executor-js/sdk";
12−import {
13− makeTestConfig,
14− memoryCredentialsPlugin,
15− serveTestHttpApp,
16−} from "@executor-js/sdk/testing";
17−
18−import { graphqlPlugin } from "./plugin";
19−import { endpointForTelemetry } from "./invoke";
20−import { introspect } from "./introspect";
21−import type { IntrospectionResult } from "./introspect";
22−import {
23− makeGreetingGraphqlSchema,
24− serveGraphqlFailureTestServer,
25− serveGraphqlTestServer,
26−} from "../testing";
27−
28−// removed: v1's scope-stack, secret-table, and credential-binding tests
29−// (initial credential bindings, user-scoped org introspection, multi-scope
30−// shadowing add/remove/configure, org-vs-user secret/connection collision,
31−// `usagesForSecret`/`usagesForConnection`, `secrets.remove` refusal). v2 has no
32−// scope stack (owner is explicit in the address), no secret table, and no
33−// credential bindings — a connection IS the credential and resolves its value
34−// through a provider. The cases below cover the v2 surface: add integration,
35−// per-connection tool production, auth-template rendering at invoke, upstream
36−// failure reshaping, and URL detection.
37−
38−const GRAPHQL = IntegrationSlug.make("greeting_graph");
39−const BEARER = AuthTemplateSlug.make("oauth");
40−
41−const introspectionResult: IntrospectionResult = {
42− __schema: {
43− queryType: { name: "Query" },
44− mutationType: { name: "Mutation" },
45− types: [
46− {
47− kind: "OBJECT",
48− name: "Query",
49− description: null,
50− fields: [
51− {
52− name: "hello",
53− description: "Say hello",
54− args: [
55− {
56− name: "name",
57− description: null,
58− type: { kind: "SCALAR", name: "String", ofType: null },
59− defaultValue: null,
60− },
61− ],
62− type: { kind: "SCALAR", name: "String", ofType: null },
63− },
64− ],
65− inputFields: null,
66− enumValues: null,
67− },
68− {
69− kind: "OBJECT",
70− name: "Mutation",
71− description: null,
72− fields: [
73− {
74− name: "setGreeting",
75− description: "Set greeting message",
76− args: [
77− {
78− name: "message",
79− description: null,
80− type: {
81− kind: "NON_NULL",
82− name: null,
83− ofType: { kind: "SCALAR", name: "String", ofType: null },
84− },
85− defaultValue: null,
86− },
87− ],
88− type: { kind: "SCALAR", name: "String", ofType: null },
89− },
90− ],
91− inputFields: null,
92− enumValues: null,
93− },
94− {
95− kind: "SCALAR",
96− name: "String",
97− description: null,
98− fields: null,
99− inputFields: null,
100− enumValues: null,
101− },
102− ],
103− },
104−};
105−
106−const introspectionJson = JSON.stringify({ data: introspectionResult });
107−const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
108−
109−describe("graphqlPlugin introspection error handling", () => {
110− it("uses query-free endpoints for invocation attributes", () => {
111− expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe(
112− "https://api.example.test/graphql",
113− );
114− });
115−
116− it.effect("includes redacted upstream text in introspection status errors", () =>
117− Effect.gen(function* () {
118− const server = yield* serveGraphqlFailureTestServer({
119− status: 500,
120− body: 'upstream failed {"access_token":"secret-value"} token=another-secret',
121− });
122−
123− const error = yield* introspect(server.endpoint).pipe(
124− Effect.provide(server.httpClientLayer),
125− Effect.flip,
126− );
127−
128− expect(error).toHaveProperty(
129− "message",
130− 'Introspection failed with status 500: upstream failed {"access_token":"[redacted]"} token=[redacted]',
131− );
132− expect(error).not.toHaveProperty("message", expect.stringContaining("secret-value"));
133− }),
134− );
135−
136− it.effect("includes safe upstream JSON messages in introspection status errors", () =>
137− Effect.gen(function* () {
138− const server = yield* serveTestHttpApp(() =>
139− Effect.succeed(
140− HttpServerResponse.jsonUnsafe(
141− { message: "Resource protected by organization SSO" },
142− { status: 403 },
143− ),
144− ),
145− );
146−
147− const error = yield* introspect(server.url("/graphql")).pipe(
148− Effect.provide(server.httpClientLayer),
149− Effect.flip,
150− );
151−
152− expect(error).toHaveProperty(
153− "message",
154− "Introspection failed with status 403: Resource protected by organization SSO",
155− );
156− }),
157− );
158−});
159−
160−describe("graphqlPlugin integration + connection lifecycle", () => {
161− it.effect("adds an integration by introspecting the live GraphQL endpoint", () =>
162− Effect.gen(function* () {
163− const server = yield* serveGreetingServer;
164− const executor = yield* createExecutor(
165− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
166− );
167−
168− const result = yield* executor["graphql-greenfield"].addIntegration({
169− slug: "live_graph",
170− endpoint: server.endpoint,
171− });
172− expect(result).toEqual({ slug: "live_graph", toolCount: 2 });
173−
174− const requests = yield* server.requests;
175− expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
176−
177− // A connection produces the integration's tools, addressed with its owner.
178− yield* executor.connections.create({
179− owner: "org",
180− name: ConnectionName.make("main"),
181− integration: IntegrationSlug.make("live_graph"),
182− template: BEARER,
183− value: "token",
184− });
185−
186− const tools = yield* executor.tools.list();
187− expect(
188− tools
189− .map((t) => String(t.address))
190− .filter((address) => address.startsWith("tools.live_graph."))
191− .sort(),
192− ).toEqual([
193− "tools.live_graph.org.main.mutation.setGreeting",
194− "tools.live_graph.org.main.query.hello",
195− ]);
196− }),
197− );
198−
199− it.effect("registers tools from introspection JSON", () =>
200− Effect.gen(function* () {
201− const executor = yield* createExecutor(
202− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
203− );
204−
205− const result = yield* executor["graphql-greenfield"].addIntegration({
206− slug: "test_api",
207− endpoint: "http://localhost:4000/graphql",
208− introspectionJson,
209− });
210− expect(result).toEqual({ slug: "test_api", toolCount: 2 });
211−
212− yield* executor.connections.create({
213− owner: "org",
214− name: ConnectionName.make("c"),
215− integration: IntegrationSlug.make("test_api"),
216− template: BEARER,
217− value: "token",
218− });
219−
220− const tools = yield* executor.tools.list();
221− const byName = new Map(tools.map((t) => [String(t.name), t] as const));
222− expect(byName.get("query.hello")?.description).toBe("Say hello");
223− expect(byName.get("mutation.setGreeting")?.description).toBe("Set greeting message");
224− }),
225− );
226−
227− it.effect("mutations require approval; queries do not", () =>
228− Effect.gen(function* () {
229− const executor = yield* createExecutor(
230− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
231− );
232−
233− yield* executor["graphql-greenfield"].addIntegration({
234− slug: "approval_test",
235− endpoint: "http://localhost:4000/graphql",
236− introspectionJson,
237− });
238− yield* executor.connections.create({
239− owner: "org",
240− name: ConnectionName.make("c"),
241− integration: IntegrationSlug.make("approval_test"),
242− template: BEARER,
243− value: "token",
244− });
245−
246− const tools = yield* executor.tools.list();
247− const mutationTool = tools.find((t) => String(t.name) === "mutation.setGreeting");
248− expect(mutationTool?.annotations?.requiresApproval).toBe(true);
249− expect(mutationTool?.annotations?.approvalDescription).toBe("mutation setGreeting");
250− const queryTool = tools.find((t) => String(t.name) === "query.hello");
251− expect(queryTool?.annotations?.requiresApproval).toBeFalsy();
252− }),
253− );
254−
255− it.effect("removing a connection removes its tools; the integration remains", () =>
256− Effect.gen(function* () {
257− const executor = yield* createExecutor(
258− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
259− );
260−
261− yield* executor["graphql-greenfield"].addIntegration({
262− slug: "removable",
263− endpoint: "http://localhost:4000/graphql",
264− introspectionJson,
265− });
266− yield* executor.connections.create({
267− owner: "org",
268− name: ConnectionName.make("c"),
269− integration: IntegrationSlug.make("removable"),
270− template: BEARER,
271− value: "token",
272− });
273−
274− const listRemovableToolAddresses = Effect.map(executor.tools.list(), (tools) =>
275− tools
276− .map((t) => String(t.address))
277− .filter((address) => address.startsWith("tools.removable.")),
278− );
279−
280− expect((yield* listRemovableToolAddresses).length).toBe(2);
281−
282− yield* executor.connections.remove({
283− owner: "org",
284− integration: IntegrationSlug.make("removable"),
285− name: ConnectionName.make("c"),
286− });
287−
288− expect(yield* listRemovableToolAddresses).toEqual([]);
289− // Integration is still in the catalog.
290− const integration = yield* executor.integrations.get(IntegrationSlug.make("removable"));
291− expect(integration?.kind).toBe("graphql-greenfield");
292− }),
293− );
294−});
295−
296−describe("graphqlPlugin invocation", () => {
297− it.effect("renders the connection value as a bearer header and invokes the query", () =>
298− Effect.gen(function* () {
299− const server = yield* serveGraphqlTestServer({
300− schema: makeGreetingGraphqlSchema(),
301− auth: {
302− validateAuthorization: (authorization) =>
303− Effect.succeed(authorization === "Bearer secret-token"),
304− },
305− });
306− const executor = yield* createExecutor(
307− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
308− );
309−
310− yield* executor["graphql-greenfield"].addIntegration({
311− slug: GRAPHQL,
312− endpoint: server.endpoint,
313− introspectionHeaders: { Authorization: "Bearer secret-token" },
314− });
315− yield* executor.connections.create({
316− owner: "org",
317− name: ConnectionName.make("main"),
318− integration: GRAPHQL,
319− template: BEARER,
320− value: "secret-token",
321− });
322− yield* server.clearRequests;
323−
324− const result = yield* executor.execute(
325− ToolAddress.make("tools.greeting_graph.org.main.query.hello"),
326− { name: "Ada" },
327− );
328− expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } });
329−
330− const requests = yield* server.requests;
331− expect(requests[0]?.headers.authorization).toBe("Bearer secret-token");
332− expect(requests[0]?.payload.variables).toEqual({ name: "Ada" });
333− }),
334− );
335−
336− it.effect("renders an apiKey header template with a prefix", () =>
337− Effect.gen(function* () {
338− const server = yield* serveGraphqlTestServer({
339− schema: makeGreetingGraphqlSchema(),
340− auth: {
341− validateAuthorization: (authorization) =>
342− Effect.succeed(authorization === "token abc123"),
343− },
344− });
345− const executor = yield* createExecutor(
346− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
347− );
348−
349− yield* executor["graphql-greenfield"].addIntegration({
350− slug: "apikey_graph",
351− endpoint: server.endpoint,
352− introspectionHeaders: { Authorization: "token abc123" },
353− authentication: [
354− {
355− slug: "apiKey",
356− type: "apiKey",
357− in: "header",
358− name: "Authorization",
359− prefix: "token ",
360− },
361− ],
362− });
363− yield* executor.connections.create({
364− owner: "org",
365− name: ConnectionName.make("main"),
366− integration: IntegrationSlug.make("apikey_graph"),
367− template: AuthTemplateSlug.make("apiKey"),
368− value: "abc123",
369− });
370− yield* server.clearRequests;
371−
372− const result = yield* executor.execute(
373− ToolAddress.make("tools.apikey_graph.org.main.query.hello"),
374− { name: "Ada" },
375− );
376− expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } });
377− const requests = yield* server.requests;
378− expect(requests[0]?.headers.authorization).toBe("token abc123");
379− }),
380− );
381−
382− it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () =>
383− Effect.gen(function* () {
384− const server = yield* serveTestHttpApp((request) =>
385− Effect.gen(function* () {
386− const webRequest = yield* HttpServerRequest.toWeb(request);
387− const body = yield* Effect.promise(() => webRequest.text());
388− if (body.includes("__schema")) {
389− return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
390− }
391− return HttpServerResponse.text("temporary upstream outage", {
392− status: 503,
393− contentType: "text/plain",
394− });
395− }).pipe(Effect.orDie),
396− );
397− const executor = yield* createExecutor(
398− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
399− );
400−
401− yield* executor["graphql-greenfield"].addIntegration({
402− slug: "http_error_graph",
403− endpoint: server.url("/graphql"),
404− });
405− yield* executor.connections.create({
406− owner: "org",
407− name: ConnectionName.make("c"),
408− integration: IntegrationSlug.make("http_error_graph"),
409− template: BEARER,
410− value: "token",
411− });
412−
413− const result = yield* executor.execute(
414− ToolAddress.make("tools.http_error_graph.org.c.query.hello"),
415− { name: "Ada" },
416− );
417−
418− expect(result).toMatchObject({
419− ok: false,
420− error: {
421− code: "graphql_http_error",
422− status: 503,
423− message: "GraphQL request failed with HTTP 503",
424− },
425− });
426− }),
427− );
428−
429− it.effect("reshapes a 401 upstream rejection into an auth failure", () =>
430− Effect.gen(function* () {
431− const server = yield* serveTestHttpApp((request) =>
432− Effect.gen(function* () {
433− const webRequest = yield* HttpServerRequest.toWeb(request);
434− const body = yield* Effect.promise(() => webRequest.text());
435− if (body.includes("__schema")) {
436− return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
437− }
438− // A non-GraphQL 401 (no `errors` array) so the upstream-rejection
439− // branch — not the GraphQL-errors branch — reshapes it.
440− return HttpServerResponse.text("Unauthorized", {
441− status: 401,
442− contentType: "text/plain",
443− });
444− }).pipe(Effect.orDie),
445− );
446− const executor = yield* createExecutor(
447− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
448− );
449−
450− yield* executor["graphql-greenfield"].addIntegration({
451− slug: "rejected_graph",
452− endpoint: server.url("/graphql"),
453− });
454− yield* executor.connections.create({
455− owner: "org",
456− name: ConnectionName.make("c"),
457− integration: IntegrationSlug.make("rejected_graph"),
458− template: BEARER,
459− value: "stale-token",
460− });
461−
462− const result = yield* executor.execute(
463− ToolAddress.make("tools.rejected_graph.org.c.query.hello"),
464− { name: "Ada" },
465− );
466−
467− expect(result).toMatchObject({
468− ok: false,
469− error: {
470− code: "connection_rejected",
471− status: 401,
472− details: { category: "authentication" },
473− },
474− });
475− }),
476− );
477−});
478−
479−describe("graphqlPlugin detect URL-token fallback", () => {
480− // Port 1 connection-refuses immediately, so introspection always fails and
481− // the URL-token fallback is the only thing that can produce a candidate.
482− it.effect("returns low-confidence candidate when path has /graphql segment", () =>
483− Effect.gen(function* () {
484− const executor = yield* createExecutor(
485− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
486− );
487− const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/graphql");
488− const gql = results.find((r) => r.kind === "graphql");
489− expect(gql?.confidence).toBe("low");
490− }),
491− );
492−
493− it.effect("does not match graphql as a substring", () =>
494− Effect.gen(function* () {
495− const executor = yield* createExecutor(
496− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
497− );
498− const results = yield* executor.integrations.detect("http://127.0.0.1:1/graphqlite");
499− expect(results.find((r) => r.kind === "graphql")).toBeUndefined();
500− }),
501− );
502−
503− it.effect("returns null when no token match and introspection fails", () =>
504− Effect.gen(function* () {
505− const executor = yield* createExecutor(
506− makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
507− );
508− const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1");
509− expect(results.find((r) => r.kind === "graphql")).toBeUndefined();
510− }),
511− );
512−});
packages/plugins/graphql-greenfield/src/sdk/plugin.ts+0−730
@@ -1,730 +0,0 @@
1−import { Effect, Match, Option, Schema } from "effect";
2−import type { Layer } from "effect";
3−import { HttpClient } from "effect/unstable/http";
4−
5−import {
6− authToolFailure,
7− definePlugin,
8− IntegrationAlreadyExistsError,
9− IntegrationSlug,
10− ToolName,
11− ToolResult,
12− tool,
13− type IntegrationConfig,
14− type PluginCtx,
15− type ToolAnnotations,
16− type ToolDef,
17− type ToolInvocationCredential,
18−} from "@executor-js/sdk";
19−
20−import {
21− introspect,
22− parseIntrospectionJson,
23− type IntrospectionResult,
24− type IntrospectionType,
25− type IntrospectionField,
26− type IntrospectionTypeRef,
27−} from "./introspect";
28−import { extract } from "./extract";
29−import {
30− GraphqlAuthRequiredError,
31− GraphqlExtractionError,
32− GraphqlIntrospectionError,
33− GraphqlInvocationError,
34−} from "./errors";
35−import { invokeWithLayer } from "./invoke";
36−import { graphqlPresets } from "./presets";
37−import {
38− type ApiKeyHeaderTemplate,
39− type ApiKeyQueryTemplate,
40− AuthTemplate,
41− ExtractedField,
42− GraphqlIntegrationConfig,
43− type GraphqlOperationKind,
44− type OAuthTemplate,
45− OperationBinding,
46−} from "./types";
47−
48−// ---------------------------------------------------------------------------
49−// Plugin config
50−// ---------------------------------------------------------------------------
51−
52−const GraphqlErrorBody = Schema.Struct({ message: Schema.String });
53−const GraphqlErrorsBody = Schema.Array(Schema.Unknown);
54−const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);
55−const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody);
56−const decodeIntegrationConfig = Schema.decodeUnknownOption(GraphqlIntegrationConfig);
57−
58−const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
59− Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
60−
61−const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>
62− errors
63− .map((error: unknown) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)
64− .find((message: string | undefined) => message !== undefined && message.length > 0);
65−
66−const GRAPHQL_PLUGIN_ID = "graphql-greenfield";
67−
68−/** The default auth template a connection applies through when the integration
69− * declares no explicit `authentication`. Renders the value as a bearer header. */
70−const DEFAULT_OAUTH_TEMPLATE_SLUG = "oauth";
71−
72−// ---------------------------------------------------------------------------
73−// Public input contracts — derived from the Effect Schemas so the wire shape
74−// and the TypeScript type stay in lockstep.
75−// ---------------------------------------------------------------------------
76−
77−const AddGraphqlIntegrationInputSchema = Schema.Struct({
78− /** The integration slug (catalog identity). */
79− slug: Schema.String,
80− /** The GraphQL endpoint URL. */
81− endpoint: Schema.String,
82− /** Display description for the catalog. */
83− description: Schema.optional(Schema.String),
84− /** Optional introspection JSON (when the endpoint can't be reached live). */
85− introspectionJson: Schema.optional(Schema.String),
86− /** Auth methods a connection can apply through. Defaults to a single bearer
87− * oauth template if omitted. */
88− authentication: Schema.optional(Schema.Array(AuthTemplate)),
89− /** Headers applied to the add-time introspection request only. */
90− introspectionHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
91−});
92−export type AddGraphqlIntegrationInput = typeof AddGraphqlIntegrationInputSchema.Type;
93−
94−const ConfigureGraphqlIntegrationInputSchema = Schema.Struct({
95− slug: Schema.String,
96− endpoint: Schema.optional(Schema.String),
97− description: Schema.optional(Schema.String),
98− authentication: Schema.optional(Schema.Array(AuthTemplate)),
99−});
100−/** Patch applied by `configureIntegration` (the `slug` identifies which one). */
101−export type ConfigureGraphqlIntegrationInput = Omit<
102− typeof ConfigureGraphqlIntegrationInputSchema.Type,
103− "slug"
104−>;
105−
106−const ConfigureGraphqlIntegrationOutputSchema = Schema.Struct({
107− configured: Schema.Boolean,
108−});
109−
110−const StaticAddIntegrationOutputSchema = Schema.Struct({
111− slug: Schema.String,
112− toolCount: Schema.Number,
113−});
114−const StaticGetIntegrationInputSchema = Schema.Struct({ slug: Schema.String });
115−const StaticGetIntegrationOutputSchema = Schema.Struct({
116− integration: Schema.NullOr(Schema.Unknown),
117−});
118−
119−const toStandard = <A, I>(schema: Schema.Codec<A, I>) =>
120− Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema));
121−
122−const StaticAddIntegrationInputStandardSchema = toStandard(AddGraphqlIntegrationInputSchema);
123−const StaticAddIntegrationOutputStandardSchema = toStandard(StaticAddIntegrationOutputSchema);
124−const StaticGetIntegrationInputStandardSchema = toStandard(StaticGetIntegrationInputSchema);
125−const StaticGetIntegrationOutputStandardSchema = toStandard(StaticGetIntegrationOutputSchema);
126−const StaticConfigureIntegrationInputStandardSchema = toStandard(
127− ConfigureGraphqlIntegrationInputSchema,
128−);
129−const StaticConfigureIntegrationOutputStandardSchema = toStandard(
130− ConfigureGraphqlIntegrationOutputSchema,
131−);
132−
133−const graphqlToolFailure = (code: string, message: string, details?: unknown) =>
134− ToolResult.fail({
135− code,
136− message,
137− ...(details === undefined ? {} : { details }),
138− });
139−
140−const graphqlAuthToolFailure = (failure: GraphqlAuthRequiredError) =>
141− authToolFailure({
142− code: failure.code,
143− message: failure.message,
144− source: { id: failure.integration },
145− credential: {
146− kind: failure.credentialKind,
147− ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),
148− },
149− ...(failure.status !== undefined ? { status: failure.status } : {}),
150− ...(failure.details !== undefined
151− ? {
152− upstream: {
153− ...(failure.status !== undefined ? { status: failure.status } : {}),
154− details: failure.details,
155− },
156− }
157− : {}),
158− recovery: { configureSourceTool: "executor.graphql-greenfield.configureIntegration" },
159− });
160−
161−// ---------------------------------------------------------------------------
162−// Detection helpers
163−// ---------------------------------------------------------------------------
164−
165−/** Match `token` as a separator-bounded run inside a URL hostname or path,
166− * used as a low-confidence detection hint when introspection fails. */
167−const urlMatchesToken = (url: URL, token: string): boolean => {
168− const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i");
169− return re.test(url.hostname) || re.test(url.pathname);
170−};
171−
172−/** Derive a slug from an endpoint URL. */
173−const slugFromEndpoint = (endpoint: string): string => {
174− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL construction throws; this helper intentionally falls back to the stable default slug
175− try {
176− const url = new URL(endpoint);
177− return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase();
178− } catch {
179− return "graphql";
180− }
181−};
182−
183−// ---------------------------------------------------------------------------
184−// Operation-string building
185−// ---------------------------------------------------------------------------
186−
187−const formatTypeRef = (ref: IntrospectionTypeRef): string =>
188− Match.value(ref.kind).pipe(
189− Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")),
190− Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")),
191− Match.option,
192− Option.getOrElse(() => ref.name ?? "Unknown"),
193− );
194−
195−const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
196− if (ref.name) return ref.name;
197− if (ref.ofType) return unwrapTypeName(ref.ofType);
198− return "Unknown";
199−};
200−
201−const buildSelectionSet = (
202− ref: IntrospectionTypeRef,
203− types: ReadonlyMap<string, IntrospectionType>,
204− depth: number,
205− seen: Set<string>,
206−): string => {
207− if (depth > 2) return "";
208−
209− const leafName = unwrapTypeName(ref);
210− if (seen.has(leafName)) return "";
211−
212− const objectType = types.get(leafName);
213− if (!objectType?.fields) return "";
214−
215− const kind = objectType.kind;
216− if (kind === "SCALAR" || kind === "ENUM") return "";
217−
218− seen.add(leafName);
219−
220− const subFields = objectType.fields
221− .filter((f: IntrospectionField) => !f.name.startsWith("__"))
222− .slice(0, 12)
223− .map((f: IntrospectionField) => {
224− const sub = buildSelectionSet(f.type, types, depth + 1, seen);
225− return sub ? `${f.name} ${sub}` : f.name;
226− });
227−
228− seen.delete(leafName);
229−
230− return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : "";
231−};
232−
233−const buildOperationStringForField = (
234− kind: GraphqlOperationKind,
235− field: IntrospectionField,
236− types: ReadonlyMap<string, IntrospectionType>,
237−): string => {
238− const opType = kind === "query" ? "query" : "mutation";
239−
240− const varDefs = field.args.map((arg) => {
241− const typeName = formatTypeRef(arg.type);
242− return `$${arg.name}: ${typeName}`;
243− });
244−
245− const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`);
246− const selectionSet = buildSelectionSet(field.type, types, 0, new Set());
247−
248− const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : "";
249− const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : "";
250−
251− return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;
252−};
253−
254−const prepareOperations = (
255− fields: readonly ExtractedField[],
256− introspection: IntrospectionResult,
257−): readonly OperationBinding[] => {
258− const typeMap = new Map<string, IntrospectionType>();
259− for (const t of introspection.__schema.types) {
260− typeMap.set(t.name, t);
261− }
262−
263− const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>();
264− const schema = introspection.__schema;
265− for (const rootKind of ["query", "mutation"] as const) {
266− const typeName = rootKind === "query" ? schema.queryType?.name : schema.mutationType?.name;
267− if (!typeName) continue;
268− const rootType = typeMap.get(typeName);
269− if (!rootType?.fields) continue;
270− for (const f of rootType.fields) {
271− if (!f.name.startsWith("__")) {
272− fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f });
273− }
274− }
275− }
276−
277− return fields.map((extracted: ExtractedField) => {
278− const prefix = extracted.kind === "mutation" ? "mutation" : "query";
279− // A tool's name keeps its `<kind>.<field>` path (e.g. `query.hello`,
280− // `mutation.setGreeting`). The address grammar treats `<tool>` as the
281− // trailing remainder (see parseToolAddress), so the dot nests naturally.
282− const toolName = `${prefix}.${extracted.fieldName}`;
283− const description = Option.getOrElse(
284− extracted.description,
285− () => `GraphQL ${extracted.kind}: ${extracted.fieldName} -> ${extracted.returnTypeName}`,
286− );
287−
288− const key = `${extracted.kind}.${extracted.fieldName}`;
289− const entry = fieldMap.get(key);
290− const operationString = entry
291− ? buildOperationStringForField(entry.kind, entry.field, typeMap)
292− : `${extracted.kind} { ${extracted.fieldName} }`;
293−
294− return OperationBinding.make({
295− toolName,
296− kind: extracted.kind,
297− fieldName: extracted.fieldName,
298− operationString,
299− variableNames: extracted.arguments.map((a) => a.name),
300− description,
301− inputSchema: Option.getOrUndefined(extracted.inputSchema),
302− });
303− });
304−};
305−
306−const annotationsFor = (binding: OperationBinding): ToolAnnotations | undefined => {
307− if (binding.kind === "mutation") {
308− return {
309− requiresApproval: true,
310− approvalDescription: `mutation ${binding.fieldName}`,
311− };
312− }
313− return undefined;
314−};
315−
316−// ---------------------------------------------------------------------------
317−// Auth-template rendering — D11: the plugin derives the resolved connection
318−// value into the integration's auth-template format. An OAuth access token and
319−// an apiKey both arrive as a single `value` and render identically.
320−// ---------------------------------------------------------------------------
321−
322−interface RenderedAuth {
323− readonly headers: Record<string, string>;
324− readonly queryParams: Record<string, string>;
325−}
326−
327−const selectAuthTemplate = (
328− authentication: readonly AuthTemplate[],
329− templateSlug: string,
330−): AuthTemplate | null => {
331− const match = authentication.find((t: AuthTemplate) => t.slug === templateSlug);
332− if (match) return match;
333− // Fall back to the first declared template, or a synthesized default oauth.
334− if (authentication.length > 0) return authentication[0] ?? null;
335− return null;
336−};
337−
338−const renderAuth = (template: AuthTemplate | null, value: string): RenderedAuth => {
339− const headers: Record<string, string> = {};
340− const queryParams: Record<string, string> = {};
341−
342− if (template === null) {
343− // No declared template — default to a bearer Authorization header.
344− headers.Authorization = `Bearer ${value}`;
345− return { headers, queryParams };
346− }
347−
348− Match.value(template).pipe(
349− Match.when({ type: "apiKey", in: "header" }, (t: ApiKeyHeaderTemplate) => {
350− headers[t.name] = `${t.prefix ?? ""}${value}`;
351− }),
352− Match.when({ type: "apiKey", in: "query" }, (t: ApiKeyQueryTemplate) => {
353− queryParams[t.name] = `${t.prefix ?? ""}${value}`;
354− }),
355− Match.when({ type: "oauth" }, (t: OAuthTemplate) => {
356− const header = t.header ?? "Authorization";
357− const prefix = t.prefix ?? "Bearer ";
358− headers[header] = `${prefix}${value}`;
359− }),
360− Match.exhaustive,
361− );
362−
363− return { headers, queryParams };
364−};
365−
366−// ---------------------------------------------------------------------------
367−// Plugin factory
368−// ---------------------------------------------------------------------------
369−
370−export interface GraphqlPluginOptions {
371− readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
372−}
373−
374−const defaultAuthentication = (
375− authentication: readonly AuthTemplate[] | undefined,
376−): readonly AuthTemplate[] =>
377− authentication && authentication.length > 0
378− ? authentication
379− : [AuthTemplate.make({ slug: DEFAULT_OAUTH_TEMPLATE_SLUG, type: "oauth" })];
380−
381−const integrationConfigFrom = (config: IntegrationConfig): GraphqlIntegrationConfig | null =>
382− Option.getOrNull(decodeIntegrationConfig(config));
383−
384−const makeGraphqlExtension = (
385− ctx: PluginCtx<Record<string, never>>,
386− httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
387−) => {
388− const buildConfig = (input: AddGraphqlIntegrationInput) =>
389− Effect.gen(function* () {
390− let introspectionResult: IntrospectionResult;
391− if (input.introspectionJson) {
392− introspectionResult = yield* parseIntrospectionJson(input.introspectionJson);
393− } else {
394− introspectionResult = yield* introspect(input.endpoint, input.introspectionHeaders).pipe(
395− Effect.provide(httpClientLayer),
396− );
397− }
398−
399− const { result, definitions } = yield* extract(introspectionResult);
400− const operations = prepareOperations(result.fields, introspectionResult);
401−
402− const config = GraphqlIntegrationConfig.make({
403− endpoint: input.endpoint,
404− ...(input.introspectionJson ? { introspectionJson: input.introspectionJson } : {}),
405− authentication: defaultAuthentication(input.authentication),
406− operations,
407− ...(Object.keys(definitions).length > 0 ? { definitions } : {}),
408− });
409− return { config, toolCount: operations.length };
410− });
411−
412− const addIntegration = (input: AddGraphqlIntegrationInput) =>
413− Effect.gen(function* () {
414− const slug = IntegrationSlug.make(input.slug);
415−
416− // Block re-adding an existing slug. The core `integrations.register`
417− // primitive upserts (so boot re-registration is idempotent), but an
418− // explicit add must NOT silently clobber an existing integration's tools,
419− // connections, and policies. To add more auth, update the existing one.
420− const existing = yield* ctx.core.integrations.get(slug);
421− if (existing) {
422− return yield* new IntegrationAlreadyExistsError({ slug });
423− }
424−
425− return yield* ctx.transaction(
426− Effect.gen(function* () {
427− const { config, toolCount } = yield* buildConfig(input);
428− yield* ctx.core.integrations.register({
429− slug,
430− description: input.description ?? input.slug,
431− config,
432− canRemove: true,
433− canRefresh: true,
434− });
435− return { slug: input.slug, toolCount };
436− }),
437− );
438− });
439−
440− const getIntegration = (slug: string) =>
441− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
442− Effect.map((record) => {
443− if (record === null) return null;
444− const config = integrationConfigFrom(record.config);
445− return { ...record, config };
446− }),
447− );
448−
449− const configureIntegration = (slug: string, input: ConfigureGraphqlIntegrationInput) =>
450− Effect.gen(function* () {
451− const integrationSlug = IntegrationSlug.make(slug);
452− const record = yield* ctx.core.integrations.get(integrationSlug);
453− if (record === null) return;
454− const existing = integrationConfigFrom(record.config);
455− if (existing === null) return;
456− const nextConfig = GraphqlIntegrationConfig.make({
457− ...existing,
458− endpoint: input.endpoint ?? existing.endpoint,
459− authentication: input.authentication ?? existing.authentication,
460− });
461− yield* ctx.core.integrations.update(integrationSlug, {
462− ...(input.description ? { description: input.description } : {}),
463− config: nextConfig,
464− });
465− });
466−
467− const removeIntegration = (slug: string) =>
468− ctx.core.integrations.remove(IntegrationSlug.make(slug));
469−
470− return {
471− addIntegration,
472− getIntegration,
473− configureIntegration,
474− removeIntegration,
475− };
476−};
477−
478−export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>;
479−
480−export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
481− return {
482− id: GRAPHQL_PLUGIN_ID as typeof GRAPHQL_PLUGIN_ID,
483− packageName: "@executor-js/plugin-graphql-greenfield",
484− integrationPresets: graphqlPresets.map((preset) => ({
485− id: preset.id,
486− name: preset.name,
487− summary: preset.summary,
488− url: preset.url,
489− endpoint: preset.endpoint,
490− ...(preset.icon ? { icon: preset.icon } : {}),
491− ...(preset.featured ? { featured: preset.featured } : {}),
492− })),
493− storage: (): Record<string, never> => ({}),
494−
495− extension: (ctx) => makeGraphqlExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer),
496−
497− staticSources: (self) => [
498− {
499− id: GRAPHQL_PLUGIN_ID,
500− kind: "executor",
501− name: "GraphQL",
502− tools: [
503− tool({
504− name: "addIntegration",
505− description:
506− "Add a GraphQL endpoint as an integration and register its operations as tools. Introspects the endpoint (or uses `introspectionJson`) and stores the operation bindings plus auth templates in the integration catalog. After adding, create a connection for the integration (via `connections.create` or `oauth.start`) to make its tools callable.",
507− annotations: {
508− requiresApproval: true,
509− approvalDescription: "Add a GraphQL integration",
510− },
511− inputSchema: StaticAddIntegrationInputStandardSchema,
512− outputSchema: StaticAddIntegrationOutputStandardSchema,
513− execute: (input) =>
514− self.addIntegration(input as AddGraphqlIntegrationInput).pipe(
515− Effect.map((result) => ToolResult.ok(result)),
516− Effect.catchTags({
517− GraphqlIntrospectionError: ({ message }: GraphqlIntrospectionError) =>
518− Effect.succeed(graphqlToolFailure("graphql_introspection_failed", message)),
519− GraphqlExtractionError: ({ message }: GraphqlExtractionError) =>
520− Effect.succeed(graphqlToolFailure("graphql_extraction_failed", message)),
521− IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) =>
522− Effect.succeed(
523− graphqlToolFailure(
524− "integration_already_exists",
525− `Integration ${slug} already exists; update it instead of re-adding.`,
526− ),
527− ),
528− }),
529− ),
530− }),
531− tool({
532− name: "getIntegration",
533− description:
534− "Inspect an existing GraphQL integration, including its endpoint, auth templates, and registered operations. Use before reconfiguring with `graphql-greenfield.configureIntegration`.",
535− inputSchema: StaticGetIntegrationInputStandardSchema,
536− outputSchema: StaticGetIntegrationOutputStandardSchema,
537− execute: (input) =>
538− self
539− .getIntegration((input as { slug: string }).slug)
540− .pipe(Effect.map((integration) => ToolResult.ok({ integration }))),
541− }),
542− tool({
543− name: "configureIntegration",
544− description:
545− "Reconfigure an existing GraphQL integration's endpoint, description, or auth templates. Does not re-introspect; existing connections keep their tools. Use `graphql-greenfield.addIntegration` (or `connections.refresh`) to re-derive tools from a new schema.",
546− annotations: {
547− requiresApproval: true,
548− approvalDescription: "Configure a GraphQL integration",
549− },
550− inputSchema: StaticConfigureIntegrationInputStandardSchema,
551− outputSchema: StaticConfigureIntegrationOutputStandardSchema,
552− execute: (input) => {
553− const { slug, ...patch } = input as ConfigureGraphqlIntegrationInput & {
554− readonly slug: string;
555− };
556− return self
557− .configureIntegration(slug, patch)
558− .pipe(Effect.as(ToolResult.ok({ configured: true })));
559− },
560− }),
561− ],
562− },
563− ],
564−
565− resolveTools: ({ config }) =>
566− Effect.sync(() => {
567− const parsed = integrationConfigFrom(config);
568− if (parsed === null) return { tools: [] as readonly ToolDef[] };
569− const tools: ToolDef[] = parsed.operations.map((op: OperationBinding) => {
570− const annotations = annotationsFor(op);
571− return {
572− name: ToolName.make(op.toolName),
573− ...(op.description ? { description: op.description } : {}),
574− ...(op.inputSchema !== undefined ? { inputSchema: op.inputSchema } : {}),
575− ...(annotations ? { annotations } : {}),
576− };
577− });
578− return {
579− tools,
580− ...(parsed.definitions ? { definitions: parsed.definitions } : {}),
581− };
582− }),
583−
584− invokeTool: ({
585− ctx,
586− credential,
587− toolRow,
588− args,
589− }: {
590− ctx: PluginCtx<Record<string, never>>;
591− credential: ToolInvocationCredential;
592− toolRow: { name: string };
593− args: unknown;
594− }) =>
595− Effect.gen(function* () {
596− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
597− const config = integrationConfigFrom(credential.config);
598− if (config === null) {
599− return yield* new GraphqlInvocationError({
600− message: `No GraphQL configuration found for integration "${credential.integration}"`,
601− statusCode: Option.none(),
602− });
603− }
604−
605− const op = config.operations.find(
606− (candidate: OperationBinding) => candidate.toolName === toolRow.name,
607− );
608− if (!op) {
609− return yield* new GraphqlInvocationError({
610− message: `No GraphQL operation found for tool "${toolRow.name}"`,
611− statusCode: Option.none(),
612− });
613− }
614−
615− if (credential.value === null) {
616− return yield* new GraphqlAuthRequiredError({
617− code: "oauth_connection_missing",
618− owner: credential.owner,
619− integration: String(credential.integration),
620− connection: String(credential.connection),
621− credentialKind: "oauth",
622− credentialLabel: "GraphQL credential",
623− message:
624− `Missing credential value for GraphQL connection ` +
625− `"${credential.connection}" on integration "${credential.integration}". ` +
626− `Re-authenticate or update the connection before retrying this tool.`,
627− });
628− }
629−
630− const template = selectAuthTemplate(config.authentication, String(credential.template));
631− const rendered = renderAuth(template, credential.value);
632−
633− const result = yield* invokeWithLayer(
634− op,
635− (args ?? {}) as Record<string, unknown>,
636− config.endpoint,
637− rendered.headers,
638− rendered.queryParams,
639− httpClientLayer,
640− );
641−
642− const errors = decodeGraphqlErrors(result.errors);
643− if (errors !== undefined && errors.length > 0) {
644− const firstMessage = extractGraphqlErrorMessage(errors);
645− return ToolResult.fail({
646− code: "graphql_errors",
647− message: firstMessage !== undefined ? firstMessage : "GraphQL request returned errors",
648− details: { errors },
649− });
650− }
651− if (result.status < 200 || result.status >= 300) {
652− if (result.status === 401 || result.status === 403) {
653− return authToolFailure({
654− code: "connection_rejected",
655− status: result.status,
656− message:
657− `Upstream rejected credentials for GraphQL integration ` +
658− `"${credential.integration}" with HTTP ${result.status}. Re-authenticate or ` +
659− `update the connection before retrying this tool.`,
660− source: { id: String(credential.integration) },
661− credential: { kind: "upstream", label: "Upstream authorization" },
662− upstream: {
663− status: result.status,
664− details: {
665− data: result.data,
666− errors: result.errors,
667− },
668− },
669− recovery: {
670− configureSourceTool: "executor.graphql-greenfield.configureIntegration",
671− },
672− });
673− }
674− return ToolResult.fail({
675− code: "graphql_http_error",
676− status: result.status,
677− message: `GraphQL request failed with HTTP ${result.status}`,
678− details: {
679− status: result.status,
680− data: result.data,
681− errors: result.errors,
682− },
683− });
684− }
685− return ToolResult.ok(result.data);
686− }).pipe(
687− Effect.catchTag("GraphqlAuthRequiredError", (error: GraphqlAuthRequiredError) =>
688− Effect.succeed(graphqlAuthToolFailure(error)),
689− ),
690− ),
691−
692− detect: ({ ctx, url }: { ctx: PluginCtx<Record<string, never>>; url: string }) =>
693− Effect.gen(function* () {
694− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
695− const trimmed = url.trim();
696− if (!trimmed || !URL.canParse(trimmed)) return null;
697− const parsedUrl = new URL(trimmed);
698−
699− const ok = yield* introspect(trimmed).pipe(
700− Effect.provide(httpClientLayer),
701− Effect.map(() => true),
702− Effect.catch(() => Effect.succeed(false)),
703− );
704−
705− const slug = slugFromEndpoint(trimmed);
706−
707− if (ok) {
708− return {
709− kind: "graphql",
710− confidence: "high" as const,
711− endpoint: trimmed,
712− name: slug,
713− slug,
714− };
715− }
716−
717− if (urlMatchesToken(parsedUrl, "graphql")) {
718− return {
719− kind: "graphql",
720− confidence: "low" as const,
721− endpoint: trimmed,
722− name: slug,
723− slug,
724− };
725− }
726−
727− return null;
728− }),
729− };
730−});
packages/plugins/graphql-greenfield/src/sdk/presets.ts+0−55
@@ -1,55 +0,0 @@
1−export interface GraphqlPreset {
2− readonly id: string;
3− readonly name: string;
4− readonly summary: string;
5− readonly url: string;
6− readonly endpoint: string;
7− readonly icon?: string;
8− readonly featured?: boolean;
9−}
10−
11−export const graphqlPresets: readonly GraphqlPreset[] = [
12− {
13− id: "github-graphql",
14− name: "GitHub GraphQL",
15− summary: "Repos, issues, PRs, and users via GitHub's GraphQL API.",
16− url: "https://api.github.com/graphql",
17− endpoint: "https://api.github.com/graphql",
18− icon: "https://github.com/favicon.ico",
19− featured: true,
20− },
21− {
22− id: "gitlab",
23− name: "GitLab",
24− summary: "Projects, merge requests, pipelines, and users.",
25− url: "https://gitlab.com/api/graphql",
26− endpoint: "https://gitlab.com/api/graphql",
27− icon: "https://gitlab.com/favicon.ico",
28− featured: true,
29− },
30− {
31− id: "linear",
32− name: "Linear",
33− summary: "Issues, projects, teams, and cycles.",
34− url: "https://api.linear.app/graphql",
35− endpoint: "https://api.linear.app/graphql",
36− icon: "https://linear.app/favicon.ico",
37− featured: true,
38− },
39− {
40− id: "monday",
41− name: "Monday.com",
42− summary: "Boards, items, columns, and workspace automation.",
43− url: "https://api.monday.com/v2",
44− endpoint: "https://api.monday.com/v2",
45− icon: "https://monday.com/favicon.ico",
46− },
47− {
48− id: "anilist",
49− name: "AniList",
50− summary: "Anime and manga database — no auth required.",
51− url: "https://graphql.anilist.co",
52− endpoint: "https://graphql.anilist.co",
53− icon: "https://anilist.co/img/icons/favicon-32x32.png",
54− },
55−];
packages/plugins/graphql-greenfield/src/sdk/types.ts+0−147
@@ -1,147 +0,0 @@
1−import { Schema } from "effect";
2−
3−// ---------------------------------------------------------------------------
4−// GraphQL operation kind
5−// ---------------------------------------------------------------------------
6−
7−export const GraphqlOperationKind = Schema.Literals(["query", "mutation"]);
8−export type GraphqlOperationKind = typeof GraphqlOperationKind.Type;
9−
10−// ---------------------------------------------------------------------------
11−// Extracted field (becomes a tool)
12−// ---------------------------------------------------------------------------
13−
14−export const GraphqlArgument = Schema.Struct({
15− name: Schema.String,
16− typeName: Schema.String,
17− required: Schema.Boolean,
18− description: Schema.OptionFromOptional(Schema.String),
19−});
20−export type GraphqlArgument = typeof GraphqlArgument.Type;
21−
22−export const ExtractedField = Schema.Struct({
23− /** e.g. "user", "createUser" */
24− fieldName: Schema.String,
25− /** "query" or "mutation" */
26− kind: GraphqlOperationKind,
27− description: Schema.OptionFromOptional(Schema.String),
28− arguments: Schema.Array(GraphqlArgument),
29− /** JSON Schema for the input (built from arguments) */
30− inputSchema: Schema.OptionFromOptional(Schema.Unknown),
31− /** The return type name for documentation */
32− returnTypeName: Schema.String,
33−});
34−export type ExtractedField = typeof ExtractedField.Type;
35−
36−export const ExtractionResult = Schema.Struct({
37− /** Schema name from introspection */
38− schemaName: Schema.OptionFromOptional(Schema.String),
39− fields: Schema.Array(ExtractedField),
40−});
41−export type ExtractionResult = typeof ExtractionResult.Type;
42−
43−// ---------------------------------------------------------------------------
44−// Operation binding — minimal data needed to invoke. Stored inside the
45−// integration's opaque config (one entry per tool name).
46−// ---------------------------------------------------------------------------
47−
48−export const OperationBinding = Schema.Struct({
49− /** The tool name — `query.hello`, `mutation.setGreeting`. */
50− toolName: Schema.String,
51− kind: GraphqlOperationKind,
52− fieldName: Schema.String,
53− /** The full GraphQL query/mutation string */
54− operationString: Schema.String,
55− /** Ordered variable names for mapping */
56− variableNames: Schema.Array(Schema.String),
57− description: Schema.optional(Schema.String),
58− inputSchema: Schema.optional(Schema.Unknown),
59−});
60−export type OperationBinding = typeof OperationBinding.Type;
61−
62−// ---------------------------------------------------------------------------
63−// Auth templates — v2 replaces v1's secret-backed header/query maps +
64−// credential bindings. An integration declares zero or more auth methods; a
65−// connection picks one by `template` slug, and `invokeTool` renders the
66−// connection's resolved value through it. (D11: "auth state derived into the
67−// auth-template format" — an OAuth access token renders exactly like an apiKey
68−// bearer because both arrive as a single resolved `value`.)
69−// ---------------------------------------------------------------------------
70−
71−/** Apply the value as an HTTP header (e.g. `Authorization: Bearer <value>`). */
72−export const ApiKeyHeaderTemplate = Schema.Struct({
73− slug: Schema.String,
74− type: Schema.Literal("apiKey"),
75− in: Schema.Literal("header"),
76− /** The header name to set. */
77− name: Schema.String,
78− /** Optional literal prefix prepended to the value (e.g. "Bearer "). */
79− prefix: Schema.optional(Schema.String),
80−});
81−export type ApiKeyHeaderTemplate = typeof ApiKeyHeaderTemplate.Type;
82−
83−/** Apply the value as a URL query parameter. */
84−export const ApiKeyQueryTemplate = Schema.Struct({
85− slug: Schema.String,
86− type: Schema.Literal("apiKey"),
87− in: Schema.Literal("query"),
88− /** The query parameter name to set. */
89− name: Schema.String,
90− prefix: Schema.optional(Schema.String),
91−});
92−export type ApiKeyQueryTemplate = typeof ApiKeyQueryTemplate.Type;
93−
94−/** Apply the (already-resolved, already-refreshed) OAuth access token as a
95− * bearer header. Renders identically to an apiKey header bound to the same
96− * header/prefix — the difference is only how the value was sourced. */
97−export const OAuthTemplate = Schema.Struct({
98− slug: Schema.String,
99− type: Schema.Literal("oauth"),
100− /** Header the token is written into. Defaults to `Authorization`. */
101− header: Schema.optional(Schema.String),
102− /** Prefix for the token. Defaults to `Bearer `. */
103− prefix: Schema.optional(Schema.String),
104− authorizationUrl: Schema.optional(Schema.String),
105− tokenUrl: Schema.optional(Schema.String),
106− scopes: Schema.optional(Schema.Array(Schema.String)),
107−});
108−export type OAuthTemplate = typeof OAuthTemplate.Type;
109−
110−export const AuthTemplate = Schema.Union([
111− ApiKeyHeaderTemplate,
112− ApiKeyQueryTemplate,
113− OAuthTemplate,
114−]);
115−export type AuthTemplate = typeof AuthTemplate.Type;
116−
117−// ---------------------------------------------------------------------------
118−// Integration config — the opaque blob the plugin stores on the integration
119−// row. Core never parses it; the plugin writes it at register time and reads
120−// it back in `resolveTools` (to produce tools) and `invokeTool` (to render
121−// auth + look up the operation).
122−// ---------------------------------------------------------------------------
123−
124−export const GraphqlIntegrationConfig = Schema.Struct({
125− /** The GraphQL endpoint URL. */
126− endpoint: Schema.String,
127− /** Optional introspection JSON (when the endpoint can't be reached live). */
128− introspectionJson: Schema.optional(Schema.String),
129− /** Auth methods a connection can apply through. */
130− authentication: Schema.Array(AuthTemplate),
131− /** Spec-derived operations, one per produced tool. */
132− operations: Schema.Array(OperationBinding),
133− /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */
134− definitions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
135−});
136−export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type;
137−
138−// ---------------------------------------------------------------------------
139−// Invocation
140−// ---------------------------------------------------------------------------
141−
142−export const InvocationResult = Schema.Struct({
143− status: Schema.Number,
144− data: Schema.NullOr(Schema.Unknown),
145− errors: Schema.NullOr(Schema.Unknown),
146−});
147−export type InvocationResult = typeof InvocationResult.Type;
packages/plugins/graphql-greenfield/src/testing/index.ts+0−258
@@ -1,258 +0,0 @@
1−import {
2− Context,
3− Data,
4− Effect,
5− Layer,
6− Predicate,
7− Ref,
8− Schema as EffectSchema,
9− Scope,
10−} from "effect";
11−import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
12−import type { GraphQLSchema } from "graphql";
13−import {
14− createSchema,
15− createYoga,
16− type GraphQLParams,
17− type YogaInitialContext,
18−} from "graphql-yoga";
19−import { OAuthTestServer, serveTestHttpApp } from "@executor-js/sdk/testing";
20−
21−const GraphqlRequestPayload = EffectSchema.Struct({
22− query: EffectSchema.optional(EffectSchema.String),
23− variables: EffectSchema.optional(EffectSchema.Record(EffectSchema.String, EffectSchema.Unknown)),
24− operationName: EffectSchema.optional(EffectSchema.NullOr(EffectSchema.String)),
25−});
26−
27−type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type;
28−
29−export interface GraphqlTestRequest {
30− readonly url: string;
31− readonly method: string;
32− readonly path: string;
33− readonly headers: Readonly<Record<string, string>>;
34− readonly payload: GraphqlRequestPayload;
35−}
36−
37−export interface GraphqlTestContext {
38− readonly request: GraphqlTestRequest;
39−}
40−
41−export interface GraphqlTestServerOptions {
42− readonly schema: GraphQLSchema;
43− readonly path?: string;
44− readonly auth?: {
45− readonly validateAuthorization: (authorization: string | null) => Effect.Effect<boolean>;
46− readonly wwwAuthenticate?: string;
47− };
48−}
49−
50−export interface GraphqlTestServerShape {
51− readonly endpoint: string;
52− readonly schema: GraphQLSchema;
53− readonly requests: Effect.Effect<readonly GraphqlTestRequest[]>;
54− readonly clearRequests: Effect.Effect<void>;
55−}
56−
57−class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
58− readonly address: unknown;
59−}> {}
60−
61−class GraphqlTestServerHandlerError extends Data.TaggedError("GraphqlTestServerHandlerError")<{
62− readonly cause: unknown;
63−}> {}
64−
65−const headersFromRequest = (headers: Headers): Readonly<Record<string, string>> =>
66− Object.fromEntries(headers.entries());
67−
68−const payloadFromParams = (params: GraphQLParams): GraphqlRequestPayload => ({
69− query: params.query,
70− variables:
71− typeof params.variables === "object" && params.variables !== null
72− ? params.variables
73− : undefined,
74− operationName: params.operationName ?? null,
75−});
76−
77−const captureRequest = (
78− initial: YogaInitialContext,
79− requests: Ref.Ref<readonly GraphqlTestRequest[]>,
80−) => {
81− const url = new URL(initial.request.url);
82− const captured: GraphqlTestRequest = {
83− url: initial.request.url,
84− method: initial.request.method,
85− path: url.pathname,
86− headers: headersFromRequest(initial.request.headers),
87− payload: payloadFromParams(initial.params),
88− };
89− return Effect.runPromise(
90− Ref.update(requests, (all) => [...all, captured]).pipe(Effect.as(captured)),
91− );
92−};
93−
94−export const serveGraphqlTestServer = (
95− options: GraphqlTestServerOptions,
96−): Effect.Effect<
97− GraphqlTestServerShape,
98− GraphqlTestServerAddressError | GraphqlTestServerHandlerError,
99− Scope.Scope
100−> =>
101− Effect.gen(function* () {
102− const requests = yield* Ref.make<readonly GraphqlTestRequest[]>([]);
103− const path = options.path ?? "/graphql";
104−
105− const yoga = createYoga<Record<string, never>, GraphqlTestContext>({
106− schema: options.schema,
107− graphqlEndpoint: path,
108− graphiql: false,
109− landingPage: false,
110− logging: false,
111− maskedErrors: false,
112− context: (initial) =>
113− captureRequest(initial, requests).then((request) => ({
114− request,
115− })),
116− });
117−
118− const server = yield* serveTestHttpApp((request) =>
119− Effect.gen(function* () {
120− if (options.auth) {
121− const accepted = yield* options.auth.validateAuthorization(
122− request.headers.authorization ?? null,
123− );
124− if (!accepted) {
125− const responseOptions = options.auth.wwwAuthenticate
126− ? {
127− status: 401,
128− headers: { "www-authenticate": options.auth.wwwAuthenticate },
129− }
130− : { status: 401 };
131− return HttpServerResponse.jsonUnsafe(
132− { errors: [{ message: "Unauthorized" }] },
133− responseOptions,
134− );
135− }
136− }
137− const webRequest = yield* HttpServerRequest.toWeb(request);
138− const response = yield* Effect.promise(() => Promise.resolve(yoga.handle(webRequest, {})));
139− return HttpServerResponse.fromWeb(response);
140− }).pipe(
141− Effect.catch(() =>
142− Effect.succeed(
143− HttpServerResponse.text("GraphQL test server failed", {
144− status: 500,
145− contentType: "text/plain",
146− }),
147− ),
148− ),
149− ),
150− ).pipe(
151− Effect.mapError((error) =>
152− Predicate.isTagged(error, "TestHttpServerAddressError")
153− ? new GraphqlTestServerAddressError({ address: error.address })
154− : new GraphqlTestServerHandlerError({ cause: error.cause }),
155− ),
156− );
157−
158− return {
159− endpoint: server.url(path),
160− schema: options.schema,
161− requests: Ref.get(requests),
162− clearRequests: Ref.set(requests, []),
163− };
164− });
165−
166−export const serveGraphqlFailureTestServer = (options: {
167− readonly status: number;
168− readonly body: string;
169− readonly contentType?: string;
170− readonly path?: string;
171−}) =>
172− serveTestHttpApp(() =>
173− Effect.succeed(
174− HttpServerResponse.text(options.body, {
175− status: options.status,
176− contentType: options.contentType ?? "text/plain",
177− }),
178− ),
179− ).pipe(
180− Effect.map((server) => ({
181− endpoint: server.url(options.path ?? "/graphql"),
182− httpClientLayer: server.httpClientLayer,
183− })),
184− );
185−
186−export class GraphqlTestServer extends Context.Service<GraphqlTestServer, GraphqlTestServerShape>()(
187− "@executor-js/plugin-graphql-greenfield/testing/GraphqlTestServer",
188−) {
189− static readonly layer = (options: GraphqlTestServerOptions) =>
190− Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options));
191−
192− static readonly layerWithOAuth = (options: Omit<GraphqlTestServerOptions, "auth">) =>
193− Layer.effect(
194− GraphqlTestServer,
195− Effect.gen(function* () {
196− const oauth = yield* OAuthTestServer;
197− return yield* serveGraphqlTestServer({
198− ...options,
199− auth: {
200− validateAuthorization: oauth.acceptsAuthorizationHeader,
201− wwwAuthenticate: 'Bearer error="invalid_token"',
202− },
203− });
204− }),
205− );
206−}
207−
208−const stringArgument = (
209− args: Readonly<Record<string, unknown>>,
210− key: string,
211− fallback: string,
212−): string => {
213− const value = args[key];
214− return typeof value === "string" ? value : fallback;
215−};
216−
217−export const makeGreetingGraphqlSchema = (
218− options: { readonly includeMutation?: boolean } = {},
219−): GraphQLSchema => {
220− const includeMutation = options.includeMutation ?? true;
221− return createSchema<GraphqlTestContext>({
222− typeDefs: /* GraphQL */ `
223− type Query {
224− hello(name: String): String
225− }
226−
227− ${
228− includeMutation
229− ? /* GraphQL */ `
230− type Mutation {
231− setGreeting(message: String!): String
232− }
233− `
234− : ""
235− }
236− `,
237− resolvers: {
238− Query: {
239− hello: (_source: unknown, args: Readonly<Record<string, unknown>>) =>
240− `Hello ${stringArgument(args, "name", "world")}`,
241− },
242− ...(includeMutation
243− ? {
244− Mutation: {
245− setGreeting: (_source: unknown, args: Readonly<Record<string, unknown>>) =>
246− stringArgument(args, "message", ""),
247− },
248− }
249− : {}),
250− },
251− });
252−};
253−
254−export const TestLayers = {
255− greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }),
256− greetingWithOAuth: () =>
257− GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }),
258−};
packages/plugins/graphql-greenfield/tsconfig.json+0−26
@@ -1,26 +0,0 @@
1−{
2− "compilerOptions": {
3− "target": "ES2022",
4− "module": "ESNext",
5− "moduleResolution": "Bundler",
6− "strict": true,
7− "skipLibCheck": true,
8− "lib": ["ES2022", "DOM"],
9− "types": ["bun-types", "node"],
10− "noUnusedLocals": true,
11− "noImplicitOverride": true,
12− "jsx": "react-jsx",
13− "plugins": [
14− {
15− "name": "@effect/language-service",
16− "ignoreEffectSuggestionsInTscExitCode": true,
17− "ignoreEffectWarningsInTscExitCode": true,
18− "diagnosticSeverity": {
19− "globalErrorInEffectFailure": "off"
20− }
21− }
22− ]
23− },
24− "include": ["src/**/*.ts", "src/**/*.tsx"],
25− "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/promise.ts", "src/sdk/presets.ts"]
26−}
packages/plugins/graphql-greenfield/tsup.config.ts+0−14
@@ -1,14 +0,0 @@
1−import { defineConfig } from "tsup";
2−
3−export default defineConfig({
4− entry: {
5− index: "src/promise.ts",
6− core: "src/sdk/index.ts",
7− testing: "src/testing/index.ts",
8− },
9− format: ["esm"],
10− dts: false,
11− sourcemap: true,
12− clean: true,
13− external: [/^@executor-js\//, /^effect/, /^@effect\//, /^graphql/, /^graphql-yoga/],
14−});
packages/plugins/graphql-greenfield/vitest.config.ts+0−13
@@ -1,13 +0,0 @@
1−import { defineConfig } from "vitest/config";
2−
3−export default defineConfig({
4− test: {
5− include: ["src/**/*.test.ts"],
6− testTimeout: 15_000,
7− server: {
8− deps: {
9− inline: ["graphql", "graphql-yoga", "@graphql-tools", "@envelop", "@whatwg-node"],
10− },
11− },
12− },
13−});
packages/plugins/graphql/src/api/group.ts+8−7
@@ -3,7 +3,7 @@ import { Schema } from "effect"; · 2 unmodified lines
33 import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared";
44
55 import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6−import { AuthTemplate } from "../sdk/types";
6+import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types";
77
88 // ---------------------------------------------------------------------------
99 // Params
@@ -24,14 +24,15 @@ const AddIntegrationPayload = Schema.Struct({ · 14 unmodified lines
2424 introspectionJson: Schema.optional(Schema.String),
2525 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
2626 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
27− authenticationTemplate: Schema.optional(Schema.Array(AuthTemplate)),
27+ authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
2828 });
2929
3030 // The `configure` payload — the custom auth methods to merge-append onto the
31−// integration's `authenticationTemplate`. Reuses the same `AuthTemplate` schema
32−// as `addIntegration` so a custom apiKey method round-trips identically.
31+// integration's `authenticationTemplate`. Reuses the same input schema as
32+// `addIntegration` (slug optional — the backend backfills it) so a custom
33+// apikey method round-trips identically.
3334 const ConfigurePayload = Schema.Struct({
34− authenticationTemplate: Schema.Array(AuthTemplate),
35+ authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),
3536 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
3637 });
3738
@@ -52,13 +53,13 @@ const GraphqlConfigView = Schema.Struct({ · 14 unmodified lines
5253 introspectionJson: Schema.optional(Schema.String),
5354 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
5455 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
55− authenticationTemplate: Schema.Array(AuthTemplate),
56+ authenticationTemplate: Schema.Array(GraphqlAuthMethod),
5657 });
5758
5859 // The configure result — the merged `authenticationTemplate` after the new
5960 // custom methods were appended/replaced.
6061 const ConfigureResponse = Schema.Struct({
61− authenticationTemplate: Schema.Array(AuthTemplate),
62+ authenticationTemplate: Schema.Array(GraphqlAuthMethod),
6263 });
6364
6465 // ---------------------------------------------------------------------------
packages/plugins/graphql/src/api/handlers.test.ts+52−19
@@ -17,8 +17,13 @@ import { Effect, Layer } from "effect"; · 16 unmodified lines
1717 import { addGroup, observabilityMiddleware } from "@executor-js/api";
1818 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";
1919
20+import { expandGraphqlAuthMethodInputs } from "../sdk/types";
2021 import type { GraphqlPluginExtension } from "../sdk/plugin";
21−import type { AuthTemplate, GraphqlIntegrationConfig } from "../sdk/types";
22+import type {
23+ GraphqlAuthMethod,
24+ GraphqlAuthMethodInput,
25+ GraphqlIntegrationConfig,
26+} from "../sdk/types";
2227 import { GraphqlExtensionService, GraphqlHandlers } from "./handlers";
2328 import { GraphqlGroup } from "./group";
2429
@@ -31,22 +36,22 @@ const makeStubExtension = ( · 6 unmodified lines
3136 ): GraphqlPluginExtension => {
3237 let counter = 0;
3338 const merge = (
34− existing: readonly AuthTemplate[],
35− incoming: readonly AuthTemplate[],
36− ): readonly AuthTemplate[] => {
37− const result: AuthTemplate[] = existing.map((entry: AuthTemplate) => entry);
38− const taken = new Set<string>(result.map((entry: AuthTemplate) => entry.slug));
39+ existing: readonly GraphqlAuthMethod[],
40+ incoming: readonly GraphqlAuthMethodInput[],
41+ ): readonly GraphqlAuthMethod[] => {
42+ const result: GraphqlAuthMethod[] = existing.map((entry: GraphqlAuthMethod) => entry);
43+ const taken = new Set<string>(result.map((entry: GraphqlAuthMethod) => entry.slug));
3944 for (const entry of incoming) {
40− const requested = entry.slug.trim();
41− const index = result.findIndex((current: AuthTemplate) => current.slug === requested);
45+ const requested = entry.slug?.trim() ?? "";
46+ const index = result.findIndex((current: GraphqlAuthMethod) => current.slug === requested);
4247 if (requested.length > 0 && index >= 0) {
43− result[index] = entry;
48+ result[index] = entry as GraphqlAuthMethod;
4449 continue;
4550 }
4651 const slug =
4752 requested.length > 0 && !taken.has(requested) ? requested : `custom_${counter++}`;
4853 taken.add(slug);
49− result.push({ ...entry, slug } as AuthTemplate);
54+ result.push({ ...entry, slug } as GraphqlAuthMethod);
5055 }
5156 return result;
5257 };
@@ -59,12 +64,17 @@ const makeStubExtension = ( · 6 unmodified lines
5964 getConfig: (slug: string) => Effect.sync(() => store.get(slug) ?? null),
6065 configureAuth: (
6166 slug: string,
62− input: { readonly authenticationTemplate: readonly AuthTemplate[] },
67+ input: { readonly authenticationTemplate: readonly GraphqlAuthMethodInput[] },
6368 ) =>
64− Effect.sync((): readonly AuthTemplate[] => {
69+ Effect.sync((): readonly GraphqlAuthMethod[] => {
6570 const current = store.get(slug);
6671 if (!current) return [];
67− const merged = merge(current.authenticationTemplate, input.authenticationTemplate);
72+ // Mirror the real configureAuth: dialect inputs expand to canonical
73+ // placements before the merge (stored configs stay canonical).
74+ const merged = merge(
75+ current.authenticationTemplate,
76+ expandGraphqlAuthMethodInputs(input.authenticationTemplate),
77+ );
6878 store.set(slug, { ...current, authenticationTemplate: merged });
6979 return merged;
7080 }),
@@ -102,7 +112,9 @@ const seededStore = (): Map<string, GraphqlIntegrationConfig> => { · 31 unmodified lines
102112 store.set("gql", {
103113 endpoint: "https://x.example/graphql",
104114 name: "GraphQL",
105− authenticationTemplate: [{ kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" }],
115+ authenticationTemplate: [
116+ { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] },
117+ ],
106118 });
107119 return store;
108120 };
@@ -134,7 +146,13 @@ describe("GraphqlHandlers — config surface", () => { · 25 unmodified lines
134146 };
135147
136148 const configureRes = yield* post(web, "http://localhost/graphql/integrations/gql/config", {
137− authenticationTemplate: [{ kind: "apiKey", slug: "custom", in: "query", name: "key" }],
149+ authenticationTemplate: [
150+ {
151+ slug: "custom",
152+ type: "apiKey",
153+ queryParams: { key: [{ type: "variable", name: "token" }] },
154+ },
155+ ],
138156 });
139157 expect(configureRes.status).toBe(200);
140158 const configureBody = (yield* Effect.promise(() => configureRes.json())) as {
@@ -159,15 +177,24 @@ describe("GraphqlHandlers — config surface", () => { · 18 unmodified lines
159177 };
160178
161179 const res = yield* post(web, "http://localhost/graphql/integrations/gql/config", {
162− authenticationTemplate: [{ kind: "apiKey", slug: "seed", in: "header", name: "X-New" }],
180+ authenticationTemplate: [
181+ {
182+ slug: "seed",
183+ type: "apiKey",
184+ headers: { "X-New": [{ type: "variable", name: "token" }] },
185+ },
186+ ],
163187 });
164188 expect(res.status).toBe(200);
165189 const body = (yield* Effect.promise(() => res.json())) as {
166− authenticationTemplate: { slug: string; name: string }[];
190+ authenticationTemplate: {
191+ slug: string;
192+ placements: { name: string }[];
193+ }[];
167194 };
168195 expect(body.authenticationTemplate).toHaveLength(1);
169196 expect(body.authenticationTemplate[0]!.slug).toBe("seed");
170− expect(body.authenticationTemplate[0]!.name).toBe("X-New");
197+ expect(body.authenticationTemplate[0]!.placements[0]!.name).toBe("X-New");
171198 }),
172199 );
173200
@@ -179,7 +206,13 @@ describe("GraphqlHandlers — config surface", () => { · 5 unmodified lines
179206 };
180207
181208 const res = yield* post(web, "http://localhost/graphql/integrations/nope/config", {
182− authenticationTemplate: [{ kind: "apiKey", slug: "custom", in: "query", name: "key" }],
209+ authenticationTemplate: [
210+ {
211+ slug: "custom",
212+ type: "apiKey",
213+ queryParams: { key: [{ type: "variable", name: "token" }] },
214+ },
215+ ],
183216 });
184217 expect(res.status).toBe(200);
185218 const body = (yield* Effect.promise(() => res.json())) as {
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+15−22
@@ -26,9 +26,8 @@ import { · 25 unmodified lines
2626
2727 import { addGraphqlIntegrationOptimistic } from "./atoms";
2828 import { GraphqlSourceFields } from "./GraphqlSourceFields";
29−import { graphqlTemplatesFromPlacements } from "./auth-method-config";
30−import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";
31−import type { AuthTemplate } from "../sdk/types";
29+import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
30+import type { GraphqlAuthMethodInput } from "../sdk/types";
3231
3332 // v2 GraphQL add flow: register the integration with its declared auth-method
3433 // LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;
@@ -57,25 +56,19 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
5756 mode: "promiseExit",
5857 });
5958
60− // The templates to register: each apikey row emits one template per named
61− // placement (GraphQL templates carry one header/query slot each). The first
62− // keeps the primary `apikey` slug (matching the prior single-method flow);
63− // the rest get deterministic `apikey-<n>` slugs. `none` rows register
64− // nothing.
65− const authenticationTemplate = useMemo<readonly AuthTemplate[]>(() => {
66− const templates: AuthTemplate[] = [];
67− for (const row of authMethodList.rows) {
68− if (row.value.kind !== "apikey") continue;
69− for (const template of graphqlTemplatesFromPlacements(row.value.placements, "")) {
70− const index = templates.length;
71− templates.push({
72− ...template,
73− slug: index === 0 ? GRAPHQL_APIKEY_TEMPLATE : `apikey-${index + 1}`,
74− });
75− }
76− }
77− return templates;
78− }, [authMethodList.rows]);
59+ // The methods to register: each apikey row declares ONE method carrying
60+ // every named placement (header + query mix in a single method). Inputs
61+ // omit slugs — the backend assigns carrier-derived ones. `none` rows
62+ // register nothing.
63+ const authenticationTemplate = useMemo<readonly GraphqlAuthMethodInput[]>(
64+ () =>
65+ authMethodList.rows.flatMap((row: AuthMethodRow) =>
66+ row.value.kind === "apikey"
67+ ? graphqlAuthMethodInputsFromPlacements(row.value.placements)
68+ : [],
69+ ),
70+ [authMethodList.rows],
71+ );
7972
8073 // Every apikey row needs at least one named placement; `none` rows are
8174 // always valid.
packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx+19−15
@@ -15,8 +15,12 @@ import { · 14 unmodified lines
1515 } from "@executor-js/react/lib/custom-auth-methods";
1616
1717 import { graphqlConfigAtom, graphqlConfigure } from "./atoms";
18−import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";
19−import type { AuthTemplate } from "../sdk/types";
18+import {
19+ authMethodsFromConfig,
20+ graphqlAuthMethodInputsFromPlacements,
21+ graphqlWireAuthInput,
22+} from "./auth-method-config";
23+import type { GraphqlAuthMethod } from "../sdk/types";
2024
2125 // ---------------------------------------------------------------------------
2226 // GraphQL Accounts hub — fills the generic detail page's `accounts` slot.
@@ -24,11 +28,11 @@ import type { AuthTemplate } from "../sdk/types"; · 1 unmodified line
2428 // Reads the integration's real `authenticationTemplate` (via `getConfig`),
2529 // converts it to generic `AuthMethod[]`, and composes the generic
2630 // `AccountsSection` — whose Add-account offers those methods plus a "+ Custom
27−// method" row (apiKey-only). The custom-method create is INJECTED here
28−// (`createCustomMethod`): generic placements → graphql `apiKey` templates
29−// (`graphqlTemplatesFromPlacements`, slug omitted → backend `custom_<id>`)
30−// merge-appended via `configure`. Stays plugin-side because it touches the
31−// graphql sdk `AuthTemplate` types.
31+// method" row (apikey-only). The custom-method create is INJECTED here
32+// (`createCustomMethod`): generic placements → ONE apikey method
33+// (`graphqlAuthMethodInputsFromPlacements`, slug omitted → backend
34+// `custom_<id>`) merge-appended via `configure`. Stays plugin-side because it
35+// touches the graphql sdk method types.
3236 // ---------------------------------------------------------------------------
3337
3438 export default function GraphqlAccountsPanel(props: {
@@ -41,7 +45,7 @@ export default function GraphqlAccountsPanel(props: { · 6 unmodified lines
4145 const configResult = useAtomValue(graphqlConfigAtom(slug));
4246 const doConfigure = useAtomSet(graphqlConfigure, { mode: "promiseExit" });
4347
44− const existingTemplate = useMemo<readonly AuthTemplate[]>(() => {
48+ const existingTemplate = useMemo<readonly GraphqlAuthMethod[]>(() => {
4549 if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
4650 return configResult.value.authenticationTemplate ?? [];
4751 }, [configResult]);
@@ -53,13 +57,13 @@ export default function GraphqlAccountsPanel(props: { · 5 unmodified lines
5357
5458 // Custom-method create/remove: the shared skeleton (merge-append → diff out
5559 // the created method; filter → replace) parameterized by the GraphQL codec.
56− // Stays plugin-side only where it touches the graphql `AuthTemplate` types.
57− const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>(
60+ // Stays plugin-side only where it touches the graphql method types.
61+ const configure = useCallback<ConfigureAuthMethods<GraphqlAuthMethod>>(
5862 async (input) => {
5963 const exit = await doConfigure({
6064 params: { slug: String(slug) },
6165 payload: {
62− authenticationTemplate: input.authenticationTemplate,
66+ authenticationTemplate: input.authenticationTemplate.map(graphqlWireAuthInput),
6367 ...(input.mode ? { mode: input.mode } : {}),
6468 },
6569 reactivityKeys: integrationWriteKeys,
@@ -69,13 +73,13 @@ export default function GraphqlAccountsPanel(props: { · 3 unmodified lines
6973 [doConfigure, slug],
7074 );
7175
72− const codec = useMemo<AuthMethodsCodec<AuthTemplate>>(
76+ const codec = useMemo<AuthMethodsCodec<GraphqlAuthMethod>>(
7377 () => ({
7478 toAuthMethods: authMethodsFromConfig,
75− // Slug blank → backend backfills `custom_<id>` per emitted template.
79+ // Inputs omit slugs — the backend merge backfills `custom_<id>`.
7680 templatesFromPlacements: (placements: readonly Placement[]) =>
77− graphqlTemplatesFromPlacements(placements, ""),
78− slugOf: (template: AuthTemplate) => template.slug,
81+ graphqlAuthMethodInputsFromPlacements(placements) as readonly GraphqlAuthMethod[],
82+ slugOf: (template: GraphqlAuthMethod) => template.slug,
7983 }),
8084 [],
8185 );
packages/plugins/graphql/src/react/auth-method-config.test.ts+164−71
@@ -1,98 +1,191 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { AuthTemplateSlug } from "@executor-js/sdk/shared";
2+import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
33
4−import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";
5−import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";
6−import type { AuthTemplate } from "../sdk/types";
4+import {
5+ authMethodsFromConfig,
6+ editorValueFromGraphqlAuthMethod,
7+ graphqlAuthMethodInputFromEditorValue,
8+ graphqlAuthMethodInputsFromPlacements,
9+} from "./auth-method-config";
710
8−describe("graphqlTemplatesFromPlacements", () => {
9− it("maps a single named header placement → one apiKey template (primary slug)", () => {
10− const templates = graphqlTemplatesFromPlacements([
11− { carrier: "header", name: "Authorization", prefix: "Bearer " },
12− ]);
13− expect(templates).toEqual([
14− {
15− kind: "apiKey",
16− slug: GRAPHQL_APIKEY_TEMPLATE,
17− in: "header",
18− name: "Authorization",
19− prefix: "Bearer ",
20− },
21− ]);
11+describe("graphqlAuthMethodInputFromEditorValue", () => {
12+ it("maps 'none' → { kind: 'none' }", () => {
13+ expect(graphqlAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
2214 });
2315
24− it("emits one template per named placement; only the first keeps the slug", () => {
25− const templates = graphqlTemplatesFromPlacements(
26− [
27− { carrier: "header", name: "Authorization", prefix: "" },
28− { carrier: "query", name: "api_key", prefix: "" },
16+ it("maps 'oauth' → { kind: 'oauth2' } (graphql oauth stores no endpoints)", () => {
17+ const value: AuthTemplateEditorValue = {
18+ kind: "oauth",
19+ authorizationUrl: "https://a.example.com/auth",
20+ tokenUrl: "https://a.example.com/token",
21+ scopes: ["read"],
22+ };
23+ expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });
24+ });
25+
26+ it("maps a header placement to an apikey method (prefix preserved)", () => {
27+ const value: AuthTemplateEditorValue = {
28+ kind: "apikey",
29+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
30+ };
31+ expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({
32+ kind: "apikey",
33+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
34+ });
35+ });
36+
37+ it("keeps EVERY named placement — header + query mix in one method", () => {
38+ const value: AuthTemplateEditorValue = {
39+ kind: "apikey",
40+ placements: [
41+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
42+ { carrier: "query", name: "team_id", prefix: "" },
2943 ],
30− "",
31− );
32− expect(templates).toEqual([
33− { kind: "apiKey", slug: "", in: "header", name: "Authorization" },
34− { kind: "apiKey", slug: "", in: "query", name: "api_key" },
35− ]);
44+ };
45+ expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({
46+ kind: "apikey",
47+ placements: [
48+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" },
49+ { carrier: "query", name: "team_id", variable: "team_id" },
50+ ],
51+ });
3652 });
3753
38− it("drops unnamed placements", () => {
39− const templates = graphqlTemplatesFromPlacements([
40− { carrier: "header", name: "", prefix: "" },
41− { carrier: "header", name: "X-Token", prefix: "" },
42− ]);
43− expect(templates).toEqual([
44− { kind: "apiKey", slug: GRAPHQL_APIKEY_TEMPLATE, in: "header", name: "X-Token" },
45− ]);
54+ it("drops unnamed placements and degrades to none when nothing is usable", () => {
55+ expect(
56+ graphqlAuthMethodInputFromEditorValue({
57+ kind: "apikey",
58+ placements: [{ carrier: "header", name: " ", prefix: "" }],
59+ }),
60+ ).toEqual({ kind: "none" });
61+ });
62+});
63+
64+describe("editorValueFromGraphqlAuthMethod", () => {
65+ it("round-trips an apikey method, making the shared token variable explicit", () => {
66+ expect(
67+ editorValueFromGraphqlAuthMethod({
68+ slug: "header",
69+ kind: "apikey",
70+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
71+ }),
72+ ).toEqual({
73+ kind: "apikey",
74+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer ", variable: "token" }],
75+ });
76+ });
77+
78+ it("round-trip edit preserves placement variables (sharing survives)", () => {
79+ const stored = {
80+ slug: "custom_two_spots",
81+ kind: "apikey",
82+ placements: [
83+ { carrier: "header", name: "X-Token" },
84+ { carrier: "query", name: "token" },
85+ ],
86+ } as const;
87+ const editor = editorValueFromGraphqlAuthMethod(stored);
88+ const back = graphqlAuthMethodInputFromEditorValue(editor);
89+ // Both placements still share the canonical `token` input (stored as
90+ // absent on the wire) — a round-trip must not split one credential in two.
91+ expect(back).toEqual({
92+ kind: "apikey",
93+ placements: [
94+ { carrier: "header", name: "X-Token" },
95+ { carrier: "query", name: "token" },
96+ ],
97+ });
98+ });
99+
100+ it("maps oauth2 to an oauth editor value with no endpoints", () => {
101+ expect(editorValueFromGraphqlAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({
102+ kind: "oauth",
103+ authorizationUrl: "",
104+ tokenUrl: "",
105+ scopes: [],
106+ });
46107 });
47108 });
48109
49110 describe("authMethodsFromConfig", () => {
50− it("maps a header apiKey template → a generic apikey method with a placement", () => {
51− const config: AuthTemplate[] = [
52− { kind: "apiKey", slug: "apiKey", in: "header", name: "Authorization", prefix: "Bearer " },
53− ];
54− expect(authMethodsFromConfig(config)).toEqual([
111+ it("projects every declared method and marks custom_ slugs as custom", () => {
112+ const methods = authMethodsFromConfig([
113+ { slug: "oauth2", kind: "oauth2" },
55114 {
56− id: "apiKey",
57− label: "API key (Authorization)",
115+ slug: "custom_abc123",
58116 kind: "apikey",
59− source: "spec",
60− template: AuthTemplateSlug.make("apiKey"),
61− placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
117+ placements: [{ carrier: "header", name: "X-Api-Key" }],
62118 },
119+ { slug: "none", kind: "none" },
63120 ]);
64− });
65121
66− it("marks a custom_ slug as a custom method", () => {
67− const config: AuthTemplate[] = [
68− { kind: "apiKey", slug: "custom_abc", in: "query", name: "token" },
69− ];
70− const [method] = authMethodsFromConfig(config);
71− expect(method?.source).toBe("custom");
72− expect(method?.placements).toEqual([{ carrier: "query", name: "token", prefix: "" }]);
122+ expect(
123+ methods.map((method) => ({
124+ id: method.id,
125+ kind: method.kind,
126+ source: method.source,
127+ template: String(method.template),
128+ })),
129+ ).toEqual([
130+ { id: "oauth2", kind: "oauth", source: "spec", template: "oauth2" },
131+ { id: "custom_abc123", kind: "apikey", source: "custom", template: "custom_abc123" },
132+ { id: "none", kind: "none", source: "spec", template: "none" },
133+ ]);
134+ expect(methods[0]?.oauth).toEqual({});
73135 });
74136
75− it("maps an oauth2 template → an oauth method", () => {
76− const config: AuthTemplate[] = [{ kind: "oauth2", slug: "oauth2" }];
77− expect(authMethodsFromConfig(config)).toEqual([
137+ it("carries multi-placement methods through to the hub", () => {
138+ const methods = authMethodsFromConfig([
78139 {
79− id: "oauth2",
80− label: "OAuth2",
81− kind: "oauth",
82− source: "spec",
83− template: AuthTemplateSlug.make("oauth2"),
84− placements: [],
85− oauth: {},
140+ slug: "custom_mix",
141+ kind: "apikey",
142+ placements: [
143+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
144+ { carrier: "query", name: "team_id", variable: "team_id" },
145+ ],
86146 },
87147 ]);
148+ expect(methods[0]?.placements).toEqual([
149+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
150+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
151+ ]);
88152 });
89153 });
90154
91−describe("round-trip (placements → templates → methods)", () => {
92− it("a header placement survives the round-trip into a generic method", () => {
93− const placements = [{ carrier: "header" as const, name: "X-Api-Key", prefix: "" }];
94− const templates = graphqlTemplatesFromPlacements(placements, "");
95− const [method] = authMethodsFromConfig(templates);
96− expect(method?.placements).toEqual(placements);
155+describe("graphqlAuthMethodInputsFromPlacements", () => {
156+ it("builds ONE method carrying every named placement", () => {
157+ expect(
158+ graphqlAuthMethodInputsFromPlacements([
159+ { carrier: "header", name: "X-Token", prefix: "Bearer " },
160+ { carrier: "query", name: "team_id", prefix: "" },
161+ ]),
162+ ).toEqual([
163+ {
164+ type: "apiKey",
165+ headers: { "X-Token": ["Bearer ", { type: "variable", name: "x_token" }] },
166+ queryParams: { team_id: [{ type: "variable", name: "team_id" }] },
167+ },
168+ ]);
169+ });
170+
171+ it("builds a query method from a query placement", () => {
172+ expect(
173+ graphqlAuthMethodInputsFromPlacements([{ carrier: "query", name: "token", prefix: "" }]),
174+ ).toEqual([{ type: "apiKey", queryParams: { token: [{ type: "variable", name: "token" }] } }]);
175+ });
176+
177+ it("skips unnamed placements", () => {
178+ expect(
179+ graphqlAuthMethodInputsFromPlacements([
180+ { carrier: "query", name: "", prefix: "" },
181+ { carrier: "query", name: "token", prefix: "" },
182+ ]),
183+ ).toEqual([{ type: "apiKey", queryParams: { token: [{ type: "variable", name: "token" }] } }]);
184+ });
185+
186+ it("is empty when no placement has a usable name", () => {
187+ expect(
188+ graphqlAuthMethodInputsFromPlacements([{ carrier: "query", name: " ", prefix: "" }]),
189+ ).toEqual([]);
97190 });
98191 });
packages/plugins/graphql/src/react/auth-method-config.ts+71−70
@@ -1,84 +1,85 @@
11 // ---------------------------------------------------------------------------
2−// GraphQL ↔ generic auth-method converters.
3−//
4−// The generic Accounts hub + add-time auth editor speak in plugin-agnostic
5−// `AuthMethod` / `Placement` values (`@executor-js/react/lib/auth-placements`).
6−// The GraphQL plugin stores auth as its own `AuthTemplate` wire shape
7−// (`{ kind:"apiKey", slug, in, name, prefix? }` / `{ kind:"oauth2", slug, … }`).
8−// These converters bridge that wire shape to/from the generic placement model,
9−// so they live with the GraphQL plugin — they touch the graphql sdk
10−// `AuthTemplate` types and would pull transport specifics into core.
2+// GraphQL ↔ generic auth-method converters — a thin oauth adapter over the
3+// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4+// apikey/none paths (multi-placement, multi-variable) live in the shared
5+// codec; GraphQL only contributes its oauth flavor: endpoint-less methods that
6+// render the connection's access token as a bearer header at invoke time
7+// (optionally overriding the header name / prefix).
118 // ---------------------------------------------------------------------------
129
1310 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11+import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
1412 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
13+import {
14+ wireAuthInputFromShared,
15+ authMethodFromSharedTemplate,
16+ editorValueFromSharedMethod,
17+ sharedMethodInputFromEditorValue,
18+ wirePlacementsFromEditor,
19+} from "@executor-js/react/lib/shared-auth-method-codec";
1520
16−import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";
17−import type { AuthTemplate } from "../sdk/types";
21+import type {
22+ GraphqlAuthMethod,
23+ GraphqlAuthMethodInput,
24+ GraphqlCanonicalAuthMethodInput,
25+} from "../sdk/types";
1826
19−// ---------------------------------------------------------------------------
20−// Templates → generic methods.
21−// ---------------------------------------------------------------------------
27+const oauthAuthMethod = (slug: string): AuthMethod => ({
28+ id: slug,
29+ label: "OAuth",
30+ kind: "oauth",
31+ source: slug.startsWith("custom_") ? "custom" : "spec",
32+ template: AuthTemplateSlug.make(slug),
33+ placements: [],
34+ oauth: {},
35+});
2236
23−const labelForApiKey = (slug: string, name: string): string => `API key (${name || slug})`;
37+/** Convert a generic editor value into one GraphQL auth-method input (no slug
38+ * — the backend assigns carrier-derived slugs). An apikey value keeps every
39+ * named placement (headers and query params mix freely); one with no usable
40+ * placement falls back to `none`. */
41+export function graphqlAuthMethodInputFromEditorValue(
42+ value: AuthTemplateEditorValue,
43+): GraphqlAuthMethodInput {
44+ if (value.kind === "oauth") return { kind: "oauth2" };
45+ return (sharedMethodInputFromEditorValue(value) ?? { kind: "none" }) as GraphqlAuthMethodInput;
46+}
2447
25−/** Map each stored GraphQL auth template to a generic `AuthMethod`. A `custom_`
26− * slug marks a user-defined method; everything else is spec-declared. */
27−export function authMethodsFromConfig(templates: readonly AuthTemplate[]): AuthMethod[] {
28− return templates.map((template: AuthTemplate): AuthMethod => {
29− const slug = String(template.slug);
30− const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";
31− if (template.kind === "oauth2") {
32− return {
33− id: slug,
34− label: "OAuth2",
35− kind: "oauth",
36− source,
37− template: AuthTemplateSlug.make(slug),
38− placements: [],
39− oauth: {},
40− };
41− }
42− const placement: Placement = {
43− carrier: template.in,
44− name: template.name,
45− prefix: template.prefix ?? "",
46− };
47− return {
48− id: slug,
49− label: labelForApiKey(slug, template.name),
50− kind: "apikey",
51− source,
52− template: AuthTemplateSlug.make(slug),
53− placements: [placement],
54− };
55− });
48+/** Convert one stored GraphQL method into the generic editor value. */
49+export function editorValueFromGraphqlAuthMethod(
50+ method: GraphqlAuthMethod,
51+): AuthTemplateEditorValue {
52+ if (method.kind === "oauth2") {
53+ // GraphQL oauth methods store no endpoints — only the bearer rendering.
54+ return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
55+ }
56+ return editorValueFromSharedMethod(method);
5657 }
5758
58−// ---------------------------------------------------------------------------
59−// Generic placements → graphql apiKey templates (inverse).
60−//
61−// GraphQL's `AuthTemplate` carries ONE header/query slot per template, so a
62−// multi-placement method emits one template per named placement. When `slug` is
63−// omitted the backend backfills `custom_<id>`; the first template keeps the
64−// integration's primary `apiKey` slug so the add flow stays stable.
65−// ---------------------------------------------------------------------------
59+/** Project the stored methods into the generic `AuthMethod[]` the hub renders.
60+ * Mirrors the server's `describeGraphqlAuthMethods`; `custom_` slugs mark
61+ * user-created methods (removable from the hub). */
62+export function authMethodsFromConfig(methods: readonly GraphqlAuthMethod[]): AuthMethod[] {
63+ return methods.map((method: GraphqlAuthMethod): AuthMethod => {
64+ if (method.kind === "oauth2") return oauthAuthMethod(method.slug);
65+ return authMethodFromSharedTemplate(method);
66+ });
67+}
6668
67−/** Build GraphQL `apiKey` templates from generic placements. The optional
68− * `slug` names the FIRST emitted template (subsequent placements get an empty
69− * slug so the backend assigns a `custom_<id>` each). */
70−export function graphqlTemplatesFromPlacements(
69+/** Build the GraphQL method input for a custom method from generic placements
70+ * — ONE method carrying every named placement (header + query mix in a single
71+ * method; each placement renders from its own input variable, or shares one).
72+ * Empty when no placement is usable. */
73+export function graphqlAuthMethodInputsFromPlacements(
7174 placements: readonly Placement[],
72− slug?: string,
73−): AuthTemplate[] {
74− const named = placements.filter((placement: Placement) => placement.name.trim().length > 0);
75− return named.map(
76− (placement: Placement, index: number): AuthTemplate => ({
77− kind: "apiKey",
78− slug: index === 0 ? (slug ?? GRAPHQL_APIKEY_TEMPLATE) : "",
79− in: placement.carrier,
80− name: placement.name,
81− ...(placement.prefix ? { prefix: placement.prefix } : {}),
82− }),
83− );
75+): GraphqlAuthMethodInput[] {
76+ const wire = wirePlacementsFromEditor(placements);
77+ if (wire.length === 0) return [];
78+ return [graphqlWireAuthInput({ kind: "apikey", placements: wire })];
8479 }
80+
81+/** Serialize a canonical method into the wire input union (apikey → the
82+ * request-shaped dialect; none/oauth2 pass through). */
83+export const graphqlWireAuthInput = (
84+ method: GraphqlAuthMethod | GraphqlCanonicalAuthMethodInput,
85+): GraphqlAuthMethodInput => wireAuthInputFromShared(method) as GraphqlAuthMethodInput;
packages/plugins/graphql/src/react/defaults.test.ts+1−22
@@ -1,27 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import {
4− GRAPHQL_APIKEY_TEMPLATE,
5− graphqlApiKeyAuthTemplate,
6− graphqlConnectionName,
7−} from "./defaults";
8−
9−describe("graphqlApiKeyAuthTemplate", () => {
10− it("builds an apiKey header template using the template slug", () => {
11− const template = graphqlApiKeyAuthTemplate("X-Api-Key");
12−
13− expect(template).toEqual({
14− kind: "apiKey",
15− slug: GRAPHQL_APIKEY_TEMPLATE,
16− in: "header",
17− name: "X-Api-Key",
18− });
19− });
20−
21− it("falls back to Authorization when no header name is given", () => {
22− expect(graphqlApiKeyAuthTemplate("").name).toBe("Authorization");
23− });
24−});
3+import { graphqlConnectionName } from "./defaults";
254
265 describe("graphqlConnectionName", () => {
276 it("is deterministic per integration + owner", () => {
packages/plugins/graphql/src/react/defaults.ts+0−20
@@ -7,26 +7,6 @@ import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; · 6 unmodified lines
77 // connection IS the credential, applied to the integration's auth template.
88 // ---------------------------------------------------------------------------
99
10−/** The slug of the apiKey auth template the add flow declares + targets. */
11−export const GRAPHQL_APIKEY_TEMPLATE = "apiKey";
12−
13−/** Build the apiKey auth template the integration stores. The connection's
14− * value is written to `headerName` at invoke time (no prefix — the user pastes
15− * the full header value, e.g. `Bearer ghp_…`). */
16−export const graphqlApiKeyAuthTemplate = (
17− headerName: string,
18−): {
19− readonly kind: "apiKey";
20− readonly slug: string;
21− readonly in: "header";
22− readonly name: string;
23−} => ({
24− kind: "apiKey",
25− slug: GRAPHQL_APIKEY_TEMPLATE,
26− in: "header",
27− name: headerName || "Authorization",
28−});
29−
3010 /** Deterministic connection name for a GraphQL integration + owner. Keeps a
3111 * single owner-scoped credential per integration so re-adding overwrites
3212 * rather than accumulating duplicates. */
packages/plugins/graphql/src/sdk/configure.test.ts+31−24
@@ -1,10 +1,10 @@
11 // ---------------------------------------------------------------------------
22 // GraphQL plugin — `configureAuth` (custom auth method merge-append) coverage.
33 //
4−// `configureAuth` merge-appends apiKey/oauth2 templates onto an existing
4+// `configureAuth` merge-appends apikey/oauth2 methods onto an existing
55 // integration's opaque `authenticationTemplate`. These tests exercise the
66 // extension method directly (the same path the HTTP `configure` handler calls):
7−// - round-trip: add a custom apiKey method → `getConfig` shows it,
7+// - round-trip: add a custom apikey method → `getConfig` shows it,
88 // - append: a configured method does not drop the integration's existing
99 // declared methods,
1010 // - slug generation: a method with a blank slug is assigned `custom_<id>`,
@@ -20,7 +20,7 @@ import { createExecutor } from "@executor-js/sdk"; · 9 unmodified lines
2020 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
2121
2222 import { graphqlPlugin } from "./plugin";
23−import type { AuthTemplate } from "./types";
23+import type { GraphqlAuthMethod } from "./types";
2424
2525 const makeExecutor = () =>
2626 createExecutor(
@@ -29,11 +29,10 @@ const makeExecutor = () => · 2 unmodified lines
2929 }),
3030 );
3131
32−const customApiKey: AuthTemplate = {
33− kind: "apiKey",
32+const customApiKey: GraphqlAuthMethod = {
3433 slug: "my_custom",
35− in: "header",
36− name: "X-Api-Key",
34+ kind: "apikey",
35+ placements: [{ carrier: "header", name: "X-Api-Key" }],
3736 };
3837
3938 describe("GraphQL Plugin — configureAuth (custom auth method)", () => {
@@ -56,7 +55,11 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 16 unmodified lines
5655 const config = yield* executor.graphql.getConfig("cfg_api");
5756 const template = config?.authenticationTemplate ?? [];
5857 expect(template).toEqual([
59− { kind: "apiKey", slug: "my_custom", in: "header", name: "X-Api-Key" },
58+ {
59+ slug: "my_custom",
60+ kind: "apikey",
61+ placements: [{ carrier: "header", name: "X-Api-Key" }],
62+ },
6063 ]);
6164 }),
6265 );
@@ -68,14 +71,16 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 5 unmodified lines
6871 yield* executor.graphql.addIntegration({
6972 endpoint: "https://x.example/graphql",
7073 slug: "cfg_append",
71− authenticationTemplate: [{ kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" }],
74+ authenticationTemplate: [
75+ { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] },
76+ ],
7277 });
7378
7479 const merged = yield* executor.graphql.configureAuth("cfg_append", {
7580 authenticationTemplate: [customApiKey],
7681 });
7782
78− expect(merged.map((m: AuthTemplate) => m.slug)).toEqual(["seed", "my_custom"]);
83+ expect(merged.map((m: GraphqlAuthMethod) => m.slug)).toEqual(["seed", "my_custom"]);
7984 }),
8085 );
8186
@@ -89,7 +94,9 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 7 unmodified lines
8994 });
9095
9196 const merged = yield* executor.graphql.configureAuth("cfg_genslug", {
92− authenticationTemplate: [{ kind: "apiKey", slug: "", in: "header", name: "X-Api-Key" }],
97+ authenticationTemplate: [
98+ { slug: "", kind: "apikey", placements: [{ carrier: "header", name: "X-Api-Key" }] },
99+ ],
93100 });
94101
95102 expect(merged).toHaveLength(1);
@@ -111,29 +118,27 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 15 unmodified lines
111118 authenticationTemplate: [
112119 // Re-submit the same slug with a different placement → replace in place.
113120 {
114− kind: "apiKey",
115121 slug: "my_custom",
116− in: "header",
117− name: "X-Other",
122+ kind: "apikey",
123+ placements: [{ carrier: "header", name: "X-Other" }],
118124 },
119− { kind: "apiKey", slug: "", in: "query", name: "api_key" },
120− { kind: "apiKey", slug: "", in: "query", name: "token" },
125+ { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "api_key" }] },
126+ { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
121127 ],
122128 });
123129
124− const slugs = merged.map((m: AuthTemplate) => m.slug);
130+ const slugs = merged.map((m: GraphqlAuthMethod) => m.slug);
125131 expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1);
126132 expect(merged).toHaveLength(3);
127133 const generated = slugs.filter((s: string) => s.startsWith("custom_"));
128134 expect(generated).toHaveLength(2);
129135 expect(new Set(generated).size).toBe(2);
130136
131− const replaced = merged.find((m: AuthTemplate) => m.slug === "my_custom")!;
137+ const replaced = merged.find((m: GraphqlAuthMethod) => m.slug === "my_custom")!;
132138 expect(replaced).toEqual({
133− kind: "apiKey",
134139 slug: "my_custom",
135− in: "header",
136− name: "X-Other",
140+ kind: "apikey",
141+ placements: [{ carrier: "header", name: "X-Other" }],
137142 });
138143 }),
139144 );
@@ -146,17 +151,19 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 6 unmodified lines
146151 endpoint: "https://x.example/graphql",
147152 slug: "cfg_replace",
148153 authenticationTemplate: [
149− { kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" },
154+ { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] },
150155 customApiKey,
151156 ],
152157 });
153158
154159 const merged = yield* executor.graphql.configureAuth("cfg_replace", {
155− authenticationTemplate: [{ kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" }],
160+ authenticationTemplate: [
161+ { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] },
162+ ],
156163 mode: "replace",
157164 });
158165
159− expect(merged.map((m: AuthTemplate) => m.slug)).toEqual(["seed"]);
166+ expect(merged.map((m: GraphqlAuthMethod) => m.slug)).toEqual(["seed"]);
160167 }),
161168 );
162169
packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts+57−8
@@ -22,13 +22,17 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 21 unmodified lines
2222 });
2323
2424 describe("describeGraphqlAuthMethods", () => {
25− it("projects a header apiKey template to one apikey method carrying the header placement", () => {
25+ it("projects a header apikey method carrying the header placement", () => {
2626 const methods = describeGraphqlAuthMethods(
2727 recordWith({
2828 endpoint: "https://x.example/graphql",
2929 name: "x",
3030 authenticationTemplate: [
31− { kind: "apiKey", slug: "api_key", in: "header", name: "X-Api-Key", prefix: "Bearer " },
31+ {
32+ slug: "api_key",
33+ kind: "apikey",
34+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
35+ },
3236 ],
3337 }),
3438 );
@@ -44,12 +48,14 @@ describe("describeGraphqlAuthMethods", () => { · 9 unmodified lines
4448 ]);
4549 });
4650
47− it("projects a query apiKey template to one apikey method carrying the query placement", () => {
51+ it("projects a query apikey method carrying the query placement", () => {
4852 const methods = describeGraphqlAuthMethods(
4953 recordWith({
5054 endpoint: "https://x.example/graphql",
5155 name: "x",
52− authenticationTemplate: [{ kind: "apiKey", slug: "qp", in: "query", name: "api_key" }],
56+ authenticationTemplate: [
57+ { slug: "qp", kind: "apikey", placements: [{ carrier: "query", name: "api_key" }] },
58+ ],
5359 }),
5460 );
5561
@@ -64,13 +70,42 @@ describe("describeGraphqlAuthMethods", () => { · 8 unmodified lines
6470 ]);
6571 });
6672
73+ it("carries multi-placement methods (and their variables) through verbatim", () => {
74+ const methods = describeGraphqlAuthMethods(
75+ recordWith({
76+ endpoint: "https://x.example/graphql",
77+ name: "x",
78+ authenticationTemplate: [
79+ {
80+ slug: "mixed",
81+ kind: "apikey",
82+ placements: [
83+ {
84+ carrier: "header",
85+ name: "Authorization",
86+ prefix: "Bearer ",
87+ variable: "api_token",
88+ },
89+ { carrier: "query", name: "team_id", variable: "team_id" },
90+ ],
91+ },
92+ ],
93+ }),
94+ );
95+
96+ expect(methods[0]?.placements).toEqual([
97+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
98+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
99+ ]);
100+ });
101+
67102 it("defaults the placement prefix to an empty string when unset", () => {
68103 const methods = describeGraphqlAuthMethods(
69104 recordWith({
70105 endpoint: "https://x.example/graphql",
71106 name: "x",
72107 authenticationTemplate: [
73− { kind: "apiKey", slug: "h", in: "header", name: "Authorization" },
108+ { slug: "h", kind: "apikey", placements: [{ carrier: "header", name: "Authorization" }] },
74109 ],
75110 }),
76111 );
@@ -80,7 +115,7 @@ describe("describeGraphqlAuthMethods", () => { · 3 unmodified lines
80115 ]);
81116 });
82117
83− it("projects an oauth2 template to one oauth method", () => {
118+ it("projects an oauth2 method to one oauth descriptor", () => {
84119 const methods = describeGraphqlAuthMethods(
85120 recordWith({
86121 endpoint: "https://x.example/graphql",
@@ -100,14 +135,28 @@ describe("describeGraphqlAuthMethods", () => { · 13 unmodified lines
100135 ]);
101136 });
102137
138+ it("projects a none method to a no-auth descriptor", () => {
139+ const methods = describeGraphqlAuthMethods(
140+ recordWith({
141+ endpoint: "https://x.example/graphql",
142+ name: "x",
143+ authenticationTemplate: [{ slug: "none", kind: "none" }],
144+ }),
145+ );
146+
147+ expect(methods).toEqual([
148+ { id: "none", label: "No authentication", kind: "none", template: "none" },
149+ ]);
150+ });
151+
103152 it("projects every declared method (multi-method specs)", () => {
104153 const methods = describeGraphqlAuthMethods(
105154 recordWith({
106155 endpoint: "https://x.example/graphql",
107156 name: "x",
108157 authenticationTemplate: [
109− { kind: "apiKey", slug: "a", in: "header", name: "X-Api-Key" },
110− { kind: "apiKey", slug: "b", in: "query", name: "token" },
158+ { slug: "a", kind: "apikey", placements: [{ carrier: "header", name: "X-Api-Key" }] },
159+ { slug: "b", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
111160 ],
112161 }),
113162 );
packages/plugins/graphql/src/sdk/index.ts+9−4
@@ -20,17 +20,22 @@ export { · 19 unmodified lines
2020 } from "./errors";
2121
2222 export {
23− ApiKeyAuthTemplate,
24− AuthTemplate,
2523 decodeGraphqlIntegrationConfig,
2624 decodeGraphqlIntegrationConfigOption,
2725 ExtractedField,
2826 ExtractionResult,
2927 GraphqlArgument,
28+ GraphqlAuthMethod,
29+ GraphqlAuthMethodInput,
3030 GraphqlIntegrationConfig,
31+ GraphqlOAuthMethod,
3132 GraphqlOperationKind,
3233 InvocationResult,
33− OAuthAuthTemplate,
34+ normalizeGraphqlAuthMethods,
3435 OperationBinding,
35− type ApiKeyAuthTemplate as ApiKeyAuthTemplateType,
3636 } from "./types";
37+
38+export { migrateGraphqlAuthConfig } from "./migrate-config";
39+
40+// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
41+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
packages/plugins/graphql/src/sdk/migrate-config.test.ts+202−0
@@ -0,0 +1,202 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Option } from "effect";
3+
4+import { migrateGraphqlAuthConfig } from "./migrate-config";
5+import { decodeGraphqlIntegrationConfigOption } from "./types";
6+
7+// ---------------------------------------------------------------------------
8+// The one-off config migration rewrites every pre-canonical GraphQL blob
9+// family into the canonical placements model. Two invariants are load-bearing:
10+// slugs are preserved verbatim (connections bind by `connection.template`)
11+// and variable names are preserved verbatim (they key stored `item_ids`).
12+// ---------------------------------------------------------------------------
13+
14+const BASE = { endpoint: "https://x.example/graphql", name: "x" } as const;
15+
16+const decodes = (config: unknown): boolean =>
17+ Option.isSome(decodeGraphqlIntegrationConfigOption(config));
18+
19+describe("migrateGraphqlAuthConfig", () => {
20+ it("lifts the v1→v2 singular `auth: {kind:'none'}` into a one-method template, slug = kind", () => {
21+ const migrated = migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "none" } });
22+ expect(migrated).toEqual({
23+ ...BASE,
24+ authenticationTemplate: [{ slug: "none", kind: "none" }],
25+ });
26+ expect(decodes(migrated)).toBe(true);
27+ });
28+
29+ it("lifts a singular `auth: {kind:'oauth2'}` (the v1→v2 migration's output)", () => {
30+ expect(migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "oauth2" } })).toEqual({
31+ ...BASE,
32+ authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }],
33+ });
34+ });
35+
36+ it("treats a config with no auth fields at all as an open endpoint", () => {
37+ expect(migrateGraphqlAuthConfig({ ...BASE })).toEqual({
38+ ...BASE,
39+ authenticationTemplate: [{ slug: "none", kind: "none" }],
40+ });
41+ });
42+
43+ it("rewrites the retired native `{kind:'apiKey', in, name}` shape, slugs preserved", () => {
44+ const migrated = migrateGraphqlAuthConfig({
45+ ...BASE,
46+ authenticationTemplate: [
47+ { kind: "apiKey", slug: "header", in: "header", name: "X-Api-Key", prefix: "Bearer " },
48+ { kind: "apiKey", slug: "custom_q1", in: "query", name: "token" },
49+ { kind: "oauth2", slug: "oauth2" },
50+ ],
51+ });
52+ expect(migrated).toEqual({
53+ ...BASE,
54+ authenticationTemplate: [
55+ {
56+ slug: "header",
57+ kind: "apikey",
58+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
59+ },
60+ {
61+ slug: "custom_q1",
62+ kind: "apikey",
63+ placements: [{ carrier: "query", name: "token" }],
64+ },
65+ { kind: "oauth2", slug: "oauth2" },
66+ ],
67+ });
68+ expect(decodes(migrated)).toBe(true);
69+ });
70+
71+ it("rewrites openapi-shaped entries the v1→v2 migration wrote, variables preserved", () => {
72+ // Exactly what `migrateOpenApiAuthTemplate` emits for a multi-input source.
73+ const migrated = migrateGraphqlAuthConfig({
74+ ...BASE,
75+ authenticationTemplate: [
76+ {
77+ slug: "apiKey",
78+ type: "apiKey",
79+ headers: {
80+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
81+ Authorization: ["Bearer ", { type: "variable", name: "dd_application_key" }],
82+ },
83+ queryParams: { team: [{ type: "variable", name: "team" }] },
84+ },
85+ { slug: "github_oauth", type: "oauth" },
86+ ],
87+ });
88+ expect(migrated).toEqual({
89+ ...BASE,
90+ authenticationTemplate: [
91+ {
92+ slug: "apiKey",
93+ kind: "apikey",
94+ placements: [
95+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
96+ {
97+ carrier: "header",
98+ name: "Authorization",
99+ prefix: "Bearer ",
100+ variable: "dd_application_key",
101+ },
102+ { carrier: "query", name: "team", variable: "team" },
103+ ],
104+ },
105+ { slug: "github_oauth", kind: "oauth2" },
106+ ],
107+ });
108+ expect(decodes(migrated)).toBe(true);
109+ });
110+
111+ it("stores the canonical `token` variable as absent and literal-only values as literals", () => {
112+ const migrated = migrateGraphqlAuthConfig({
113+ ...BASE,
114+ authenticationTemplate: [
115+ {
116+ slug: "apiKey",
117+ type: "apiKey",
118+ headers: {
119+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
120+ "X-Api-Version": "2023-01-01",
121+ },
122+ },
123+ ],
124+ }) as { authenticationTemplate: readonly unknown[] };
125+ expect(migrated.authenticationTemplate[0]).toEqual({
126+ slug: "apiKey",
127+ kind: "apikey",
128+ placements: [
129+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
130+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
131+ ],
132+ });
133+ });
134+
135+ it("folds a singular oauth2 `auth` alongside apiKey entries into the declared set", () => {
136+ // The v1→v2 migration wrote BOTH when a source had placements and oauth.
137+ const migrated = migrateGraphqlAuthConfig({
138+ ...BASE,
139+ auth: { kind: "oauth2" },
140+ authenticationTemplate: [
141+ {
142+ slug: "apiKey",
143+ type: "apiKey",
144+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
145+ },
146+ ],
147+ });
148+ expect(migrated).toEqual({
149+ ...BASE,
150+ authenticationTemplate: [
151+ {
152+ slug: "apiKey",
153+ kind: "apikey",
154+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
155+ },
156+ { slug: "oauth2", kind: "oauth2" },
157+ ],
158+ });
159+ expect(decodes(migrated)).toBe(true);
160+ });
161+
162+ it("is idempotent — a canonical config returns null (no rewrite)", () => {
163+ const canonical = {
164+ ...BASE,
165+ authenticationTemplate: [
166+ { slug: "oauth2", kind: "oauth2" },
167+ {
168+ slug: "header",
169+ kind: "apikey",
170+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
171+ },
172+ { slug: "none", kind: "none" },
173+ ],
174+ };
175+ expect(migrateGraphqlAuthConfig(canonical)).toBeNull();
176+ // And the lifted output of every family is itself canonical.
177+ const lifted = migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "oauth2" } });
178+ expect(migrateGraphqlAuthConfig(lifted)).toBeNull();
179+ const liftedBoth = migrateGraphqlAuthConfig({
180+ ...BASE,
181+ auth: { kind: "oauth2" },
182+ authenticationTemplate: [{ kind: "apiKey", slug: "header", in: "header", name: "X-Api-Key" }],
183+ });
184+ expect(migrateGraphqlAuthConfig(liftedBoth)).toBeNull();
185+ });
186+
187+ it("leaves foreign and unmigratable blobs untouched", () => {
188+ // MCP remote configs also carry an endpoint — `transport` marks them.
189+ expect(
190+ migrateGraphqlAuthConfig({ transport: "remote", endpoint: "https://x.example/mcp" }),
191+ ).toBeNull();
192+ expect(migrateGraphqlAuthConfig({ not: "graphql" })).toBeNull();
193+ expect(migrateGraphqlAuthConfig(null)).toBeNull();
194+ expect(
195+ migrateGraphqlAuthConfig({
196+ ...BASE,
197+ authenticationTemplate: [{ slug: "x", kind: "mystery" }],
198+ }),
199+ ).toBeNull();
200+ expect(migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "mystery" } })).toBeNull();
201+ });
202+});
packages/plugins/graphql/src/sdk/migrate-config.ts+148−0
@@ -0,0 +1,148 @@
1+// ---------------------------------------------------------------------------
2+// One-off config migration — rewrite pre-canonical GraphQL integration
3+// `config` blobs into the canonical placements model. Runs once per database
4+// (boot for local/selfhost, out-of-band script for cloud); runtime code
5+// decodes ONLY the canonical shape (`decodeGraphqlIntegrationConfig*`).
6+//
7+// Stored legacy families (all observed in real databases):
8+// 1. the v1→v2 migration's singular `auth: {kind:"none"|"oauth2"}` (the
9+// graphql config had no `authenticationTemplate` unless v1 carried
10+// credential placements), or a config with no auth fields at all
11+// 2. `authenticationTemplate` entries of the retired native shape:
12+// `{kind:"apiKey", slug, in:"header"|"query", name, prefix?}`
13+// 3. openapi-shaped entries the v1→v2 migration wrote:
14+// `{slug, type:"apiKey", headers/queryParams: <template values>}` and
15+// `{slug, type:"oauth", …}`
16+//
17+// Invariants: slugs are preserved verbatim (`connection.template` binds by
18+// them) and so are variable names (they key the connections' stored
19+// `item_ids`).
20+// ---------------------------------------------------------------------------
21+
22+import { Option, Schema } from "effect";
23+import {
24+ apiKeyMethodFromLegacyTemplate,
25+ decodeLegacyApiKeyTemplate,
26+} from "@executor-js/sdk/http-auth";
27+
28+import { decodeGraphqlIntegrationConfigOption, type GraphqlAuthMethod } from "./types";
29+
30+const LegacySingleAuth = Schema.Union([
31+ Schema.Struct({ kind: Schema.Literal("none") }),
32+ Schema.Struct({ kind: Schema.Literal("oauth2") }),
33+]);
34+const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth);
35+
36+const LegacyNativeApiKey = Schema.Struct({
37+ slug: Schema.String,
38+ kind: Schema.Literal("apiKey"),
39+ in: Schema.Literals(["header", "query"]),
40+ name: Schema.String,
41+ prefix: Schema.optional(Schema.String),
42+});
43+const LegacyOAuthTemplate = Schema.Struct({
44+ slug: Schema.String,
45+ type: Schema.Literal("oauth"),
46+});
47+const decodeLegacyNativeApiKey = Schema.decodeUnknownOption(LegacyNativeApiKey);
48+const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate);
49+
50+const CanonicalMethod = Schema.decodeUnknownOption(
51+ Schema.Union([
52+ Schema.Struct({ slug: Schema.String, kind: Schema.Literals(["none", "oauth2"]) }),
53+ Schema.Struct({
54+ slug: Schema.String,
55+ kind: Schema.Literal("apikey"),
56+ label: Schema.optional(Schema.String),
57+ placements: Schema.Array(Schema.Unknown),
58+ }),
59+ ]),
60+);
61+
62+/** Rewrite one stored method entry to canonical, or null when undecodable. */
63+const migrateMethodEntry = (entry: unknown): GraphqlAuthMethod | null => {
64+ const canonical = Option.getOrNull(CanonicalMethod(entry));
65+ if (canonical !== null) return entry as GraphqlAuthMethod;
66+
67+ const native = Option.getOrNull(decodeLegacyNativeApiKey(entry));
68+ if (native !== null) {
69+ return {
70+ slug: native.slug,
71+ kind: "apikey",
72+ placements: [
73+ {
74+ carrier: native.in,
75+ name: native.name,
76+ ...(native.prefix !== undefined ? { prefix: native.prefix } : {}),
77+ },
78+ ],
79+ };
80+ }
81+
82+ const legacyApiKey = Option.getOrNull(decodeLegacyApiKeyTemplate(entry));
83+ if (legacyApiKey !== null) return apiKeyMethodFromLegacyTemplate(legacyApiKey);
84+
85+ const legacyOAuth = Option.getOrNull(decodeLegacyOAuthTemplate(entry));
86+ if (legacyOAuth !== null) return { slug: legacyOAuth.slug, kind: "oauth2" };
87+
88+ return null;
89+};
90+
91+const parseConfig = (config: unknown): unknown | null =>
92+ Option.isSome(decodeGraphqlIntegrationConfigOption(config)) ? config : null;
93+
94+/** Rewrite a stored GraphQL integration config blob into the canonical shape.
95+ * Returns the rewritten config, or `null` when no rewrite is needed (already
96+ * canonical, or not this plugin's shape at all). Throws nothing: an
97+ * unmigratable blob also returns `null` and stays untouched. Idempotent —
98+ * feeding the output back returns `null`. */
99+export const migrateGraphqlAuthConfig = (config: unknown): unknown | null => {
100+ if (typeof config !== "object" || config === null) return null;
101+ // A GraphQL config is keyed by its endpoint; `transport` marks MCP blobs.
102+ if (!("endpoint" in config) || typeof config.endpoint !== "string") return null;
103+ if ("transport" in config) return null;
104+
105+ const legacyAuth =
106+ "auth" in config
107+ ? Option.getOrNull(decodeLegacySingleAuth((config as { auth: unknown }).auth))
108+ : null;
109+
110+ if (!("authenticationTemplate" in config)) {
111+ // Family 1: singular `auth`, or an open endpoint with no auth field.
112+ if ("auth" in config && legacyAuth === null) return null;
113+ const kind = legacyAuth?.kind ?? "none";
114+ const { auth: _auth, ...rest } = config as { auth?: unknown };
115+ const migrated = { ...rest, authenticationTemplate: [{ slug: kind, kind }] };
116+ return parseConfig(migrated);
117+ }
118+
119+ const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate;
120+ if (!Array.isArray(entries)) return null;
121+
122+ const migrated = entries.map(migrateMethodEntry);
123+ if (migrated.some((entry) => entry === null)) return null;
124+ const methods = migrated as GraphqlAuthMethod[];
125+
126+ // The v1→v2 migration wrote BOTH `auth: {kind:"oauth2"}` and an apiKey
127+ // `authenticationTemplate` when a source had credential placements and
128+ // oauth; fold the singular auth into the declared set (slug = kind, the
129+ // slug the connection migration bound oauth connections to).
130+ if (
131+ legacyAuth?.kind === "oauth2" &&
132+ !methods.some((method: GraphqlAuthMethod) => method.kind === "oauth2")
133+ ) {
134+ methods.push({ slug: "oauth2", kind: "oauth2" });
135+ }
136+
137+ const entriesChanged =
138+ methods.length !== entries.length ||
139+ methods.some((entry: GraphqlAuthMethod, index: number) => entry !== entries[index]);
140+ if (!entriesChanged && legacyAuth === null) return null;
141+
142+ const { auth: _auth, ...withoutAuth } = config as { auth?: unknown };
143+ const next = {
144+ ...(legacyAuth !== null ? withoutAuth : config),
145+ authenticationTemplate: methods,
146+ };
147+ return parseConfig(next);
148+};
packages/plugins/graphql/src/sdk/multi-placement-auth.test.ts+206−0
@@ -0,0 +1,206 @@
1+// ---------------------------------------------------------------------------
2+// GraphQL multi-placement auth (wire-level)
3+//
4+// The extreme case the unified model exists for: ONE auth method that mixes a
5+// bearer HEADER and a team-id QUERY PARAM, each rendered from its own
6+// credential input — plus the single-placement query case, and the explicit
7+// failure when an input is missing. Assertions are made against what the
8+// server actually RECEIVED (connect-time introspection and tool invocation).
9+// ---------------------------------------------------------------------------
10+
11+import { describe, expect, it } from "@effect/vitest";
12+import { Effect } from "effect";
13+
14+import {
15+ AuthTemplateSlug,
16+ ConnectionName,
17+ IntegrationSlug,
18+ ToolAddress,
19+ createExecutor,
20+} from "@executor-js/sdk";
21+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
22+
23+import { graphqlPlugin } from "./plugin";
24+import { variable } from "@executor-js/sdk/http-auth";
25+import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
26+
27+const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
28+
29+const makeExecutor = () =>
30+ createExecutor(
31+ makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
32+ );
33+
34+const toolAddr = (integration: string, connection: string, tool: string): ToolAddress =>
35+ ToolAddress.make(`tools.${integration}.org.${connection}.${tool}`);
36+
37+describe("GraphQL multi-placement auth", () => {
38+ it.effect("the request-shaped authoring dialect lands identically on the wire", () =>
39+ Effect.gen(function* () {
40+ const server = yield* serveGreetingServer;
41+ const executor = yield* makeExecutor();
42+
43+ yield* executor.graphql.addIntegration({
44+ endpoint: server.endpoint,
45+ slug: "authored_gql",
46+ name: "Authored GraphQL",
47+ authenticationTemplate: [
48+ {
49+ slug: "token_and_team",
50+ type: "apiKey",
51+ headers: { Authorization: ["Bearer ", variable("api_token")] },
52+ queryParams: { team_id: [variable("team_id")] },
53+ },
54+ ],
55+ });
56+
57+ yield* executor.connections.create({
58+ owner: "org",
59+ name: ConnectionName.make("main"),
60+ integration: IntegrationSlug.make("authored_gql"),
61+ template: AuthTemplateSlug.make("token_and_team"),
62+ values: { api_token: "tok_A", team_id: "team_B" },
63+ });
64+
65+ const requests = yield* server.requests;
66+ expect(requests.length).toBeGreaterThan(0);
67+ const last = requests[requests.length - 1]!;
68+ expect(last.headers["authorization"]).toBe("Bearer tok_A");
69+ expect(last.url.includes("team_id=team_B")).toBe(true);
70+ }),
71+ );
72+
73+ it.effect("one method renders a bearer header AND a team-id query param", () =>
74+ Effect.gen(function* () {
75+ const server = yield* serveGreetingServer;
76+ const executor = yield* makeExecutor();
77+
78+ yield* executor.graphql.addIntegration({
79+ endpoint: server.endpoint,
80+ slug: "mixed_gql",
81+ name: "Mixed GraphQL",
82+ authenticationTemplate: [
83+ {
84+ slug: "token_and_team",
85+ kind: "apikey",
86+ placements: [
87+ {
88+ carrier: "header",
89+ name: "Authorization",
90+ prefix: "Bearer ",
91+ variable: "api_token",
92+ },
93+ { carrier: "query", name: "team_id", variable: "team_id" },
94+ ],
95+ },
96+ ],
97+ });
98+
99+ yield* executor.connections.create({
100+ owner: "org",
101+ name: ConnectionName.make("main"),
102+ integration: IntegrationSlug.make("mixed_gql"),
103+ template: AuthTemplateSlug.make("token_and_team"),
104+ values: { api_token: "tok_A", team_id: "team_B" },
105+ });
106+
107+ // Connect-time introspection runs WITH the connection's rendered auth —
108+ // both carriers, each from its own credential input.
109+ const afterConnect = yield* server.requests;
110+ const introspections = afterConnect.filter((request) =>
111+ request.payload.query?.includes("__schema"),
112+ );
113+ expect(introspections.length).toBeGreaterThan(0);
114+ expect(introspections.every((r) => r.headers.authorization === "Bearer tok_A")).toBe(true);
115+ expect(introspections.every((r) => r.url.includes("team_id=team_B"))).toBe(true);
116+
117+ yield* server.clearRequests;
118+
119+ const result = yield* executor.execute(toolAddr("mixed_gql", "main", "query.hello"), {
120+ name: "Ada",
121+ });
122+ expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } });
123+
124+ const requests = yield* server.requests;
125+ expect(requests.length).toBeGreaterThan(0);
126+ expect(requests.every((r) => r.headers.authorization === "Bearer tok_A")).toBe(true);
127+ expect(requests.every((r) => r.url.includes("team_id=team_B"))).toBe(true);
128+ }),
129+ );
130+
131+ it.effect("a query-only method renders ?token= and no Authorization header", () =>
132+ Effect.gen(function* () {
133+ const server = yield* serveGreetingServer;
134+ const executor = yield* makeExecutor();
135+
136+ yield* executor.graphql.addIntegration({
137+ endpoint: server.endpoint,
138+ slug: "query_gql",
139+ name: "Query GraphQL",
140+ authenticationTemplate: [
141+ { kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
142+ ],
143+ });
144+
145+ // Slug-less single-query-placement methods get the `query` slug.
146+ yield* executor.connections.create({
147+ owner: "org",
148+ name: ConnectionName.make("main"),
149+ integration: IntegrationSlug.make("query_gql"),
150+ template: AuthTemplateSlug.make("query"),
151+ value: "tok_secret_123",
152+ });
153+
154+ yield* server.clearRequests;
155+
156+ const result = yield* executor.execute(toolAddr("query_gql", "main", "query.hello"), {
157+ name: "Ada",
158+ });
159+ expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } });
160+
161+ const requests = yield* server.requests;
162+ expect(requests.length).toBeGreaterThan(0);
163+ expect(requests.every((r) => r.url.includes("token=tok_secret_123"))).toBe(true);
164+ expect(requests.every((r) => r.headers.authorization === undefined)).toBe(true);
165+ }),
166+ );
167+
168+ it.effect("invoking with a missing credential input fails explicitly, not silently", () =>
169+ Effect.gen(function* () {
170+ const server = yield* serveGreetingServer;
171+ const executor = yield* makeExecutor();
172+
173+ yield* executor.graphql.addIntegration({
174+ endpoint: server.endpoint,
175+ slug: "strict_gql",
176+ name: "Strict GraphQL",
177+ authenticationTemplate: [
178+ {
179+ slug: "two_inputs",
180+ kind: "apikey",
181+ placements: [
182+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "a" },
183+ { carrier: "query", name: "team_id", variable: "b" },
184+ ],
185+ },
186+ ],
187+ });
188+
189+ // Only one of the two inputs is supplied.
190+ yield* executor.connections.create({
191+ owner: "org",
192+ name: ConnectionName.make("half"),
193+ integration: IntegrationSlug.make("strict_gql"),
194+ template: AuthTemplateSlug.make("two_inputs"),
195+ values: { a: "tok_A" },
196+ });
197+
198+ const result = (yield* executor.execute(toolAddr("strict_gql", "half", "query.hello"), {
199+ name: "Ada",
200+ })) as { ok: boolean; error?: { code?: string; message?: string } };
201+ expect(result.ok).toBe(false);
202+ expect(result.error).toMatchObject({ code: "connection_value_missing" });
203+ expect(String(result.error?.message ?? "")).toContain("b");
204+ }),
205+ );
206+});
packages/plugins/graphql/src/sdk/plugin.test.ts+3−5
@@ -328,7 +328,7 @@ describe("graphqlPlugin real protocol server", () => { · 327 unmodified lines
328328 slug: "live_invoke",
329329 queryParams: { trace: "on" },
330330 authenticationTemplate: [
331− { kind: "apiKey", slug: "header", in: "header", name: "x-static" },
331+ { slug: "header", kind: "apikey", placements: [{ carrier: "header", name: "x-static" }] },
332332 ],
333333 });
334334 yield* createOrgConnection(executor, {
@@ -768,11 +768,9 @@ describe("graphqlPlugin", () => { · 433 unmodified lines
768768 introspectionJson,
769769 authenticationTemplate: [
770770 {
771− kind: "apiKey",
772771 slug: "header",
773− in: "header",
774− name: "Authorization",
775− prefix: "Bearer ",
772+ kind: "apikey",
773+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
776774 },
777775 ],
778776 });
packages/plugins/graphql/src/sdk/plugin.ts+107−96
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
1313 ToolName,
1414 ToolResult,
1515 type AuthMethodDescriptor,
16− type AuthPlacementDescriptor,
1716 type IntegrationConfig,
1817 type IntegrationRecord,
1918 type PluginCtx,
@@ -22,6 +21,16 @@ import { · 2 unmodified lines
2221 type ToolDef,
2322 } from "@executor-js/sdk/core";
2423
24+import {
25+ TOKEN_VARIABLE,
26+ describeApiKeyAuthMethod,
27+ describeNoneAuthMethod,
28+ oauthBearerPlacement,
29+ renderAuthPlacements,
30+ requiredPlacementVariables,
31+ type RenderedAuthPlacements,
32+} from "@executor-js/sdk/http-auth";
33+
2534 import {
2635 introspect,
2736 parseIntrospectionJson,
@@ -40,12 +49,15 @@ import { invokeWithLayer } from "./invoke"; · 12 unmodified lines
4049 import { graphqlPresets } from "./presets";
4150 import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store";
4251 import {
43− AuthTemplate,
52+ GraphqlAuthMethodInput,
4453 decodeGraphqlIntegrationConfig,
4554 decodeGraphqlIntegrationConfigOption,
4655 ExtractedField,
4756 GraphqlIntegrationConfig,
57+ expandGraphqlAuthMethodInputs,
58+ normalizeGraphqlAuthMethods,
4859 OperationBinding,
60+ type GraphqlAuthMethod,
4961 type GraphqlOperationKind,
5062 } from "./types";
5163
@@ -72,8 +84,6 @@ const GRAPHQL_PLUGIN_ID = "graphql"; · 20 unmodified lines
7284 // Extension input shapes
7385 // ---------------------------------------------------------------------------
7486
75−const AuthTemplateSchema = AuthTemplate;
76−
7787 /** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL;
7888 * `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson`
7989 * supplies the schema when the endpoint disables live introspection; `headers`
@@ -87,7 +97,7 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 7 unmodified lines
8797 introspectionJson: Schema.optional(Schema.String),
8898 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
8999 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
90− authenticationTemplate: Schema.optional(Schema.Array(AuthTemplateSchema)),
100+ authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
91101 });
92102 export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type;
93103
@@ -96,17 +106,17 @@ const GraphqlConfigureInputSchema = Schema.Struct({ · 2 unmodified lines
96106 endpoint: Schema.optional(Schema.String),
97107 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
98108 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
99− authenticationTemplate: Schema.optional(Schema.Array(AuthTemplateSchema)),
109+ authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
100110 });
101111 export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type;
102112
103113 /** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/
104114 * :slug/config`). Unlike `configure` (which REPLACES the whole config for the
105− * generic repair path), `configureAuth` MERGE-APPENDS these templates onto the
115+ * generic repair path), `configureAuth` MERGE-APPENDS these methods onto the
106116 * integration's existing `authenticationTemplate`, mirroring OpenAPI's
107117 * `configure`. */
108118 const GraphqlConfigureAuthInputSchema = Schema.Struct({
109− authenticationTemplate: Schema.Array(AuthTemplateSchema),
119+ authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),
110120 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
111121 });
112122 export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type;
@@ -337,26 +347,21 @@ const annotationsFor = (binding: OperationBinding): ToolAnnotations => { · 224 unmodified lines
337347 };
338348
339349 // ---------------------------------------------------------------------------
340−// Auth template rendering (D11) — apply the resolved credential value through
341−// the template the connection references, exactly like an apiKey bearer.
350+// Auth method rendering (D11) — apply the connection's resolved values through
351+// the method the connection references. An oauth2 method is the conventional
352+// bearer placement (with the method's optional header/prefix override) over
353+// the resolved access token; an apikey method renders its declared placements.
342354 // ---------------------------------------------------------------------------
343355
344−interface RenderedAuth {
345− readonly headers: Record<string, string>;
346− readonly queryParams: Record<string, string>;
347−}
348−
349−const renderAuthTemplate = (template: AuthTemplate, value: string): RenderedAuth => {
350− if (template.kind === "oauth2") {
351− const header = template.header ?? "Authorization";
352− const prefix = template.prefix ?? "Bearer ";
353− return { headers: { [header]: `${prefix}${value}` }, queryParams: {} };
354− }
355− const rendered = template.prefix ? `${template.prefix}${value}` : value;
356− if (template.in === "query") {
357− return { headers: {}, queryParams: { [template.name]: rendered } };
356+const renderGraphqlAuthMethod = (
357+ method: GraphqlAuthMethod,
358+ values: Record<string, string | null>,
359+): RenderedAuthPlacements => {
360+ if (method.kind === "apikey") return renderAuthPlacements(method.placements, values);
361+ if (method.kind === "oauth2") {
362+ return renderAuthPlacements([oauthBearerPlacement(method.header, method.prefix)], values);
358363 }
359− return { headers: { [template.name]: rendered }, queryParams: {} };
364+ return { headers: {}, queryParams: {} };
360365 };
361366
362367 // ---------------------------------------------------------------------------
@@ -387,23 +392,23 @@ const toStoredOperations = ( · 24 unmodified lines
387392 * auth-required endpoint introspects successfully here rather than at add-time. */
388393 const introspectHeadersForConnection = (
389394 config: GraphqlIntegrationConfig,
390− credentialValue: string | null,
395+ values: Record<string, string | null>,
391396 templateSlug: AuthTemplateSlug | null,
392−): RenderedAuth => {
397+): RenderedAuthPlacements => {
393398 const headers: Record<string, string> = { ...(config.headers ?? {}) };
394399 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
395− if (credentialValue !== null) {
396− // Render the exact template the connection references; with no slug
397− // (connection row not yet persisted) fall back to the first declared.
398− const template =
399− (templateSlug !== null
400− ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug))
401− : undefined) ?? config.authenticationTemplate[0];
402− if (template) {
403− const rendered = renderAuthTemplate(template, credentialValue);
404− Object.assign(headers, rendered.headers);
405− Object.assign(queryParams, rendered.queryParams);
406− }
400+ // Render the exact method the connection references; with no slug
401+ // (connection row not yet persisted) fall back to the first declared.
402+ const method =
403+ (templateSlug !== null
404+ ? config.authenticationTemplate.find(
405+ (m: GraphqlAuthMethod) => m.slug === String(templateSlug),
406+ )
407+ : undefined) ?? config.authenticationTemplate[0];
408+ if (method) {
409+ const rendered = renderGraphqlAuthMethod(method, values);
410+ Object.assign(headers, rendered.headers);
411+ Object.assign(queryParams, rendered.queryParams);
407412 }
408413 return { headers, queryParams };
409414 };
@@ -413,14 +418,14 @@ const introspectHeadersForConnection = ( · 3 unmodified lines
413418 * present; otherwise this introspects the endpoint with the rendered credential. */
414419 const introspectForConnection = (
415420 config: GraphqlIntegrationConfig,
416− credentialValue: string | null,
421+ values: Record<string, string | null>,
417422 templateSlug: AuthTemplateSlug | null,
418423 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
419424 ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {
420425 if (config.introspectionJson) {
421426 return parseIntrospectionJson(config.introspectionJson);
422427 }
423− const auth = introspectHeadersForConnection(config, credentialValue, templateSlug);
428+ const auth = introspectHeadersForConnection(config, values, templateSlug);
424429 return introspect(
425430 config.endpoint,
426431 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,
@@ -438,22 +443,22 @@ const materializeOperations = ( · 11 unmodified lines
438443 config: GraphqlIntegrationConfig,
439444 credential: {
440445 readonly template: AuthTemplateSlug;
441− readonly value: string | null;
446+ readonly values: Record<string, string | null>;
442447 },
443448 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
444449 ): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>
445450 Effect.gen(function* () {
446− // Render the exact template this connection references (we have its slug
451+ // Render the exact method this connection references (we have its slug
447452 // here, unlike `resolveTools`) so an auth-required endpoint introspects.
448− const template = config.authenticationTemplate.find(
449− (t: AuthTemplate) => t.slug === String(credential.template),
453+ const method = config.authenticationTemplate.find(
454+ (m: GraphqlAuthMethod) => m.slug === String(credential.template),
450455 );
451456 const headers: Record<string, string> = { ...(config.headers ?? {}) };
452457 const queryParams: Record<string, string> = {
453458 ...(config.queryParams ?? {}),
454459 };
455− if (template && credential.value !== null) {
456− const rendered = renderAuthTemplate(template, credential.value);
460+ if (method) {
461+ const rendered = renderGraphqlAuthMethod(method, credential.values);
457462 Object.assign(headers, rendered.headers);
458463 Object.assign(queryParams, rendered.queryParams);
459464 }
@@ -489,44 +494,29 @@ const materializeOperations = ( · 29 unmodified lines
489494 // through the catalog's `authMethods` to the hub / Add-account flows. Exported
490495 // for tests.
491496 //
492−// apiKey → one apikey method carrying a single header/query placement (the
493−// `name` from the template, with the template's `prefix` defaulted to
494−// `""`).
497+// none → a no-auth method carrying no credential inputs
498+// apikey → carried placements (headers / query params) verbatim
495499 // oauth2 → one oauth method (no resolved endpoints; graphql renders the
496500 // connection value as a bearer at invoke time).
497501 // ---------------------------------------------------------------------------
498502
499−const graphqlApiKeyLabel = (placement: AuthPlacementDescriptor): string =>
500− `API key (${placement.name || (placement.carrier === "header" ? "header" : "query")})`;
501−
502503 export const describeGraphqlAuthMethods = (
503504 record: IntegrationRecord,
504505 ): readonly AuthMethodDescriptor[] => {
505506 const config = Option.getOrUndefined(decodeGraphqlIntegrationConfigOption(record.config));
506507 if (!config) return [];
507− return config.authenticationTemplate.map((template: AuthTemplate): AuthMethodDescriptor => {
508− const slug = template.slug;
509− if (template.kind === "oauth2") {
508+ return config.authenticationTemplate.map((method: GraphqlAuthMethod): AuthMethodDescriptor => {
509+ if (method.kind === "apikey") return describeApiKeyAuthMethod(method);
510+ if (method.kind === "oauth2") {
510511 return {
511− id: slug,
512+ id: method.slug,
512513 label: "OAuth",
513514 kind: "oauth",
514− template: slug,
515+ template: method.slug,
515516 oauth: {},
516517 };
517518 }
518− const placement: AuthPlacementDescriptor = {
519− carrier: template.in,
520− name: template.name,
521− prefix: template.prefix ?? "",
522− };
523− return {
524− id: slug,
525− label: graphqlApiKeyLabel(placement),
526− kind: "apikey",
527− template: slug,
528− placements: [placement],
529− };
519+ return describeNoneAuthMethod(method.slug);
530520 });
531521 };
532522
@@ -555,7 +545,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 22 unmodified lines
555545 : {}),
556546 ...(input.headers !== undefined ? { headers: input.headers } : {}),
557547 ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}),
558− authenticationTemplate: input.authenticationTemplate ?? [],
548+ authenticationTemplate: input.authenticationTemplate
549+ ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
550+ : [],
559551 });
560552
561553 /** Register the integration in the catalog. Registering a source is a
@@ -663,7 +655,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 101 unmodified lines
663655 ...((input.queryParams ?? current.queryParams) !== undefined
664656 ? { queryParams: input.queryParams ?? current.queryParams }
665657 : {}),
666− authenticationTemplate: input.authenticationTemplate ?? current.authenticationTemplate,
658+ authenticationTemplate: input.authenticationTemplate
659+ ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
660+ : current.authenticationTemplate,
667661 });
668662
669663 yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
@@ -691,18 +685,27 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines
691685 const configureAuthMethods = (
692686 slug: string,
693687 input: GraphqlConfigureAuthInput,
694− ): Effect.Effect<readonly AuthTemplate[], StorageFailure> =>
688+ ): Effect.Effect<readonly GraphqlAuthMethod[], StorageFailure> =>
695689 ctx.transaction(
696690 Effect.gen(function* () {
697691 const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
698− if (!record) return [] as readonly AuthTemplate[];
692+ if (!record) return [] as readonly GraphqlAuthMethod[];
699693 const current = Option.getOrNull(decodeGraphqlIntegrationConfigOption(record.config));
700− if (!current) return [] as readonly AuthTemplate[];
694+ if (!current) return [] as readonly GraphqlAuthMethod[];
701695
696+ // Replace mode declares the full set — backfill kind-based slugs.
697+ // Merge mode appends: `mergeAuthTemplates` replaces on slug match and
698+ // assigns fresh `custom_<id>` slugs to slug-less entries, so a custom
699+ // method never silently displaces a declared one.
702700 const merged =
703701 input.mode === "replace"
704− ? input.authenticationTemplate
705− : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate);
702+ ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
703+ : mergeAuthTemplates(
704+ current.authenticationTemplate,
705+ expandGraphqlAuthMethodInputs(
706+ input.authenticationTemplate,
707+ ) as readonly GraphqlAuthMethod[],
708+ );
706709
707710 const next = GraphqlIntegrationConfig.make({
708711 ...current,
@@ -849,25 +852,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 140 unmodified lines
849852 resolveTools: ({
850853 config,
851854 template,
852− getValue,
855+ getValues,
853856 }: {
854857 readonly config: IntegrationConfig;
855858 readonly template: AuthTemplateSlug | null;
856− readonly getValue: () => Effect.Effect<string | null, unknown>;
859+ readonly getValues: () => Effect.Effect<Record<string, string | null>, unknown>;
857860 }) =>
858861 Effect.gen(function* () {
859862 const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);
860863 if (Option.isNone(decoded)) return { tools: [] };
861864 const graphqlConfig = decoded.value;
862865 // Live introspection (no stored snapshot) needs the connection's
863− // credential for auth-required endpoints; resolve it lazily.
864− const credentialValue =
866+ // credential inputs for auth-required endpoints; resolve them lazily.
867+ const values =
865868 graphqlConfig.introspectionJson === undefined
866− ? yield* getValue().pipe(Effect.catch(() => Effect.succeed(null)))
867− : null;
869+ ? yield* getValues().pipe(
870+ Effect.catch(() => Effect.succeed({} as Record<string, string | null>)),
871+ )
872+ : ({} as Record<string, string | null>);
868873 const introspection = yield* introspectForConnection(
869874 graphqlConfig,
870− credentialValue,
875+ values,
871876 template,
872877 options?.httpClientLayer ?? httpClientLayerFallback,
873878 ).pipe(Effect.option);
@@ -929,29 +934,35 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 55 unmodified lines
929934 ...(config.queryParams ?? {}),
930935 };
931936
932− const template = config.authenticationTemplate.find(
933− (t: AuthTemplate) => t.slug === String(credential.template),
937+ const method = config.authenticationTemplate.find(
938+ (m: GraphqlAuthMethod) => m.slug === String(credential.template),
934939 );
935− if (template) {
936− if (credential.value === null) {
940+ if (method && method.kind !== "none") {
941+ // A method with unresolved inputs fails the invocation explicitly
942+ // instead of dialing unauthenticated. oauth2 requires the resolved
943+ // access token (`token`); apikey requires every placement variable.
944+ const missing = (
945+ method.kind === "oauth2"
946+ ? [TOKEN_VARIABLE]
947+ : requiredPlacementVariables(method.placements)
948+ ).filter((variable) => credential.values[variable] == null);
949+ if (missing.length > 0) {
937950 return yield* new GraphqlAuthRequiredError({
938951 code:
939− template.kind === "oauth2"
940− ? "oauth_connection_missing"
941− : "connection_value_missing",
952+ method.kind === "oauth2" ? "oauth_connection_missing" : "connection_value_missing",
942953 message:
943− template.kind === "oauth2"
954+ method.kind === "oauth2"
944955 ? `Missing OAuth connection value for GraphQL integration "${integration}" (connection "${credential.connection}")`
945− : `Missing credential value for GraphQL integration "${integration}" (connection "${credential.connection}")`,
956+ : `Missing credential value for GraphQL integration "${integration}" (connection "${credential.connection}") for input(s): ${missing.join(", ")}`,
946957 owner: credential.owner,
947958 integration,
948959 connection: String(credential.connection),
949− credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
950− credentialLabel: template.kind === "oauth2" ? "OAuth sign-in" : "API key",
960+ credentialKind: method.kind === "oauth2" ? "oauth" : "secret",
961+ credentialLabel: method.kind === "oauth2" ? "OAuth sign-in" : "API key",
951962 template: String(credential.template),
952963 });
953964 }
954− const rendered = renderAuthTemplate(template, credential.value);
965+ const rendered = renderGraphqlAuthMethod(method, credential.values);
955966 Object.assign(headers, rendered.headers);
956967 Object.assign(queryParams, rendered.queryParams);
957968 }
packages/plugins/graphql/src/sdk/types.ts+101−32
@@ -1,4 +1,12 @@
11 import { Schema } from "effect";
2+import {
3+ ApiKeyAuthTemplate,
4+ apiKeyMethodFromAuthTemplate,
5+ isApiKeyAuthTemplate,
6+ ApiKeyAuthMethod,
7+ NoneAuthMethod,
8+ normalizeAuthMethodSlugs,
9+} from "@executor-js/sdk/http-auth";
210
311 // ---------------------------------------------------------------------------
412 // GraphQL operation kind
@@ -55,35 +63,27 @@ export const OperationBinding = Schema.Struct({ · 50 unmodified lines
5563 export type OperationBinding = typeof OperationBinding.Type;
5664
5765 // ---------------------------------------------------------------------------
58−// Authentication template (v2)
66+// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
67+// plus GraphQL's own oauth variant. The integration's
68+// `config.authenticationTemplate` declares zero or more methods, each with a
69+// stable `slug` a connection binds against (`connection.template`). There are
70+// no secret slots and no credential bindings — a connection IS the credential,
71+// and the plugin renders its resolved values onto the request through the
72+// bound method (D11).
5973 //
60−// The integration's `config.authenticationTemplate` describes WHERE a
61−// connection's resolved value is applied: an apiKey header / query param (with
62−// an optional prefix like `Bearer `) or an OAuth bearer header. There are no
63−// secret slots and no credential bindings — a connection IS the credential, and
64−// the plugin renders `credential.value` onto the request through the template
65−// matched by `credential.template` (D11: "auth state derived into the
66−// auth-template format" — an OAuth access token renders exactly like an apiKey
67−// bearer).
74+// none — no credential (open endpoint)
75+// apikey — render the connection's values through the method's header/query
76+// placements (one credential input per distinct placement
77+// `variable`; a method may mix carriers — e.g. a bearer header
78+// plus a team-id query param)
79+// oauth2 — the value is an OAuth access token, applied as a bearer header
80+// (optionally overriding the header name / prefix). GraphQL oauth
81+// stores no endpoints — only how the token is rendered.
6882 // ---------------------------------------------------------------------------
6983
70−/** An apiKey-style template: place the value in a header or query parameter,
71− * optionally prefixed (e.g. `Bearer `). */
72−export const ApiKeyAuthTemplate = Schema.Struct({
73− kind: Schema.Literal("apiKey"),
74− /** The template slug a connection references via `connection.template`. */
75− slug: Schema.String,
76− in: Schema.Literals(["header", "query"]),
77− /** The header / query-parameter name the value is written to. */
78− name: Schema.String,
79− /** Optional prefix prepended to the value (e.g. `Bearer `). */
80− prefix: Schema.optional(Schema.String),
81−});
82−export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type;
83−
84−/** An OAuth bearer template: write `Authorization: Bearer <access-token>`. The
85− * resolved (and refreshed) access token is `credential.value`. */
86−export const OAuthAuthTemplate = Schema.Struct({
84+/** An OAuth bearer method: write `<header>: <prefix><access-token>`. The
85+ * resolved (and refreshed) access token is the connection's `token` value. */
86+export const GraphqlOAuthMethod = Schema.Struct({
8787 kind: Schema.Literal("oauth2"),
8888 slug: Schema.String,
8989 /** The header to write the bearer token to. Defaults to `Authorization`. */
@@ -91,10 +91,75 @@ export const OAuthAuthTemplate = Schema.Struct({ · 1 unmodified line
9191 /** The token prefix. Defaults to `Bearer `. */
9292 prefix: Schema.optional(Schema.String),
9393 });
94−export type OAuthAuthTemplate = typeof OAuthAuthTemplate.Type;
95−
96−export const AuthTemplate = Schema.Union([ApiKeyAuthTemplate, OAuthAuthTemplate]);
97−export type AuthTemplate = typeof AuthTemplate.Type;
94+export type GraphqlOAuthMethod = typeof GraphqlOAuthMethod.Type;
95+
96+export const GraphqlAuthMethod = Schema.Union([
97+ NoneAuthMethod,
98+ ApiKeyAuthMethod,
99+ GraphqlOAuthMethod,
100+]);
101+export type GraphqlAuthMethod = typeof GraphqlAuthMethod.Type;
102+
103+/** Input variant of `GraphqlAuthMethod` — callers (UI, agents) may omit the
104+ * slug; `normalizeGraphqlAuthMethods` backfills it. */
105+export const GraphqlAuthMethodInput = Schema.Union([
106+ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }),
107+ Schema.Struct({
108+ slug: Schema.optional(Schema.String),
109+ kind: Schema.Literal("oauth2"),
110+ header: Schema.optional(Schema.String),
111+ prefix: Schema.optional(Schema.String),
112+ }),
113+ // Credential methods are authored request-shaped — the ONE apikey input
114+ // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
115+ // variable("token")] }, queryParams: { … } }`. Stored configs and the
116+ // catalog read as canonical placements; `apiKeyAuthTemplateFromMethod`
117+ // serializes them back for read-modify-write flows.
118+ ApiKeyAuthTemplate,
119+]);
120+export type GraphqlAuthMethodInput = typeof GraphqlAuthMethodInput.Type;
121+
122+/** The expansion target: input arms with the dialect resolved to canonical
123+ * placements (slug still optional — backfill is a separate pass). */
124+export type GraphqlCanonicalAuthMethodInput =
125+ | Exclude<GraphqlAuthMethodInput, ApiKeyAuthTemplate>
126+ | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string });
127+
128+/** The default slug for a slug-less input method. Carrier-derived for the
129+ * single-placement apikey cases (`header` / `query`) so the UI, agent, and
130+ * migration paths all converge on the same names. */
131+const defaultGraphqlAuthSlug = (method: GraphqlCanonicalAuthMethodInput): string => {
132+ if (method.kind !== "apikey") return method.kind;
133+ if (method.placements.length === 1) {
134+ return method.placements[0]!.carrier === "header" ? "header" : "query";
135+ }
136+ return "apikey";
137+};
138+
139+/** Expand request-shaped dialect entries into canonical placements; canonical
140+ * entries pass through. Slug backfill is the caller's concern
141+ * (`normalizeGraphqlAuthMethods` for declare flows, `mergeAuthTemplates` for
142+ * the custom-method merge). */
143+export const expandGraphqlAuthMethodInputs = (
144+ methods: readonly GraphqlAuthMethodInput[],
145+): readonly GraphqlCanonicalAuthMethodInput[] =>
146+ methods.map(
147+ (method): GraphqlCanonicalAuthMethodInput =>
148+ isApiKeyAuthTemplate(method)
149+ ? (apiKeyMethodFromAuthTemplate(method) as GraphqlCanonicalAuthMethodInput)
150+ : (method as GraphqlCanonicalAuthMethodInput),
151+ );
152+
153+/** Assign each method a stable slug: a caller-provided one wins, otherwise a
154+ * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The
155+ * request-shaped dialect is expanded to canonical placements first. */
156+export const normalizeGraphqlAuthMethods = (
157+ methods: readonly GraphqlAuthMethodInput[],
158+): readonly GraphqlAuthMethod[] =>
159+ normalizeAuthMethodSlugs(
160+ expandGraphqlAuthMethodInputs(methods),
161+ defaultGraphqlAuthSlug,
162+ ) as readonly GraphqlAuthMethod[];
98163
99164 // ---------------------------------------------------------------------------
100165 // Integration config — the opaque-to-core blob the graphql plugin stores on the
@@ -114,11 +179,15 @@ export const GraphqlIntegrationConfig = Schema.Struct({ · 13 unmodified lines
114179 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
115180 /** Static query parameters applied to every request. */
116181 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
117− /** Auth methods a connection can be applied through. */
118− authenticationTemplate: Schema.Array(AuthTemplate),
182+ /** Declared auth methods — how a connection's values are rendered onto
183+ * requests. A connection's `template` picks one by slug. */
184+ authenticationTemplate: Schema.Array(GraphqlAuthMethod),
119185 });
120186 export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type;
121187
188+// Decodes ONLY the canonical shape. Pre-canonical stored shapes are rewritten
189+// by the one-off config migration (`migrate-config.ts`), not decoded here —
190+// runtime code knows only the canonical model.
122191 export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig);
123192 export const decodeGraphqlIntegrationConfigOption =
124193 Schema.decodeUnknownOption(GraphqlIntegrationConfig);
packages/plugins/http-source/CHANGELOG.md+0−33
@@ -1,33 +0,0 @@
1−# @executor-js/plugin-http-source
2−
3−## 2.0.4
4−
5−### Patch Changes
6−
7−- Updated dependencies []:
8− - @executor-js/sdk@1.5.3
9− - @executor-js/react@1.4.25
10−
11−## 2.0.3
12−
13−### Patch Changes
14−
15−- Updated dependencies []:
16− - @executor-js/sdk@1.5.2
17− - @executor-js/react@1.4.24
18−
19−## 2.0.2
20−
21−### Patch Changes
22−
23−- Updated dependencies []:
24− - @executor-js/sdk@1.5.1
25− - @executor-js/react@1.4.23
26−
27−## 2.0.1
28−
29−### Patch Changes
30−
31−- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]:
32− - @executor-js/sdk@1.5.0
33− - @executor-js/react@1.4.22
packages/plugins/http-source/package.json+0−79
@@ -1,79 +0,0 @@
1−{
2− "name": "@executor-js/plugin-http-source",
3− "version": "2.0.4",
4− "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/http-source",
5− "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
7− },
8− "license": "MIT",
9− "repository": {
10− "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
12− "directory": "packages/plugins/http-source"
13− },
14− "files": [
15− "dist"
16− ],
17− "type": "module",
18− "exports": {
19− ".": "./src/index.ts",
20− "./sdk": "./src/sdk/index.ts",
21− "./react": "./src/react/index.ts"
22− },
23− "publishConfig": {
24− "access": "public",
25− "exports": {
26− ".": {
27− "import": {
28− "types": "./dist/index.d.ts",
29− "default": "./dist/index.js"
30− }
31− },
32− "./sdk": {
33− "import": {
34− "types": "./dist/sdk.d.ts",
35− "default": "./dist/sdk.js"
36− }
37− },
38− "./react": {
39− "import": {
40− "types": "./dist/react.d.ts",
41− "default": "./dist/react.js"
42− }
43− }
44− }
45− },
46− "scripts": {
47− "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)",
48− "typecheck": "tsgo --noEmit",
49− "test": "vitest run --passWithNoTests",
50− "test:watch": "vitest",
51− "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
52− },
53− "dependencies": {
54− "@executor-js/sdk": "workspace:*",
55− "effect": "catalog:"
56− },
57− "devDependencies": {
58− "@effect/vitest": "catalog:",
59− "@executor-js/react": "workspace:*",
60− "@types/node": "catalog:",
61− "@types/react": "catalog:",
62− "bun-types": "catalog:",
63− "react": "catalog:",
64− "tsup": "catalog:",
65− "vitest": "catalog:"
66− },
67− "peerDependencies": {
68− "@executor-js/react": "workspace:*",
69− "react": "catalog:"
70− },
71− "peerDependenciesMeta": {
72− "@executor-js/react": {
73− "optional": true
74− },
75− "react": {
76− "optional": true
77− }
78− }
79−}
packages/plugins/http-source/src/index.ts+0−1
@@ -1 +0,0 @@
1−export * from "./sdk/index";
packages/plugins/http-source/src/react/index.ts+0−25
@@ -1,25 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// @executor-js/plugin-http-source/react — v2 client surface.
3−//
4−// The v1 client (http-credentials editor) was built entirely on deleted v1
5−// concepts: SecretBackedValue, ScopeId, ConfiguredCredentialBinding, secret
6−// pickers, and `@executor-js/react`'s credential-binding components. In v2 a
7−// connection IS the credential and the value origin is a single paste / OAuth
8−// flow / provider reference — there is no per-header secret-binding editor.
9−//
10−// The full connection-create UI is owned by the shared `@executor-js/react`
11−// package (not yet migrated to v2). Until that lands, this module re-exports the
12−// browser-safe v2 auth-template model so client code can render an http
13−// integration's auth methods.
14−// ---------------------------------------------------------------------------
15−
16−export {
17− CREDENTIAL_VARIABLE,
18− variable,
19− type APIKeyAuthentication,
20− type Authentication,
21− type AuthenticationTemplateValue,
22− type AuthenticationVariable,
23− type HttpSourceConfig,
24− type HttpSourceIntegration,
25−} from "../sdk/types";
packages/plugins/http-source/src/sdk/configure.test.ts+0−223
@@ -1,223 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// http-source plugin — `configure` (add custom auth method) coverage. Mirrors
3−// the openapi plugin's configure tests against the http-source extension.
4−//
5−// - round-trip: register an integration → `configure` adds a custom apiKey
6−// method → `getConfig` shows it with the correct header placement +
7−// `variable("token")` slot,
8−// - slug generation for a method submitted without a slug,
9−// - slug uniqueness/dedupe (matching slug replaces in place; two slugless get
10−// distinct generated slugs),
11−// - the merged template renders against a connection value at invoke time.
12−// ---------------------------------------------------------------------------
13−
14−import { describe, expect, it } from "@effect/vitest";
15−import { Effect, Layer } from "effect";
16−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17−
18−import {
19− createExecutor,
20− AuthTemplateSlug,
21− ConnectionName,
22− IntegrationSlug,
23− ToolAddress,
24−} from "@executor-js/sdk";
25−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
26−
27−import { httpSourcePlugin } from "./plugin";
28−import { variable, type APIKeyAuthentication, type Authentication } from "./types";
29−
30−const HTTP_SLUG = "http-source" as const;
31−
32−const capturingLayer = (
33− capture: { request?: HttpClientRequest.HttpClientRequest },
34− response: Response,
35−): Layer.Layer<HttpClient.HttpClient> =>
36− Layer.succeed(
37− HttpClient.HttpClient,
38− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
39− capture.request = request;
40− return Effect.succeed(HttpClientResponse.fromWeb(request, response));
41− }),
42− );
43−
44−const testConfig = (httpClientLayer?: Layer.Layer<HttpClient.HttpClient>) => {
45− const base = makeTestConfig({
46− plugins: [httpSourcePlugin(), memoryCredentialsPlugin()] as const,
47− });
48− // `httpClientLayer` lives on the executor config (not a `makeTestConfig`
49− // option) — the http-source plugin reads `ctx.httpClientLayer` at invoke time.
50− return httpClientLayer ? { ...base, httpClientLayer } : base;
51−};
52−
53−// A custom apiKey method placing the connection value into `x-api-key`.
54−const customApiKey: APIKeyAuthentication = {
55− slug: AuthTemplateSlug.make("my_custom"),
56− type: "apiKey",
57− headers: { "x-api-key": [variable("token")] },
58−};
59−
60−// Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that
61−// omits it, which `configure` should backfill with a generated `custom_<id>`.
62−// The cast is confined to this one boundary helper.
63−const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication =>
64− // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it
65− ({ type: "apiKey", ...placement }) as unknown as Authentication;
66−
67−// Register an http-source integration directly through the plugin extension.
68−interface HttpRegistrar {
69− readonly register: (input: {
70− readonly slug: IntegrationSlug;
71− readonly description: string;
72− readonly config: {
73− readonly baseUrl: string;
74− readonly authenticationTemplate: readonly Authentication[];
75− };
76− }) => Effect.Effect<void, unknown>;
77−}
78−
79−const registerHttp = (
80− ext: HttpRegistrar,
81− slug: string,
82− authenticationTemplate: readonly Authentication[] = [],
83−) =>
84− ext.register({
85− slug: IntegrationSlug.make(slug),
86− description: slug,
87− config: { baseUrl: "https://api.example.com", authenticationTemplate },
88− });
89−
90−describe("httpSourcePlugin — configure (custom auth method)", () => {
91− it.effect("adds a custom apiKey method and getConfig reflects it", () =>
92− Effect.gen(function* () {
93− const executor = yield* createExecutor(testConfig());
94−
95− yield* registerHttp(executor[HTTP_SLUG], "cfg_api");
96−
97− const merged = yield* executor[HTTP_SLUG].configure("cfg_api", {
98− authenticationTemplate: [customApiKey],
99− });
100−
101− expect(merged).toHaveLength(1);
102− expect(String(merged[0]!.slug)).toBe("my_custom");
103−
104− const config = yield* executor[HTTP_SLUG].getConfig("cfg_api");
105− const template = config?.authenticationTemplate ?? [];
106− expect(template).toHaveLength(1);
107− const entry = template[0] as APIKeyAuthentication;
108− expect(entry.type).toBe("apiKey");
109− expect(String(entry.slug)).toBe("my_custom");
110− expect(entry.headers).toEqual({ "x-api-key": [variable("token")] });
111− }),
112− );
113−
114− it.effect("appends without dropping existing template entries", () =>
115− Effect.gen(function* () {
116− const executor = yield* createExecutor(testConfig());
117−
118− const seed: Authentication = {
119− slug: AuthTemplateSlug.make("seed"),
120− type: "apiKey",
121− headers: { authorization: ["Bearer ", variable("token")] },
122− };
123−
124− yield* registerHttp(executor[HTTP_SLUG], "cfg_append", [seed]);
125−
126− const merged = yield* executor[HTTP_SLUG].configure("cfg_append", {
127− authenticationTemplate: [customApiKey],
128− });
129−
130− expect(merged.map((m: Authentication) => String(m.slug))).toEqual(["seed", "my_custom"]);
131− }),
132− );
133−
134− it.effect("generates a custom_<id> slug for a method submitted without one", () =>
135− Effect.gen(function* () {
136− const executor = yield* createExecutor(testConfig());
137−
138− yield* registerHttp(executor[HTTP_SLUG], "cfg_genslug");
139−
140− const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } });
141−
142− const merged = yield* executor[HTTP_SLUG].configure("cfg_genslug", {
143− authenticationTemplate: [slugless],
144− });
145−
146− expect(merged).toHaveLength(1);
147− expect(String(merged[0]!.slug)).toMatch(/^custom_[a-z0-9]+$/);
148− }),
149− );
150−
151− it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () =>
152− Effect.gen(function* () {
153− const executor = yield* createExecutor(testConfig());
154−
155− yield* registerHttp(executor[HTTP_SLUG], "cfg_dedupe", [customApiKey]);
156−
157− const replacement: APIKeyAuthentication = {
158− slug: AuthTemplateSlug.make("my_custom"),
159− type: "apiKey",
160− headers: { "x-other": [variable("token")] },
161− };
162− const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } });
163− const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } });
164−
165− const merged = yield* executor[HTTP_SLUG].configure("cfg_dedupe", {
166− authenticationTemplate: [replacement, slugless, slugless2],
167− });
168−
169− const slugs = merged.map((m: Authentication) => String(m.slug));
170− expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1);
171− expect(merged).toHaveLength(3);
172− const generated = slugs.filter((s: string) => s.startsWith("custom_"));
173− expect(generated).toHaveLength(2);
174− expect(new Set(generated).size).toBe(2);
175− const replaced = merged.find(
176− (m: Authentication) => String(m.slug) === "my_custom",
177− ) as APIKeyAuthentication;
178− expect(replaced.headers).toEqual({ "x-other": [variable("token")] });
179− }),
180− );
181−
182− it.effect("a configured custom method renders against a connection value at invoke time", () =>
183− Effect.gen(function* () {
184− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
185− const layer = capturingLayer(
186− capture,
187− new Response('{"ok":true}', {
188− status: 200,
189− headers: { "content-type": "application/json" },
190− }),
191− );
192− const executor = yield* createExecutor(testConfig(layer));
193−
194− yield* registerHttp(executor[HTTP_SLUG], "cfg_invoke");
195−
196− yield* executor[HTTP_SLUG].configure("cfg_invoke", {
197− authenticationTemplate: [customApiKey],
198− });
199−
200− yield* executor.connections.create({
201− owner: "org",
202− name: ConnectionName.make("main"),
203− integration: IntegrationSlug.make("cfg_invoke"),
204− template: AuthTemplateSlug.make("my_custom"),
205− value: "configured-secret",
206− });
207−
208− yield* executor.execute(ToolAddress.make("tools.cfg_invoke.org.main.request"), {
209− method: "GET",
210− path: "/ping",
211− });
212−
213− expect(capture.request!.headers["x-api-key"]).toBe("configured-secret");
214− }),
215− );
216−
217− it.effect("getConfig returns null for an unknown integration", () =>
218− Effect.gen(function* () {
219− const executor = yield* createExecutor(testConfig());
220− expect(yield* executor[HTTP_SLUG].getConfig("nope")).toBeNull();
221− }),
222− );
223−});
packages/plugins/http-source/src/sdk/errors.ts+0−18
@@ -1,18 +0,0 @@
1−import { Schema } from "effect";
2−
3−/** A raw HTTP request issued by the http-source `request` tool failed at the
4− * transport layer (connection error, body read failure). Upstream non-2xx
5− * responses are NOT errors — they're returned as a shaped `HttpResponse`. */
6−export class HttpRequestError extends Schema.TaggedErrorClass<HttpRequestError>()(
7− "HttpRequestError",
8− {
9− message: Schema.String,
10− cause: Schema.optional(Schema.Unknown),
11− },
12−) {}
13−
14−/** The integration config stored on the row wasn't the expected http-source
15− * shape (missing `baseUrl` / `authenticationTemplate`). */
16−export class HttpConfigError extends Schema.TaggedErrorClass<HttpConfigError>()("HttpConfigError", {
17− message: Schema.String,
18−}) {}
packages/plugins/http-source/src/sdk/index.ts+0−33
@@ -1,33 +0,0 @@
1−export {
2− httpSourcePlugin,
3− HTTP_SOURCE_PLUGIN_ID,
4− REQUEST_TOOL_NAME,
5− type HttpSourceExtension,
6− type HttpSourceConfigureInput,
7− type RegisterHttpIntegrationInput,
8−} from "./plugin";
9−
10−export {
11− CREDENTIAL_VARIABLE,
12− HttpMethod,
13− HttpRequestArgs,
14− HttpResponse,
15− variable,
16− type APIKeyAuthentication,
17− type Authentication,
18− type AuthenticationTemplateValue,
19− type AuthenticationVariable,
20− type HttpSourceConfig,
21− type HttpSourceIntegration,
22−} from "./types";
23−
24−export {
25− applyAuthTemplate,
26− findAuthTemplate,
27− renderAuthTemplate,
28− type RenderedAuth,
29−} from "./template";
30−
31−export { issueRequest, type IssueRequestInput } from "./request";
32−
33−export { HttpConfigError, HttpRequestError } from "./errors";
packages/plugins/http-source/src/sdk/plugin.test.ts+0−210
@@ -1,210 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Layer } from "effect";
3−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4−
5−import {
6− AuthTemplateSlug,
7− ConnectionName,
8− IntegrationSlug,
9− isToolResult,
10− type PluginCtx,
11− type ResolveToolsInput,
12− type ToolInvocationCredential,
13− type ToolResult,
14− type ToolRow,
15−} from "@executor-js/sdk";
16−
17−import { httpSourcePlugin, REQUEST_TOOL_NAME } from "./plugin";
18−import { variable, type HttpSourceConfig } from "./types";
19−
20−const plugin = httpSourcePlugin();
21−
22−// The store + invoke-input types the plugin's hooks expect (store inferred as
23−// `{}` from `storage: () => ({})`). Derive them so the test inputs line up.
24−type Store = ReturnType<typeof plugin.storage>;
25−type InvokeInput = Parameters<NonNullable<typeof plugin.invokeTool>>[0];
26−
27−const config: HttpSourceConfig = {
28− baseUrl: "https://api.example.com",
29− authenticationTemplate: [
30− {
31− slug: AuthTemplateSlug.make("apiKey"),
32− type: "apiKey",
33− headers: { Authorization: ["Bearer ", variable("token")] },
34− },
35− ],
36−};
37−
38−const capturingLayer = (
39− capture: { request?: HttpClientRequest.HttpClientRequest },
40− response: Response,
41−): Layer.Layer<HttpClient.HttpClient> =>
42− Layer.succeed(
43− HttpClient.HttpClient,
44− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
45− capture.request = request;
46− return Effect.succeed(HttpClientResponse.fromWeb(request, response));
47− }),
48− );
49−
50−const emptyHttpLayer: Layer.Layer<HttpClient.HttpClient> = capturingLayer(
51− {},
52− new Response("{}", {
53− status: 200,
54− headers: { "content-type": "application/json" },
55− }),
56−);
57−
58−// Test fixture: the hooks only read `ctx.httpClientLayer` (and `detect` ignores
59−// ctx entirely), so a stub carrying just that is sufficient.
60−const ctxWith = (httpClientLayer: Layer.Layer<HttpClient.HttpClient>): PluginCtx<Store> =>
61− // lint-allow-double-cast: test stub — hooks under test only touch httpClientLayer
62− ({ httpClientLayer }) as unknown as PluginCtx<Store>;
63−
64−const credential = (value: string | null): ToolInvocationCredential => ({
65− owner: "user",
66− integration: IntegrationSlug.make("example"),
67− connection: ConnectionName.make("default"),
68− template: AuthTemplateSlug.make("apiKey"),
69− value,
70− values: value === null ? {} : { token: value },
71− config,
72−});
73−
74−const toolRow =
75− // lint-allow-double-cast: test fixture — invokeTool doesn't read toolRow fields
76− {
77− name: String(REQUEST_TOOL_NAME),
78− integration: "example",
79− connection: "default",
80− } as unknown as ToolRow;
81−
82−const noElicit = (() => Effect.die("no elicit")) as InvokeInput["elicit"];
83−
84−// Assert an invokeTool result is a failed ToolResult and return its error code.
85−const failureCode = (out: unknown): string => {
86− expect(isToolResult(out)).toBe(true);
87− const result = out as ToolResult<unknown>;
88− expect(result.ok).toBe(false);
89− return result.ok ? "" : result.error.code;
90−};
91−
92−describe("httpSourcePlugin.resolveTools", () => {
93− it.effect("produces a single `request` tool referencing the base URL", () =>
94− Effect.gen(function* () {
95− const input: ResolveToolsInput = {
96− integration: {
97− slug: IntegrationSlug.make("example"),
98− description: "Example",
99− kind: "http-source",
100− canRemove: true,
101− canRefresh: true,
102− authMethods: [],
103− },
104− config,
105− connection: {
106− owner: "user",
107− integration: IntegrationSlug.make("example"),
108− name: ConnectionName.make("default"),
109− },
110− template: null,
111− getValue: () => Effect.succeed(null),
112− };
113− const result = yield* plugin.resolveTools!(input);
114− expect(result.tools).toHaveLength(1);
115− expect(String(result.tools[0]!.name)).toBe("request");
116− expect(result.tools[0]!.description).toContain("https://api.example.com");
117− }),
118− );
119−});
120−
121−describe("httpSourcePlugin.invokeTool", () => {
122− it.effect("renders the auth template onto the request and returns ToolResult.ok", () =>
123− Effect.gen(function* () {
124− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
125− const layer = capturingLayer(
126− capture,
127− new Response('{"ok":true}', {
128− status: 200,
129− headers: { "content-type": "application/json" },
130− }),
131− );
132−
133− const input: InvokeInput = {
134− ctx: ctxWith(layer),
135− toolRow,
136− credential: credential("sk-secret"),
137− args: { method: "GET", path: "/ping" },
138− elicit: noElicit,
139− };
140−
141− const out = yield* plugin.invokeTool!(input);
142− expect(isToolResult(out)).toBe(true);
143− expect(out).toMatchObject({ ok: true, data: { status: 200 } });
144− expect(capture.request!.headers["authorization"]).toBe("Bearer sk-secret");
145− expect(capture.request!.url).toContain("https://api.example.com/ping");
146− }),
147− );
148−
149− it.effect("returns an auth failure ToolResult when the credential value is missing", () =>
150− Effect.gen(function* () {
151− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
152− const layer = capturingLayer(capture, new Response("{}", { status: 200 }));
153−
154− const input: InvokeInput = {
155− ctx: ctxWith(layer),
156− toolRow,
157− credential: credential(null),
158− args: { path: "/ping" },
159− elicit: noElicit,
160− };
161−
162− const out = yield* plugin.invokeTool!(input);
163− expect(failureCode(out)).toBe("connection_value_missing");
164− // No request should have been issued.
165− expect(capture.request).toBeUndefined();
166− }),
167− );
168−
169− it.effect("rejects args without a `path`", () =>
170− Effect.gen(function* () {
171− const input: InvokeInput = {
172− ctx: ctxWith(emptyHttpLayer),
173− toolRow,
174− credential: credential("sk"),
175− args: { method: "GET" },
176− elicit: noElicit,
177− };
178− const out = yield* plugin.invokeTool!(input);
179− expect(failureCode(out)).toBe("invalid_arguments");
180− }),
181− );
182−});
183−
184−describe("httpSourcePlugin.detect", () => {
185− it.effect("claims any http(s) URL at low confidence", () =>
186− Effect.gen(function* () {
187− const result = yield* plugin.detect!({
188− ctx: ctxWith(emptyHttpLayer),
189− url: "https://api.acme.dev/v1",
190− });
191− expect(result).toEqual({
192− kind: "http-source",
193− confidence: "low",
194− endpoint: "https://api.acme.dev/v1",
195− name: "api.acme.dev",
196− slug: "api.acme.dev",
197− });
198− }),
199− );
200−
201− it.effect("ignores non-http URLs", () =>
202− Effect.gen(function* () {
203− const result = yield* plugin.detect!({
204− ctx: ctxWith(emptyHttpLayer),
205− url: "ftp://example.com",
206− });
207− expect(result).toBeNull();
208− }),
209− );
210−});
packages/plugins/http-source/src/sdk/plugin.ts+0−404
@@ -1,404 +0,0 @@
1−import { Effect, type Layer } from "effect";
2−import type { HttpClient } from "effect/unstable/http";
3−
4−import {
5− AuthTemplateSlug,
6− IntegrationAlreadyExistsError,
7− IntegrationSlug,
8− ToolName,
9− ToolResult,
10− authToolFailure,
11− definePlugin,
12− type IntegrationConfig,
13− type IntegrationDetectionResult,
14− type InvokeToolInput,
15− type PluginCtx,
16− type ResolveToolsInput,
17− type ResolveToolsResult,
18− type StorageFailure,
19− type ToolDef,
20−} from "@executor-js/sdk";
21−
22−import { HttpRequestError } from "./errors";
23−import { applyAuthTemplate, findAuthTemplate, requiredVariables } from "./template";
24−import { issueRequest } from "./request";
25−import {
26− type Authentication,
27− type HttpMethod,
28− type HttpRequestArgs,
29− type HttpSourceConfig,
30−} from "./types";
31−
32−// ---------------------------------------------------------------------------
33−// Plugin identity
34−// ---------------------------------------------------------------------------
35−
36−export const HTTP_SOURCE_PLUGIN_ID = "http-source";
37−export const REQUEST_TOOL_NAME = ToolName.make("request");
38−
39−// ---------------------------------------------------------------------------
40−// Config narrowing — core stores `IntegrationConfig` as an opaque blob; read it
41−// back into the http-source shape.
42−// ---------------------------------------------------------------------------
43−
44−const asHttpConfig = (config: IntegrationConfig): HttpSourceConfig | null => {
45− if (typeof config !== "object" || config === null) return null;
46− const candidate = config as Partial<HttpSourceConfig>;
47− if (typeof candidate.baseUrl !== "string") return null;
48− if (!Array.isArray(candidate.authenticationTemplate)) return null;
49− return {
50− baseUrl: candidate.baseUrl,
51− authenticationTemplate: candidate.authenticationTemplate as readonly Authentication[],
52− defaultHeaders: candidate.defaultHeaders,
53− };
54−};
55−
56−// ---------------------------------------------------------------------------
57−// Auth-template merge — `configure` appends custom auth methods to the
58−// integration's existing `authenticationTemplate`. Mirrors the openapi plugin:
59−// a method whose slug matches an existing entry replaces it in place; a method
60−// with no usable slug is assigned a generated `custom_<id>` slug, collision-
61−// checked against the merged set.
62−// ---------------------------------------------------------------------------
63−
64−const shortId = (): string => Math.random().toString(36).slice(2, 8);
65−
66−const freshCustomSlug = (taken: ReadonlySet<string>): AuthTemplateSlug => {
67− let candidate = `custom_${shortId()}`;
68− while (taken.has(candidate)) candidate = `custom_${shortId()}`;
69− return AuthTemplateSlug.make(candidate);
70−};
71−
72−const mergeAuthenticationTemplate = (
73− existing: readonly Authentication[],
74− incoming: readonly Authentication[],
75−): readonly Authentication[] => {
76− const result: Authentication[] = existing.map((entry: Authentication) => entry);
77− const taken = new Set<string>(result.map((entry: Authentication) => String(entry.slug)));
78− for (const entry of incoming) {
79− // `slug` is branded as required, but JSON callers may omit it; read defensively.
80− const rawSlug = (entry as { readonly slug?: unknown }).slug;
81− const requested = typeof rawSlug === "string" ? rawSlug.trim() : "";
82− const existingIndex = result.findIndex(
83− (current: Authentication) => String(current.slug) === requested,
84− );
85− if (requested.length > 0 && existingIndex >= 0) {
86− result[existingIndex] = entry;
87− continue;
88− }
89− const slug =
90− requested.length > 0 && !taken.has(requested)
91− ? AuthTemplateSlug.make(requested)
92− : freshCustomSlug(taken);
93− taken.add(String(slug));
94− result.push({ ...entry, slug } as Authentication);
95− }
96− return result;
97−};
98−
99−// ---------------------------------------------------------------------------
100−// Argument coercion — the executor passes through `args: unknown`. Decode into
101−// the request envelope, tolerating loose shapes.
102−// ---------------------------------------------------------------------------
103−
104−const decodeRequestArgs = (args: unknown): HttpRequestArgs | null => {
105− if (typeof args !== "object" || args === null) return null;
106− const record = args as Record<string, unknown>;
107− if (typeof record.path !== "string") return null;
108− const method =
109− typeof record.method === "string" ? (record.method.toUpperCase() as HttpMethod) : undefined;
110− return {
111− method,
112− path: record.path,
113− query:
114− typeof record.query === "object" && record.query !== null
115− ? (record.query as Record<string, unknown>)
116− : undefined,
117− headers:
118− typeof record.headers === "object" && record.headers !== null
119− ? (record.headers as Record<string, string>)
120− : undefined,
121− body: record.body,
122− };
123−};
124−
125−// ---------------------------------------------------------------------------
126−// The generic `request` tool — identical per connection (raw HTTP has no spec),
127−// so `resolveTools` never needs `getValue`. The JSON schema is a literal so the
128−// plugin doesn't depend on a Schema→JSON-Schema conversion at runtime.
129−// ---------------------------------------------------------------------------
130−
131−const REQUEST_INPUT_SCHEMA = {
132− type: "object",
133− properties: {
134− method: {
135− type: "string",
136− enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
137− description: "HTTP method. Defaults to GET.",
138− },
139− path: {
140− type: "string",
141− description: "Path appended to the integration's base URL, or an absolute http(s) URL.",
142− },
143− query: {
144− type: "object",
145− additionalProperties: true,
146− description: "Query string parameters.",
147− },
148− headers: {
149− type: "object",
150− additionalProperties: { type: "string" },
151− description: "Additional request headers.",
152− },
153− body: {
154− description: "Request body. Objects are JSON-encoded; strings pass through.",
155− },
156− },
157− required: ["path"],
158− additionalProperties: false,
159−} as const;
160−
161−const REQUEST_OUTPUT_SCHEMA = {
162− type: "object",
163− properties: {
164− status: { type: "number" },
165− headers: { type: "object", additionalProperties: { type: "string" } },
166− body: {},
167− },
168− required: ["status", "headers", "body"],
169−} as const;
170−
171−const requestToolDef = (baseUrl: string): ToolDef => ({
172− name: REQUEST_TOOL_NAME,
173− description: `Issue a raw HTTP request against ${baseUrl}.`,
174− inputSchema: REQUEST_INPUT_SCHEMA,
175− outputSchema: REQUEST_OUTPUT_SCHEMA,
176− annotations: {
177− // Raw HTTP can mutate; the executor's tool-policy layer can require approval
178− // per address. We flag it conservatively.
179− requiresApproval: true,
180− approvalDescription: `Raw HTTP request to ${baseUrl}`,
181− },
182−});
183−
184−// ---------------------------------------------------------------------------
185−// Extension — the plugin's public API. `register` writes an http-source
186−// integration into the catalog with its opaque config.
187−// ---------------------------------------------------------------------------
188−
189−export interface RegisterHttpIntegrationInput {
190− readonly slug: IntegrationSlug;
191− readonly description: string;
192− readonly config: HttpSourceConfig;
193− readonly canRemove?: boolean;
194−}
195−
196−/** Add / merge custom auth methods onto an existing http-source integration's
197− * `authenticationTemplate`. Mirrors the openapi plugin's `configure`. */
198−export interface HttpSourceConfigureInput {
199− readonly authenticationTemplate: readonly Authentication[];
200−}
201−
202−// ---------------------------------------------------------------------------
203−// Plugin
204−// ---------------------------------------------------------------------------
205−
206−export const httpSourcePlugin = definePlugin(() => ({
207− id: HTTP_SOURCE_PLUGIN_ID,
208− packageName: "@executor-js/plugin-http-source",
209−
210− storage: () => ({}),
211−
212− // The extension's shape is inferred from this factory's return value;
213− // `HttpSourceExtension` below is derived from it (no duplicated shape).
214− extension: (ctx: PluginCtx) => ({
215− register: (
216− input: RegisterHttpIntegrationInput,
217− ): Effect.Effect<void, IntegrationAlreadyExistsError | StorageFailure> =>
218− Effect.gen(function* () {
219− // Block re-adding an existing slug. The core `integrations.register`
220− // primitive upserts (so boot re-registration is idempotent), but this
221− // user-facing add must NOT silently clobber an existing integration's
222− // tools, connections, and policies. To add more auth, update the
223− // existing integration instead.
224− const existing = yield* ctx.core.integrations.get(input.slug);
225− if (existing) {
226− return yield* new IntegrationAlreadyExistsError({ slug: input.slug });
227− }
228− yield* ctx.core.integrations.register({
229− slug: input.slug,
230− description: input.description,
231− config: input.config,
232− canRemove: input.canRemove ?? true,
233− // Raw-HTTP tools are static per connection, but refresh is harmless
234− // (re-stamps the same `request` tool), so allow it.
235− canRefresh: true,
236− });
237− }),
238−
239− /** Read the integration's full opaque config (including its
240− * `authenticationTemplate`). Returns null when the integration is absent
241− * or its config isn't a valid http-source config. */
242− getConfig: (slug: string): Effect.Effect<HttpSourceConfig | null, StorageFailure> =>
243− ctx.core.integrations
244− .get(IntegrationSlug.make(slug))
245− .pipe(Effect.map((record) => (record ? asHttpConfig(record.config) : null))),
246−
247− /** Add / merge custom auth methods onto the integration's
248− * `authenticationTemplate`. Returns the resulting template array. */
249− configure: (
250− slug: string,
251− input: HttpSourceConfigureInput,
252− ): Effect.Effect<readonly Authentication[], StorageFailure> =>
253− ctx.transaction(
254− Effect.gen(function* () {
255− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
256− if (!record) return [] as readonly Authentication[];
257− const current = asHttpConfig(record.config);
258− if (!current) return [] as readonly Authentication[];
259−
260− const merged = mergeAuthenticationTemplate(
261− current.authenticationTemplate,
262− input.authenticationTemplate,
263− );
264−
265− const next: HttpSourceConfig = {
266− ...current,
267− authenticationTemplate: merged,
268− };
269−
270− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
271− config: next satisfies HttpSourceConfig as IntegrationConfig,
272− });
273−
274− return merged;
275− }),
276− ),
277− }),
278−
279− // Per-connection tool production. Raw HTTP yields one generic `request` tool;
280− // identical for every connection, so `getValue` is never called.
281− resolveTools: (input: ResolveToolsInput): Effect.Effect<ResolveToolsResult, StorageFailure> =>
282− Effect.sync(() => {
283− const config = asHttpConfig(input.config);
284− const baseUrl = config?.baseUrl ?? "the configured endpoint";
285− return { tools: [requestToolDef(baseUrl)] };
286− }),
287−
288− // Apply the resolved credential to the outbound request via the integration's
289− // auth template (D11) and issue it.
290− invokeTool: (input: InvokeToolInput): Effect.Effect<unknown, unknown> =>
291− Effect.gen(function* () {
292− const config = asHttpConfig(input.credential.config);
293− if (config === null) {
294− return ToolResult.fail({
295− code: "http_config_invalid",
296− message:
297− "The http-source integration config is missing baseUrl / authenticationTemplate.",
298− retryable: false,
299− });
300− }
301−
302− const args = decodeRequestArgs(input.args);
303− if (args === null) {
304− return ToolResult.fail({
305− code: "invalid_arguments",
306− message: "Expected a request with at least a `path` string.",
307− retryable: false,
308− });
309− }
310−
311− // A selected auth template with any unresolved input means the credential
312− // couldn't be resolved — surface a reauth-style failure keyed to the
313− // connection (owner/integration/name), not a v1 slot. Checks every input the
314− // template requires (e.g. both of Datadog's two keys), not just the primary.
315− const selectedTemplate = findAuthTemplate(
316− config.authenticationTemplate,
317− input.credential.template,
318− );
319− if (selectedTemplate) {
320− const missing = requiredVariables(selectedTemplate).filter((name) => {
321− const value = input.credential.values[name];
322− return value == null || value === "";
323− });
324− if (missing.length > 0) {
325− return authToolFailure({
326− code: "connection_value_missing",
327− message: `No credential value resolved for connection "${input.credential.connection}" on integration "${input.credential.integration}".`,
328− credential: {
329− kind: "secret",
330− label: String(input.credential.connection),
331− },
332− });
333− }
334− }
335−
336− const auth = applyAuthTemplate(
337− config.authenticationTemplate,
338− input.credential.template,
339− input.credential.values,
340− );
341−
342− const httpClientLayer: Layer.Layer<HttpClient.HttpClient> = input.ctx.httpClientLayer;
343−
344− const response = yield* issueRequest(
345− {
346− baseUrl: config.baseUrl,
347− method: args.method ?? "GET",
348− path: args.path,
349− query: args.query,
350− headers: args.headers,
351− body: args.body,
352− auth,
353− defaultHeaders: config.defaultHeaders,
354− },
355− httpClientLayer,
356− ).pipe(
357− // `HttpRequestError` is a typed tagged error; reshape it into a
358− // value-based `ToolResult` so transport failures surface as data.
359− Effect.catchTag("HttpRequestError", (failure: HttpRequestError) =>
360− Effect.succeed(
361− ToolResult.fail({
362− code: "http_request_failed",
363− message: failure.message,
364− retryable: true,
365− }),
366− ),
367− ),
368− );
369−
370− // `issueRequest` resolves to `HttpResponse`; on transport failure the
371− // catch above replaced it with a `ToolResult`. Discriminate.
372− return "ok" in response ? response : ToolResult.ok(response);
373− }),
374−
375− // Raw HTTP claims any http(s) URL as a low-confidence fallback so onboarding
376− // can offer it when no spec-derived plugin matches.
377− detect: (input: {
378− readonly ctx: PluginCtx;
379− readonly url: string;
380− }): Effect.Effect<IntegrationDetectionResult | null, never> =>
381− Effect.sync(() => {
382− if (!/^https?:\/\//i.test(input.url)) return null;
383− let host: string;
384− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing of arbitrary onboarding input; fall back to the raw string
385− try {
386− host = new URL(input.url).host;
387− } catch {
388− host = input.url;
389− }
390− return {
391− kind: HTTP_SOURCE_PLUGIN_ID,
392− confidence: "low",
393− endpoint: input.url,
394− name: host,
395− slug: host,
396− };
397− }),
398−}));
399−
400−/** The plugin's extension API (`executor["http-source"]`) — derived from the
401− * inferred `extension` factory return so the shape isn't duplicated. */
402−export type HttpSourceExtension = ReturnType<
403− NonNullable<ReturnType<typeof httpSourcePlugin>["extension"]>
404−>;
packages/plugins/http-source/src/sdk/request.test.ts+0−142
@@ -1,142 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Layer } from "effect";
3−import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/unstable/http";
4−
5−import { issueRequest } from "./request";
6−import type { RenderedAuth } from "./template";
7−
8−/* A capturing in-memory HttpClient: records the request it was handed and
9− * returns a canned web Response. No network. */
10−const capturingClient = (
11− capture: { request?: HttpClientRequest.HttpClientRequest },
12− response: Response,
13−): Layer.Layer<HttpClient.HttpClient> =>
14− Layer.succeed(
15− HttpClient.HttpClient,
16− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
17− capture.request = request;
18− return Effect.succeed(HttpClientResponse.fromWeb(request, response));
19− }),
20− );
21−
22−const noAuth: RenderedAuth = { headers: {}, queryParams: {} };
23−
24−describe("issueRequest", () => {
25− it.effect("issues a GET, merges auth headers + query, parses JSON body", () =>
26− Effect.gen(function* () {
27− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
28− const layer = capturingClient(
29− capture,
30− new Response('{"hello":"world"}', {
31− status: 200,
32− headers: { "content-type": "application/json" },
33− }),
34− );
35−
36− const result = yield* issueRequest(
37− {
38− baseUrl: "https://api.example.com",
39− method: "GET",
40− path: "/things",
41− query: { limit: 5 },
42− headers: { "x-trace": "abc" },
43− auth: {
44− headers: { Authorization: "Bearer tok" },
45− queryParams: { api_key: "k" },
46− },
47− defaultHeaders: { "x-default": "d" },
48− },
49− layer,
50− );
51−
52− expect(result.status).toBe(200);
53− expect(result.body).toEqual({ hello: "world" });
54−
55− const sent = capture.request!;
56− expect(sent.method).toBe("GET");
57− expect(sent.url).toContain("https://api.example.com/things");
58− const params = UrlParams.toString(sent.urlParams);
59− expect(params).toContain("limit=5");
60− expect(params).toContain("api_key=k");
61− expect(sent.headers["authorization"]).toBe("Bearer tok");
62− expect(sent.headers["x-trace"]).toBe("abc");
63− expect(sent.headers["x-default"]).toBe("d");
64− }),
65− );
66−
67− it.effect("returns non-2xx upstream responses as data, not an error", () =>
68− Effect.gen(function* () {
69− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
70− const layer = capturingClient(
71− capture,
72− new Response("nope", {
73− status: 404,
74− headers: { "content-type": "text/plain" },
75− }),
76− );
77−
78− const result = yield* issueRequest(
79− {
80− baseUrl: "https://api.example.com",
81− method: "GET",
82− path: "/missing",
83− auth: noAuth,
84− },
85− layer,
86− );
87−
88− expect(result.status).toBe(404);
89− expect(result.body).toBe("nope");
90− }),
91− );
92−
93− it.effect("JSON-encodes object bodies on POST", () =>
94− Effect.gen(function* () {
95− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
96− const layer = capturingClient(capture, new Response(null, { status: 204 }));
97−
98− const result = yield* issueRequest(
99− {
100− baseUrl: "https://api.example.com",
101− method: "POST",
102− path: "things",
103− body: { name: "n" },
104− auth: noAuth,
105− },
106− layer,
107− );
108−
109− expect(result.status).toBe(204);
110− expect(result.body).toBe(null);
111− const sent = capture.request!;
112− expect(sent.method).toBe("POST");
113− // path without leading slash is still joined under the base
114− expect(sent.url).toContain("https://api.example.com/things");
115− }),
116− );
117−
118− it.effect("treats an absolute path as the full URL (ignores baseUrl)", () =>
119− Effect.gen(function* () {
120− const capture: { request?: HttpClientRequest.HttpClientRequest } = {};
121− const layer = capturingClient(
122− capture,
123− new Response("{}", {
124− status: 200,
125− headers: { "content-type": "application/json" },
126− }),
127− );
128−
129− yield* issueRequest(
130− {
131− baseUrl: "https://api.example.com",
132− method: "GET",
133− path: "https://other.example.org/x",
134− auth: noAuth,
135− },
136− layer,
137− );
138−
139− expect(capture.request!.url).toContain("https://other.example.org/x");
140− }),
141− );
142−});
packages/plugins/http-source/src/sdk/request.ts+0−134
@@ -1,134 +0,0 @@
1−import { Effect, Layer } from "effect";
2−import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3−
4−import { HttpRequestError } from "./errors";
5−import type { RenderedAuth } from "./template";
6−import type { HttpMethod, HttpResponse } from "./types";
7−
8−/* Build + issue a raw HTTP request for the http-source `request` tool. The auth
9− * template's rendered headers/query (from the connection's resolved value) are
10− * merged with the per-call args; the response is shaped into `HttpResponse`. */
11−
12−const normalizeContentType = (ct: string | null | undefined): string =>
13− ct?.split(";")[0]?.trim().toLowerCase() ?? "";
14−
15−const isJsonContentType = (ct: string | null | undefined): boolean => {
16− const normalized = normalizeContentType(ct);
17− if (!normalized) return false;
18− return normalized === "application/json" || normalized.includes("+json");
19−};
20−
21−const joinUrl = (baseUrl: string, path: string): string => {
22− if (/^https?:\/\//i.test(path)) return path;
23− const base = baseUrl.replace(/\/+$/, "");
24− const suffix = path.replace(/^\/+/, "");
25− return suffix.length > 0 ? `${base}/${suffix}` : base;
26−};
27−
28−const applyBody = (
29− request: HttpClientRequest.HttpClientRequest,
30− body: unknown,
31−): HttpClientRequest.HttpClientRequest => {
32− if (body === undefined || body === null) return request;
33− if (typeof body === "string") {
34− return HttpClientRequest.bodyText(request, body, "text/plain");
35− }
36− return HttpClientRequest.bodyJsonUnsafe(request, body);
37−};
38−
39−export interface IssueRequestInput {
40− readonly baseUrl: string;
41− readonly method: HttpMethod;
42− readonly path: string;
43− readonly query?: Record<string, unknown>;
44− readonly headers?: Record<string, string>;
45− readonly body?: unknown;
46− /** Headers/query the connection's auth template contributes. */
47− readonly auth: RenderedAuth;
48− /** Non-secret default headers from the integration config. */
49− readonly defaultHeaders?: Record<string, string>;
50−}
51−
52−const buildRequest = (input: IssueRequestInput): HttpClientRequest.HttpClientRequest => {
53− const url = joinUrl(input.baseUrl, input.path);
54− let request = HttpClientRequest.make(input.method)(url);
55−
56− for (const [name, value] of Object.entries(input.defaultHeaders ?? {})) {
57− request = HttpClientRequest.setHeader(request, name, value);
58− }
59− for (const [name, value] of Object.entries(input.headers ?? {})) {
60− request = HttpClientRequest.setHeader(request, name, value);
61− }
62− for (const [name, value] of Object.entries(input.auth.headers)) {
63− request = HttpClientRequest.setHeader(request, name, value);
64− }
65−
66− for (const [name, value] of Object.entries(input.query ?? {})) {
67− if (value === undefined || value === null) continue;
68− request = HttpClientRequest.setUrlParam(request, name, String(value));
69− }
70− for (const [name, value] of Object.entries(input.auth.queryParams)) {
71− request = HttpClientRequest.setUrlParam(request, name, value);
72− }
73−
74− return applyBody(request, input.body);
75−};
76−
77−/** Issue the request through a provided HttpClient layer, returning a shaped
78− * `HttpResponse` (status / headers / parsed-or-text body). */
79−export const issueRequest = (
80− input: IssueRequestInput,
81− httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
82−): Effect.Effect<HttpResponse, HttpRequestError> =>
83− Effect.gen(function* () {
84− const client = yield* HttpClient.HttpClient;
85− const request = buildRequest(input);
86−
87− const response = yield* client.execute(request).pipe(
88− Effect.mapError(
89− (cause: unknown) =>
90− new HttpRequestError({
91− message: `HTTP request to ${joinUrl(input.baseUrl, input.path)} failed`,
92− cause,
93− }),
94− ),
95− );
96−
97− const status = response.status;
98− const headers: Record<string, string> = { ...response.headers };
99− const contentType = response.headers["content-type"] ?? null;
100−
101− const body: unknown =
102− status === 204
103− ? null
104− : isJsonContentType(contentType)
105− ? yield* response.json.pipe(
106− Effect.catch(() => response.text),
107− Effect.mapError(
108− (cause: unknown) =>
109− new HttpRequestError({
110− message: "Failed to read response body",
111− cause,
112− }),
113− ),
114− )
115− : yield* response.text.pipe(
116− Effect.mapError(
117− (cause: unknown) =>
118− new HttpRequestError({
119− message: "Failed to read response body",
120− cause,
121− }),
122− ),
123− );
124−
125− return { status, headers, body };
126− }).pipe(
127− Effect.provide(httpClientLayer),
128− Effect.withSpan("plugin.http-source.request", {
129− attributes: {
130− "http.method": input.method,
131− "http.url": joinUrl(input.baseUrl, input.path),
132− },
133− }),
134− );
packages/plugins/http-source/src/sdk/template.test.ts+0−113
@@ -1,113 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3−
4−import {
5− applyAuthTemplate,
6− findAuthTemplate,
7− renderAuthTemplate,
8− requiredVariables,
9−} from "./template";
10−import { variable, type Authentication } from "./types";
11−
12−// removed: the v1 `httpCredentialsFromConfiguredCredentialBindings` test
13−// (react/http-credentials.test.ts) — it exercised the v1 secret/scope/binding
14−// model (ScopeId + SecretId + ConfiguredCredentialBinding + per-header slots),
15−// all deleted in v2. In v2 a connection IS the credential and its value renders
16−// through the integration's auth template, which the cases below cover.
17−
18−const apiKeyHeader: Authentication = {
19− slug: AuthTemplateSlug.make("apiKey-header"),
20− type: "apiKey",
21− headers: { Authorization: ["Bearer ", variable("token")] },
22−};
23−
24−const apiKeyQuery: Authentication = {
25− slug: AuthTemplateSlug.make("apiKey-query"),
26− type: "apiKey",
27− queryParams: { api_key: [variable("token")] },
28−};
29−
30−const oauth: Authentication = {
31− slug: AuthTemplateSlug.make("oauth"),
32− type: "oauth",
33− authorizationUrl: "https://auth.example.com/authorize",
34− tokenUrl: "https://auth.example.com/token",
35− scopes: ["read"],
36−};
37−
38−// Two distinct credential inputs on one method (e.g. Datadog) — each header
39−// renders from its own variable.
40−const datadog: Authentication = {
41− slug: AuthTemplateSlug.make("datadog"),
42− type: "apiKey",
43− headers: {
44− "DD-API-KEY": [variable("dd_api_key")],
45− "DD-APPLICATION-KEY": [variable("dd_application_key")],
46− },
47−};
48−
49−describe("renderAuthTemplate", () => {
50− it("renders an apiKey header template, substituting the credential variable", () => {
51− const rendered = renderAuthTemplate(apiKeyHeader, { token: "sk-123" });
52− expect(rendered.headers).toEqual({ Authorization: "Bearer sk-123" });
53− expect(rendered.queryParams).toEqual({});
54− });
55−
56− it("renders an apiKey query template", () => {
57− const rendered = renderAuthTemplate(apiKeyQuery, { token: "qtoken" });
58− expect(rendered.queryParams).toEqual({ api_key: "qtoken" });
59− expect(rendered.headers).toEqual({});
60− });
61−
62− it("renders two distinct inputs into their own headers, no cross-bleed", () => {
63− const rendered = renderAuthTemplate(datadog, { dd_api_key: "a", dd_application_key: "b" });
64− expect(rendered.headers).toEqual({ "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" });
65− });
66−
67− it("renders an oauth access token exactly like a bearer apiKey (D11)", () => {
68− const rendered = renderAuthTemplate(oauth, { token: "access-tok" });
69− expect(rendered.headers).toEqual({ Authorization: "Bearer access-tok" });
70− });
71−
72− it("renders nothing for oauth when the token input is unresolved", () => {
73− expect(renderAuthTemplate(oauth, {})).toEqual({ headers: {}, queryParams: {} });
74− });
75−
76− it("renders nothing when the template is absent", () => {
77− expect(renderAuthTemplate(undefined, { token: "v" })).toEqual({
78− headers: {},
79− queryParams: {},
80− });
81− });
82−});
83−
84−describe("requiredVariables", () => {
85− it("lists the single token for oauth", () => {
86− expect(requiredVariables(oauth)).toEqual(["token"]);
87− });
88−
89− it("lists every distinct variable across an apiKey method's placements", () => {
90− expect([...requiredVariables(datadog)].sort()).toEqual(["dd_api_key", "dd_application_key"]);
91− });
92−});
93−
94−describe("findAuthTemplate / applyAuthTemplate", () => {
95− const templates: readonly Authentication[] = [apiKeyHeader, apiKeyQuery, oauth];
96−
97− it("finds a template by its slug", () => {
98− expect(findAuthTemplate(templates, AuthTemplateSlug.make("oauth"))).toBe(oauth);
99− expect(findAuthTemplate(templates, AuthTemplateSlug.make("missing"))).toBeUndefined();
100− });
101−
102− it("selects the connection's template by slug and renders it", () => {
103− const rendered = applyAuthTemplate(templates, AuthTemplateSlug.make("apiKey-query"), {
104− token: "abc",
105− });
106− expect(rendered.queryParams).toEqual({ api_key: "abc" });
107− });
108−
109− it("renders nothing when the connection's template slug doesn't match", () => {
110− const rendered = applyAuthTemplate(templates, AuthTemplateSlug.make("nope"), { token: "abc" });
111− expect(rendered).toEqual({ headers: {}, queryParams: {} });
112− });
113−});
packages/plugins/http-source/src/sdk/template.ts+0−106
@@ -1,106 +0,0 @@
1−import type { AuthTemplateSlug } from "@executor-js/sdk/shared";
2−
3−import {
4− CREDENTIAL_VARIABLE,
5− type APIKeyAuthentication,
6− type Authentication,
7− type AuthenticationTemplateValue,
8−} from "./types";
9−
10−/* Render an integration's auth template onto an outbound request (D11: "auth
11− * state derived into the auth-template format"). A connection resolves a MAP of
12− * named inputs (`variable → value`); a single-secret connection has just
13− * `{ token }`, an apiKey method with two distinct inputs (e.g. Datadog) one entry
14− * per template variable. Each `variable("<name>")` renders from the matching map
15− * entry — a refreshed OAuth access token is simply the `token` entry.
16− *
17− * Returns the headers + query params the template contributes. The plugin's
18− * `invokeTool` merges these into the request it builds from the tool args. */
19−
20−export interface RenderedAuth {
21− readonly headers: Record<string, string>;
22− readonly queryParams: Record<string, string>;
23−}
24−
25−const EMPTY_AUTH: RenderedAuth = { headers: {}, queryParams: {} };
26−
27−const renderTemplateValue = (
28− parts: AuthenticationTemplateValue,
29− values: Record<string, string | null>,
30−): string => {
31− if (typeof parts === "string") return parts;
32− let out = "";
33− for (const part of parts) {
34− if (typeof part === "string") {
35− out += part;
36− } else if (part.type === "variable") {
37− // Each variable renders from its own input; an absent input renders empty.
38− out += values[part.name] ?? "";
39− }
40− }
41− return out;
42−};
43−
44−/** Find the integration auth template a connection's `template` slug selects. */
45−export const findAuthTemplate = (
46− templates: readonly Authentication[],
47− slug: AuthTemplateSlug,
48−): Authentication | undefined =>
49− templates.find((template: Authentication) => template.slug === slug);
50−
51−/** The distinct input variables a template references — the credential inputs a
52− * connection must supply. An oauth template needs the single `token`; an apiKey
53− * template needs every `variable("<name>")` across its header/query placements. */
54−export const requiredVariables = (template: Authentication): readonly string[] => {
55− if (template.type === "oauth") return [CREDENTIAL_VARIABLE];
56− const names = new Set<string>();
57− const collect = (parts: AuthenticationTemplateValue): void => {
58− if (typeof parts === "string") return;
59− for (const part of parts) {
60− if (typeof part !== "string" && part.type === "variable") names.add(part.name);
61− }
62− };
63− for (const parts of Object.values(template.headers ?? {})) collect(parts);
64− for (const parts of Object.values(template.queryParams ?? {})) collect(parts);
65− return [...names];
66−};
67−
68−/** Render an `apiKey`/`oauth` template into request headers + query params using
69− * the connection's resolved input `values`. An oauth template carries no
70− * placement fields, so its access token (the `token` input) is applied as a
71− * conventional `Authorization: Bearer <token>` header — the "derived into
72− * template format" rendering. */
73−export const renderAuthTemplate = (
74− template: Authentication | undefined,
75− values: Record<string, string | null>,
76−): RenderedAuth => {
77− if (template === undefined) return EMPTY_AUTH;
78−
79− if (template.type === "oauth") {
80− const token = values[CREDENTIAL_VARIABLE];
81− if (token == null) return EMPTY_AUTH;
82− return {
83− headers: { Authorization: `Bearer ${token}` },
84− queryParams: {},
85− };
86− }
87−
88− const apiKey: APIKeyAuthentication = template;
89− const headers: Record<string, string> = {};
90− const queryParams: Record<string, string> = {};
91− for (const [name, parts] of Object.entries(apiKey.headers ?? {})) {
92− headers[name] = renderTemplateValue(parts, values);
93− }
94− for (const [name, parts] of Object.entries(apiKey.queryParams ?? {})) {
95− queryParams[name] = renderTemplateValue(parts, values);
96− }
97− return { headers, queryParams };
98−};
99−
100−/** Resolve the auth a connection contributes: pick its template by slug, render
101− * it with the resolved input values. */
102−export const applyAuthTemplate = (
103− templates: readonly Authentication[],
104− slug: AuthTemplateSlug,
105− values: Record<string, string | null>,
106−): RenderedAuth => renderAuthTemplate(findAuthTemplate(templates, slug), values);
packages/plugins/http-source/src/sdk/types.ts+0−97
@@ -1,97 +0,0 @@
1−import { Schema } from "effect";
2−
3−import type { AuthTemplateSlug, Integration, OAuthAuthentication } from "@executor-js/sdk/shared";
4−
5−/* ─────────────────────── template variables ───────────────────────
6− * Brought over from the v2 openapi scaffold (packages/sdk-v2/src/openapi/
7− * types.ts). The apiKey template declares WHERE a connection's resolved value
8− * is placed on the outbound request (header / query) using `variable()` refs.
9− * That placement is HTTP-transport-specific, which is why it lives with the
10− * http plugin and not core. The oauth template is mechanism-intrinsic and comes
11− * from core (`OAuthAuthentication`). An integration's `Authentication` union
12− * composes the two — and an OAuth access token renders through the (oauth)
13− * template exactly like an apiKey bearer (D11). */
14−
15−export interface AuthenticationVariable {
16− readonly type: "variable";
17− readonly name: string;
18−}
19−
20−/** A literal string, or a parts-array mixing literals and variable refs. */
21−export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
22−
23−export const variable = (name: string): AuthenticationVariable => ({
24− type: "variable",
25− name,
26−});
27−
28−/** The conventional variable name an http auth template renders the connection's
29− * resolved credential value into. `applyAuthTemplate` substitutes this. */
30−export const CREDENTIAL_VARIABLE = "token";
31−
32−export interface APIKeyAuthentication {
33− readonly slug: AuthTemplateSlug;
34− readonly type: "apiKey";
35− readonly headers?: Record<string, AuthenticationTemplateValue>;
36− readonly queryParams?: Record<string, AuthenticationTemplateValue>;
37−}
38−
39−export type Authentication = OAuthAuthentication | APIKeyAuthentication;
40−
41−/* ─────────────────────── http integration config ───────────────────────
42− * The opaque plugin config core stores on an http-source integration row. Core
43− * never parses it; the plugin reads it back in `resolveTools` (to produce the
44− * connection's request tool) and `invokeTool` (to render the auth template). */
45−
46−export interface HttpSourceConfig {
47− /** Base URL the connection's requests are issued against (e.g.
48− * `https://api.example.com`). A tool call's `path` is appended to it. */
49− readonly baseUrl: string;
50− /** Auth methods this integration declares. A connection picks one by `template`
51− * slug; `invokeTool` renders it with the connection's resolved value. */
52− readonly authenticationTemplate: readonly Authentication[];
53− /** Optional default headers applied to every request (non-secret). */
54− readonly defaultHeaders?: Record<string, string>;
55−}
56−
57−/* ─────────────────────── request tool wire schemas ───────────────────────
58− * Raw-HTTP tools take an explicit request envelope. Spec-derived plugins
59− * (openapi) shape one tool per operation; the http kind exposes a single
60− * generic `request` tool whose schema is identical per connection. */
61−
62−export const HttpMethod = Schema.Literals([
63− "GET",
64− "POST",
65− "PUT",
66− "PATCH",
67− "DELETE",
68− "HEAD",
69− "OPTIONS",
70−]);
71−export type HttpMethod = typeof HttpMethod.Type;
72−
73−export const HttpRequestArgs = Schema.Struct({
74− method: HttpMethod.pipe(Schema.optional),
75− /** Path appended to the integration's `baseUrl`. May be absolute. */
76− path: Schema.String,
77− query: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
78− headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
79− /** Request body. Objects are JSON-encoded; strings pass through. */
80− body: Schema.Unknown.pipe(Schema.optional),
81−}).annotate({ identifier: "HttpRequestArgs" });
82−export type HttpRequestArgs = typeof HttpRequestArgs.Type;
83−
84−export const HttpResponse = Schema.Struct({
85− status: Schema.Number,
86− headers: Schema.Record(Schema.String, Schema.String),
87− body: Schema.Unknown,
88−}).annotate({ identifier: "HttpResponse" });
89−export type HttpResponse = typeof HttpResponse.Type;
90−
91−/* ─────────────────────── the integration projection ───────────────────────
92− * Extends the core `Integration` identity with the auth templates a provider
93− * declares — the http-kind type-specific shape. Core never references it. */
94−
95−export type HttpSourceIntegration = Integration & {
96− readonly authenticationTemplate: readonly Authentication[];
97−};
packages/plugins/http-source/tsconfig.json+0−23
@@ -1,23 +0,0 @@
1−{
2− "compilerOptions": {
3− "target": "ES2022",
4− "module": "ESNext",
5− "moduleResolution": "Bundler",
6− "strict": true,
7− "skipLibCheck": true,
8− "lib": ["ES2022", "DOM"],
9− "types": ["bun-types", "node"],
10− "noUnusedLocals": true,
11− "noImplicitOverride": true,
12− "jsx": "react-jsx",
13− "plugins": [
14− {
15− "name": "@effect/language-service",
16− "ignoreEffectSuggestionsInTscExitCode": true,
17− "ignoreEffectWarningsInTscExitCode": true,
18− "diagnosticSeverity": {}
19− }
20− ]
21− },
22− "include": ["src/**/*.ts", "src/**/*.tsx"]
23−}
packages/plugins/http-source/tsup.config.ts+0−14
@@ -1,14 +0,0 @@
1−import { defineConfig } from "tsup";
2−
3−export default defineConfig({
4− entry: {
5− index: "src/index.ts",
6− sdk: "src/sdk/index.ts",
7− react: "src/react/index.ts",
8− },
9− format: ["esm"],
10− dts: false,
11− sourcemap: true,
12− clean: true,
13− external: [/^@executor-js\//, /^effect/, /^@effect\//, /^react/],
14−});
packages/plugins/http-source/vitest.config.ts+0−7
@@ -1,7 +0,0 @@
1−import { defineConfig } from "vitest/config";
2−
3−export default defineConfig({
4− test: {
5− include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
6− },
7−});
packages/plugins/mcp/src/api/group.ts+2−2
@@ -10,7 +10,7 @@ import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; · 9 unmodified lines
1010 import {
1111 McpAuthMethod,
1212 McpAuthMethodInput,
13− McpAuthTemplate,
13+ McpAuthShorthand,
1414 McpIntegrationConfig,
1515 } from "../sdk/types";
1616
@@ -40,7 +40,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 23 unmodified lines
4040 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
4141 /** Single-method shorthand (legacy callers); ignored when
4242 * `authenticationTemplate` is present. */
43− auth: Schema.optional(McpAuthTemplate),
43+ auth: Schema.optional(McpAuthShorthand),
4444 });
4545
4646 const AddStdioServerPayload = Schema.Struct({
packages/plugins/mcp/src/react/AddMcpSource.tsx+2−2
@@ -37,7 +37,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 36 unmodified lines
3737 import type { McpAuthMethodInput } from "../sdk/types";
3838 import { probeMcpEndpoint, addMcpServer } from "./atoms";
3939 import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
40−import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config";
40+import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
4141 import { mcpPresets, type McpPreset } from "../sdk/presets";
4242
4343 // The remote add flow REGISTERS the server's declared auth methods through the
@@ -322,7 +322,7 @@ export default function AddMcpSource(props: { · 278 unmodified lines
322322 // Every row registers as a declared method (a lone no-auth row registers
323323 // the open-server method). Slugs are assigned server-side by kind.
324324 const methods = authMethodList.rows.map((row: AuthMethodRow) =>
325− mcpAuthMethodInputFromEditorValue(row.value),
325+ mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(row.value)),
326326 );
327327 const slug = await registerIntegration(
328328 methods.length > 0 ? methods : [{ kind: "none" as const }],
packages/plugins/mcp/src/react/EditMcpSource.tsx+36−11
@@ -4,6 +4,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 3 unmodified lines
44 import * as Exit from "effect/Exit";
55
66 import { IntegrationSlug } from "@executor-js/sdk/shared";
7+import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth";
78 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
89 import {
910 AuthMethodListEditor,
@@ -24,10 +25,15 @@ import { Badge } from "@executor-js/react/components/badge"; · 14 unmodified lines
2425 import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
2526
2627 import { configureMcpAuth, mcpServerAtom } from "./atoms";
27−import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types";
28+import type {
29+ McpAuthMethod,
30+ McpCanonicalAuthMethodInput,
31+ McpIntegrationConfig,
32+} from "../sdk/types";
2833 import {
2934 editorValueFromMcpAuthMethod,
3035 mcpAuthMethodInputFromEditorValue,
36+ mcpWireAuthInput,
3137 } from "./auth-method-config";
3238
3339 type McpServer = {
@@ -43,10 +49,30 @@ type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; · 9 unmodified lines
4349
4450 const methodSeedLabel = (method: McpAuthMethod): string => {
4551 if (method.kind === "oauth2") return "OAuth";
46− if (method.kind === "header") return `API key (${method.headerName})`;
52+ if (method.kind === "apikey") return apiKeyMethodLabel(method);
4753 return "No authentication";
4854 };
4955
56+const samePlacements = (
57+ a: readonly AuthPlacement[] | undefined,
58+ b: readonly AuthPlacement[] | undefined,
59+): boolean => {
60+ const left = a ?? [];
61+ const right = b ?? [];
62+ if (left.length !== right.length) return false;
63+ return left.every((placement: AuthPlacement, index: number) => {
64+ const other = right[index];
65+ return (
66+ other !== undefined &&
67+ placement.carrier === other.carrier &&
68+ placement.name === other.name &&
69+ (placement.prefix ?? "") === (other.prefix ?? "") &&
70+ (placement.variable ?? "") === (other.variable ?? "") &&
71+ (placement.literal ?? null) === (other.literal ?? null)
72+ );
73+ });
74+};
75+
5076 // ---------------------------------------------------------------------------
5177 // Remote edit — v2: the integration's endpoint is part of its identity
5278 // (opaque-to-core config); the editable surface is the declared auth-method
@@ -80,9 +106,9 @@ function RemoteEdit(props: { · 27 unmodified lines
80106 // The edited methods, slugs preserved for seeded rows so existing
81107 // connections (bound by template slug) stay attached. New rows omit the
82108 // slug — the backend assigns kind-based ones.
83− const editedMethods = useMemo<readonly McpAuthMethodInput[]>(
109+ const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>(
84110 () =>
85− list.rows.map((row: AuthMethodRow): McpAuthMethodInput => {
111+ list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {
86112 const input = mcpAuthMethodInputFromEditorValue(row.value);
87113 return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;
88114 }),
@@ -92,16 +118,13 @@ function RemoteEdit(props: { · 3 unmodified lines
92118 const methodsChanged = useMemo(() => {
93119 const stored = server.config.authenticationTemplate;
94120 if (editedMethods.length !== stored.length) return true;
95− return editedMethods.some((method: McpAuthMethodInput, index: number) => {
121+ return editedMethods.some((method: McpCanonicalAuthMethodInput, index: number) => {
96122 const current = stored[index];
97123 if (!current) return true;
98124 if ((method.slug ?? "") !== current.slug) return true;
99125 if (method.kind !== current.kind) return true;
100− if (method.kind === "header" && current.kind === "header") {
101− return (
102− method.headerName !== current.headerName ||
103− (method.prefix ?? "") !== (current.prefix ?? "")
104− );
126+ if (method.kind === "apikey" && current.kind === "apikey") {
127+ return !samePlacements(method.placements, current.placements);
105128 }
106129 return false;
107130 });
@@ -114,7 +137,9 @@ function RemoteEdit(props: { · 6 unmodified lines
114137 params: { slug: server.slug },
115138 payload: {
116139 authenticationTemplate:
117− editedMethods.length > 0 ? editedMethods : [{ kind: "none" as const }],
140+ editedMethods.length > 0
141+ ? editedMethods.map(mcpWireAuthInput)
142+ : [{ kind: "none" as const }],
118143 mode: "replace",
119144 },
120145 reactivityKeys: integrationWriteKeys,
packages/plugins/mcp/src/react/McpAccountsPanel.tsx+6−2
@@ -15,7 +15,11 @@ import { · 14 unmodified lines
1515 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
1616
1717 import { configureMcpAuth, mcpServerAtom } from "./atoms";
18−import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config";
18+import {
19+ authMethodsFromConfig,
20+ mcpAuthMethodInputsFromPlacements,
21+ mcpWireAuthInput,
22+} from "./auth-method-config";
1923 import type { McpAuthMethod } from "../sdk/types";
2024
2125 // ---------------------------------------------------------------------------
@@ -60,7 +64,7 @@ export default function McpAccountsPanel(props: { · 38 unmodified lines
6064 const exit = await doConfigureAuth({
6165 params: { slug },
6266 payload: {
63− authenticationTemplate: input.authenticationTemplate,
67+ authenticationTemplate: input.authenticationTemplate.map(mcpWireAuthInput),
6468 ...(input.mode ? { mode: input.mode } : {}),
6569 },
6670 reactivityKeys: integrationWriteKeys,
packages/plugins/mcp/src/react/auth-method-config.test.ts+103−32
@@ -23,64 +23,88 @@ describe("mcpAuthMethodInputFromEditorValue", () => { · 22 unmodified lines
2323 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });
2424 });
2525
26− it("maps apiKey → a header method from the first named header placement (with prefix)", () => {
26+ it("maps a header placement to an apikey method (prefix preserved)", () => {
2727 const value: AuthTemplateEditorValue = {
2828 kind: "apikey",
2929 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
3030 };
3131 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
32− kind: "header",
33− headerName: "Authorization",
34− prefix: "Bearer ",
32+ kind: "apikey",
33+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
3534 });
3635 });
3736
38− it("omits the prefix when blank", () => {
37+ it("maps a query placement to an apikey method (servers like ui.sh use ?token=)", () => {
3938 const value: AuthTemplateEditorValue = {
4039 kind: "apikey",
41− placements: [{ carrier: "header", name: "X-Token", prefix: "" }],
40+ placements: [{ carrier: "query", name: "token", prefix: "" }],
4241 };
4342 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
44− kind: "header",
45− headerName: "X-Token",
46− });
47− });
48−
49− it("skips a query placement (MCP carries a single header) and falls back to none", () => {
50− const value: AuthTemplateEditorValue = {
5143 kind: "apikey",
52− placements: [{ carrier: "query", name: "api_key", prefix: "" }],
53− };
54− expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "none" });
44+ placements: [{ carrier: "query", name: "token" }],
45+ });
5546 });
5647
57− it("uses the first NAMED header placement (skips unnamed)", () => {
48+ it("keeps EVERY named placement — header + query mix in one method", () => {
5849 const value: AuthTemplateEditorValue = {
5950 kind: "apikey",
6051 placements: [
61− { carrier: "header", name: "", prefix: "" },
62− { carrier: "header", name: "X-Token", prefix: "" },
52+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
53+ { carrier: "query", name: "team_id", prefix: "" },
6354 ],
6455 };
6556 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
66− kind: "header",
67− headerName: "X-Token",
57+ kind: "apikey",
58+ placements: [
59+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" },
60+ { carrier: "query", name: "team_id", variable: "team_id" },
61+ ],
6862 });
6963 });
64+
65+ it("drops unnamed placements and degrades to none when nothing is usable", () => {
66+ expect(
67+ mcpAuthMethodInputFromEditorValue({
68+ kind: "apikey",
69+ placements: [{ carrier: "header", name: " ", prefix: "" }],
70+ }),
71+ ).toEqual({ kind: "none" });
72+ });
7073 });
7174
7275 describe("editorValueFromMcpAuthMethod", () => {
73− it("round-trips a header method into an apikey editor value", () => {
76+ it("round-trips an apikey method, making the shared token variable explicit", () => {
7477 expect(
7578 editorValueFromMcpAuthMethod({
7679 slug: "header",
77− kind: "header",
78− headerName: "X-Api-Key",
79− prefix: "Bearer ",
80+ kind: "apikey",
81+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
8082 }),
8183 ).toEqual({
8284 kind: "apikey",
83− placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
85+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer ", variable: "token" }],
86+ });
87+ });
88+
89+ it("round-trip edit preserves placement variables (sharing survives)", () => {
90+ const stored = {
91+ slug: "custom_two_spots",
92+ kind: "apikey",
93+ placements: [
94+ { carrier: "header", name: "X-Token" },
95+ { carrier: "query", name: "token" },
96+ ],
97+ } as const;
98+ const editor = editorValueFromMcpAuthMethod(stored);
99+ const back = mcpAuthMethodInputFromEditorValue(editor);
100+ // Both placements still share the canonical `token` input (stored as
101+ // absent on the wire) — a round-trip must not split one credential in two.
102+ expect(back).toEqual({
103+ kind: "apikey",
104+ placements: [
105+ { carrier: "header", name: "X-Token" },
106+ { carrier: "query", name: "token" },
107+ ],
84108 });
85109 });
86110
@@ -99,7 +123,11 @@ describe("authMethodsFromConfig", () => { · 12 unmodified lines
99123 const methods = authMethodsFromConfig(
100124 [
101125 { slug: "oauth2", kind: "oauth2" },
102− { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" },
126+ {
127+ slug: "custom_abc123",
128+ kind: "apikey",
129+ placements: [{ carrier: "header", name: "X-Api-Key" }],
130+ },
103131 { slug: "none", kind: "none" },
104132 ],
105133 "https://mcp.example.com/mcp",
@@ -119,21 +147,64 @@ describe("authMethodsFromConfig", () => { · 13 unmodified lines
119147 ]);
120148 expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
121149 });
150+
151+ it("carries multi-placement methods through to the hub", () => {
152+ const methods = authMethodsFromConfig(
153+ [
154+ {
155+ slug: "custom_mix",
156+ kind: "apikey",
157+ placements: [
158+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
159+ { carrier: "query", name: "team_id", variable: "team_id" },
160+ ],
161+ },
162+ ],
163+ "https://mcp.example.com/mcp",
164+ );
165+ expect(methods[0]?.placements).toEqual([
166+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
167+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
168+ ]);
169+ });
122170 });
123171
124172 describe("mcpAuthMethodInputsFromPlacements", () => {
125− it("builds one header method from the first named header placement", () => {
173+ it("builds ONE method carrying every named placement", () => {
126174 expect(
127175 mcpAuthMethodInputsFromPlacements([
128− { carrier: "query", name: "api_key", prefix: "" },
129176 { carrier: "header", name: "X-Token", prefix: "Bearer " },
177+ { carrier: "query", name: "team_id", prefix: "" },
178+ ]),
179+ ).toEqual([
180+ {
181+ kind: "apikey",
182+ placements: [
183+ { carrier: "header", name: "X-Token", prefix: "Bearer ", variable: "x_token" },
184+ { carrier: "query", name: "team_id", variable: "team_id" },
185+ ],
186+ },
187+ ]);
188+ });
189+
190+ it("builds a query method from a query placement (the ui.sh '?token=' case)", () => {
191+ expect(
192+ mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "token", prefix: "" }]),
193+ ).toEqual([{ kind: "apikey", placements: [{ carrier: "query", name: "token" }] }]);
194+ });
195+
196+ it("skips unnamed placements", () => {
197+ expect(
198+ mcpAuthMethodInputsFromPlacements([
199+ { carrier: "query", name: "", prefix: "" },
200+ { carrier: "query", name: "token", prefix: "" },
130201 ]),
131− ).toEqual([{ kind: "header", headerName: "X-Token", prefix: "Bearer " }]);
202+ ).toEqual([{ kind: "apikey", placements: [{ carrier: "query", name: "token" }] }]);
132203 });
133204
134− it("is empty when no usable header placement exists", () => {
205+ it("is empty when no placement has a usable name", () => {
135206 expect(
136− mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }]),
207+ mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: " ", prefix: "" }]),
137208 ).toEqual([]);
138209 });
139210 });
packages/plugins/mcp/src/react/auth-method-config.ts+47−79
@@ -1,40 +1,51 @@
11 // ---------------------------------------------------------------------------
2−// MCP ↔ generic auth-template converter.
3−//
4−// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the
5−// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP
6−// stores a slugged `authenticationTemplate` array (`none` / `header` /
7−// `oauth2`, one method per entry — a header method carries a single header,
8−// not an array). These converters live with the MCP plugin because they touch
9−// the transport-specific `McpAuthMethod` types.
2+// MCP ↔ generic auth-method converters — a thin oauth adapter over the shared
3+// codec (`@executor-js/react/lib/shared-auth-method-codec`). The apikey/none
4+// paths (multi-placement, multi-variable) live in the shared codec; MCP only
5+// contributes its oauth flavor: endpoint-less methods whose metadata is
6+// discovered at connect time (`discoveryUrl` = the MCP endpoint).
107 // ---------------------------------------------------------------------------
118
129 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1310 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
1411 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
12+import {
13+ authMethodFromSharedTemplate,
14+ editorValueFromSharedMethod,
15+ sharedMethodInputFromEditorValue,
16+ wirePlacementsFromEditor,
17+} from "@executor-js/react/lib/shared-auth-method-codec";
1518
16−import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types";
19+import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec";
20+import type { McpAuthMethod, McpAuthMethodInput, McpCanonicalAuthMethodInput } from "../sdk/types";
21+
22+/** Serialize a canonical method into the wire input union (apikey → the
23+ * request-shaped dialect; none/oauth2 pass through). */
24+export const mcpWireAuthInput = (
25+ method: McpAuthMethod | McpCanonicalAuthMethodInput,
26+): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;
27+
28+const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({
29+ id: slug,
30+ label: "OAuth",
31+ kind: "oauth",
32+ source: slug.startsWith("custom_") ? "custom" : "spec",
33+ template: AuthTemplateSlug.make(slug),
34+ placements: [],
35+ oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true },
36+});
1737
1838 /** Convert a generic editor value into one MCP auth-method input (no slug —
19− * the backend assigns kind-based slugs). An apiKey method maps to a `header`
20− * method using its FIRST named header placement, preserving the prefix; an
21− * apiKey value with no usable header placement falls back to `none`. */
39+ * the backend assigns carrier-derived slugs). An apikey value keeps every
40+ * named placement (headers and query params mix freely); one with no usable
41+ * placement falls back to `none`. */
2242 export function mcpAuthMethodInputFromEditorValue(
2343 value: AuthTemplateEditorValue,
24−): McpAuthMethodInput {
44+): McpCanonicalAuthMethodInput {
2545 if (value.kind === "oauth") return { kind: "oauth2" };
26− if (value.kind === "apikey") {
27− const header = value.placements.find(
28− (placement) => placement.carrier === "header" && placement.name.trim().length > 0,
29− );
30− if (!header) return { kind: "none" };
31− return {
32− kind: "header",
33− headerName: header.name.trim(),
34− ...(header.prefix ? { prefix: header.prefix } : {}),
35− };
36− }
37− return { kind: "none" };
46+ return (sharedMethodInputFromEditorValue(value) ?? {
47+ kind: "none",
48+ }) as McpCanonicalAuthMethodInput;
3849 }
3950
4051 /** Convert one stored MCP method into the generic editor value. */
@@ -42,13 +53,7 @@ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplat · 1 unmodified line
4253 if (method.kind === "oauth2") {
4354 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
4455 }
45− if (method.kind === "header") {
46− return {
47− kind: "apikey",
48− placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
49− };
50− }
51− return { kind: "none" };
56+ return editorValueFromSharedMethod(method);
5257 }
5358
5459 /** Project the stored methods into the generic `AuthMethod[]` the hub renders.
@@ -60,56 +65,19 @@ export function authMethodsFromConfig( · 5 unmodified lines
6065 endpoint: string,
6166 ): AuthMethod[] {
6267 return methods.map((method: McpAuthMethod): AuthMethod => {
63− const source: "spec" | "custom" = method.slug.startsWith("custom_") ? "custom" : "spec";
64− const template = AuthTemplateSlug.make(method.slug);
65− if (method.kind === "oauth2") {
66− return {
67− id: method.slug,
68− label: "OAuth",
69− kind: "oauth",
70− source,
71− template,
72− placements: [],
73− oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true },
74− };
75− }
76− if (method.kind === "header") {
77− return {
78− id: method.slug,
79− label: `API key (${method.headerName})`,
80− kind: "apikey",
81− source,
82− template,
83− placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
84− };
85− }
86− return {
87− id: method.slug,
88− label: "No authentication",
89− kind: "none",
90− source,
91− template,
92− placements: [],
93− };
68+ if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);
69+ return authMethodFromSharedTemplate(method);
9470 });
9571 }
9672
97−/** Build MCP header-method inputs for a custom method from generic placements.
98− * MCP carries one header per method, so only the FIRST named header placement
99− * is used (query placements have no MCP credential carrier). Empty when no
100− * usable header placement exists. */
73+/** Build the MCP method input for a custom method from generic placements —
74+ * ONE method carrying every named placement (header + query mix in a single
75+ * method; each placement renders from its own input variable, or shares one).
76+ * Empty when no placement is usable. */
10177 export function mcpAuthMethodInputsFromPlacements(
10278 placements: readonly Placement[],
103−): McpAuthMethodInput[] {
104− const header = placements.find(
105− (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0,
106− );
107− if (!header) return [];
108− return [
109− {
110− kind: "header",
111− headerName: header.name.trim(),
112− ...(header.prefix ? { prefix: header.prefix } : {}),
113− },
114− ];
79+): McpCanonicalAuthMethodInput[] {
80+ const wire = wirePlacementsFromEditor(placements);
81+ if (wire.length === 0) return [];
82+ return [{ kind: "apikey", placements: wire }];
11583 }
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+63−46
@@ -7,8 +7,9 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 6 unmodified lines
77 // `describeMcpAuthMethods` projects the stored MCP config into the catalog's
88 // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is
99 // pure/sync and must tolerate a malformed or foreign config blob by returning
10−// `[]`. Legacy configs (singular `auth`, or no auth field at all) are lifted
11−// into a one-element `authenticationTemplate` whose slug is the method kind.
10+// `[]`. Pre-canonical blobs are rewritten by the one-off config migration
11+// (see migrate-config.test.ts), so this projection only ever sees the
12+// canonical placements model.
1213 // ---------------------------------------------------------------------------
1314
1415 const recordWith = (config: IntegrationConfig): IntegrationRecord => ({
@@ -45,13 +46,17 @@ describe("describeMcpAuthMethods", () => { · 30 unmodified lines
4546 ]);
4647 });
4748
48− it("projects a header method to an apikey method carrying the header placement", () => {
49+ it("projects an apikey header method carrying the placement", () => {
4950 const methods = describeMcpAuthMethods(
5051 recordWith({
5152 transport: "remote",
5253 endpoint: "https://x.example/mcp",
5354 authenticationTemplate: [
54− { slug: "header", kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
55+ {
56+ slug: "header",
57+ kind: "apikey",
58+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
59+ },
5560 ],
5661 }),
5762 );
@@ -67,83 +72,85 @@ describe("describeMcpAuthMethods", () => { · 9 unmodified lines
6772 ]);
6873 });
6974
70− it("projects every declared method (multi-method configs)", () => {
75+ it("projects an apikey query method (the ui.sh '?token=' shape)", () => {
7176 const methods = describeMcpAuthMethods(
7277 recordWith({
7378 transport: "remote",
74− endpoint: "https://x.example/mcp",
79+ endpoint: "https://ui.sh/mcp",
7580 authenticationTemplate: [
76− { slug: "oauth2", kind: "oauth2" },
77− { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" },
81+ { slug: "query", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
7882 ],
7983 }),
8084 );
8185
82− expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([
83− { id: "oauth2", kind: "oauth", template: "oauth2" },
84− { id: "custom_abc123", kind: "apikey", template: "custom_abc123" },
86+ expect(methods).toEqual([
87+ {
88+ id: "query",
89+ label: "API key (token)",
90+ kind: "apikey",
91+ template: "query",
92+ placements: [{ carrier: "query", name: "token", prefix: "" }],
93+ },
8594 ]);
8695 });
8796
88− it("defaults the header prefix to an empty string when unset", () => {
97+ it("projects a mixed header+query method with per-placement variables", () => {
8998 const methods = describeMcpAuthMethods(
9099 recordWith({
91100 transport: "remote",
92101 endpoint: "https://x.example/mcp",
93− authenticationTemplate: [{ slug: "header", kind: "header", headerName: "Authorization" }],
102+ authenticationTemplate: [
103+ {
104+ slug: "custom_mix",
105+ kind: "apikey",
106+ placements: [
107+ {
108+ carrier: "header",
109+ name: "Authorization",
110+ prefix: "Bearer ",
111+ variable: "api_token",
112+ },
113+ { carrier: "query", name: "team_id", variable: "team_id" },
114+ ],
115+ },
116+ ],
94117 }),
95118 );
96119
97120 expect(methods[0]?.placements).toEqual([
98− { carrier: "header", name: "Authorization", prefix: "" },
121+ { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
122+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
99123 ]);
100124 });
101125
102− it("projects an open (none) method to a no-auth method", () => {
126+ it("projects every declared method (multi-method configs)", () => {
103127 const methods = describeMcpAuthMethods(
104128 recordWith({
105129 transport: "remote",
106130 endpoint: "https://x.example/mcp",
107− authenticationTemplate: [{ slug: "none", kind: "none" }],
131+ authenticationTemplate: [
132+ { slug: "oauth2", kind: "oauth2" },
133+ {
134+ slug: "custom_abc123",
135+ kind: "apikey",
136+ placements: [{ carrier: "header", name: "X-Api-Key" }],
137+ },
138+ ],
108139 }),
109140 );
110− expect(methods).toEqual([
111− {
112− id: "none",
113− label: "No authentication",
114− kind: "none",
115− template: "none",
116− },
117− ]);
118− });
119141
120− it("lifts a legacy singular `auth` config into one method slugged by kind", () => {
121− const methods = describeMcpAuthMethods(
122− recordWith({
123− transport: "remote",
124− endpoint: "https://x.example/oauth/mcp",
125− auth: { kind: "oauth2" },
126− }),
127− );
128− expect(methods).toEqual([
129− {
130− id: "oauth2",
131− label: "OAuth",
132− kind: "oauth",
133− template: "oauth2",
134− oauth: {
135− discoveryUrl: "https://x.example/oauth/mcp",
136− supportsDynamicRegistration: true,
137− },
138− },
142+ expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([
143+ { id: "oauth2", kind: "oauth", template: "oauth2" },
144+ { id: "custom_abc123", kind: "apikey", template: "custom_abc123" },
139145 ]);
140146 });
141147
142− it("treats legacy remote configs with no auth field as open/no-auth", () => {
148+ it("projects an open (none) method to a no-auth method", () => {
143149 const methods = describeMcpAuthMethods(
144150 recordWith({
145151 transport: "remote",
146152 endpoint: "https://x.example/mcp",
153+ authenticationTemplate: [{ slug: "none", kind: "none" }],
147154 }),
148155 );
149156 expect(methods).toEqual([
@@ -163,10 +170,20 @@ describe("describeMcpAuthMethods", () => { · 13 unmodified lines
163170 expect(methods).toEqual([]);
164171 });
165172
166− it("returns [] for a malformed / foreign config blob", () => {
173+ it("returns [] for a malformed / foreign / pre-migration config blob", () => {
167174 expect(describeMcpAuthMethods(recordWith({ not: "an mcp config" }))).toEqual([]);
168175 expect(describeMcpAuthMethods(recordWith(null))).toEqual([]);
169176 expect(describeMcpAuthMethods(recordWith("garbage"))).toEqual([]);
177+ // Pre-canonical blobs are the config migration's job, not the projection's.
178+ expect(
179+ describeMcpAuthMethods(
180+ recordWith({
181+ transport: "remote",
182+ endpoint: "https://x.example/mcp",
183+ authenticationTemplate: [{ slug: "header", kind: "header", headerName: "X" }],
184+ }),
185+ ),
186+ ).toEqual([]);
170187 });
171188
172189 it("projects remote endpoint as display metadata", () => {
packages/plugins/mcp/src/sdk/index.ts+8−1
@@ -12,7 +12,9 @@ export { · 11 unmodified lines
1212 } from "./plugin";
1313
1414 export {
15− McpAuthTemplate,
15+ McpAuthMethod,
16+ McpAuthMethodInput,
17+ McpAuthShorthand,
1618 McpIntegrationConfig,
1719 McpRemoteIntegrationConfig,
1820 McpStdioIntegrationConfig,
@@ -23,6 +25,11 @@ export { · 4 unmodified lines
2325 parseMcpIntegrationConfig,
2426 } from "./types";
2527
28+export { migrateMcpAuthConfig } from "./migrate-config";
29+
30+// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
31+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
32+
2633 export {
2734 McpConnectionError,
2835 McpToolDiscoveryError,
packages/plugins/mcp/src/sdk/migrate-config.test.ts+165−0
@@ -0,0 +1,165 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { migrateMcpAuthConfig } from "./migrate-config";
4+import { parseMcpIntegrationConfig } from "./types";
5+
6+// ---------------------------------------------------------------------------
7+// The one-off config migration rewrites every pre-canonical MCP blob family
8+// into the canonical placements model. Two invariants are load-bearing:
9+// slugs are preserved verbatim (connections bind by `connection.template`)
10+// and variable names are preserved verbatim (they key stored `item_ids`).
11+// ---------------------------------------------------------------------------
12+
13+const REMOTE = { transport: "remote", endpoint: "https://x.example/mcp" } as const;
14+
15+describe("migrateMcpAuthConfig", () => {
16+ it("lifts a singular `auth: {kind:'header'}` into a one-method template, slug = kind", () => {
17+ const migrated = migrateMcpAuthConfig({
18+ ...REMOTE,
19+ auth: { kind: "header", headerName: "Authorization", prefix: "Bearer " },
20+ });
21+ expect(migrated).toEqual({
22+ ...REMOTE,
23+ authenticationTemplate: [
24+ {
25+ slug: "header",
26+ kind: "apikey",
27+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
28+ },
29+ ],
30+ });
31+ expect(parseMcpIntegrationConfig(migrated)).not.toBeNull();
32+ });
33+
34+ it("lifts a singular `auth: {kind:'oauth2'}` (the v1→v2 migration's output)", () => {
35+ expect(migrateMcpAuthConfig({ ...REMOTE, auth: { kind: "oauth2" } })).toEqual({
36+ ...REMOTE,
37+ authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }],
38+ });
39+ });
40+
41+ it("treats a remote config with no auth field as an open server", () => {
42+ expect(migrateMcpAuthConfig({ ...REMOTE })).toEqual({
43+ ...REMOTE,
44+ authenticationTemplate: [{ slug: "none", kind: "none" }],
45+ });
46+ });
47+
48+ it("rewrites retired single-placement `header` / `query` methods, slugs preserved", () => {
49+ const migrated = migrateMcpAuthConfig({
50+ ...REMOTE,
51+ authenticationTemplate: [
52+ { slug: "header", kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },
53+ { slug: "custom_q1", kind: "query", paramName: "token" },
54+ { slug: "none", kind: "none" },
55+ ],
56+ });
57+ expect(migrated).toEqual({
58+ ...REMOTE,
59+ authenticationTemplate: [
60+ {
61+ slug: "header",
62+ kind: "apikey",
63+ placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
64+ },
65+ {
66+ slug: "custom_q1",
67+ kind: "apikey",
68+ placements: [{ carrier: "query", name: "token" }],
69+ },
70+ { slug: "none", kind: "none" },
71+ ],
72+ });
73+ });
74+
75+ it("rewrites openapi-shaped entries the v1→v2 migration wrote, variables preserved", () => {
76+ // Exactly what `migrateOpenApiAuthTemplate` emits for a two-input source.
77+ const migrated = migrateMcpAuthConfig({
78+ ...REMOTE,
79+ authenticationTemplate: [
80+ {
81+ slug: "apiKey",
82+ type: "apiKey",
83+ headers: {
84+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
85+ Authorization: ["Bearer ", { type: "variable", name: "dd_application_key" }],
86+ },
87+ queryParams: { team: [{ type: "variable", name: "team" }] },
88+ },
89+ ],
90+ });
91+ expect(migrated).toEqual({
92+ ...REMOTE,
93+ authenticationTemplate: [
94+ {
95+ slug: "apiKey",
96+ kind: "apikey",
97+ placements: [
98+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
99+ {
100+ carrier: "header",
101+ name: "Authorization",
102+ prefix: "Bearer ",
103+ variable: "dd_application_key",
104+ },
105+ { carrier: "query", name: "team", variable: "team" },
106+ ],
107+ },
108+ ],
109+ });
110+ });
111+
112+ it("stores the canonical `token` variable as absent and literal-only values as literals", () => {
113+ const migrated = migrateMcpAuthConfig({
114+ ...REMOTE,
115+ authenticationTemplate: [
116+ {
117+ slug: "apiKey",
118+ type: "apiKey",
119+ headers: {
120+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
121+ "X-Api-Version": "2023-01-01",
122+ },
123+ },
124+ ],
125+ }) as { authenticationTemplate: readonly unknown[] };
126+ expect(migrated.authenticationTemplate[0]).toEqual({
127+ slug: "apiKey",
128+ kind: "apikey",
129+ placements: [
130+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
131+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
132+ ],
133+ });
134+ });
135+
136+ it("is idempotent — a canonical config returns null (no rewrite)", () => {
137+ const canonical = {
138+ ...REMOTE,
139+ authenticationTemplate: [
140+ { slug: "oauth2", kind: "oauth2" },
141+ {
142+ slug: "header",
143+ kind: "apikey",
144+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
145+ },
146+ ],
147+ };
148+ expect(migrateMcpAuthConfig(canonical)).toBeNull();
149+ // And the lifted output of every family is itself canonical.
150+ const lifted = migrateMcpAuthConfig({ ...REMOTE, auth: { kind: "oauth2" } });
151+ expect(migrateMcpAuthConfig(lifted)).toBeNull();
152+ });
153+
154+ it("leaves stdio, foreign, and unmigratable blobs untouched", () => {
155+ expect(migrateMcpAuthConfig({ transport: "stdio", command: "run" })).toBeNull();
156+ expect(migrateMcpAuthConfig({ not: "mcp" })).toBeNull();
157+ expect(migrateMcpAuthConfig(null)).toBeNull();
158+ expect(
159+ migrateMcpAuthConfig({
160+ ...REMOTE,
161+ authenticationTemplate: [{ slug: "x", kind: "mystery" }],
162+ }),
163+ ).toBeNull();
164+ });
165+});
packages/plugins/mcp/src/sdk/migrate-config.ts+185−0
@@ -0,0 +1,185 @@
1+// ---------------------------------------------------------------------------
2+// One-off config migration — rewrite pre-canonical MCP integration `config`
3+// blobs into the canonical placements model. Runs once per database (boot for
4+// local/selfhost, out-of-band script for cloud); runtime code decodes ONLY
5+// the canonical shape (`parseMcpIntegrationConfig`).
6+//
7+// Stored legacy families (all observed in real databases):
8+// 1. singular `auth: {kind:"none"|"header"|"oauth2"}` (pre-array configs),
9+// or a remote config with no auth field at all (open server)
10+// 2. `authenticationTemplate` entries of the retired single-placement
11+// shapes: `{slug, kind:"header", headerName, prefix?}` /
12+// `{slug, kind:"query", paramName, prefix?}`
13+// 3. openapi-shaped entries the v1→v2 migration wrote:
14+// `{slug, type:"apiKey", headers/queryParams: <template values>}` and
15+// `{slug, type:"oauth", …}`
16+//
17+// Invariants: slugs are preserved verbatim (`connection.template` binds by
18+// them) and so are variable names (they key the connections' stored
19+// `item_ids`).
20+// ---------------------------------------------------------------------------
21+
22+import { Option, Schema } from "effect";
23+import {
24+ apiKeyMethodFromLegacyTemplate,
25+ decodeLegacyApiKeyTemplate,
26+} from "@executor-js/sdk/http-auth";
27+
28+import { parseMcpIntegrationConfig, type McpAuthMethod } from "./types";
29+
30+const LegacySingleAuth = Schema.Union([
31+ Schema.Struct({ kind: Schema.Literal("none") }),
32+ Schema.Struct({
33+ kind: Schema.Literal("header"),
34+ headerName: Schema.String,
35+ prefix: Schema.optional(Schema.String),
36+ }),
37+ Schema.Struct({
38+ kind: Schema.Literal("query"),
39+ paramName: Schema.String,
40+ prefix: Schema.optional(Schema.String),
41+ }),
42+ Schema.Struct({ kind: Schema.Literal("oauth2") }),
43+]);
44+const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth);
45+
46+const LegacyHeaderMethod = Schema.Struct({
47+ slug: Schema.String,
48+ kind: Schema.Literal("header"),
49+ headerName: Schema.String,
50+ prefix: Schema.optional(Schema.String),
51+});
52+const LegacyQueryMethod = Schema.Struct({
53+ slug: Schema.String,
54+ kind: Schema.Literal("query"),
55+ paramName: Schema.String,
56+ prefix: Schema.optional(Schema.String),
57+});
58+const LegacyOAuthTemplate = Schema.Struct({
59+ slug: Schema.String,
60+ type: Schema.Literal("oauth"),
61+});
62+const decodeLegacyHeaderMethod = Schema.decodeUnknownOption(LegacyHeaderMethod);
63+const decodeLegacyQueryMethod = Schema.decodeUnknownOption(LegacyQueryMethod);
64+const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate);
65+
66+const CanonicalMethod = Schema.decodeUnknownOption(
67+ Schema.Union([
68+ Schema.Struct({ slug: Schema.String, kind: Schema.Literals(["none", "oauth2"]) }),
69+ Schema.Struct({
70+ slug: Schema.String,
71+ kind: Schema.Literal("apikey"),
72+ label: Schema.optional(Schema.String),
73+ placements: Schema.Array(Schema.Unknown),
74+ }),
75+ ]),
76+);
77+
78+/** Rewrite one stored method entry to canonical, or null when undecodable. */
79+const migrateMethodEntry = (entry: unknown): McpAuthMethod | null => {
80+ const canonical = Option.getOrNull(CanonicalMethod(entry));
81+ if (canonical !== null) return entry as McpAuthMethod;
82+
83+ const header = Option.getOrNull(decodeLegacyHeaderMethod(entry));
84+ if (header !== null) {
85+ return {
86+ slug: header.slug,
87+ kind: "apikey",
88+ placements: [
89+ {
90+ carrier: "header",
91+ name: header.headerName,
92+ ...(header.prefix !== undefined ? { prefix: header.prefix } : {}),
93+ },
94+ ],
95+ };
96+ }
97+
98+ const query = Option.getOrNull(decodeLegacyQueryMethod(entry));
99+ if (query !== null) {
100+ return {
101+ slug: query.slug,
102+ kind: "apikey",
103+ placements: [
104+ {
105+ carrier: "query",
106+ name: query.paramName,
107+ ...(query.prefix !== undefined ? { prefix: query.prefix } : {}),
108+ },
109+ ],
110+ };
111+ }
112+
113+ const legacyApiKey = Option.getOrNull(decodeLegacyApiKeyTemplate(entry));
114+ if (legacyApiKey !== null) return apiKeyMethodFromLegacyTemplate(legacyApiKey);
115+
116+ const legacyOAuth = Option.getOrNull(decodeLegacyOAuthTemplate(entry));
117+ if (legacyOAuth !== null) return { slug: legacyOAuth.slug, kind: "oauth2" };
118+
119+ return null;
120+};
121+
122+const slugFromSingleAuth = (auth: typeof LegacySingleAuth.Type): string => auth.kind;
123+
124+const methodFromSingleAuth = (auth: typeof LegacySingleAuth.Type): McpAuthMethod => {
125+ if (auth.kind === "header") {
126+ return {
127+ slug: slugFromSingleAuth(auth),
128+ kind: "apikey",
129+ placements: [
130+ {
131+ carrier: "header",
132+ name: auth.headerName,
133+ ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}),
134+ },
135+ ],
136+ };
137+ }
138+ if (auth.kind === "query") {
139+ return {
140+ slug: slugFromSingleAuth(auth),
141+ kind: "apikey",
142+ placements: [
143+ {
144+ carrier: "query",
145+ name: auth.paramName,
146+ ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}),
147+ },
148+ ],
149+ };
150+ }
151+ return { slug: auth.kind, kind: auth.kind };
152+};
153+
154+/** Rewrite a stored MCP integration config blob into the canonical shape.
155+ * Returns the rewritten config, or `null` when no rewrite is needed (already
156+ * canonical, a stdio config, or not this plugin's shape at all). Throws
157+ * nothing: an unmigratable blob also returns `null` and stays untouched.
158+ * Idempotent — feeding the output back returns `null`. */
159+export const migrateMcpAuthConfig = (config: unknown): unknown | null => {
160+ if (typeof config !== "object" || config === null) return null;
161+ if (!("transport" in config) || config.transport !== "remote") return null;
162+
163+ if (!("authenticationTemplate" in config)) {
164+ // Family 1: singular `auth`, or an open server with no auth field.
165+ const auth =
166+ "auth" in config
167+ ? Option.getOrNull(decodeLegacySingleAuth(config.auth))
168+ : { kind: "none" as const };
169+ if (auth === null) return null;
170+ const { auth: _auth, ...rest } = config as { auth?: unknown };
171+ const migrated = { ...rest, authenticationTemplate: [methodFromSingleAuth(auth)] };
172+ return parseMcpIntegrationConfig(migrated) !== null ? migrated : null;
173+ }
174+
175+ const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate;
176+ if (!Array.isArray(entries)) return null;
177+
178+ const migrated = entries.map(migrateMethodEntry);
179+ if (migrated.some((entry) => entry === null)) return null;
180+ const changed = migrated.some((entry, index) => entry !== entries[index]);
181+ if (!changed) return null;
182+
183+ const next = { ...config, authenticationTemplate: migrated };
184+ return parseMcpIntegrationConfig(next) !== null ? next : null;
185+};
packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts+228−0
@@ -0,0 +1,228 @@
1+// ---------------------------------------------------------------------------
2+// MCP multi-placement auth (wire-level)
3+//
4+// The extreme case the unified model exists for: ONE auth method that mixes a
5+// bearer HEADER and a team-id QUERY PARAM, each rendered from its own
6+// credential input — plus the single-placement query case (servers like ui.sh
7+// authenticate via `?token=`), and the explicit failure when an input is
8+// missing. Methods are authored request-shaped (the one input dialect); assertions are
9+// made against what the server actually RECEIVED.
10+// ---------------------------------------------------------------------------
11+
12+import { describe, expect, it } from "@effect/vitest";
13+import { Effect } from "effect";
14+
15+import {
16+ AuthTemplateSlug,
17+ ConnectionName,
18+ IntegrationSlug,
19+ ToolAddress,
20+ createExecutor,
21+} from "@executor-js/sdk";
22+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
23+
24+import { mcpPlugin } from "./plugin";
25+import { variable } from "@executor-js/sdk/http-auth";
26+import { makeEchoMcpServer, serveMcpServer } from "../testing";
27+
28+const serveRecordingServer = serveMcpServer(() =>
29+ makeEchoMcpServer({
30+ name: "multi-auth-test",
31+ toolName: "whoami",
32+ toolDescription: "Echoes a marker so the test can prove the invoke reached the server",
33+ inputName: "marker",
34+ text: (marker) => `ok:${marker}`,
35+ }),
36+);
37+
38+describe("MCP multi-placement auth", () => {
39+ it.effect("one method renders a bearer header AND a team-id query param", () =>
40+ Effect.gen(function* () {
41+ const server = yield* serveRecordingServer;
42+ const executor = yield* createExecutor(
43+ makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
44+ );
45+
46+ yield* executor.mcp.addServer({
47+ name: "Mixed MCP",
48+ endpoint: server.url,
49+ slug: "mixed_mcp",
50+ authenticationTemplate: [
51+ {
52+ slug: "token_and_team",
53+ type: "apiKey",
54+ headers: { Authorization: ["Bearer ", variable("api_token")] },
55+ queryParams: { team_id: [variable("team_id")] },
56+ },
57+ ],
58+ });
59+
60+ yield* executor.connections.create({
61+ owner: "org",
62+ name: ConnectionName.make("main"),
63+ integration: IntegrationSlug.make("mixed_mcp"),
64+ template: AuthTemplateSlug.make("token_and_team"),
65+ values: { api_token: "tok_A", team_id: "team_B" },
66+ });
67+
68+ const before = (yield* server.requests).length;
69+ const result = yield* executor.execute(
70+ ToolAddress.make("tools.mixed_mcp.org.main.whoami"),
71+ { marker: "mixed" },
72+ { onElicitation: "accept-all" },
73+ );
74+ expect(result).toMatchObject({
75+ ok: true,
76+ data: { content: [{ type: "text", text: "ok:mixed" }] },
77+ });
78+
79+ const requests = (yield* server.requests).slice(before);
80+ expect(requests.length).toBeGreaterThan(0);
81+ // BOTH carriers rendered, each from its own credential input.
82+ expect(requests.every((request) => request.authorization === "Bearer tok_A")).toBe(true);
83+ expect(requests.every((request) => request.url.includes("team_id=team_B"))).toBe(true);
84+ }),
85+ );
86+
87+ it.effect("a query-only method renders ?token= and no Authorization header (ui.sh)", () =>
88+ Effect.gen(function* () {
89+ const server = yield* serveRecordingServer;
90+ const executor = yield* createExecutor(
91+ makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
92+ );
93+
94+ yield* executor.mcp.addServer({
95+ name: "Query MCP",
96+ endpoint: server.url,
97+ slug: "query_mcp",
98+ authenticationTemplate: [{ type: "apiKey", queryParams: { token: [variable("token")] } }],
99+ });
100+
101+ // Slug-less single-query-placement methods get the `query` slug.
102+ yield* executor.connections.create({
103+ owner: "org",
104+ name: ConnectionName.make("main"),
105+ integration: IntegrationSlug.make("query_mcp"),
106+ template: AuthTemplateSlug.make("query"),
107+ value: "tok_secret_123",
108+ });
109+
110+ const before = (yield* server.requests).length;
111+ const result = yield* executor.execute(
112+ ToolAddress.make("tools.query_mcp.org.main.whoami"),
113+ { marker: "qconn" },
114+ { onElicitation: "accept-all" },
115+ );
116+ expect(result).toMatchObject({ ok: true });
117+
118+ const requests = (yield* server.requests).slice(before);
119+ expect(requests.length).toBeGreaterThan(0);
120+ expect(requests.every((request) => request.url.includes("token=tok_secret_123"))).toBe(true);
121+ expect(requests.every((request) => request.authorization === undefined)).toBe(true);
122+ }),
123+ );
124+
125+ it.effect("a connection binding one method does not leak into another method's shape", () =>
126+ Effect.gen(function* () {
127+ const server = yield* serveRecordingServer;
128+ const executor = yield* createExecutor(
129+ makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
130+ );
131+
132+ // Two declared methods; each connection picks one by template slug.
133+ yield* executor.mcp.addServer({
134+ name: "Multi MCP",
135+ endpoint: server.url,
136+ slug: "multi_mcp",
137+ authenticationTemplate: [
138+ {
139+ slug: "bearer",
140+ type: "apiKey",
141+ headers: { Authorization: ["Bearer ", variable("token")] },
142+ },
143+ {
144+ slug: "query_token",
145+ type: "apiKey",
146+ queryParams: { auth_token: [variable("token")] },
147+ },
148+ ],
149+ });
150+
151+ yield* executor.connections.create({
152+ owner: "org",
153+ name: ConnectionName.make("hconn"),
154+ integration: IntegrationSlug.make("multi_mcp"),
155+ template: AuthTemplateSlug.make("bearer"),
156+ value: "header-secret",
157+ });
158+ yield* executor.connections.create({
159+ owner: "org",
160+ name: ConnectionName.make("qconn"),
161+ integration: IntegrationSlug.make("multi_mcp"),
162+ template: AuthTemplateSlug.make("query_token"),
163+ value: "query-secret",
164+ });
165+
166+ const beforeHeader = (yield* server.requests).length;
167+ yield* executor.execute(
168+ ToolAddress.make("tools.multi_mcp.org.hconn.whoami"),
169+ { marker: "h" },
170+ { onElicitation: "accept-all" },
171+ );
172+ const headerRequests = (yield* server.requests).slice(beforeHeader);
173+ expect(headerRequests.every((r) => r.authorization === "Bearer header-secret")).toBe(true);
174+ expect(headerRequests.every((r) => !r.url.includes("auth_token="))).toBe(true);
175+
176+ const beforeQuery = (yield* server.requests).length;
177+ yield* executor.execute(
178+ ToolAddress.make("tools.multi_mcp.org.qconn.whoami"),
179+ { marker: "q" },
180+ { onElicitation: "accept-all" },
181+ );
182+ const queryRequests = (yield* server.requests).slice(beforeQuery);
183+ expect(queryRequests.every((r) => r.url.includes("auth_token=query-secret"))).toBe(true);
184+ expect(queryRequests.every((r) => r.authorization === undefined)).toBe(true);
185+ }),
186+ );
187+
188+ it.effect("invoking with a missing credential input fails explicitly, not silently", () =>
189+ Effect.gen(function* () {
190+ const server = yield* serveRecordingServer;
191+ const executor = yield* createExecutor(
192+ makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
193+ );
194+
195+ yield* executor.mcp.addServer({
196+ name: "Strict MCP",
197+ endpoint: server.url,
198+ slug: "strict_mcp",
199+ authenticationTemplate: [
200+ {
201+ slug: "two_inputs",
202+ type: "apiKey",
203+ headers: { Authorization: ["Bearer ", variable("a")] },
204+ queryParams: { team_id: [variable("b")] },
205+ },
206+ ],
207+ });
208+
209+ // Only one of the two inputs is supplied.
210+ yield* executor.connections.create({
211+ owner: "org",
212+ name: ConnectionName.make("half"),
213+ integration: IntegrationSlug.make("strict_mcp"),
214+ template: AuthTemplateSlug.make("two_inputs"),
215+ values: { a: "tok_A" },
216+ });
217+
218+ const result = (yield* executor.execute(
219+ ToolAddress.make("tools.strict_mcp.org.half.whoami"),
220+ { marker: "x" },
221+ { onElicitation: "accept-all" },
222+ )) as { ok: boolean; error?: { code?: string; message?: string } };
223+ expect(result.ok).toBe(false);
224+ expect(result.error).toMatchObject({ code: "connection_value_missing" });
225+ expect(String(result.error?.message ?? "")).toContain("b");
226+ }),
227+ );
228+});
packages/plugins/mcp/src/sdk/plugin.test.ts+8−3
@@ -315,10 +315,12 @@ describe("mcpPlugin", () => { · 314 unmodified lines
315315 });
316316
317317 const merged = yield* executor.mcp.configureAuth("oauth_mcp", {
318− authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }],
318+ authenticationTemplate: [
319+ { type: "apiKey", headers: { "X-Api-Key": [{ type: "variable", name: "token" }] } },
320+ ],
319321 });
320322
321− expect(merged.map((method) => method.kind)).toEqual(["oauth2", "header"]);
323+ expect(merged.map((method) => method.kind)).toEqual(["oauth2", "apikey"]);
322324 expect(merged[0]?.slug).toBe("oauth2");
323325 expect(merged[1]?.slug).toMatch(/^custom_/);
324326
@@ -341,7 +343,10 @@ describe("mcpPlugin", () => { · 16 unmodified lines
341343 const merged = yield* executor.mcp.configureAuth("open_mcp", {
342344 authenticationTemplate: [
343345 { kind: "oauth2" },
344− { kind: "header", headerName: "Authorization", prefix: "Bearer " },
346+ {
347+ type: "apiKey",
348+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
349+ },
345350 ],
346351 mode: "replace",
347352 });
packages/plugins/mcp/src/sdk/plugin.ts+58−34
@@ -28,6 +28,14 @@ import { · 27 unmodified lines
2828 ToolName,
2929 } from "@executor-js/sdk";
3030
31+import {
32+ TOKEN_VARIABLE,
33+ describeApiKeyAuthMethod,
34+ describeNoneAuthMethod,
35+ renderAuthPlacements,
36+ requiredPlacementVariables,
37+} from "@executor-js/sdk/http-auth";
38+
3139 import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
3240 import { discoverTools } from "./discover";
3341 import { McpConnectionError, McpToolDiscoveryError } from "./errors";
@@ -37,11 +45,12 @@ import { mcpPresets } from "./presets"; · 3 unmodified lines
3745 import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
3846 import {
3947 McpAuthMethodInput,
40− McpAuthTemplate,
48+ McpAuthShorthand,
4149 McpRemoteTransport,
4250 type McpAuthMethod,
4351 type McpToolAnnotations,
44− mcpAuthMethodFromLegacy,
52+ expandMcpAuthMethodInputs,
53+ mcpAuthMethodFromShorthand,
4554 normalizeMcpAuthMethods,
4655 parseMcpIntegrationConfig,
4756 type McpIntegrationConfig as McpIntegrationConfigType,
@@ -145,7 +154,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 97 unmodified lines
145154 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
146155 /** Single-method shorthand (legacy callers). Ignored when
147156 * `authenticationTemplate` is present. Defaults to none. */
148− auth: Schema.optional(McpAuthTemplate),
157+ auth: Schema.optional(McpAuthShorthand),
149158 });
150159
151160 const McpStdioServerInputSchema = Schema.Struct({
@@ -266,7 +275,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 114 unmodified lines
266275 headers: input.headers,
267276 authenticationTemplate: input.authenticationTemplate
268277 ? normalizeMcpAuthMethods(input.authenticationTemplate)
269− : [mcpAuthMethodFromLegacy(input.auth ?? { kind: "none" })],
278+ : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],
270279 };
271280 };
272281
@@ -429,7 +438,7 @@ const selectAuthMethod = ( · 156 unmodified lines
429438
430439 const buildConnectorInput = (
431440 config: McpIntegrationConfigType,
432− value: string | null,
441+ values: Record<string, string | null>,
433442 templateSlug: string | null,
434443 allowStdio: boolean,
435444 ): Effect.Effect<ConnectorInput, McpConnectionError> => {
@@ -452,24 +461,27 @@ const buildConnectorInput = ( · 16 unmodified lines
452461 } satisfies McpStdioIntegrationConfig);
453462 }
454463
464+ // Credential placements render OVER the integration's static headers /
465+ // query params — a same-named static entry is overwritten.
455466 const headers: Record<string, string> = { ...(config.headers ?? {}) };
467+ const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
456468 let authProvider: OAuthClientProvider | undefined;
457469
458470 const auth = selectAuthMethod(config, templateSlug);
459− if (auth?.kind === "header" && value !== null) {
460− headers[auth.headerName] = auth.prefix ? `${auth.prefix}${value}` : value;
461− } else if (auth?.kind === "oauth2" && value !== null) {
462− authProvider = makeOAuthProvider(value);
471+ if (auth?.kind === "apikey") {
472+ const rendered = renderAuthPlacements(auth.placements, values);
473+ Object.assign(headers, rendered.headers);
474+ Object.assign(queryParams, rendered.queryParams);
475+ } else if (auth?.kind === "oauth2") {
476+ const token = values[TOKEN_VARIABLE];
477+ if (token != null) authProvider = makeOAuthProvider(token);
463478 }
464479
465480 return Effect.succeed({
466481 transport: "remote" as const,
467482 endpoint: config.endpoint,
468483 remoteTransport: config.remoteTransport ?? "auto",
469− queryParams:
470− config.queryParams && Object.keys(config.queryParams).length > 0
471− ? config.queryParams
472− : undefined,
484+ queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
473485 headers: Object.keys(headers).length > 0 ? headers : undefined,
474486 authProvider,
475487 });
@@ -483,7 +495,7 @@ const buildConnectorInput = ( · 7 unmodified lines
483495 //
484496 // none → a no-auth method carrying no credential inputs
485497 // stdio → [] (no remote connection to configure)
486−// header → an apikey method carrying the header placement
498+// apikey → carried placements (headers / query params) verbatim
487499 // oauth2 → an oauth method carrying the MCP endpoint to probe
488500 // (`discoveryUrl`); endpoints are discovered live at
489501 // connect time, so they are NOT pre-resolved here. We
@@ -499,15 +511,7 @@ export const describeMcpAuthMethods = ( · 9 unmodified lines
499511 if (!config || config.transport === "stdio") return [];
500512
501513 return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {
502− if (method.kind === "header") {
503− return {
504− id: method.slug,
505− label: `API key (${method.headerName})`,
506− kind: "apikey",
507− template: method.slug,
508− placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
509− };
510− }
514+ if (method.kind === "apikey") return describeApiKeyAuthMethod(method);
511515 if (method.kind === "oauth2") {
512516 return {
513517 id: method.slug,
@@ -517,12 +521,7 @@ export const describeMcpAuthMethods = ( · 3 unmodified lines
517521 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },
518522 };
519523 }
520− return {
521− id: method.slug,
522− label: "No authentication",
523− kind: "none",
524− template: method.slug,
525− };
524+ return describeNoneAuthMethod(method.slug);
526525 });
527526 };
528527
@@ -837,7 +836,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 308 unmodified lines
837836 ? normalizeMcpAuthMethods(input.authenticationTemplate)
838837 : mergeAuthTemplates(
839838 current.authenticationTemplate,
840− input.authenticationTemplate as readonly McpAuthMethod[],
839+ expandMcpAuthMethodInputs(
840+ input.authenticationTemplate,
841+ ) as readonly McpAuthMethod[],
841842 );
842843
843844 yield* ctx.core.integrations.update(slugFrom(slug), {
@@ -869,16 +870,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 25 unmodified lines
869870 // Discovery failures (auth not ready, server down) yield an empty tool set
870871 // rather than failing — the connection still lands and can be refreshed.
871872 // -----------------------------------------------------------------------
872− resolveTools: ({ config, connection, template, getValue }) =>
873+ resolveTools: ({ config, connection, template, getValues }) =>
873874 Effect.gen(function* () {
874875 const parsed = parseMcpIntegrationConfig(config);
875876 if (!parsed) return { tools: [] as readonly ToolDef[] };
876877
877− const value = yield* getValue().pipe(Effect.orElseSucceed(() => null));
878+ // Discovery tolerates unresolved credentials (an open server lists
879+ // tools unauthenticated; a bad value just yields zero tools).
880+ const values = yield* getValues().pipe(
881+ Effect.orElseSucceed(() => ({}) as Record<string, string | null>),
882+ );
878883
879884 const built = yield* buildConnectorInput(
880885 parsed,
881− value,
886+ values,
882887 template === null ? null : String(template),
883888 allowStdio,
884889 ).pipe(
@@ -925,9 +930,28 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines
925930 const transport: string =
926931 parsed.transport === "stdio" ? "stdio" : (parsed.remoteTransport ?? "auto");
927932
933+ // An apikey method with unresolved inputs fails the invocation
934+ // explicitly instead of dialing unauthenticated.
935+ if (parsed.transport === "remote") {
936+ const method = selectAuthMethod(parsed, String(credential.template));
937+ if (method?.kind === "apikey") {
938+ const missing = requiredPlacementVariables(method.placements).filter(
939+ (variable) => credential.values[variable] == null,
940+ );
941+ if (missing.length > 0) {
942+ return authToolFailure({
943+ code: "connection_value_missing",
944+ message: `Connection has no resolvable credential value for input(s): ${missing.join(", ")}. Re-create the connection with the required value(s).`,
945+ source: { id: String(credential.integration) },
946+ credential: { kind: "upstream", label: String(credential.connection) },
947+ });
948+ }
949+ }
950+ }
951+
928952 const connector: McpConnector = yield* buildConnectorInput(
929953 parsed,
930− credential.value,
954+ credential.values,
931955 String(credential.template),
932956 allowStdio,
933957 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
packages/plugins/mcp/src/sdk/types.ts+110−83
@@ -1,16 +1,23 @@
11 import { Effect, Option, Schema } from "effect";
2+import {
3+ ApiKeyAuthMethod,
4+ ApiKeyAuthTemplate,
5+ NoneAuthMethod,
6+ apiKeyMethodFromAuthTemplate,
7+ isApiKeyAuthTemplate,
8+ normalizeAuthMethodSlugs,
9+} from "@executor-js/sdk/http-auth";
210
311 // ---------------------------------------------------------------------------
412 // MCP plugin v2 data model.
513 //
614 // An MCP integration is one server. Its `config` blob (opaque to core, stored
7−// on the integration row) carries everything needed to dial the server plus an
8−// `auth` *template* describing how a connection's resolved value is applied to
9−// the request. A connection IS the credential: at execute time core resolves
10−// the connection's value through its provider (refreshing OAuth tokens), and
11−// the plugin renders it onto the request per the template (D11). The same path
12−// covers an API key bearer and an OAuth access token — both resolve to a value
13−// and render through their template.
15+// on the integration row) carries everything needed to dial the server plus
16+// the declared auth methods describing how a connection's resolved credential
17+// values are applied to the request. A connection IS the credential: at
18+// execute time core resolves the connection's values through its provider
19+// (refreshing OAuth tokens), and the plugin renders them onto the request per
20+// the method the connection binds (D11).
1421 // ---------------------------------------------------------------------------
1522
1623 // ---------------------------------------------------------------------------
@@ -25,35 +32,35 @@ export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", · 8 unmodified lines
2532 export type McpTransport = typeof McpTransport.Type;
2633
2734 // ---------------------------------------------------------------------------
28−// Auth methods — the ways a connection's resolved value can be applied to the
29−// request. An integration declares zero or more methods, each with a stable
30−// `slug` a connection binds against (`connection.template`), mirroring the
31−// OpenAPI/GraphQL `authenticationTemplate` arrays.
35+// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
36+// plus MCP's own oauth variant. An integration declares zero or more methods,
37+// each with a stable `slug` a connection binds against (`connection.template`),
38+// mirroring the OpenAPI/GraphQL `authenticationTemplate` arrays.
3239 //
3340 // none — no credential (open server)
34−// header — render the value into a request header (e.g. `Authorization:
35−// Bearer <value>`); `prefix` is prepended to the value
41+// apikey — render the connection's values through the method's header/query
42+// placements (one credential input per distinct placement
43+// `variable`; servers like ui.sh authenticate via a `?token=`
44+// query placement, and a method may mix carriers — e.g. a bearer
45+// header plus a team-id query param)
3646 // oauth2 — the value is an OAuth access token, applied as a Bearer header
37−// via the MCP SDK's OAuthClientProvider
47+// via the MCP SDK's OAuthClientProvider. MCP oauth carries no
48+// stored endpoints: metadata is discovered live at connect time.
3849 // ---------------------------------------------------------------------------
3950
40−export const McpAuthMethod = Schema.Union([
41− Schema.Struct({ slug: Schema.String, kind: Schema.Literal("none") }),
42− Schema.Struct({
43− slug: Schema.String,
44− kind: Schema.Literal("header"),
45− headerName: Schema.String,
46− prefix: Schema.optional(Schema.String),
47− }),
48− Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }),
49−]);
51+export const McpOAuthMethod = Schema.Struct({
52+ slug: Schema.String,
53+ kind: Schema.Literal("oauth2"),
54+});
55+export type McpOAuthMethod = typeof McpOAuthMethod.Type;
56+
57+export const McpAuthMethod = Schema.Union([NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod]);
5058 export type McpAuthMethod = typeof McpAuthMethod.Type;
5159
52−/** Legacy single-method template (pre-array configs and the `auth` input
53− * shorthand on `addServer`): the method without a slug. Lifted into a
54− * one-element `authenticationTemplate` whose slug is the method kind, which
55− * matches the template slugs existing connections were created against. */
56−export const McpAuthTemplate = Schema.Union([
60+/** Single-method `auth` shorthand on `addServer` — agent convenience for the
61+ * common cases. Normalized into `authenticationTemplate` at the boundary;
62+ * never stored. */
63+export const McpAuthShorthand = Schema.Union([
5764 Schema.Struct({ kind: Schema.Literal("none") }),
5865 Schema.Struct({
5966 kind: Schema.Literal("header"),
@@ -62,41 +69,84 @@ export const McpAuthTemplate = Schema.Union([ · 2 unmodified lines
6269 }),
6370 Schema.Struct({ kind: Schema.Literal("oauth2") }),
6471 ]);
65−export type McpAuthTemplate = typeof McpAuthTemplate.Type;
66−
67−export const mcpAuthMethodFromLegacy = (auth: McpAuthTemplate): McpAuthMethod => ({
68− ...auth,
69− slug: auth.kind,
70−});
72+export type McpAuthShorthand = typeof McpAuthShorthand.Type;
73+
74+/** Expand the `auth` shorthand into a declared method. Slugs match what the
75+ * shorthand has always produced (`none` / `header` / `oauth2`) so existing
76+ * connections bound to them keep matching. */
77+export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMethod => {
78+ if (auth.kind === "header") {
79+ return {
80+ slug: "header",
81+ kind: "apikey",
82+ placements: [
83+ {
84+ carrier: "header",
85+ name: auth.headerName,
86+ ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}),
87+ },
88+ ],
89+ };
90+ }
91+ return { slug: auth.kind, kind: auth.kind };
92+};
7193
7294 /** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug;
73− * `normalizeMcpAuthMethods` backfills it from the kind. */
95+ * `normalizeMcpAuthMethods` backfills it. */
7496 export const McpAuthMethodInput = Schema.Union([
7597 Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }),
76− Schema.Struct({
77− slug: Schema.optional(Schema.String),
78− kind: Schema.Literal("header"),
79− headerName: Schema.String,
80− prefix: Schema.optional(Schema.String),
81− }),
8298 Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }),
99+ // Credential methods are authored request-shaped — the ONE apikey input
100+ // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
101+ // variable("token")] }, queryParams: { … } }`. Stored configs and the
102+ // catalog read as canonical placements; `apiKeyAuthTemplateFromMethod`
103+ // serializes them back for read-modify-write flows.
104+ ApiKeyAuthTemplate,
83105 ]);
84106 export type McpAuthMethodInput = typeof McpAuthMethodInput.Type;
85107
86−/** Assign each method a stable slug: a caller-provided one wins, otherwise the
87− * kind (`oauth2`, `header`, `none`), suffixed `_2`, `_3`, … on collision. */
108+/** The expansion target: input arms with the dialect resolved to canonical
109+ * placements (slug still optional — backfill is a separate pass). */
110+export type McpCanonicalAuthMethodInput =
111+ | Exclude<McpAuthMethodInput, ApiKeyAuthTemplate>
112+ | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string });
113+
114+/** The default slug for a slug-less input method. Carrier-derived for the
115+ * single-placement apikey cases (`header` / `query`) — the slugs those
116+ * methods have always had — so the shorthand, UI, and migration paths all
117+ * converge on the same names. */
118+const defaultMcpAuthSlug = (method: McpCanonicalAuthMethodInput): string => {
119+ if (method.kind !== "apikey") return method.kind;
120+ if (method.placements.length === 1) {
121+ return method.placements[0]!.carrier === "header" ? "header" : "query";
122+ }
123+ return "apikey";
124+};
125+
126+/** Expand request-shaped dialect entries into canonical placements; canonical
127+ * entries pass through. Slug backfill is the caller's concern
128+ * (`normalizeMcpAuthMethods` for declare flows, `mergeAuthTemplates` for the
129+ * custom-method merge). */
130+export const expandMcpAuthMethodInputs = (
131+ methods: readonly McpAuthMethodInput[],
132+): readonly McpCanonicalAuthMethodInput[] =>
133+ methods.map(
134+ (method): McpCanonicalAuthMethodInput =>
135+ isApiKeyAuthTemplate(method)
136+ ? (apiKeyMethodFromAuthTemplate(method) as McpCanonicalAuthMethodInput)
137+ : (method as McpCanonicalAuthMethodInput),
138+ );
139+
140+/** Assign each method a stable slug: a caller-provided one wins, otherwise a
141+ * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The
142+ * request-shaped dialect is expanded to canonical placements first. */
88143 export const normalizeMcpAuthMethods = (
89144 methods: readonly McpAuthMethodInput[],
90−): readonly McpAuthMethod[] => {
91− const taken = new Set<string>();
92− return methods.map((method: McpAuthMethodInput): McpAuthMethod => {
93− const requested = method.slug?.trim() || method.kind;
94− let slug = requested;
95− for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`;
96− taken.add(slug);
97− return { ...method, slug };
98− });
99−};
145+): readonly McpAuthMethod[] =>
146+ normalizeAuthMethodSlugs(
147+ expandMcpAuthMethodInputs(methods),
148+ defaultMcpAuthSlug,
149+ ) as readonly McpAuthMethod[];
100150
101151 // ---------------------------------------------------------------------------
102152 // Integration config — the opaque blob stored on the integration row. A
@@ -118,7 +168,7 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 15 unmodified lines
118168 queryParams: Schema.optional(StringMap),
119169 /** Static headers sent on every request (non-credential) */
120170 headers: Schema.optional(StringMap),
121− /** Declared auth methods — how a connection's value is rendered onto
171+ /** Declared auth methods — how a connection's values are rendered onto
122172 * requests. A connection's `template` picks one by slug. */
123173 authenticationTemplate: Schema.Array(McpAuthMethod),
124174 });
@@ -144,36 +194,13 @@ export const McpIntegrationConfig = Schema.Union([ · 19 unmodified lines
144194 export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;
145195
146196 const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);
147−const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate);
148197
149198 /** Parse an opaque integration `config` blob into a typed MCP config, or null
150− * if it isn't this plugin's shape. Stored legacy shapes are lifted in place:
151− * a singular `auth` becomes a one-element `authenticationTemplate` (slug =
152− * kind, matching the template slugs existing connections carry), and a remote
153− * config with neither field is an open server (`[{ slug: "none", … }]`). */
154−export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => {
155− if (
156− typeof config === "object" &&
157− config !== null &&
158− "transport" in config &&
159− config.transport === "remote" &&
160− !("authenticationTemplate" in config)
161− ) {
162− const legacyAuth =
163− "auth" in config
164− ? Option.getOrNull(decodeLegacyAuth(config.auth))
165− : { kind: "none" as const };
166− if (legacyAuth === null) return null;
167− const { auth: _auth, ...rest } = config as { auth?: unknown };
168− return Option.getOrNull(
169− decodeIntegrationConfig({
170− ...rest,
171− authenticationTemplate: [mcpAuthMethodFromLegacy(legacyAuth)],
172− }),
173− );
174− }
175− return Option.getOrNull(decodeIntegrationConfig(config));
176−};
199+ * if it isn't this plugin's (canonical) shape. Pre-canonical stored shapes
200+ * are rewritten by the one-off config migration (`migrate-config.ts`), not
201+ * decoded here — runtime code knows only the canonical model. */
202+export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null =>
203+ Option.getOrNull(decodeIntegrationConfig(config));
177204
178205 // ---------------------------------------------------------------------------
179206 // Tool annotations — upstream MCP ToolAnnotations we honour (destructiveHint
packages/plugins/openapi/src/api/group.ts+15−24
@@ -1,5 +1,6 @@
11 import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
22 import { Schema } from "effect";
3+import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
34 import {
45 InternalError,
56 IntegrationAlreadyExistsError,
@@ -46,29 +47,19 @@ const OpenApiSpecInputPayload = Schema.Union([ · 40 unmodified lines
4647 }),
4748 ]);
4849
49−const AuthenticationVariablePayload = Schema.Struct({
50− type: Schema.Literal("variable"),
51− name: Schema.String,
50+const OAuthTemplatePayload = Schema.Struct({
51+ slug: Schema.String,
52+ kind: Schema.Literal("oauth2"),
53+ authorizationUrl: Schema.String,
54+ tokenUrl: Schema.String,
55+ scopes: Schema.Array(Schema.String),
5256 });
53−const AuthenticationTemplateValuePayload = Schema.Union([
54− Schema.String,
55− Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])),
56−]);
57−const AuthenticationPayload = Schema.Union([
58− Schema.Struct({
59− slug: Schema.String,
60− type: Schema.Literal("apiKey"),
61− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
62− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
63− }),
64− Schema.Struct({
65− slug: Schema.String,
66− type: Schema.Literal("oauth"),
67− authorizationUrl: Schema.String,
68− tokenUrl: Schema.String,
69− scopes: Schema.Array(Schema.String),
70− }),
71−]);
57+
58+/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */
59+const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
60+
61+/** Auth in RESPONSES: the canonical stored shapes (placements). */
62+const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
7263
7364 const AddSpecPayload = Schema.Struct({
7465 spec: OpenApiSpecInputPayload,
@@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines
119110 baseUrl: Schema.optional(Schema.String),
120111 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
121112 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
122− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
113+ authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
123114 });
124115
125116 // The configure result — the merged `authenticationTemplate` after the new
126117 // custom methods were appended/replaced.
127118 const ConfigureResponse = Schema.Struct({
128− authenticationTemplate: Schema.Array(AuthenticationPayload),
119+ authenticationTemplate: Schema.Array(AuthenticationResponse),
129120 });
130121
131122 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+2−14
@@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
22 import { Context, Effect } from "effect";
33
44 import { addGroup, capture } from "@executor-js/api";
5−import { AuthTemplateSlug } from "@executor-js/sdk/shared";
6−import type { Authentication } from "../sdk";
75 import type { OpenApiPluginExtension } from "../sdk/plugin";
86 import { OpenApiGroup } from "./group";
97
@@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines
6058 baseUrl: payload.baseUrl,
6159 headers: payload.headers ? { ...payload.headers } : undefined,
6260 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
63− authenticationTemplate: payload.authenticationTemplate?.map(
64− (entry): Authentication => ({
65− ...entry,
66− slug: AuthTemplateSlug.make(entry.slug),
67− }),
68− ),
61+ authenticationTemplate: payload.authenticationTemplate,
6962 });
7063 }),
7164 ),
@@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
115108 Effect.gen(function* () {
116109 const ext = yield* OpenApiExtensionService;
117110 const authenticationTemplate = yield* ext.configure(params.slug, {
118− authenticationTemplate: payload.authenticationTemplate.map(
119− (entry): Authentication => ({
120− ...entry,
121− slug: AuthTemplateSlug.make(entry.slug),
122− }),
123− ),
111+ authenticationTemplate: payload.authenticationTemplate,
124112 mode: payload.mode ?? "merge",
125113 });
126114 return { authenticationTemplate: [...authenticationTemplate] };
packages/plugins/openapi/src/promise.ts+6−9
@@ -7,12 +7,9 @@ export type { · 6 unmodified lines
77 OpenApiPreviewInput,
88 } from "./sdk/plugin";
99
10−// Auth-template authoring helpers. `variable("token")` marks where a
11−// connection's resolved credential renders into an `apiKey` template.
12−export { variable, TOKEN_VARIABLE } from "./sdk/types";
13−export type {
14− Authentication,
15− APIKeyAuthentication,
16− AuthenticationVariable,
17− AuthenticationTemplateValue,
18−} from "./sdk/types";
10+// Auth-template authoring helpers. Author apikey methods as canonical
11+// placements, or request-shaped: `headers: { Authorization: ["Bearer ",
12+// variable("token")] }` — both normalize to the same stored model.
13+export { TOKEN_VARIABLE } from "./sdk/types";
14+export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types";
15+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−20
@@ -34,7 +34,11 @@ import { · 33 unmodified lines
3434 useSlugAlreadyExists,
3535 } from "@executor-js/react/lib/integration-add";
3636
37−import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";
37+import {
38+ authenticationFromEditorValue,
39+ editorValueFromAuthentication,
40+ openApiWireAuthInput,
41+} from "./auth-method-config";
3842 import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
3943 import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
4044 import { GoogleProductPicker } from "./GoogleProductPicker";
@@ -45,13 +49,7 @@ import { · 4 unmodified lines
4549 type GoogleOpenApiPreset,
4650 } from "../sdk/google-presets";
4751 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
48−import {
49− type APIKeyAuthentication,
50− type Authentication,
51− type ServerInfo,
52− TOKEN_VARIABLE,
53− variable,
54−} from "../sdk/types";
52+import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
5553 import { expandServerUrlOptions } from "../sdk/openapi-utils";
5654
5755 const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
@@ -172,14 +170,16 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi · 114 unmodified lines
172170 const apiKeyTemplateFromHeaderPreset = (
173171 preset: HeaderPreset,
174172 slug: AuthTemplateSlug,
175−): APIKeyAuthentication => {
176− const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {};
177− for (const headerName of preset.secretHeaders) {
173+): APIKeyAuthentication => ({
174+ slug,
175+ kind: "apikey",
176+ // Every secret header shares the one credential input (the canonical
177+ // `token`, stored as an absent placement variable).
178+ placements: preset.secretHeaders.map((headerName) => {
178179 const prefix = headerPrefix(preset, headerName);
179− headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)];
180− }
181− return { slug, type: "apiKey", headers };
182−};
180+ return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
181+ }),
182+});
183183
184184 const oauthTemplateFromPreset = (
185185 preset: OAuth2Preset,
@@ -188,7 +188,7 @@ const oauthTemplateFromPreset = ( · 2 unmodified lines
188188 scopes: readonly string[],
189189 ): OAuthAuthentication => ({
190190 slug,
191− type: "oauth",
191+ kind: "oauth2",
192192 authorizationUrl: resolveOAuthUrl(
193193 Option.getOrElse(preset.authorizationUrl, () => ""),
194194 baseUrl,
@@ -454,10 +454,8 @@ export default function AddOpenApiSource(props: { · 259 unmodified lines
454454 baseUrl: resolvedBaseUrl,
455455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
456456 ? {
457− authenticationTemplate: editedAuthenticationTemplate.map((entry) => ({
458− ...entry,
459− slug: String(entry.slug),
460− })),
457+ // Serialize to the wire input dialect (apikey → request-shaped).
458+ authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),
461459 }
462460 : {}),
463461 },
packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx+6−2
@@ -18,7 +18,11 @@ import { · 17 unmodified lines
1818 } from "@executor-js/react/lib/custom-auth-methods";
1919
2020 import { openApiConfigAtom, openapiConfigure } from "./atoms";
21−import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";
21+import {
22+ authMethodsFromConfig,
23+ templateFromPlacements,
24+ openApiWireAuthInput,
25+} from "./auth-method-config";
2226 import { googleAudienceWarningsForUrls } from "../sdk/google-presets";
2327 import type { Authentication } from "../sdk/types";
2428
@@ -82,7 +86,7 @@ export default function OpenApiAccountsPanel(props: { · 57 unmodified lines
8286 const exit = await doConfigure({
8387 params: { slug },
8488 payload: {
85− authenticationTemplate: input.authenticationTemplate,
89+ authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
8690 ...(input.mode ? { mode: input.mode } : {}),
8791 },
8892 reactivityKeys: integrationWriteKeys,
packages/plugins/openapi/src/react/auth-method-config.test.ts+90−83
@@ -1,111 +1,118 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3−import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
43
54 import {
5+ authMethodsFromConfig,
66 authenticationFromEditorValue,
77 editorValueFromAuthentication,
88 templateFromPlacements,
99 } from "./auth-method-config";
10−import { TOKEN_VARIABLE, variable, type Authentication } from "../sdk/types";
10+import type { Authentication } from "../sdk/types";
1111
12−describe("editorValueFromAuthentication", () => {
13− it("maps a bearer apiKey header template → an apikey editor value with prefix", () => {
14− const template: Authentication = {
15− slug: AuthTemplateSlug.make("apikey-0"),
16− type: "apiKey",
17− headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },
18− };
19− expect(editorValueFromAuthentication(template)).toEqual({
20− kind: "apikey",
21− placements: [
22− { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },
23− ],
24− });
25− });
26−
27− it("maps a query apiKey template → an apikey editor value (empty prefix)", () => {
28− const template: Authentication = {
29− slug: AuthTemplateSlug.make("apikey-1"),
30− type: "apiKey",
31− queryParams: { api_key: [variable(TOKEN_VARIABLE)] },
32− };
33− expect(editorValueFromAuthentication(template)).toEqual({
34− kind: "apikey",
35− placements: [{ carrier: "query", name: "api_key", prefix: "", variable: "token" }],
36− });
37− });
38−
39− it("maps an oauth template → an oauth editor value carrying endpoints + scopes", () => {
40− const template: Authentication = {
41− slug: AuthTemplateSlug.make("oauth-google"),
42− type: "oauth",
43− authorizationUrl: "https://accounts.example.com/o/oauth2/auth",
44− tokenUrl: "https://oauth2.example.com/token",
45− scopes: ["openid", "email"],
46− };
47− expect(editorValueFromAuthentication(template)).toEqual({
12+describe("authMethodsFromConfig", () => {
13+ it("projects oauth templates with their stored endpoints + scopes", () => {
14+ const methods = authMethodsFromConfig([
15+ {
16+ slug: AuthTemplateSlug.make("oauth"),
17+ kind: "oauth2",
18+ authorizationUrl: "https://x.example/auth",
19+ tokenUrl: "https://x.example/token",
20+ scopes: ["read"],
21+ },
22+ ]);
23+ expect(methods[0]).toMatchObject({
24+ id: "oauth",
4825 kind: "oauth",
49− authorizationUrl: "https://accounts.example.com/o/oauth2/auth",
50− tokenUrl: "https://oauth2.example.com/token",
51− scopes: ["openid", "email"],
26+ source: "spec",
27+ oauth: {
28+ authorizationUrl: "https://x.example/auth",
29+ tokenUrl: "https://x.example/token",
30+ scopes: ["read"],
31+ },
5232 });
5333 });
54−});
5534
56−describe("authenticationFromEditorValue", () => {
57− it("returns null for a 'none' value (nothing to register)", () => {
58− expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
35+ it("projects apikey methods, multi-placement and multi-variable intact", () => {
36+ const methods = authMethodsFromConfig([
37+ {
38+ slug: "custom_dd",
39+ kind: "apikey",
40+ placements: [
41+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
42+ { carrier: "query", name: "team_id", variable: "team_id" },
43+ ],
44+ },
45+ ]);
46+ expect(methods[0]).toMatchObject({ id: "custom_dd", kind: "apikey", source: "custom" });
47+ expect(methods[0]?.placements).toEqual([
48+ { carrier: "header", name: "DD-API-KEY", prefix: "", variable: "dd_api_key" },
49+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
50+ ]);
5951 });
52+});
6053
61− it("builds an apiKey template from placements, stamping the slug", () => {
62− const value: AuthTemplateEditorValue = {
54+describe("editor round-trip", () => {
55+ it("apikey stored → editor → stored preserves placements and shared token input", () => {
56+ const stored: Authentication = {
57+ slug: "bearer",
6358 kind: "apikey",
64− placements: [{ carrier: "header", name: "X-Token", prefix: "" }],
59+ placements: [
60+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
61+ { carrier: "query", name: "token" },
62+ ],
6563 };
66− expect(authenticationFromEditorValue(value, "custom_x")).toEqual(
67− templateFromPlacements(value.placements, "custom_x"),
68− );
64+ const editor = editorValueFromAuthentication(stored);
65+ const back = authenticationFromEditorValue(editor, "bearer");
66+ expect(back).toEqual(stored);
6967 });
7068
71− it("builds an oauth template from an oauth value", () => {
72− const value: AuthTemplateEditorValue = {
69+ it("oauth stored → editor carries endpoints + scopes", () => {
70+ expect(
71+ editorValueFromAuthentication({
72+ slug: AuthTemplateSlug.make("oauth"),
73+ kind: "oauth2",
74+ authorizationUrl: "https://x.example/auth",
75+ tokenUrl: "https://x.example/token",
76+ scopes: ["a", "b"],
77+ }),
78+ ).toEqual({
7379 kind: "oauth",
74− authorizationUrl: "https://a.example.com/auth",
75− tokenUrl: "https://a.example.com/token",
76− scopes: ["read"],
77− };
78− expect(authenticationFromEditorValue(value, "oauth-0")).toEqual({
79− slug: AuthTemplateSlug.make("oauth-0"),
80− type: "oauth",
81− authorizationUrl: "https://a.example.com/auth",
82− tokenUrl: "https://a.example.com/token",
83− scopes: ["read"],
80+ authorizationUrl: "https://x.example/auth",
81+ tokenUrl: "https://x.example/token",
82+ scopes: ["a", "b"],
8483 });
8584 });
85+
86+ it("none editor value yields no method", () => {
87+ expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
88+ });
8689 });
8790
88−describe("round-trip (apiKey + oauth)", () => {
89− it("apiKey: editorValue ∘ authentication is identity for the placement shape", () => {
90− const template: Authentication = {
91− slug: AuthTemplateSlug.make("apikey-0"),
92− type: "apiKey",
93− headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },
94− };
95− const value = editorValueFromAuthentication(template);
96− const back = authenticationFromEditorValue(value, "apikey-0");
97− expect(back).toEqual(template);
91+describe("templateFromPlacements", () => {
92+ it("multi-placement custom methods assign distinct name-derived variables", () => {
93+ expect(
94+ templateFromPlacements(
95+ [
96+ { carrier: "header", name: "DD-API-KEY", prefix: "" },
97+ { carrier: "header", name: "DD-APPLICATION-KEY", prefix: "" },
98+ ],
99+ "custom_dd",
100+ ),
101+ ).toEqual({
102+ slug: "custom_dd",
103+ kind: "apikey",
104+ placements: [
105+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
106+ { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
107+ ],
108+ });
98109 });
99110
100− it("oauth: editorValue ∘ authentication preserves endpoints + scopes", () => {
101− const template: Authentication = {
102− slug: AuthTemplateSlug.make("oauth-x"),
103− type: "oauth",
104− authorizationUrl: "https://a.example.com/auth",
105− tokenUrl: "https://a.example.com/token",
106− scopes: ["read", "write"],
107− };
108− const back = authenticationFromEditorValue(editorValueFromAuthentication(template), "oauth-x");
109− expect(back).toEqual(template);
111+ it("a lone placement keeps the canonical token input (stored absent)", () => {
112+ expect(templateFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }])).toEqual({
113+ slug: "",
114+ kind: "apikey",
115+ placements: [{ carrier: "query", name: "api_key" }],
116+ });
110117 });
111118 });
packages/plugins/openapi/src/react/auth-method-config.ts+45−158
@@ -1,192 +1,79 @@
11 // ---------------------------------------------------------------------------
2−// OpenAPI ↔ generic auth-method converters.
3−//
4−// The generic Accounts hub speaks in plugin-agnostic `AuthMethod` /
5−// `Placement` values (`@executor-js/react/lib/auth-placements`). The OpenAPI
6−// plugin stores auth as the HTTP-transport-specific `APIKeyAuthentication`
7−// template (header/query slots templated with `variable("token")`). These two
8−// converters bridge the wire template to/from the generic placement model, so
9−// they live with the OpenAPI plugin — they touch the openapi sdk
10−// `Authentication` types and would pull transport specifics into core.
2+// OpenAPI ↔ generic auth-method converters — a thin oauth adapter over the
3+// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4+// apikey/none paths (multi-placement, multi-variable) live in the shared
5+// codec; OpenAPI only contributes its oauth flavor: stored endpoints + scopes
6+// (`kind: "oauth2"`, the core `OAuthAuthentication` shape) that pre-fill the client-registration form.
117 // ---------------------------------------------------------------------------
128
139 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1410 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
1511 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
16−
1712 import {
18− TOKEN_VARIABLE,
19− variable,
20− type APIKeyAuthentication,
21− type Authentication,
22− type AuthenticationTemplateValue,
23−} from "../sdk/types";
13+ authMethodFromSharedTemplate,
14+ editorPlacementsFromWire,
15+ editorValueFromSharedMethod,
16+ wireAuthInputFromShared,
17+ wirePlacementsFromEditor,
18+} from "@executor-js/react/lib/shared-auth-method-codec";
2419
25−// ---------------------------------------------------------------------------
26−// Template value → placement prefix.
27−//
28−// A header/query slot serializes the credential as `name -> [prefix, token]`,
29−// where `prefix` is the leading literal string before the `variable("token")`
30−// part. A bare `[token]` (or string-only) value has an empty prefix.
31−// ---------------------------------------------------------------------------
20+import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types";
3221
33−const isVariablePart = (part: string | { readonly type: "variable"; readonly name: string }) =>
34− typeof part !== "string" && part.type === "variable";
22+/** Serialize a canonical method into the wire input union (apikey → the
23+ * request-shaped dialect; oauth passes through). */
24+export const openApiWireAuthInput = (method: Authentication): AuthenticationInput =>
25+ method.kind === "oauth2" ? method : (wireAuthInputFromShared(method) as AuthenticationInput);
3526
36−/** Extract the literal prefix preceding the credential variable, plus the
37− * variable name that placement renders from (`token` for single-input methods,
38− * a distinct name per input for multi-input ones). */
39−const parseTemplateValue = (
40− value: AuthenticationTemplateValue,
41−): { readonly prefix: string; readonly variable: string } => {
42− if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
43− const parts: string[] = [];
44− for (const part of value) {
45− if (isVariablePart(part)) {
46− return { prefix: parts.join(""), variable: (part as { readonly name: string }).name };
47− }
48− if (typeof part === "string") parts.push(part);
49− }
50− return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
51−};
27+export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] =>
28+ editorPlacementsFromWire(template.placements);
5229
53−export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => {
54− const placements: Placement[] = [];
55− for (const [name, value] of Object.entries(template.headers ?? {})) {
56− const { prefix, variable } = parseTemplateValue(value);
57− placements.push({ carrier: "header", name, prefix, variable });
58− }
59− for (const [name, value] of Object.entries(template.queryParams ?? {})) {
60− const { prefix, variable } = parseTemplateValue(value);
61− placements.push({ carrier: "query", name, prefix, variable });
62− }
63− return placements;
64−};
65−
66−// ---------------------------------------------------------------------------
67−// Templates → generic methods.
68−// ---------------------------------------------------------------------------
69−
70−const labelForApiKey = (slug: string, placements: readonly Placement[]): string => {
71− const first = placements[0];
72− if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
73− return `API key (${slug})`;
30+const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): AuthMethod => {
31+ const slug = String(template.slug);
32+ return {
33+ id: slug,
34+ label: "OAuth2",
35+ kind: "oauth",
36+ source: slug.startsWith("custom_") ? "custom" : "spec",
37+ template: AuthTemplateSlug.make(slug),
38+ placements: [],
39+ // Carry the integration's declared endpoints/scopes so the
40+ // client-registration form pre-fills them.
41+ oauth: {
42+ authorizationUrl: template.authorizationUrl,
43+ tokenUrl: template.tokenUrl,
44+ scopes: template.scopes,
45+ },
46+ };
7447 };
7548
7649 /** Map each stored auth template to a generic `AuthMethod`. */
7750 export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {
7851 return templates.map((template: Authentication): AuthMethod => {
79− const slug = String(template.slug);
80− const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";
81− if (template.type === "oauth") {
82− return {
83− id: slug,
84− label: "OAuth2",
85− kind: "oauth",
86− source,
87− template: AuthTemplateSlug.make(slug),
88− placements: [],
89− // Carry the integration's declared endpoints/scopes so the
90− // client-registration form pre-fills them.
91− oauth: {
92− authorizationUrl: template.authorizationUrl,
93− tokenUrl: template.tokenUrl,
94− scopes: template.scopes,
95− },
96− };
97− }
98− const placements = placementsFromApiKey(template);
99− return {
100− id: slug,
101− label: labelForApiKey(slug, placements),
102− kind: "apikey",
103− source,
104− template: AuthTemplateSlug.make(slug),
105− placements,
106− };
52+ if (template.kind === "oauth2") return oauthAuthMethod(template);
53+ return authMethodFromSharedTemplate(template);
10754 });
10855 }
10956
110−// ---------------------------------------------------------------------------
111−// Generic placements → apiKey template (inverse).
112−//
113−// Each placement becomes a header/query slot whose value is
114−// `prefix ? [prefix, variable("token")] : [variable("token")]`. A custom method
115−// may omit `slug`; the backend backfills `custom_<id>`.
116−// ---------------------------------------------------------------------------
117−
118−/** Slugify a placement name into a variable identifier: `DD-API-KEY` →
119− * `dd_api_key`. */
120−const slugifyVariable = (name: string): string =>
121− name
122− .toLowerCase()
123− .replace(/[^a-z0-9]+/g, "_")
124− .replace(/^_+|_+$/g, "");
125−
126−/** Assign an input variable to each (named) placement. A lone input is the
127− * canonical `token`; multiple inputs each get their own distinct variable so a
128− * connection can carry a different value per location. An explicit
129− * `placement.variable` (from a round-trip) is honored. */
130−const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => {
131− const named = placements.filter((p) => p.name);
132− const out = new Map<Placement, string>();
133− if (named.length <= 1) {
134− for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE);
135− return out;
136− }
137− const taken = new Set<string>();
138− for (const p of named) {
139− const base = slugifyVariable(p.name) || "input";
140− let candidate = p.variable ?? base;
141− let n = 2;
142− while (taken.has(candidate)) candidate = `${base}_${n++}`;
143− taken.add(candidate);
144− out.set(p, candidate);
145− }
146− return out;
147−};
148−
149−const valueFromPlacement = (placement: Placement, varName: string): AuthenticationTemplateValue =>
150− placement.prefix ? [placement.prefix, variable(varName)] : [variable(varName)];
151−
152−/** Build an `APIKeyAuthentication` template from generic placements. When
153− * `slug` is omitted the backend assigns a `custom_<id>` slug. */
57+/** Build an apikey method from generic placements. When `slug` is omitted the
58+ * backend assigns a `custom_<id>` slug. */
15459 export function templateFromPlacements(
15560 placements: readonly Placement[],
15661 slug?: string,
15762 ): APIKeyAuthentication {
158− const variables = assignVariables(placements);
159− const headers: Record<string, AuthenticationTemplateValue> = {};
160− const queryParams: Record<string, AuthenticationTemplateValue> = {};
161− for (const placement of placements) {
162− if (!placement.name) continue;
163− const varName = variables.get(placement) ?? TOKEN_VARIABLE;
164− if (placement.carrier === "header") {
165− headers[placement.name] = valueFromPlacement(placement, varName);
166− } else {
167− queryParams[placement.name] = valueFromPlacement(placement, varName);
168− }
169− }
17063 return {
171− slug: AuthTemplateSlug.make(slug ?? ""),
172− type: "apiKey",
173− ...(Object.keys(headers).length > 0 ? { headers } : {}),
174− ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
64+ slug: slug ?? "",
65+ kind: "apikey",
66+ placements: wirePlacementsFromEditor(placements),
17567 };
17668 }
17769
17870 // ---------------------------------------------------------------------------
17971 // Stored `Authentication` ⇆ generic `AuthTemplateEditorValue`.
180−//
181−// The shared add-time `AuthTemplateEditor` edits a plugin-agnostic value; these
182−// bridge it to/from the OpenAPI wire template so a spec-detected method seeds an
183−// editable default and the user's edits round-trip back to `Authentication[]`
184−// on submit. apiKey → placements; oauth → endpoints + scopes.
18572 // ---------------------------------------------------------------------------
18673
18774 /** Convert one stored `Authentication` template into a generic editor value. */
18875 export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {
189− if (template.type === "oauth") {
76+ if (template.kind === "oauth2") {
19077 return {
19178 kind: "oauth",
19279 authorizationUrl: template.authorizationUrl ?? "",
@@ -194,7 +81,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 1 unmodified line
19481 scopes: template.scopes ?? [],
19582 };
19683 }
197− return { kind: "apikey", placements: placementsFromApiKey(template) };
84+ return editorValueFromSharedMethod(template);
19885 }
19986
20087 /** Build an `OAuthAuthentication` template from a generic oauth editor value. */
@@ -203,7 +90,7 @@ const oauthTemplateFromEditorValue = ( · 2 unmodified lines
20390 slug?: string,
20491 ): Authentication => ({
20592 slug: AuthTemplateSlug.make(slug ?? ""),
206− type: "oauth",
93+ kind: "oauth2",
20794 authorizationUrl: value.authorizationUrl,
20895 tokenUrl: value.tokenUrl,
20996 scopes: [...value.scopes],
packages/plugins/openapi/src/sdk/config.ts+21−65
@@ -1,7 +1,12 @@
11 import { Option, Schema } from "effect";
2+import {
3+ ApiKeyAuthMethod,
4+ TOKEN_VARIABLE,
5+ renderAuthPlacements,
6+ requiredPlacementVariables,
7+} from "@executor-js/sdk/http-auth";
28
3−import type { Authentication, AuthenticationTemplateValue, AuthenticationVariable } from "./types";
4−import { TOKEN_VARIABLE } from "./types";
9+import type { Authentication } from "./types";
510
611 // ---------------------------------------------------------------------------
712 // OpenAPI integration config — the opaque blob stored on the catalog
@@ -15,35 +20,15 @@ import { TOKEN_VARIABLE } from "./types"; · 7 unmodified lines
1520 // - the auth templates a connection's value is rendered through.
1621 // ---------------------------------------------------------------------------
1722
18−const AuthenticationVariableSchema = Schema.Struct({
19− type: Schema.Literal("variable"),
20− name: Schema.String,
21−});
22−
23−const AuthenticationTemplateValueSchema = Schema.Union([
24− Schema.String,
25− Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
26−]);
27−
28−const APIKeyAuthenticationSchema = Schema.Struct({
29− slug: Schema.String,
30− type: Schema.Literal("apiKey"),
31− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
32− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
33−});
34−
3523 const OAuthAuthenticationSchema = Schema.Struct({
3624 slug: Schema.String,
37− type: Schema.Literal("oauth"),
25+ kind: Schema.Literal("oauth2"),
3826 authorizationUrl: Schema.String,
3927 tokenUrl: Schema.String,
4028 scopes: Schema.Array(Schema.String),
4129 });
4230
43−export const AuthenticationSchema = Schema.Union([
44− OAuthAuthenticationSchema,
45− APIKeyAuthenticationSchema,
46−]);
31+export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]);
4732
4833 export const OpenApiIntegrationConfigSchema = Schema.Struct({
4934 /** Inlined OpenAPI document text (resolved + parsed source of truth). */
@@ -80,66 +65,37 @@ export const decodeOpenApiIntegrationConfig = (value: unknown): OpenApiIntegrati · 30 unmodified lines
8065
8166 // ---------------------------------------------------------------------------
8267 // Template rendering — "auth state derived into the auth-template format"
83−// (D11). The resolved credential value renders into the template's
84−// `variable("token")` slots, identically for apiKey and oauth (the oauth value
85−// IS the access token). Returns the headers + query params to apply.
68+// (D11). An apiKey method renders through the shared placements renderer; an
69+// oauth template (no explicit placement) renders a bearer `authorization`
70+// header from the `token` input (the access token).
8671 // ---------------------------------------------------------------------------
8772
88−const isVariable = (part: string | AuthenticationVariable): part is AuthenticationVariable =>
89− typeof part !== "string";
90−
91−const renderTemplateValue = (
92− template: AuthenticationTemplateValue,
93− values: Record<string, string | null>,
94−): string => {
95− if (typeof template === "string") return template;
96− return template.map((part) => (isVariable(part) ? (values[part.name] ?? "") : part)).join("");
97−};
98−
9973 export interface RenderedAuth {
10074 readonly headers: Record<string, string>;
10175 readonly queryParams: Record<string, string>;
10276 }
10377
10478 /** Render an auth template against a connection's resolved input `values`
105− * (`variable → value`). For an apiKey template, each `variable("<name>")` is
106− * substituted from its own entry, so a method with two distinct inputs (e.g.
107− * Datadog) fills each header from a different value. For an oauth template (no
108− * explicit placement), render a bearer Authorization header from `token`. */
79+ * (`variable → value`). Each placement substitutes from its own entry, so a
80+ * method with two distinct inputs (e.g. Datadog) fills each header from a
81+ * different value. */
10982 export const renderAuthTemplate = (
11083 template: Authentication,
11184 values: Record<string, string | null>,
11285 ): RenderedAuth => {
113− if (template.type === "oauth") {
86+ if (template.kind === "oauth2") {
11487 return {
11588 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },
11689 queryParams: {},
11790 };
11891 }
119− const headers: Record<string, string> = {};
120− const queryParams: Record<string, string> = {};
121− for (const [name, tmpl] of Object.entries(template.headers ?? {})) {
122− headers[name] = renderTemplateValue(tmpl, values);
123− }
124− for (const [name, tmpl] of Object.entries(template.queryParams ?? {})) {
125− queryParams[name] = renderTemplateValue(tmpl, values);
126− }
127− return { headers, queryParams };
92+ return renderAuthPlacements(template.placements, values);
12893 };
12994
13095 /** The distinct input variables a template references — the inputs a connection
131− * must supply. An oauth template needs `token`; an apiKey template needs every
132− * `variable("<name>")` across its placements. */
96+ * must supply. An oauth template needs `token`; an apiKey method needs every
97+ * variable across its placements. */
13398 export const requiredTemplateVariables = (template: Authentication): readonly string[] => {
134− if (template.type === "oauth") return [TOKEN_VARIABLE];
135− const names = new Set<string>();
136− const collect = (tmpl: AuthenticationTemplateValue): void => {
137− if (typeof tmpl === "string") return;
138− for (const part of tmpl) {
139− if (isVariable(part)) names.add(part.name);
140− }
141− };
142− for (const tmpl of Object.values(template.headers ?? {})) collect(tmpl);
143− for (const tmpl of Object.values(template.queryParams ?? {})) collect(tmpl);
144− return [...names];
99+ if (template.kind === "oauth2") return [TOKEN_VARIABLE];
100+ return requiredPlacementVariables(template.placements);
145101 };
packages/plugins/openapi/src/sdk/configure.test.ts+72−27
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 // integration's opaque config. These tests exercise the extension method
66 // directly (the same path the HTTP `configure` handler calls):
77 // - round-trip: add a custom apiKey method → `getConfig` shows it with the
8−// correct header/query placement + `variable("token")` slot,
8+// correct header/query placements,
99 // - slug generation: a method with no slug is assigned `custom_<id>`,
1010 // - slug uniqueness/dedupe: a method whose slug matches an existing entry
1111 // replaces it rather than duplicating, and two slug-less methods in one call
@@ -28,7 +28,8 @@ import { · 16 unmodified lines
2828 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
2929
3030 import { openApiPlugin } from "./plugin";
31−import { variable, type APIKeyAuthentication, type Authentication } from "./types";
31+import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types";
32+import { variable } from "@executor-js/sdk/http-auth";
3233 import {
3334 makeOpenApiHttpApiTestSourceConfig,
3435 serveOpenApiHttpApiTestServer,
@@ -71,9 +72,10 @@ const specText = () => { · 36 unmodified lines
7172 return spec.url;
7273 };
7374
74−// A custom apiKey method that places the connection value into `x-api-key`.
75−const customApiKey: APIKeyAuthentication = {
76− slug: AuthTemplateSlug.make("my_custom"),
75+// A custom apiKey method that places the connection value into `x-api-key`,
76+// authored request-shaped (the one apikey input dialect).
77+const customApiKey: AuthenticationInput = {
78+ slug: "my_custom",
7779 type: "apiKey",
7880 headers: { "x-api-key": [variable("token")] },
7981 };
@@ -81,9 +83,9 @@ const customApiKey: APIKeyAuthentication = { · 1 unmodified line
8183 // Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that
8284 // omits it, which `configure` should backfill with a generated `custom_<id>`.
8385 // The cast is confined to this one boundary helper.
84−const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication =>
85− // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it
86− ({ type: "apiKey", ...placement }) as unknown as Authentication;
86+const sluglessApiKey = (
87+ template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
88+): AuthenticationInput => ({ type: "apiKey", ...template });
8789
8890 describe("OpenAPI Plugin — configure (custom auth method)", () => {
8991 it.effect("adds a custom apiKey method and getConfig reflects it", () =>
@@ -107,10 +109,10 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
107109 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];
108110 expect(template).toHaveLength(1);
109111 const entry = template[0] as APIKeyAuthentication;
110− expect(entry.type).toBe("apiKey");
112+ expect(entry.kind).toBe("apikey");
111113 expect(String(entry.slug)).toBe("my_custom");
112− // The header placement preserves the `variable("token")` slot verbatim.
113− expect(entry.headers).toEqual({ "x-api-key": [variable("token")] });
114+ // The placement preserves the canonical `token` input (stored absent).
115+ expect(entry.placements).toEqual([{ carrier: "header", name: "x-api-key" }]);
114116 }),
115117 ),
116118 );
@@ -120,8 +122,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
120122 Effect.gen(function* () {
121123 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
122124
123− const seedTemplate: Authentication = {
124− slug: AuthTemplateSlug.make("seed"),
125+ const seedTemplate: AuthenticationInput = {
126+ slug: "seed",
125127 type: "apiKey",
126128 headers: { authorization: ["Bearer ", variable("token")] },
127129 };
@@ -151,9 +153,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 23 unmodified lines
151153 slug: "cfg_genslug",
152154 });
153155
154− const slugless = sluglessApiKey({
155− headers: { "x-api-key": [variable("token")] },
156− });
156+ const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } });
157157
158158 const merged = yield* executor.openapi.configure("cfg_genslug", {
159159 authenticationTemplate: [slugless],
@@ -177,17 +177,13 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
177177 });
178178
179179 // Re-submit the same slug with a different placement → replace in place.
180− const replacement: APIKeyAuthentication = {
181− slug: AuthTemplateSlug.make("my_custom"),
180+ const replacement: AuthenticationInput = {
181+ slug: "my_custom",
182182 type: "apiKey",
183183 headers: { "x-other": [variable("token")] },
184184 };
185− const slugless = sluglessApiKey({
186− queryParams: { api_key: [variable("token")] },
187− });
188− const slugless2 = sluglessApiKey({
189− queryParams: { token: [variable("token")] },
190− });
185+ const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } });
186+ const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } });
191187
192188 const merged = yield* executor.openapi.configure("cfg_dedupe", {
193189 authenticationTemplate: [replacement, slugless, slugless2],
@@ -205,7 +201,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 11 unmodified lines
205201 const replaced = merged.find(
206202 (m: Authentication) => String(m.slug) === "my_custom",
207203 ) as APIKeyAuthentication;
208− expect(replaced.headers).toEqual({ "x-other": [variable("token")] });
204+ expect(replaced.placements).toEqual([{ carrier: "header", name: "x-other" }]);
209205 }),
210206 ),
211207 );
@@ -215,8 +211,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
215211 Effect.gen(function* () {
216212 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
217213
218− const seedTemplate: Authentication = {
219− slug: AuthTemplateSlug.make("seed"),
214+ const seedTemplate: AuthenticationInput = {
215+ slug: "seed",
220216 type: "apiKey",
221217 headers: { authorization: ["Bearer ", variable("token")] },
222218 };
@@ -273,6 +269,55 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 50 unmodified lines
273269 ),
274270 );
275271
272+ it.effect("the request-shaped dialect configures a method and renders on the wire", () =>
273+ Effect.scoped(
274+ Effect.gen(function* () {
275+ const server = yield* servePluginTestApi();
276+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
277+
278+ yield* executor.openapi.addSpec({
279+ spec: { kind: "blob", value: server.specJson },
280+ slug: "cfg_authored",
281+ baseUrl: server.baseUrl,
282+ });
283+
284+ // Authored request-shaped; stored canonical.
285+ yield* executor.openapi.configure("cfg_authored", {
286+ authenticationTemplate: [
287+ {
288+ slug: "bearer",
289+ type: "apiKey",
290+ headers: { authorization: ["Bearer ", variable("token")] },
291+ },
292+ ],
293+ });
294+
295+ const config = yield* executor.openapi.getConfig("cfg_authored");
296+ expect(config?.authenticationTemplate?.[0]).toEqual({
297+ slug: "bearer",
298+ kind: "apikey",
299+ placements: [{ carrier: "header", name: "authorization", prefix: "Bearer " }],
300+ });
301+
302+ yield* executor.connections.create({
303+ owner: "org",
304+ name: ConnectionName.make("main"),
305+ integration: IntegrationSlug.make("cfg_authored"),
306+ template: AuthTemplateSlug.make("bearer"),
307+ value: "authored-secret",
308+ });
309+
310+ const result = unwrapInvocation(
311+ yield* executor.execute(
312+ ToolAddress.make("tools.cfg_authored.org.main.items.echoHeaders"),
313+ {},
314+ ),
315+ ).data as { authorization?: string };
316+ expect(result.authorization).toBe("Bearer authored-secret");
317+ }),
318+ ),
319+ );
320+
276321 it.effect("getConfig returns null for an unknown integration", () =>
277322 Effect.scoped(
278323 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+6−7
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 } from "@executor-js/sdk/core";
88
99 import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";
10−import { variable, type Authentication } from "./types";
10+import { type Authentication } from "./types";
1111
1212 // ---------------------------------------------------------------------------
1313 // `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`
@@ -33,8 +33,8 @@ describe("describeOpenApiAuthMethods", () => { · 19 unmodified lines
3333 recordWith([
3434 {
3535 slug: AuthTemplateSlug.make("bearer"),
36− type: "apiKey",
37− headers: { Authorization: ["Bearer ", variable("token")] },
36+ kind: "apikey",
37+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
3838 },
3939 ]),
4040 );
@@ -45,9 +45,8 @@ describe("describeOpenApiAuthMethods", () => { · 4 unmodified lines
4545 label: "API key (Authorization)",
4646 kind: "apikey",
4747 template: "bearer",
48− placements: [
49− { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },
50− ],
48+ // The canonical `token` input is stored (and projected) as absent.
49+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
5150 },
5251 ]);
5352 });
@@ -57,7 +56,7 @@ describe("describeOpenApiAuthMethods", () => { · 3 unmodified lines
5756 recordWith([
5857 {
5958 slug: AuthTemplateSlug.make("oauth"),
60− type: "oauth",
59+ kind: "oauth2",
6160 authorizationUrl: "https://auth.example/authorize",
6261 tokenUrl: "https://auth.example/token",
6362 scopes: ["read", "write"],
packages/plugins/openapi/src/sdk/google-bundle.test.ts+2−2
@@ -205,8 +205,8 @@ describe("Google bundle add flow", () => { · 204 unmodified lines
205205 // collapses under `https://mail.google.com/`, so the requested consent
206206 // is clean rather than the raw per-method union.
207207 const config = yield* executor.openapi.getConfig("google");
208− const oauth = config?.authenticationTemplate?.find((entry) => entry.type === "oauth");
209− expect(oauth?.type === "oauth" ? [...oauth.scopes].sort() : undefined).toEqual(
208+ const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209+ expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210210 [
211211 "https://mail.google.com/",
212212 "https://www.googleapis.com/auth/calendar",
packages/plugins/openapi/src/sdk/google-discovery.test.ts+5−5
@@ -243,7 +243,7 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI · 242 unmodified lines
243243 // than v1's `oauth2` source config.
244244 // removed: identityScopes assertion — that field belonged to the v1
245245 // OAuth2SourceConfig slot model, which no longer exists in v2.
246− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
246+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
247247 expect(oauthTemplate).toBeDefined();
248248 }),
249249 );
@@ -440,8 +440,8 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 190 unmodified lines
440440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");
442442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
444− expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([
443+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444+ expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445445 "https://www.googleapis.com/auth/gmail.metadata",
446446 "https://www.googleapis.com/auth/chat.spaces.readonly",
447447 ]);
@@ -579,8 +579,8 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent · 131 unmodified lines
579579
580580 // The derived oauth auth template carries the compacted/filtered set
581581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
583− expect(oauthTemplate?.type === "oauth" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
582+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583+ expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584584 [...expectedConsentScopes].sort(),
585585 );
586586
packages/plugins/openapi/src/sdk/google-discovery.ts+1−1
@@ -623,7 +623,7 @@ const googleOauthTemplate = (scopes: Record<string, string>): readonly Authentic · 622 unmodified lines
623623 [
624624 {
625625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626− type: "oauth",
626+ kind: "oauth2",
627627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629629 scopes: Object.keys(scopes),
packages/plugins/openapi/src/sdk/index.ts+7−3
@@ -67,9 +67,13 @@ export { · 66 unmodified lines
6767 OperationId,
6868 HttpMethod,
6969 ParameterLocation,
70− variable,
70+ TOKEN_VARIABLE,
71+ normalizeOpenApiAuthInputs,
7172 type Authentication,
73+ type AuthenticationInput,
7274 type APIKeyAuthentication,
73− type AuthenticationVariable,
74− type AuthenticationTemplateValue,
7575 } from "./types";
76+// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
77+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
78+
79+export { migrateOpenApiAuthConfig } from "./migrate-config";
packages/plugins/openapi/src/sdk/migrate-config.test.ts+225−0
@@ -0,0 +1,225 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { renderAuthTemplate } from "./config";
4+import { migrateOpenApiAuthConfig } from "./migrate-config";
5+import type { Authentication } from "./types";
6+
7+// ---------------------------------------------------------------------------
8+// The one-off config migration rewrites the retired `variable()`-templated
9+// apiKey shape into canonical placements. Slugs and variable names are
10+// preserved verbatim (`connection.template` / `item_ids` contracts), and the
11+// EQUIVALENCE tests pin that a migrated template renders byte-identically to
12+// what the legacy renderer produced for the same values.
13+// ---------------------------------------------------------------------------
14+
15+const BASE = { spec: "{}", baseUrl: "https://api.example.com" } as const;
16+
17+describe("migrateOpenApiAuthConfig", () => {
18+ it("rewrites a single-input bearer template (prefix + token)", () => {
19+ const migrated = migrateOpenApiAuthConfig({
20+ ...BASE,
21+ authenticationTemplate: [
22+ {
23+ slug: "bearer",
24+ type: "apiKey",
25+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
26+ },
27+ ],
28+ });
29+ expect(migrated).toEqual({
30+ ...BASE,
31+ authenticationTemplate: [
32+ {
33+ slug: "bearer",
34+ kind: "apikey",
35+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
36+ },
37+ ],
38+ });
39+ });
40+
41+ it("rewrites a multi-input template (Datadog), variables preserved verbatim", () => {
42+ const migrated = migrateOpenApiAuthConfig({
43+ ...BASE,
44+ authenticationTemplate: [
45+ {
46+ slug: "datadog",
47+ type: "apiKey",
48+ headers: {
49+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
50+ "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
51+ },
52+ queryParams: { site: [{ type: "variable", name: "site" }] },
53+ },
54+ ],
55+ });
56+ expect(migrated).toEqual({
57+ ...BASE,
58+ authenticationTemplate: [
59+ {
60+ slug: "datadog",
61+ kind: "apikey",
62+ placements: [
63+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
64+ { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
65+ { carrier: "query", name: "site", variable: "site" },
66+ ],
67+ },
68+ ],
69+ });
70+ });
71+
72+ it("rewrites literal-only template values into literal placements", () => {
73+ const migrated = migrateOpenApiAuthConfig({
74+ ...BASE,
75+ authenticationTemplate: [
76+ {
77+ slug: "versioned",
78+ type: "apiKey",
79+ headers: {
80+ "X-Api-Version": "2023-01-01",
81+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
82+ },
83+ },
84+ ],
85+ }) as { authenticationTemplate: readonly unknown[] };
86+ expect(migrated.authenticationTemplate[0]).toEqual({
87+ slug: "versioned",
88+ kind: "apikey",
89+ placements: [
90+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
91+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
92+ ],
93+ });
94+ });
95+
96+ it('re-keys the retired `type: "oauth"` spelling to `kind: "oauth2"`', () => {
97+ const migrated = migrateOpenApiAuthConfig({
98+ ...BASE,
99+ authenticationTemplate: [
100+ {
101+ slug: "oauth",
102+ type: "oauth",
103+ authorizationUrl: "https://x.example/auth",
104+ tokenUrl: "https://x.example/token",
105+ scopes: ["read"],
106+ },
107+ {
108+ slug: "key",
109+ type: "apiKey",
110+ queryParams: { key: [{ type: "variable", name: "token" }] },
111+ },
112+ ],
113+ }) as { authenticationTemplate: readonly unknown[] };
114+ expect(migrated.authenticationTemplate[0]).toEqual({
115+ slug: "oauth",
116+ kind: "oauth2",
117+ authorizationUrl: "https://x.example/auth",
118+ tokenUrl: "https://x.example/token",
119+ scopes: ["read"],
120+ });
121+ });
122+
123+ it("is idempotent — canonical configs return null", () => {
124+ const canonical = {
125+ ...BASE,
126+ authenticationTemplate: [
127+ {
128+ slug: "bearer",
129+ kind: "apikey",
130+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
131+ },
132+ ],
133+ };
134+ expect(migrateOpenApiAuthConfig(canonical)).toBeNull();
135+ const lifted = migrateOpenApiAuthConfig({
136+ ...BASE,
137+ authenticationTemplate: [
138+ { slug: "b", type: "apiKey", headers: { A: [{ type: "variable", name: "token" }] } },
139+ ],
140+ });
141+ expect(migrateOpenApiAuthConfig(lifted)).toBeNull();
142+ });
143+
144+ it("leaves template-less, foreign, and unmigratable blobs untouched", () => {
145+ expect(migrateOpenApiAuthConfig({ ...BASE })).toBeNull();
146+ expect(migrateOpenApiAuthConfig({ transport: "remote", endpoint: "x" })).toBeNull();
147+ expect(migrateOpenApiAuthConfig(null)).toBeNull();
148+ expect(
149+ migrateOpenApiAuthConfig({ ...BASE, authenticationTemplate: [{ garbage: true }] }),
150+ ).toBeNull();
151+ });
152+});
153+
154+// ---------------------------------------------------------------------------
155+// Equivalence — the migrated template renders EXACTLY what the legacy
156+// renderer produced. Expected outputs below are the legacy renderer's
157+// behavior, written out by hand so the old code can stay deleted.
158+// ---------------------------------------------------------------------------
159+
160+describe("legacy render equivalence", () => {
161+ const migrateTemplate = (legacy: unknown): Authentication => {
162+ const migrated = migrateOpenApiAuthConfig({
163+ ...BASE,
164+ authenticationTemplate: [legacy],
165+ }) as { authenticationTemplate: readonly Authentication[] };
166+ return migrated.authenticationTemplate[0]!;
167+ };
168+
169+ it("bearer header (prefix + token)", () => {
170+ const template = migrateTemplate({
171+ slug: "bearer",
172+ type: "apiKey",
173+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
174+ });
175+ expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
176+ headers: { Authorization: "Bearer tok_1" },
177+ queryParams: {},
178+ });
179+ });
180+
181+ it("two distinct inputs across headers and query (Datadog-style)", () => {
182+ const template = migrateTemplate({
183+ slug: "datadog",
184+ type: "apiKey",
185+ headers: {
186+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
187+ "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
188+ },
189+ queryParams: { team: [{ type: "variable", name: "team" }] },
190+ });
191+ expect(
192+ renderAuthTemplate(template, { dd_api_key: "a", dd_application_key: "b", team: "t" }),
193+ ).toEqual({
194+ headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" },
195+ queryParams: { team: "t" },
196+ });
197+ });
198+
199+ it("literal-only value renders verbatim with no credential", () => {
200+ const template = migrateTemplate({
201+ slug: "versioned",
202+ type: "apiKey",
203+ headers: {
204+ "X-Api-Version": "2023-01-01",
205+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
206+ },
207+ });
208+ expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
209+ headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" },
210+ queryParams: {},
211+ });
212+ });
213+
214+ it("bare query token (no prefix)", () => {
215+ const template = migrateTemplate({
216+ slug: "key",
217+ type: "apiKey",
218+ queryParams: { api_key: [{ type: "variable", name: "token" }] },
219+ });
220+ expect(renderAuthTemplate(template, { token: "k1" })).toEqual({
221+ headers: {},
222+ queryParams: { api_key: "k1" },
223+ });
224+ });
225+});
packages/plugins/openapi/src/sdk/migrate-config.ts+70−0
@@ -0,0 +1,70 @@
1+// ---------------------------------------------------------------------------
2+// One-off config migration — rewrite pre-canonical OpenAPI integration
3+// `config` blobs into the canonical placements model. Runs once per database
4+// (boot for local/selfhost, out-of-band script for cloud); runtime code
5+// decodes ONLY the canonical shape (`decodeOpenApiIntegrationConfig`).
6+//
7+// The retired shape is the `variable()`-templated apiKey:
8+// { slug, type: "apiKey", headers/queryParams: Record<name,
9+// string | (string | {type:"variable", name})[]> }
10+// Each entry becomes one placement: prefix = literals before the first
11+// variable, the variable name preserved VERBATIM (it keys the connections'
12+// stored `item_ids`; the canonical `token` is stored as absent), a
13+// literal-only value becomes a `literal` placement. OAuth templates
14+// re-key from the retired `type: "oauth"` spelling to `kind: "oauth2"`.
15+// ---------------------------------------------------------------------------
16+
17+import { Option } from "effect";
18+import {
19+ apiKeyMethodFromLegacyTemplate,
20+ decodeLegacyApiKeyTemplate,
21+} from "@executor-js/sdk/http-auth";
22+
23+import { decodeOpenApiIntegrationConfig } from "./config";
24+
25+const isCanonicalEntry = (entry: unknown): boolean =>
26+ typeof entry === "object" &&
27+ entry !== null &&
28+ "kind" in entry &&
29+ ((entry as { kind: unknown }).kind === "apikey" ||
30+ (entry as { kind: unknown }).kind === "oauth2");
31+
32+/** The retired oauth spelling: `type: "oauth"` instead of `kind: "oauth2"`. */
33+const isLegacyOAuthEntry = (
34+ entry: unknown,
35+): entry is { readonly type: "oauth" } & Record<string, unknown> =>
36+ typeof entry === "object" &&
37+ entry !== null &&
38+ "type" in entry &&
39+ (entry as { type: unknown }).type === "oauth";
40+
41+const migrateEntry = (entry: unknown): unknown | null => {
42+ if (isCanonicalEntry(entry)) return entry;
43+ if (isLegacyOAuthEntry(entry)) {
44+ const { type: _type, ...rest } = entry;
45+ return { ...rest, kind: "oauth2" };
46+ }
47+ const legacy = Option.getOrNull(decodeLegacyApiKeyTemplate(entry));
48+ if (legacy !== null) return apiKeyMethodFromLegacyTemplate(legacy);
49+ return null;
50+};
51+
52+/** Rewrite a stored OpenAPI integration config blob into the canonical shape.
53+ * Returns the rewritten config, or `null` when no rewrite is needed (already
54+ * canonical, no auth templates, or not this plugin's shape). Idempotent. */
55+export const migrateOpenApiAuthConfig = (config: unknown): unknown | null => {
56+ if (typeof config !== "object" || config === null) return null;
57+ if (!("spec" in config)) return null;
58+ if (!("authenticationTemplate" in config)) return null;
59+
60+ const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate;
61+ if (!Array.isArray(entries)) return null;
62+
63+ const migrated = entries.map(migrateEntry);
64+ if (migrated.some((entry) => entry === null)) return null;
65+ const changed = migrated.some((entry, index) => entry !== entries[index]);
66+ if (!changed) return null;
67+
68+ const next = { ...config, authenticationTemplate: migrated };
69+ return decodeOpenApiIntegrationConfig(next) !== null ? next : null;
70+};
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+3−3
@@ -36,7 +36,7 @@ import { · 35 unmodified lines
3636 } from "@executor-js/plugin-openapi/testing";
3737
3838 import { openApiPlugin } from "./plugin";
39−import { variable, type Authentication } from "./types";
39+import { type AuthenticationInput } from "./types";
4040
4141 // ---------------------------------------------------------------------------
4242 // Test API — a single endpoint that echoes the Authorization header so the
@@ -68,10 +68,10 @@ const serveVercel = () => · 25 unmodified lines
6868 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });
6969
7070 // Bearer template: the connection value renders into `Authorization: Bearer …`.
71−const bearerTemplate: Authentication = {
71+const bearerTemplate: AuthenticationInput = {
7272 slug: AuthTemplateSlug.make("bearer"),
7373 type: "apiKey",
74− headers: { authorization: ["Bearer ", variable("token")] },
74+ headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
7575 };
7676
7777 const testPlugins = () =>
packages/plugins/openapi/src/sdk/plugin.test.ts+5−5
@@ -32,7 +32,7 @@ import { · 31 unmodified lines
3232 } from "@executor-js/sdk/testing";
3333
3434 import { openApiPlugin } from "./plugin";
35−import { variable, type Authentication } from "./types";
35+import { type AuthenticationInput } from "./types";
3636 import {
3737 addOpenApiTestConnection,
3838 makeOpenApiHttpApiTestSourceConfig,
@@ -154,16 +154,16 @@ const servePluginTestApi = () => · 115 unmodified lines
154154 });
155155
156156 // An apiKey auth template that places the connection value into `x-api-key`.
157−const apiKeyTemplate: Authentication = {
157+const apiKeyTemplate: AuthenticationInput = {
158158 slug: AuthTemplateSlug.make("apiKey"),
159159 type: "apiKey",
160− headers: { "x-api-key": [variable("token")] },
160+ headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] },
161161 };
162162
163163 // An oauth template — the connection value renders as a bearer token.
164−const oauthTemplate: Authentication = {
164+const oauthTemplate: AuthenticationInput = {
165165 slug: AuthTemplateSlug.make("oauth"),
166− type: "oauth",
166+ kind: "oauth2",
167167 authorizationUrl: "https://auth.example.test/authorize",
168168 tokenUrl: "https://auth.example.test/token",
169169 scopes: ["read"],
packages/plugins/openapi/src/sdk/plugin.ts+29−87
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
1313 mergeAuthTemplates,
1414 tool,
1515 type AuthMethodDescriptor,
16− type AuthPlacementDescriptor,
1716 type Integration,
1817 type IntegrationConfig,
1918 type IntegrationRecord,
@@ -44,8 +43,9 @@ import { annotationsForOperation, invokeWithLayer } from "./invoke"; · 24 unmodified lines
4443 import { previewSpec, type SpecPreview } from "./preview";
4544 import { openApiPresets } from "./presets";
4645 import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
47−import type { Authentication, AuthenticationTemplateValue } from "./types";
48−import { OperationBinding, TOKEN_VARIABLE } from "./types";
46+import type { Authentication } from "./types";
47+import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
48+import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
4949
5050 // ---------------------------------------------------------------------------
5151 // Plugin config
@@ -154,8 +154,9 @@ export interface OpenApiSpecConfig { · 102 unmodified lines
154154 readonly headers?: Record<string, string>;
155155 /** Static query params applied to every request. */
156156 readonly queryParams?: Record<string, string>;
157− /** Auth methods a connection's value renders through. */
158− readonly authenticationTemplate?: readonly Authentication[];
157+ /** Auth methods a connection's value renders through — canonical
158+ * placements or the request-shaped authoring dialect. */
159+ readonly authenticationTemplate?: readonly AuthenticationInput[];
159160 }
160161
161162 export interface OpenApiExtensionFailure {
@@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines
169170 * already exists, replaces) the integration's existing template array. A
170171 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
171172 * slug that is collision-checked against the existing template. */
172− readonly authenticationTemplate: readonly Authentication[];
173+ readonly authenticationTemplate: readonly AuthenticationInput[];
173174 readonly mode?: "merge" | "replace";
174175 }
175176
@@ -295,28 +296,18 @@ const OpenApiSpecInputSchema = Schema.Union([ · 119 unmodified lines
295296 }),
296297 ]);
297298
298−const AuthenticationVariableSchema = Schema.Struct({
299− type: Schema.Literal("variable"),
300− name: Schema.String,
301−});
302−const AuthenticationTemplateValueSchema = Schema.Union([
303− Schema.String,
304− Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
305−]);
306299 const AuthenticationSchema = Schema.Union([
307300 Schema.Struct({
308301 slug: Schema.String,
309− type: Schema.Literal("apiKey"),
310− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
311− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
312− }),
313− Schema.Struct({
314− slug: Schema.String,
315− type: Schema.Literal("oauth"),
302+ kind: Schema.Literal("oauth2"),
316303 authorizationUrl: Schema.String,
317304 tokenUrl: Schema.String,
318305 scopes: Schema.Array(Schema.String),
319306 }),
307+ // Credential methods are authored request-shaped — the ONE apikey input
308+ // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
309+ // variable("token")] }, queryParams: { … } }`.
310+ ApiKeyAuthTemplate,
320311 ]);
321312
322313 const AddSourceInputSchema = Schema.Struct({
@@ -525,53 +516,10 @@ const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | un · 202 unmodified lines
525516 // Declared auth methods — project the stored `authenticationTemplate` into the
526517 // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
527518 // `authMethodsFromConfig` (in the React auth-method-config module) on the
528−// server so the catalog field is consistent. OpenAPI also renders its own
529−// accounts slot, so this projection is consistency work, not a fix; an absent /
530−// malformed config yields `[]` with no regression.
531−//
532−// Placement prefix extraction: a header/query slot serializes the credential as
533−// `name -> [prefix, variable("token")]`; the leading literals before the
534−// `variable("token")` part are the prefix (empty for a bare `[token]`).
519+// server so the catalog field is consistent. apikey/none projection comes from
520+// the shared model; the oauth method carries the stored endpoints + scopes.
535521 // ---------------------------------------------------------------------------
536522
537−/** Extract the literal prefix preceding the credential variable, plus the
538− * variable name a placement renders from (`token` for single-input methods, a
539− * distinct name per input for multi-input ones). */
540−const parseTemplateValue = (
541− value: AuthenticationTemplateValue,
542−): { readonly prefix: string; readonly variable: string } => {
543− if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
544− const parts: string[] = [];
545− for (const part of value) {
546− if (typeof part !== "string" && part.type === "variable") {
547− return { prefix: parts.join(""), variable: part.name };
548− }
549− if (typeof part === "string") parts.push(part);
550− }
551− return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
552−};
553−
554−const placementsFromAuthentication = (
555− template: Extract<Authentication, { readonly type: "apiKey" }>,
556−): readonly AuthPlacementDescriptor[] => {
557− const placements: AuthPlacementDescriptor[] = [];
558− for (const [name, value] of Object.entries(template.headers ?? {})) {
559− const { prefix, variable } = parseTemplateValue(value);
560− placements.push({ carrier: "header", name, prefix, variable });
561− }
562− for (const [name, value] of Object.entries(template.queryParams ?? {})) {
563− const { prefix, variable } = parseTemplateValue(value);
564− placements.push({ carrier: "query", name, prefix, variable });
565− }
566− return placements;
567−};
568−
569−const apiKeyLabel = (slug: string, placements: readonly AuthPlacementDescriptor[]): string => {
570− const first = placements[0];
571− if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
572− return `API key (${slug})`;
573−};
574−
575523 export const describeOpenApiAuthMethods = (
576524 record: IntegrationRecord,
577525 ): readonly AuthMethodDescriptor[] => {
@@ -579,13 +527,12 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
579527 if (!config) return [];
580528 return (config.authenticationTemplate ?? []).map(
581529 (template: Authentication): AuthMethodDescriptor => {
582− const slug = String(template.slug);
583− if (template.type === "oauth") {
530+ if (template.kind === "oauth2") {
584531 return {
585− id: slug,
532+ id: String(template.slug),
586533 label: "OAuth2",
587534 kind: "oauth",
588− template: slug,
535+ template: String(template.slug),
589536 oauth: {
590537 authorizationUrl: template.authorizationUrl,
591538 tokenUrl: template.tokenUrl,
@@ -593,14 +540,7 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
593540 },
594541 };
595542 }
596− const placements = placementsFromAuthentication(template);
597− return {
598− id: slug,
599− label: apiKeyLabel(slug, placements),
600− kind: "apikey",
601− template: slug,
602− placements,
603− };
543+ return describeApiKeyAuthMethod(template);
604544 },
605545 );
606546 };
@@ -798,7 +738,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 191 unmodified lines
798738 // Google Discovery converter derived from the spec (the bundle add
799739 // path relies on this — it has no preview to detect auth from).
800740 ...(config.authenticationTemplate
801− ? { authenticationTemplate: config.authenticationTemplate }
741+ ? {
742+ authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
743+ }
802744 : resolved.authenticationTemplate
803745 ? { authenticationTemplate: resolved.authenticationTemplate }
804746 : {}),
@@ -891,13 +833,11 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 86 unmodified lines
891833 const current = decodeOpenApiIntegrationConfig(record.config);
892834 if (!current) return [] as readonly Authentication[];
893835
836+ const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
894837 const merged =
895838 input.mode === "replace"
896− ? input.authenticationTemplate
897− : mergeAuthTemplates(
898− current.authenticationTemplate ?? [],
899− input.authenticationTemplate,
900− );
839+ ? incoming
840+ : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
901841
902842 const next: OpenApiIntegrationConfig = {
903843 ...current,
@@ -959,7 +899,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 55 unmodified lines
959899 headers: input.headers,
960900 queryParams: input.queryParams,
961901 authenticationTemplate: input.authenticationTemplate as
962− | readonly Authentication[]
902+ | readonly AuthenticationInput[]
963903 | undefined,
964904 })
965905 .pipe(
@@ -1074,12 +1014,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 108 unmodified lines
10741014 if (missing.length > 0) {
10751015 return openApiAuthToolFailure({
10761016 code:
1077− template.type === "oauth" ? "oauth_connection_missing" : "connection_value_missing",
1017+ template.kind === "oauth2"
1018+ ? "oauth_connection_missing"
1019+ : "connection_value_missing",
10781020 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
10791021 owner: credential.owner,
10801022 integration,
10811023 connection: String(credential.connection),
1082− credentialKind: template.type === "oauth" ? "oauth" : "secret",
1024+ credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
10831025 });
10841026 }
10851027 const rendered = renderAuthTemplate(template, credential.values);
packages/plugins/openapi/src/sdk/types.ts+45−30
@@ -1,43 +1,58 @@
11 import { Schema } from "effect";
2−import type { AuthTemplateSlug } from "@executor-js/sdk/shared";
3−import type { OAuthAuthentication } from "@executor-js/sdk/shared";
2+import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
3+import {
4+ apiKeyMethodFromAuthTemplate,
5+ isApiKeyAuthTemplate,
6+ type ApiKeyAuthMethod,
7+ type ApiKeyAuthTemplate,
8+} from "@executor-js/sdk/http-auth";
49
510 // ---------------------------------------------------------------------------
6−// Auth-template model (ported from the v2 scaffold `openapi/types.ts`).
11+// Auth-template model.
712 //
8−// The apiKey template is HTTP-transport-specific: it declares where the user's
9−// credential goes on the outbound request (headers / query params) via the
10−// `variable()` templating below. That placement is why it lives with the
11−// openapi plugin rather than in core. The oauth template is mechanism-intrinsic
12−// and comes from core (`OAuthAuthentication`); an integration's `Authentication`
13−// union composes the two. Client credentials (clientId/secret) live on the core
14−// `OAuthClient`, not here.
13+// The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`,
14+// the same shape the graphql/mcp plugins store): N header/query placements,
15+// each rendered from its own credential input. The oauth template is
16+// mechanism-intrinsic and comes from core (`OAuthAuthentication`, keyed
17+// `kind: "oauth2"` with stored endpoints+scopes); an integration's
18+// `Authentication` union composes the two. Client credentials
19+// (clientId/secret) live on the core `OAuthClient`, not here.
20+//
21+// Pre-canonical stored templates (`type: "apiKey"` with `variable()`-templated
22+// header/query records) are rewritten by the one-off config migration
23+// (`migrate-config.ts`) — runtime code knows only this model.
1524 // ---------------------------------------------------------------------------
1625
17−export type AuthenticationVariable = {
18− readonly type: "variable";
19− readonly name: string;
20−};
21−
22−/** A literal string, or a parts-array mixing literals and variable refs. */
23−export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
26+export { TOKEN_VARIABLE } from "@executor-js/sdk/http-auth";
2427
25−export const variable = (name: string): AuthenticationVariable => ({
26− type: "variable",
27− name,
28−});
28+export type APIKeyAuthentication = ApiKeyAuthMethod;
2929
30−/** The variable name the resolved credential value renders into. */
31−export const TOKEN_VARIABLE = "token" as const;
30+/** Every method is keyed by `kind` — `kind: "oauth2"` | `kind: "apikey"`. */
31+export type Authentication = OAuthAuthentication | APIKeyAuthentication;
3232
33−export type APIKeyAuthentication = {
34− readonly slug: AuthTemplateSlug;
35− readonly type: "apiKey";
36− readonly headers?: Record<string, AuthenticationTemplateValue>;
37− readonly queryParams?: Record<string, AuthenticationTemplateValue>;
33+/** What auth inputs accept: oauth templates (wire-typed: plain slug) plus the
34+ * request-shaped apikey dialect (`type: "apiKey"`, headers/queryParams
35+ * records) — the ONE apikey authoring shape. Stored configs and the catalog
36+ * read as canonical placements; `apiKeyAuthTemplateFromMethod` serializes
37+ * them back for read-modify-write flows. */
38+export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & {
39+ readonly slug: string;
3840 };
39−
40−export type Authentication = OAuthAuthentication | APIKeyAuthentication;
41+export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate;
42+
43+/** Expand the request-shaped dialect into canonical placements and brand the
44+ * oauth slugs. A dialect entry without a slug gets a blank one —
45+ * `mergeAuthTemplates` backfills `custom_<id>`. */
46+export const normalizeOpenApiAuthInputs = (
47+ inputs: readonly AuthenticationInput[],
48+): readonly Authentication[] =>
49+ inputs.map((input): Authentication => {
50+ if (!isApiKeyAuthTemplate(input)) {
51+ return { ...input, slug: AuthTemplateSlug.make(input.slug) };
52+ }
53+ const method = apiKeyMethodFromAuthTemplate(input);
54+ return { ...method, slug: method.slug ?? "" };
55+ });
4156
4257 // ---------------------------------------------------------------------------
4358 // Branded IDs
packages/react/src/lib/auth-placements.tsx+6−1
@@ -31,6 +31,10 @@ export interface Placement { · 30 unmodified lines
3131 * each get a distinct variable. Two placements sharing a variable share one
3232 * value. */
3333 readonly variable?: string;
34+ /** Set when the placement renders this exact value instead of a credential
35+ * (a static header/param the method carries). Carried through edits; the
36+ * editor doesn't create these. */
37+ readonly literal?: string;
3438 }
3539
3640 /** A fresh, empty header placement — the default first row in an editor. */
@@ -42,7 +46,7 @@ export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", p · 5 unmodified lines
4246 * `source` distinguishes integration-declared ("spec") methods from
4347 * user-defined ("custom") ones. `template` is the auth-template slug the
4448 * method applies a connection through. */
45−export type AuthMethodKind = "oauth" | "apikey" | "custom" | "none";
49+export type AuthMethodKind = "oauth" | "apikey" | "none";
4650
4751 /** Provider OAuth endpoints/scopes an `oauth` method declares, used to pre-fill
4852 * the client-registration form so the user only pastes their client id/secret.
@@ -167,6 +171,7 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod · 118 unmodified lines
167171 name: placement.name,
168172 prefix: placement.prefix,
169173 ...(placement.variable ? { variable: placement.variable } : {}),
174+ ...(placement.literal !== undefined ? { literal: placement.literal } : {}),
170175 }),
171176 )
172177 : DEFAULT_PLACEMENTS;
packages/react/src/lib/shared-auth-method-codec.ts+224−0
@@ -0,0 +1,224 @@
1+// ---------------------------------------------------------------------------
2+// Shared auth-method codec — the one bridge between the canonical
3+// placements-based wire model (`@executor-js/sdk/http-auth`: apikey/none methods
4+// every protocol plugin stores) and the client's presentational shapes
5+// (`AuthMethod`, `Placement`, `AuthTemplateEditorValue`).
6+//
7+// OAuth methods are the only per-plugin wire variant (openapi stores
8+// endpoints+scopes, graphql a header override, mcp discovers at connect), so
9+// each plugin contributes a small OAUTH ADAPTER and composes everything else
10+// from here. This replaces the three per-plugin codec modules that each
11+// reimplemented (and disagreed on) the apikey path — MCP silently dropped all
12+// but the first placement, GraphQL split one method into N single-placement
13+// templates.
14+// ---------------------------------------------------------------------------
15+
16+import { AuthTemplateSlug } from "@executor-js/sdk/shared";
17+import {
18+ TOKEN_VARIABLE,
19+ apiKeyAuthTemplateFromMethod,
20+ apiKeyMethodLabel,
21+ type ApiKeyAuthMethod,
22+ type ApiKeyAuthTemplate,
23+ type AuthPlacement,
24+ type NoneAuthMethod,
25+} from "@executor-js/sdk/http-auth";
26+
27+import type { AuthTemplateEditorValue } from "../components/auth-template-editor";
28+import type { AuthMethod, Placement } from "./auth-placements";
29+import type { AuthMethodsCodec } from "./custom-auth-methods";
30+
31+export type SharedAuthMethod = NoneAuthMethod | ApiKeyAuthMethod;
32+
33+/** A method input the backend slugs itself (custom-method create flow). */
34+export type SharedAuthMethodInput =
35+ | { readonly slug?: string; readonly kind: "none" }
36+ | {
37+ readonly slug?: string;
38+ readonly kind: "apikey";
39+ readonly label?: string;
40+ readonly placements: readonly AuthPlacement[];
41+ };
42+
43+export const isSharedAuthMethod = (template: {
44+ readonly kind: string;
45+}): template is SharedAuthMethod => template.kind === "apikey" || template.kind === "none";
46+
47+// ---------------------------------------------------------------------------
48+// Wire placements ⇆ editor placements.
49+// ---------------------------------------------------------------------------
50+
51+/** Wire → editor. The variable is made explicit (`token` when absent) so a
52+ * round-trip preserves which placements share a credential input. */
53+export const editorPlacementsFromWire = (
54+ placements: readonly AuthPlacement[],
55+): readonly Placement[] =>
56+ placements.map(
57+ (placement: AuthPlacement): Placement => ({
58+ carrier: placement.carrier,
59+ name: placement.name,
60+ prefix: placement.prefix ?? "",
61+ ...(placement.literal !== undefined
62+ ? { literal: placement.literal }
63+ : { variable: placement.variable ?? TOKEN_VARIABLE }),
64+ }),
65+ );
66+
67+/** Slugify a placement name into a variable identifier: `DD-API-KEY` →
68+ * `dd_api_key`. */
69+const slugifyVariable = (name: string): string =>
70+ name
71+ .toLowerCase()
72+ .replace(/[^a-z0-9]+/g, "_")
73+ .replace(/^_+|_+$/g, "");
74+
75+/** Assign an input variable to each named credential placement. A lone input
76+ * is the canonical `token`; multiple inputs each get their own distinct
77+ * variable so a connection carries a different value per location. An
78+ * explicit `placement.variable` (from a round-trip) is honored — that is how
79+ * two placements keep SHARING one input across an edit. */
80+const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => {
81+ const named = placements.filter((p: Placement) => p.name.trim() && p.literal === undefined);
82+ const out = new Map<Placement, string>();
83+ if (named.length <= 1) {
84+ for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE);
85+ return out;
86+ }
87+ // Explicit variables are kept VERBATIM — and may repeat: that is how two
88+ // placements share one credential input. Only derived names dedupe.
89+ const taken = new Set<string>();
90+ for (const p of named) {
91+ if (p.variable) {
92+ out.set(p, p.variable);
93+ taken.add(p.variable);
94+ }
95+ }
96+ for (const p of named) {
97+ if (p.variable) continue;
98+ const base = slugifyVariable(p.name) || "input";
99+ let candidate = base;
100+ let n = 2;
101+ while (taken.has(candidate)) candidate = `${base}_${n++}`;
102+ taken.add(candidate);
103+ out.set(p, candidate);
104+ }
105+ return out;
106+};
107+
108+/** Editor → wire. Unnamed placements are dropped; the canonical `token`
109+ * variable is stored as absent (the wire convention single-input connections
110+ * depend on — their stored values are keyed `token`). */
111+export const wirePlacementsFromEditor = (
112+ placements: readonly Placement[],
113+): readonly AuthPlacement[] => {
114+ const variables = assignVariables(placements);
115+ return placements
116+ .filter((p: Placement) => p.name.trim().length > 0 || p.literal !== undefined)
117+ .map((p: Placement): AuthPlacement => {
118+ const variable = variables.get(p);
119+ return {
120+ carrier: p.carrier,
121+ name: p.name.trim(),
122+ ...(p.prefix ? { prefix: p.prefix } : {}),
123+ ...(p.literal !== undefined
124+ ? { literal: p.literal }
125+ : variable && variable !== TOKEN_VARIABLE
126+ ? { variable }
127+ : {}),
128+ };
129+ });
130+};
131+
132+// ---------------------------------------------------------------------------
133+// Method ⇆ editor value / presentational method.
134+// ---------------------------------------------------------------------------
135+
136+/** Convert one stored apikey/none method into the generic editor value. */
137+export const editorValueFromSharedMethod = (method: SharedAuthMethod): AuthTemplateEditorValue =>
138+ method.kind === "apikey"
139+ ? { kind: "apikey", placements: editorPlacementsFromWire(method.placements) }
140+ : { kind: "none" };
141+
142+/** Convert an apikey/none editor value back into a method input (no slug —
143+ * the backend assigns one). Returns null for oauth values: those convert
144+ * through the plugin's oauth adapter. An apikey value with no usable
145+ * placements degrades to `none`. */
146+export const sharedMethodInputFromEditorValue = (
147+ value: AuthTemplateEditorValue,
148+): SharedAuthMethodInput | null => {
149+ if (value.kind === "oauth") return null;
150+ if (value.kind === "none") return { kind: "none" };
151+ const placements = wirePlacementsFromEditor(value.placements);
152+ return placements.length > 0 ? { kind: "apikey", placements } : { kind: "none" };
153+};
154+
155+/** Serialize an apikey method (stored or slug-optional) into the wire input
156+ * dialect — auth INPUTS accept only the request-shaped template; stored
157+ * configs and the catalog read as placements. Non-apikey arms pass through
158+ * untouched. */
159+export const wireAuthInputFromShared = <T extends { readonly kind: string }>(
160+ method:
161+ | T
162+ | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string; readonly kind: "apikey" }),
163+): T | ApiKeyAuthTemplate =>
164+ method.kind === "apikey"
165+ ? apiKeyAuthTemplateFromMethod(
166+ method as Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string },
167+ )
168+ : (method as T);
169+
170+const sourceOf = (slug: string): "spec" | "custom" =>
171+ slug.startsWith("custom_") ? "custom" : "spec";
172+
173+/** Project one stored apikey/none method into the presentational `AuthMethod`
174+ * the hub renders (mirrors the server's catalog projection). */
175+export const authMethodFromSharedTemplate = (method: SharedAuthMethod): AuthMethod => {
176+ if (method.kind === "apikey") {
177+ return {
178+ id: method.slug,
179+ label: apiKeyMethodLabel(method),
180+ kind: "apikey",
181+ source: sourceOf(method.slug),
182+ template: AuthTemplateSlug.make(method.slug),
183+ placements: editorPlacementsFromWire(method.placements),
184+ };
185+ }
186+ return {
187+ id: method.slug,
188+ label: "No authentication",
189+ kind: "none",
190+ source: sourceOf(method.slug),
191+ template: AuthTemplateSlug.make(method.slug),
192+ placements: [],
193+ };
194+};
195+
196+// ---------------------------------------------------------------------------
197+// The codec factory for `useCustomMethodActions` — apikey/none handled here,
198+// oauth delegated to the plugin's adapter.
199+// ---------------------------------------------------------------------------
200+
201+export interface SharedCodecOAuthAdapter<TOAuth extends { readonly slug: string }> {
202+ readonly isOAuth: (template: SharedAuthMethod | TOAuth) => template is TOAuth;
203+ readonly toAuthMethod: (template: TOAuth) => AuthMethod;
204+}
205+
206+export function makeSharedAuthMethodCodec<TOAuth extends { readonly slug: string }>(
207+ adapter: SharedCodecOAuthAdapter<TOAuth>,
208+): AuthMethodsCodec<SharedAuthMethod | TOAuth> {
209+ return {
210+ toAuthMethods: (templates) =>
211+ templates.map((template) =>
212+ adapter.isOAuth(template)
213+ ? adapter.toAuthMethod(template)
214+ : authMethodFromSharedTemplate(template),
215+ ),
216+ templatesFromPlacements: (placements) => {
217+ const wire = wirePlacementsFromEditor(placements);
218+ if (wire.length === 0) return [];
219+ // Slug-less: the backend's merge assigns `custom_<id>`.
220+ return [{ slug: "", kind: "apikey", placements: wire } as ApiKeyAuthMethod];
221+ },
222+ slugOf: (template) => template.slug,
223+ };
224+}