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
47## Package Roles47## Package Roles
48 48
49- `packages/core/sdk`: executor core contracts, plugin wiring, scopes, sources,49- `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).
51- `packages/core/storage-*`: storage adapters and storage test support.54- `packages/core/storage-*`: storage adapters and storage test support.
52- `packages/plugins/*`: protocol and provider plugins. Plugin-specific55- `packages/plugins/*`: protocol and provider plugins. Plugin-specific
53 runtime, React, API, and testing helpers should live with the owning plugin.56 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
13 "db:studio": "drizzle-kit studio",13 "db:studio": "drizzle-kit studio",
14 "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio",14 "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio",
15 "db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",15 "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",
16 "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",18 "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
17 "build": "node scripts/build.mjs",19 "build": "node scripts/build.mjs",
18 "preview": "vite preview",20 "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
15import postgres from "postgres";
16import { Option } from "effect";
17import { planAuthConfigMigration, type AuthConfigMigrationRow } from "@executor-js/sdk/http-auth";
18import {
19 decodeGraphqlIntegrationConfigOption,
20 migrateGraphqlAuthConfig,
21} from "@executor-js/plugin-graphql";
22import { migrateMcpAuthConfig, parseMcpIntegrationConfig } from "@executor-js/plugin-mcp";
23import {
24 decodeOpenApiIntegrationConfig,
25 migrateOpenApiAuthConfig,
26} from "@executor-js/plugin-openapi";
27
28const 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.
37const 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
43const connectionString = process.env.DATABASE_URL;
44if (!connectionString) {
45 console.error("DATABASE_URL is not set");
46 process.exit(1);
47}
48const dryRun = process.argv.includes("--dry-run");
49
50// Direct (non-Hyperdrive) connection — PlanetScale requires TLS.
51const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
52
53try {
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
4 4
5import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";5import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
6 6
7import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
8
7import { resolveAuthProviders } from "./auth";9import { resolveAuthProviders } from "./auth";
10import { authConfigTransforms } from "./db/auth-config-migration";
8import { makeSelfHostAdminApiLayer } from "./admin/handlers";11import { makeSelfHostAdminApiLayer } from "./admin/handlers";
9import { makeSelfHostSystemApiLayer } from "./system/handlers";12import { makeSelfHostSystemApiLayer } from "./system/handlers";
10import { selfHostAccountMiddleware } from "./account";13import { selfHostAccountMiddleware } from "./account";
@@ -54,6 +57,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 43 unmodified lines
54 version: SELF_HOST_SCHEMA_VERSION,57 version: SELF_HOST_SCHEMA_VERSION,
55 });58 });
56 59
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
57 // ---- auth providers ---------------------------------------------------65 // ---- auth providers ---------------------------------------------------
58 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account66 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
59 // API + MCP OAuth seam, all over the shared libSQL handle.67 // 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
8import type { AuthConfigTransform } from "@executor-js/sdk/http-auth";
9import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql";
10import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp";
11import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi";
12
13export 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
8import type { AuthConfigTransform } from "@executor-js/sdk/http-auth";
9import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql";
10import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp";
11import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi";
12
13export 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
5import { createHash } from "node:crypto";5import { createHash } from "node:crypto";
6 6
7import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk";7import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk";
8import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
8import { collectTables } from "@executor-js/api/server";9import { collectTables } from "@executor-js/api/server";
9import { loadPluginsFromJsonc } from "@executor-js/config";10import { loadPluginsFromJsonc } from "@executor-js/config";
10 11
11import executorConfig from "../executor.config";12import executorConfig from "../executor.config";
13import { authConfigTransforms } from "./db/auth-config-migration";
12import { createSqliteFumaDb } from "./db/sqlite-fumadb";14import { createSqliteFumaDb } from "./db/sqlite-fumadb";
13import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";15import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration";
14 16
@@ -167,6 +169,15 @@ const createLocalExecutorLayer = () => { · 152 unmodified lines
167 (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),169 (db) => Effect.promise(() => db.close()).pipe(Effect.ignore),
168 );170 );
169 171
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
170 // webBaseUrl is where the executor's web UI listens — same port as the181 // webBaseUrl is where the executor's web UI listens — same port as the
171 // daemon API since the daemon serves both. Mirrors serve.ts's port182 // daemon API since the daemon serves both. Mirrors serve.ts's port
172 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL183 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
bun.lock+0−55
@@ -846,57 +846,6 @@ · 845 unmodified lines
846 "react",846 "react",
847 ],847 ],
848 },848 },
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 },
900 "packages/plugins/keychain": {849 "packages/plugins/keychain": {
901 "name": "@executor-js/plugin-keychain",850 "name": "@executor-js/plugin-keychain",
902 "version": "1.5.3",851 "version": "1.5.3",
@@ -1598,10 +1547,6 @@ · 695 unmodified lines
1598 1547
1599 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],1548 "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"],
1600 1549
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
1605 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],1550 "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"],
1606 1551
1607 "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"],1552 "@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
4_/}4_/}
5 5
6```ts6```ts
7// An integration is the API surface. The apiKey template declares where a7// An integration is the API surface. The auth template declares where a
8// connection's credential is placed on each request — here, an `X-API-Key`8// connection's credential is placed on each request — here, an `X-API-Key`
9// header. `variable("token")` is the slot the resolved credential renders into.9// header. `variable("token")` is the slot the resolved credential renders into.
10await executor.openapi.addSpec({10await executor.openapi.addSpec({
e2e/scenarios/auth-methods.test.ts+226−20
@@ -1,26 +1,33 @@
1// Cross-target: multi-method authentication — an integration declares SEVERAL1// Cross-target: multi-method authentication — an integration declares SEVERAL
2// auth methods and a connection picks one by template slug. Covers the model2// auth methods and a connection picks one by template slug. Covers the unified
3// rework: MCP's slugged `authenticationTemplate` array, the merge-append3// placements model: one apikey method can mix header and query placements,
4// configureAuth flow (a custom API key must never displace a detected OAuth4// each rendered from its own credential input; the merge-append configureAuth
5// method), declaring a method on a server that advertises none, and GraphQL's5// flow (a custom API key must never displace a detected OAuth method);
6// multi-method add. Entirely through the typed clients — no MCP server is6// declaring a method on a server that advertises none; GraphQL's multi-method
7// dialed (registration and method configuration are catalog statements).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.
8import { randomBytes } from "node:crypto";11import { randomBytes } from "node:crypto";
9 12
10import { expect } from "@effect/vitest";13import { expect } from "@effect/vitest";
11import { Effect } from "effect";14import { Effect } from "effect";
12import { composePluginApi } from "@executor-js/api/server";15import { composePluginApi } from "@executor-js/api/server";
13import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";16import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
17import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
14import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";18import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
15import { IntegrationSlug } from "@executor-js/sdk/shared";19import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20
21import { variable } from "@executor-js/sdk/http-auth";
16 22
17import { scenario } from "../src/scenario";23import { scenario } from "../src/scenario";
18 24
19const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const);25const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const);
20 26
21const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;27const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
22 28
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.
24const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp";31const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp";
25 32
26scenario(33scenario(
@@ -30,7 +37,7 @@ scenario( · 3 unmodified lines
30 Effect.gen(function* () {37 Effect.gen(function* () {
31 const identity = yield* ctx.target.newIdentity();38 const identity = yield* ctx.target.newIdentity();
32 const client = yield* ctx.api.client(api, identity);39 const client = yield* ctx.api.client(api, identity);
33 const slug = freshSlug("mcp-multiauth");40 const slug = freshSlug("mcp_multiauth");
34 41
35 yield* client.mcp.addServer({42 yield* client.mcp.addServer({
36 payload: {43 payload: {
@@ -40,7 +47,7 @@ scenario( · 3 unmodified lines
40 slug,47 slug,
41 authenticationTemplate: [48 authenticationTemplate: [
42 { kind: "oauth2" },49 { kind: "oauth2" },
43 { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " },50 { type: "apiKey", headers: { "X-Api-Key": ["Bearer ", variable("token")] } },
44 ],51 ],
45 },52 },
46 });53 });
@@ -50,7 +57,8 @@ scenario( · 3 unmodified lines
50 params: { slug: IntegrationSlug.make(slug) },57 params: { slug: IntegrationSlug.make(slug) },
51 });58 });
52 59
53 // Both methods project into the catalog, slugged by kind, so the60 // Both methods project into the catalog (a slug-less single-header
61 // apikey method gets the carrier-derived `header` slug), so the
54 // connect flow can offer either and a connection binds one by slug.62 // connect flow can offer either and a connection binds one by slug.
55 expect(63 expect(
56 integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })),64 integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })),
@@ -81,7 +89,7 @@ scenario( · 24 unmodified lines
81 Effect.gen(function* () {89 Effect.gen(function* () {
82 const identity = yield* ctx.target.newIdentity();90 const identity = yield* ctx.target.newIdentity();
83 const client = yield* ctx.api.client(api, identity);91 const client = yield* ctx.api.client(api, identity);
84 const slug = freshSlug("mcp-oauth-plus-key");92 const slug = freshSlug("mcp_oauth_plus_key");
85 93
86 // The add flow registered what the probe detected: OAuth only.94 // The add flow registered what the probe detected: OAuth only.
87 yield* client.mcp.addServer({95 yield* client.mcp.addServer({
@@ -99,13 +107,15 @@ scenario( · 11 unmodified lines
99 const configured = yield* client.mcp.configureAuth({107 const configured = yield* client.mcp.configureAuth({
100 params: { slug: IntegrationSlug.make(slug) },108 params: { slug: IntegrationSlug.make(slug) },
101 payload: {109 payload: {
102 authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }],110 authenticationTemplate: [
111 { type: "apiKey", headers: { "X-Api-Key": [variable("token")] } },
112 ],
103 },113 },
104 });114 });
105 expect(115 expect(
106 configured.authenticationTemplate.map((m) => m.kind),116 configured.authenticationTemplate.map((m) => m.kind),
107 "the declared set now holds both methods",117 "the declared set now holds both methods",
108 ).toEqual(["oauth2", "header"]);118 ).toEqual(["oauth2", "apikey"]);
109 expect(119 expect(
110 configured.authenticationTemplate[1]?.slug,120 configured.authenticationTemplate[1]?.slug,
111 "the custom method gets its own custom_ slug",121 "the custom method gets its own custom_ slug",
@@ -135,7 +145,7 @@ scenario( · 23 unmodified lines
135 Effect.gen(function* () {145 Effect.gen(function* () {
136 const identity = yield* ctx.target.newIdentity();146 const identity = yield* ctx.target.newIdentity();
137 const client = yield* ctx.api.client(api, identity);147 const client = yield* ctx.api.client(api, identity);
138 const slug = freshSlug("mcp-open-plus-key");148 const slug = freshSlug("mcp_open_plus_key");
139 149
140 // No declared auth — the server advertises nothing.150 // No declared auth — the server advertises nothing.
141 yield* client.mcp.addServer({151 yield* client.mcp.addServer({
@@ -155,7 +165,7 @@ scenario( · 13 unmodified lines
155 params: { slug: IntegrationSlug.make(slug) },165 params: { slug: IntegrationSlug.make(slug) },
156 payload: {166 payload: {
157 authenticationTemplate: [167 authenticationTemplate: [
158 { kind: "header", headerName: "Authorization", prefix: "Bearer " },168 { type: "apiKey", headers: { Authorization: ["Bearer ", variable("token")] } },
159 ],169 ],
160 },170 },
161 });171 });
@@ -184,7 +194,7 @@ scenario( · 22 unmodified lines
184 Effect.gen(function* () {194 Effect.gen(function* () {
185 const identity = yield* ctx.target.newIdentity();195 const identity = yield* ctx.target.newIdentity();
186 const client = yield* ctx.api.client(api, identity);196 const client = yield* ctx.api.client(api, identity);
187 const slug = freshSlug("graphql-multiauth");197 const slug = freshSlug("graphql_multiauth");
188 198
189 yield* client.graphql.addIntegration({199 yield* client.graphql.addIntegration({
190 payload: {200 payload: {
@@ -192,8 +202,8 @@ scenario( · 1 unmodified line
192 slug,202 slug,
193 name: "Multi-auth GraphQL",203 name: "Multi-auth GraphQL",
194 authenticationTemplate: [204 authenticationTemplate: [
195 { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" },205 { slug: "apiKey", type: "apiKey", headers: { "X-Api-Key": [variable("token")] } },
196 { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" },206 { slug: "apikey-2", type: "apiKey", queryParams: { api_key: [variable("token")] } },
197 ],207 ],
198 },208 },
199 });209 });
@@ -222,3 +232,199 @@ scenario( · 22 unmodified lines
222 );232 );
223 }),233 }),
224);234);
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
245scenario(
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
154 }),154 }),
155 ),155 ),
156);156);
157
158scenario(
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
46 slug,46 slug,
47 authenticationTemplate: [47 authenticationTemplate: [
48 { kind: "oauth2" },48 { kind: "oauth2" },
49 { kind: "header", headerName: "Authorization", prefix: "Bearer " },49 {
50 type: "apiKey",
51 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
52 },
50 ],53 ],
51 },54 },
52 });55 });
e2e/selfhost/oauth-app-modal.test.ts+1−1
@@ -63,7 +63,7 @@ scenario( · 62 unmodified lines
63 authenticationTemplate: [63 authenticationTemplate: [
64 {64 {
65 slug: "oauth",65 slug: "oauth",
66 type: "oauth",66 kind: "oauth2",
67 authorizationUrl: "https://auth.example/authorize",67 authorizationUrl: "https://auth.example/authorize",
68 tokenUrl: "https://auth.example/token",68 tokenUrl: "https://auth.example/token",
69 scopes: [],69 scopes: [],
examples/all-plugins/src/main.ts+1−1
@@ -235,7 +235,7 @@ const program = Effect.gen(function* () { · 234 unmodified lines
235 baseUrl: "https://example.com/api",235 baseUrl: "https://example.com/api",
236 authenticationTemplate: [236 authenticationTemplate: [
237 {237 {
238 slug: AuthTemplateSlug.make("apiKey"),238 slug: "apiKey",
239 type: "apiKey",239 type: "apiKey",
240 headers: { "X-API-Key": [variable("token")] },240 headers: { "X-API-Key": [variable("token")] },
241 },241 },
examples/docs-sdk-quickstart/src/main.ts+1−1
@@ -99,7 +99,7 @@ const executor = await createExecutor({ · 98 unmodified lines
99// docs:end create-executor99// docs:end create-executor
100 100
101// docs:start add-integration101// docs:start add-integration
102// An integration is the API surface. The apiKey template declares where a102// An integration is the API surface. The auth template declares where a
103// connection's credential is placed on each request — here, an `X-API-Key`103// connection's credential is placed on each request — here, an `X-API-Key`
104// header. `variable("token")` is the slot the resolved credential renders into.104// header. `variable("token")` is the slot the resolved credential renders into.
105await executor.openapi.addSpec({105await executor.openapi.addSpec({
examples/promise-sdk/src/main.ts+1−1
@@ -130,7 +130,7 @@ await executor.connections.create({ · 129 unmodified lines
130// template: "bearer",130// template: "bearer",
131// value: "sk_live_...",131// value: "sk_live_...",
132// });132// });
133void variable;133void variable; // referenced by the commented authoring example above
134 134
135// ---------------------------------------------------------------------------135// ---------------------------------------------------------------------------
136// 4. GraphQL — introspect an endpoint as an integration, then connect.136// 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
34 carrier: Schema.Literals(["header", "query"]),34 carrier: Schema.Literals(["header", "query"]),
35 name: Schema.String,35 name: Schema.String,
36 prefix: Schema.String,36 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),
37});43});
38 44
39/** OAuth specifics — mirrors the SDK's `AuthMethodOAuthDescriptor`. */45/** OAuth specifics — mirrors the SDK's `AuthMethodOAuthDescriptor`. */
packages/core/sdk/package.json+8−1
@@ -23,7 +23,8 @@ · 22 unmodified lines
23 "./promise": "./src/promise.ts",23 "./promise": "./src/promise.ts",
24 "./client": "./src/client.ts",24 "./client": "./src/client.ts",
25 "./testing": "./src/testing.ts",25 "./testing": "./src/testing.ts",
26 "./migration": "./src/migration-spec.ts"26 "./migration": "./src/migration-spec.ts",
27 "./http-auth": "./src/http-auth/index.ts"
27 },28 },
28 "publishConfig": {29 "publishConfig": {
29 "access": "public",30 "access": "public",
@@ -69,6 +70,12 @@ · 39 unmodified lines
69 "types": "./dist/migration-spec.d.ts",70 "types": "./dist/migration-spec.d.ts",
70 "default": "./dist/migration-spec.js"71 "default": "./dist/migration-spec.js"
71 }72 }
73 },
74 "./http-auth": {
75 "import": {
76 "types": "./dist/http-auth.d.ts",
77 "default": "./dist/http-auth.js"
78 }
72 }79 }
73 }80 }
74 },81 },
packages/core/sdk/src/executor.ts+30−11
@@ -1547,17 +1547,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1546 unmodified lines
1547 Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null),1547 Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null),
1548 );1548 );
1549 1549
1550 const resolveConnectionValueByRef = (1550 // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`,
1551 ref: ConnectionRef,1551 // `getValues`) is `StorageFailure`-typed; fold a reauth-required resolution
1552 ): Effect.Effect<string | null, StorageFailure> =>1552 // failure into a StorageError so the public surface stays stable.
1553 Effect.gen(function* () {1553 const foldResolutionFailure = <A>(
1554 const row = yield* findConnectionRow(ref);1554 effect: Effect.Effect<A, StorageFailure | CredentialResolutionError>,
1555 if (!row) return null;1555 ): Effect.Effect<A, StorageFailure> =>
1556 return yield* resolveConnectionValue(row);1556 effect.pipe(
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.
1561 Effect.catchTag("CredentialResolutionError", (err) =>1557 Effect.catchTag("CredentialResolutionError", (err) =>
1562 Effect.fail(1558 Effect.fail(
1563 new StorageError({1559 new StorageError({
@@ -1569,6 +1565,28 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
1569 ),1565 ),
1570 );1566 );
1571 1567
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
1572 // ------------------------------------------------------------------1590 // ------------------------------------------------------------------
1573 // Integrations1591 // Integrations
1574 // ------------------------------------------------------------------1592 // ------------------------------------------------------------------
@@ -1810,6 +1828,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 235 unmodified lines
1810 connection: ref,1828 connection: ref,
1811 template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,1829 template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
1812 getValue: () => resolveConnectionValueByRef(ref),1830 getValue: () => resolveConnectionValueByRef(ref),
1831 getValues: () => resolveConnectionValuesByRef(ref),
1813 })1832 })
1814 .pipe(1833 .pipe(
1815 Effect.mapError((cause) =>1834 Effect.mapError((cause) =>
packages/core/sdk/src/http-auth/auth-method.test.ts+223−0
@@ -0,0 +1,223 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
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
15describe("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
106describe("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
125describe("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
140describe("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
169describe("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 @@
1import { Schema } from "effect";
2import 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 }`. */
28export const TOKEN_VARIABLE = "token";
29
30export const AuthCarrier = Schema.Literals(["header", "query"]);
31export 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). */
43export 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});
54export type AuthPlacement = typeof AuthPlacement.Type;
55
56export 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});
63export type ApiKeyAuthMethod = typeof ApiKeyAuthMethod.Type;
64
65/** An open integration — connections carry no credential. */
66export const NoneAuthMethod = Schema.Struct({
67 slug: Schema.String,
68 kind: Schema.Literal("none"),
69});
70export type NoneAuthMethod = typeof NoneAuthMethod.Type;
71
72// ---------------------------------------------------------------------------
73// Rendering
74// ---------------------------------------------------------------------------
75
76export interface RenderedAuthPlacements {
77 readonly headers: Record<string, string>;
78 readonly queryParams: Record<string, string>;
79}
80
81const 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`. */
95export 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. */
112export 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. */
126export 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. */
139export 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
159export 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
167const 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
175export 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
183export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Option, Schema } from "effect";
3
4import { ApiKeyAuthTemplate, apiKeyMethodFromAuthTemplate, variable } from "./authoring";
5import { renderAuthPlacements } from "./auth-method";
6
7const decode = Schema.decodeUnknownOption(ApiKeyAuthTemplate);
8
9describe("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
25import { Schema } from "effect";
26
27import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method";
28
29export 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")]`. */
36export const variable = (name: string): AuthTemplateVariable => ({ type: "variable", name });
37
38const VariablePart = Schema.Struct({
39 type: Schema.Literal("variable"),
40 name: Schema.String,
41});
42
43const isVariablePart = (part: string | AuthTemplateVariable): part is AuthTemplateVariable =>
44 typeof part !== "string";
45
46/** `string` (a static literal) or `prefix literals… + one variable, last`. */
47export 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]);
62export type AuthTemplateValue = typeof AuthTemplateValue.Type;
63
64/** The request-shaped apikey authoring dialect. `slug` optional (backfilled
65 * like every other input); `label` optional. */
66export 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});
73export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type;
74
75const 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). */
96export 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. */
119export 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. */
145export 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 @@
1export {
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.
19export {
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.
30export {
31 LegacyApiKeyTemplate,
32 LegacyTemplateValue,
33 apiKeyMethodFromLegacyTemplate,
34 decodeLegacyApiKeyTemplate,
35} from "./legacy";
36export {
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
19import { Schema } from "effect";
20
21import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method";
22
23const LegacyVariablePart = Schema.Struct({
24 type: Schema.Literal("variable"),
25 name: Schema.String,
26});
27
28export const LegacyTemplateValue = Schema.Union([
29 Schema.String,
30 Schema.Array(Schema.Union([Schema.String, LegacyVariablePart])),
31]);
32export 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). */
36export 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});
42export type LegacyApiKeyTemplate = typeof LegacyApiKeyTemplate.Type;
43
44export const decodeLegacyApiKeyTemplate = Schema.decodeUnknownOption(LegacyApiKeyTemplate);
45
46const 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. */
71export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 planAuthConfigMigration,
5 runSqliteAuthConfigMigration,
6 type AuthConfigTransform,
7 type SqliteAuthConfigClient,
8} from "./migrate";
9
10const transforms: Record<string, AuthConfigTransform> = {
11 mcp: (config) => {
12 const blob = config as { legacy?: boolean };
13 return blob.legacy ? { migrated: true } : null;
14 },
15};
16
17describe("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.
32const 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
49describe("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
10export type AuthConfigTransform = (config: unknown) => unknown | null;
11
12export 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
19export 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. */
28export 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
49export 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. */
59export 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
35 * methods; a distinct name per input for multi-input ones (e.g. Datadog).35 * methods; a distinct name per input for multi-input ones (e.g. Datadog).
36 * Absent → treated as `token`. */36 * Absent → treated as `token`. */
37 readonly variable?: string;37 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;
38}42}
39 43
40/** OAuth specifics for an `oauth` auth method. For probe-at-connect providers44/** 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
25export type OAuthGrant = "authorization_code" | "client_credentials";25export type OAuthGrant = "authorization_code" | "client_credentials";
26 26
27/** Provider OAuth config an integration declares as one of its auth templates —27/** 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. */
29export interface OAuthAuthentication {30export interface OAuthAuthentication {
30 readonly slug: AuthTemplateSlug;31 readonly slug: AuthTemplateSlug;
31 readonly type: "oauth";32 readonly kind: "oauth2";
32 readonly authorizationUrl: string;33 readonly authorizationUrl: string;
33 readonly tokenUrl: string;34 readonly tokenUrl: string;
34 readonly scopes: readonly string[];35 readonly scopes: readonly string[];
packages/core/sdk/src/plugin.ts+4−0
@@ -201,6 +201,10 @@ export interface ResolveToolsInput { · 200 unmodified lines
201 /** Lazily resolve the connection's credential value via its provider — only201 /** Lazily resolve the connection's credential value via its provider — only
202 * the kinds that actually call out (mcp) pay for it. */202 * the kinds that actually call out (mcp) pay for it. */
203 readonly getValue: () => Effect.Effect<string | null, StorageFailure>;203 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>;
204}208}
205 209
206export interface ResolveToolsResult {210export interface ResolveToolsResult {
packages/core/sdk/tsup.config.ts+1−0
@@ -8,6 +8,7 @@ export default defineConfig({ · 7 unmodified lines
8 "host-internal": "src/host-internal.ts",8 "host-internal": "src/host-internal.ts",
9 client: "src/client.ts",9 client: "src/client.ts",
10 "migration-spec": "src/migration-spec.ts",10 "migration-spec": "src/migration-spec.ts",
11 "http-auth": "src/http-auth/index.ts",
11 testing: "src/testing.ts",12 testing: "src/testing.ts",
12 },13 },
13 format: ["esm"],14 format: ["esm"],
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+3−3
@@ -43,7 +43,7 @@ import { · 42 unmodified lines
43 type FumaTables,43 type FumaTables,
44} from "@executor-js/sdk";44} from "@executor-js/sdk";
45import { makeExecutorToolInvoker } from "@executor-js/execution";45import { makeExecutorToolInvoker } from "@executor-js/execution";
46import { openApiPlugin, variable, type Authentication } from "@executor-js/plugin-openapi";46import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi";
47 47
48import { makeDynamicWorkerExecutor } from "./executor";48import { makeDynamicWorkerExecutor } from "./executor";
49 49
@@ -85,8 +85,8 @@ const memoryProvider = (): CredentialProvider => { · 35 unmodified lines
85// Minimal apiKey template: the resolved connection value renders into an85// Minimal apiKey template: the resolved connection value renders into an
86// `x-api-key` header. The body round-trip tests don't assert on auth, but a86// `x-api-key` header. The body round-trip tests don't assert on auth, but a
87// template + connection are required for tools to exist per-connection.87// template + connection are required for tools to exist per-connection.
88const apiKeyTemplate: Authentication = {88const apiKeyTemplate: AuthenticationInput = {
89 slug: AuthTemplateSlug.make("apiKey"),89 slug: "apiKey",
90 type: "apiKey",90 type: "apiKey",
91 headers: { "x-api-key": [variable("token")] },91 headers: { "x-api-key": [variable("token")] },
92};92};
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 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";
3import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared";
4
5import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6import { AuthTemplate } from "../sdk/types";
7
8// ---------------------------------------------------------------------------
9// Params
10// ---------------------------------------------------------------------------
11
12const IntegrationParams = {
13 slug: Schema.String,
14};
15
16// ---------------------------------------------------------------------------
17// Payloads
18// ---------------------------------------------------------------------------
19
20const 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
33const AddIntegrationResponse = Schema.Struct({
34 slug: Schema.String,
35 toolCount: Schema.Number,
36});
37
38const 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
53const IntrospectionError = GraphqlIntrospectionError.annotate({ httpApiStatus: 400 });
54const ExtractionError = GraphqlExtractionError.annotate({ httpApiStatus: 400 });
55
56const 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
69export 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 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { Context, Effect } from "effect";
3
4import { addGroup, capture } from "@executor-js/api";
5import type { GraphqlPluginExtension } from "../sdk/plugin";
6import { 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
17export class GraphqlExtensionService extends Context.Service<
18 GraphqlExtensionService,
19 GraphqlPluginExtension
20>()("GraphqlGreenfieldExtensionService") {}
21
22// ---------------------------------------------------------------------------
23// Composed API — core + graphql group
24// ---------------------------------------------------------------------------
25
26const ExecutorApiWithGraphql = addGroup(GraphqlGroup);
27
28// ---------------------------------------------------------------------------
29// Handlers
30// ---------------------------------------------------------------------------
31
32export 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 @@
1import { definePlugin } from "@executor-js/sdk";
2
3import { graphqlPlugin, type GraphqlPluginOptions } from "../sdk/plugin";
4import { GraphqlGroup } from "./group";
5import { GraphqlHandlers, GraphqlExtensionService } from "./handlers";
6
7export { GraphqlGroup } from "./group";
8export { 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`.
15export 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 @@
1export { graphqlPlugin } from "./sdk/plugin";
2export 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 @@
1import { Data, Schema } from "effect";
2import type { Option } from "effect";
3import type { AuthToolFailureCode } from "@executor-js/sdk";
4
5export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIntrospectionError>()(
6 "GraphqlIntrospectionError",
7 {
8 message: Schema.String,
9 },
10) {}
11
12export class GraphqlExtractionError extends Schema.TaggedErrorClass<GraphqlExtractionError>()(
13 "GraphqlExtractionError",
14 {
15 message: Schema.String,
16 },
17) {}
18
19export 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. */
29export 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 @@
1import { Effect, Match, Option } from "effect";
2
3import { GraphqlExtractionError } from "./errors";
4import type {
5 IntrospectionResult,
6 IntrospectionSchema,
7 IntrospectionType,
8 IntrospectionTypeRef,
9 IntrospectionInputValue,
10} from "./introspect";
11import {
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 */
23const 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) */
30const isNonNull = (ref: IntrospectionTypeRef): boolean => ref.kind === "NON_NULL";
31
32// ---------------------------------------------------------------------------
33// Build shared definitions from all INPUT_OBJECT and ENUM types
34// ---------------------------------------------------------------------------
35
36const 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
82const 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
120const 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
136const 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!]!") */
165const 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
177const 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
217export 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
224export 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 @@
1export { introspect, parseIntrospectionJson } from "./introspect";
2export { extract, type ExtractionOutput } from "./extract";
3export { invoke, invokeWithLayer, endpointForTelemetry } from "./invoke";
4export {
5 graphqlPlugin,
6 type GraphqlPluginExtension,
7 type GraphqlPluginOptions,
8 type AddGraphqlIntegrationInput,
9 type ConfigureGraphqlIntegrationInput,
10} from "./plugin";
11
12export {
13 GraphqlIntrospectionError,
14 GraphqlExtractionError,
15 GraphqlInvocationError,
16 GraphqlAuthRequiredError,
17} from "./errors";
18
19export {
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 @@
1import { Effect, Option, Schema } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
4import { GraphqlIntrospectionError } from "./errors";
5
6// ---------------------------------------------------------------------------
7// Introspection query — standard GraphQL introspection
8// ---------------------------------------------------------------------------
9
10const 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
84const IntrospectionTypeRefLeaf = Schema.Struct({
85 kind: Schema.String,
86 name: Schema.NullOr(Schema.String),
87 ofType: Schema.optional(Schema.Null),
88});
89
90const IntrospectionTypeRef5 = Schema.Struct({
91 kind: Schema.String,
92 name: Schema.NullOr(Schema.String),
93 ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)),
94});
95
96const IntrospectionTypeRef4 = Schema.Struct({
97 kind: Schema.String,
98 name: Schema.NullOr(Schema.String),
99 ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)),
100});
101
102const IntrospectionTypeRef3 = Schema.Struct({
103 kind: Schema.String,
104 name: Schema.NullOr(Schema.String),
105 ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)),
106});
107
108const IntrospectionTypeRef2 = Schema.Struct({
109 kind: Schema.String,
110 name: Schema.NullOr(Schema.String),
111 ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)),
112});
113
114const IntrospectionTypeRefSchema = Schema.Struct({
115 kind: Schema.String,
116 name: Schema.NullOr(Schema.String),
117 ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef2)),
118});
119
120const IntrospectionInputValueSchema = Schema.Struct({
121 name: Schema.String,
122 description: Schema.NullOr(Schema.String),
123 type: IntrospectionTypeRefSchema,
124 defaultValue: Schema.NullOr(Schema.String),
125});
126
127const IntrospectionFieldSchema = Schema.Struct({
128 name: Schema.String,
129 description: Schema.NullOr(Schema.String),
130 args: Schema.Array(IntrospectionInputValueSchema),
131 type: IntrospectionTypeRefSchema,
132});
133
134const 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
150const 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
158const IntrospectionResponseSchema = Schema.Struct({
159 data: Schema.optional(IntrospectionResultSchema),
160 errors: Schema.optional(Schema.Array(Schema.Unknown)),
161});
162
163const 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
174const IntrospectionJsonSchema = Schema.Union([
175 Schema.Struct({ data: IntrospectionResultSchema }),
176 IntrospectionResultSchema,
177]);
178const JsonTextSchema = Schema.fromJsonString(Schema.Unknown);
179
180const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema);
181
182export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type;
183export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type;
184export type IntrospectionField = typeof IntrospectionFieldSchema.Type;
185export type IntrospectionEnumValue = NonNullable<
186 (typeof IntrospectionTypeSchema.Type)["enumValues"]
187>[number];
188export type IntrospectionType = typeof IntrospectionTypeSchema.Type;
189export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"];
190export type IntrospectionResult = typeof IntrospectionResultSchema.Type;
191
192const 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
207const 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
222const 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
232export 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
332export 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 @@
1import { Effect, Layer, Option } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
4import { GraphqlInvocationError } from "./errors";
5import { 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
12const 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
21export 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
33const 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
45export 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
130export 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 @@
1import { describe, it, expect } from "@effect/vitest";
2import { Effect } from "effect";
3import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
5import {
6 AuthTemplateSlug,
7 ConnectionName,
8 createExecutor,
9 IntegrationSlug,
10 ToolAddress,
11} from "@executor-js/sdk";
12import {
13 makeTestConfig,
14 memoryCredentialsPlugin,
15 serveTestHttpApp,
16} from "@executor-js/sdk/testing";
17
18import { graphqlPlugin } from "./plugin";
19import { endpointForTelemetry } from "./invoke";
20import { introspect } from "./introspect";
21import type { IntrospectionResult } from "./introspect";
22import {
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
38const GRAPHQL = IntegrationSlug.make("greeting_graph");
39const BEARER = AuthTemplateSlug.make("oauth");
40
41const 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
106const introspectionJson = JSON.stringify({ data: introspectionResult });
107const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
108
109describe("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
160describe("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
296describe("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
479describe("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 @@
1import { Effect, Match, Option, Schema } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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
20import {
21 introspect,
22 parseIntrospectionJson,
23 type IntrospectionResult,
24 type IntrospectionType,
25 type IntrospectionField,
26 type IntrospectionTypeRef,
27} from "./introspect";
28import { extract } from "./extract";
29import {
30 GraphqlAuthRequiredError,
31 GraphqlExtractionError,
32 GraphqlIntrospectionError,
33 GraphqlInvocationError,
34} from "./errors";
35import { invokeWithLayer } from "./invoke";
36import { graphqlPresets } from "./presets";
37import {
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
52const GraphqlErrorBody = Schema.Struct({ message: Schema.String });
53const GraphqlErrorsBody = Schema.Array(Schema.Unknown);
54const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);
55const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody);
56const decodeIntegrationConfig = Schema.decodeUnknownOption(GraphqlIntegrationConfig);
57
58const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
59 Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
60
61const 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
66const 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. */
70const 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
77const 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});
92export type AddGraphqlIntegrationInput = typeof AddGraphqlIntegrationInputSchema.Type;
93
94const 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). */
101export type ConfigureGraphqlIntegrationInput = Omit<
102 typeof ConfigureGraphqlIntegrationInputSchema.Type,
103 "slug"
104>;
105
106const ConfigureGraphqlIntegrationOutputSchema = Schema.Struct({
107 configured: Schema.Boolean,
108});
109
110const StaticAddIntegrationOutputSchema = Schema.Struct({
111 slug: Schema.String,
112 toolCount: Schema.Number,
113});
114const StaticGetIntegrationInputSchema = Schema.Struct({ slug: Schema.String });
115const StaticGetIntegrationOutputSchema = Schema.Struct({
116 integration: Schema.NullOr(Schema.Unknown),
117});
118
119const toStandard = <A, I>(schema: Schema.Codec<A, I>) =>
120 Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema));
121
122const StaticAddIntegrationInputStandardSchema = toStandard(AddGraphqlIntegrationInputSchema);
123const StaticAddIntegrationOutputStandardSchema = toStandard(StaticAddIntegrationOutputSchema);
124const StaticGetIntegrationInputStandardSchema = toStandard(StaticGetIntegrationInputSchema);
125const StaticGetIntegrationOutputStandardSchema = toStandard(StaticGetIntegrationOutputSchema);
126const StaticConfigureIntegrationInputStandardSchema = toStandard(
127 ConfigureGraphqlIntegrationInputSchema,
128);
129const StaticConfigureIntegrationOutputStandardSchema = toStandard(
130 ConfigureGraphqlIntegrationOutputSchema,
131);
132
133const graphqlToolFailure = (code: string, message: string, details?: unknown) =>
134 ToolResult.fail({
135 code,
136 message,
137 ...(details === undefined ? {} : { details }),
138 });
139
140const 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. */
167const 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. */
173const 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
187const 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
195const unwrapTypeName = (ref: IntrospectionTypeRef): string => {
196 if (ref.name) return ref.name;
197 if (ref.ofType) return unwrapTypeName(ref.ofType);
198 return "Unknown";
199};
200
201const 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
233const 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
254const 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
306const 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
322interface RenderedAuth {
323 readonly headers: Record<string, string>;
324 readonly queryParams: Record<string, string>;
325}
326
327const 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
338const 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
370export interface GraphqlPluginOptions {
371 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
372}
373
374const 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
381const integrationConfigFrom = (config: IntegrationConfig): GraphqlIntegrationConfig | null =>
382 Option.getOrNull(decodeIntegrationConfig(config));
383
384const 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
478export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>;
479
480export 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 @@
1export 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
11export 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 @@
1import { Schema } from "effect";
2
3// ---------------------------------------------------------------------------
4// GraphQL operation kind
5// ---------------------------------------------------------------------------
6
7export const GraphqlOperationKind = Schema.Literals(["query", "mutation"]);
8export type GraphqlOperationKind = typeof GraphqlOperationKind.Type;
9
10// ---------------------------------------------------------------------------
11// Extracted field (becomes a tool)
12// ---------------------------------------------------------------------------
13
14export const GraphqlArgument = Schema.Struct({
15 name: Schema.String,
16 typeName: Schema.String,
17 required: Schema.Boolean,
18 description: Schema.OptionFromOptional(Schema.String),
19});
20export type GraphqlArgument = typeof GraphqlArgument.Type;
21
22export 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});
34export type ExtractedField = typeof ExtractedField.Type;
35
36export const ExtractionResult = Schema.Struct({
37 /** Schema name from introspection */
38 schemaName: Schema.OptionFromOptional(Schema.String),
39 fields: Schema.Array(ExtractedField),
40});
41export 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
48export 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});
60export 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>`). */
72export 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});
81export type ApiKeyHeaderTemplate = typeof ApiKeyHeaderTemplate.Type;
82
83/** Apply the value as a URL query parameter. */
84export 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});
92export 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. */
97export 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});
108export type OAuthTemplate = typeof OAuthTemplate.Type;
109
110export const AuthTemplate = Schema.Union([
111 ApiKeyHeaderTemplate,
112 ApiKeyQueryTemplate,
113 OAuthTemplate,
114]);
115export 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
124export 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});
136export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type;
137
138// ---------------------------------------------------------------------------
139// Invocation
140// ---------------------------------------------------------------------------
141
142export const InvocationResult = Schema.Struct({
143 status: Schema.Number,
144 data: Schema.NullOr(Schema.Unknown),
145 errors: Schema.NullOr(Schema.Unknown),
146});
147export type InvocationResult = typeof InvocationResult.Type;
packages/plugins/graphql-greenfield/src/testing/index.ts+0−258
@@ -1,258 +0,0 @@
1import {
2 Context,
3 Data,
4 Effect,
5 Layer,
6 Predicate,
7 Ref,
8 Schema as EffectSchema,
9 Scope,
10} from "effect";
11import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
12import type { GraphQLSchema } from "graphql";
13import {
14 createSchema,
15 createYoga,
16 type GraphQLParams,
17 type YogaInitialContext,
18} from "graphql-yoga";
19import { OAuthTestServer, serveTestHttpApp } from "@executor-js/sdk/testing";
20
21const 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
27type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type;
28
29export 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
37export interface GraphqlTestContext {
38 readonly request: GraphqlTestRequest;
39}
40
41export 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
50export 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
57class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
58 readonly address: unknown;
59}> {}
60
61class GraphqlTestServerHandlerError extends Data.TaggedError("GraphqlTestServerHandlerError")<{
62 readonly cause: unknown;
63}> {}
64
65const headersFromRequest = (headers: Headers): Readonly<Record<string, string>> =>
66 Object.fromEntries(headers.entries());
67
68const 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
77const 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
94export 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
166export 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
186export 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
208const 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
217export 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
254export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export 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
3import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared";3import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared";
4 4
5import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";5import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6import { AuthTemplate } from "../sdk/types";6import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// Params9// Params
@@ -24,14 +24,15 @@ const AddIntegrationPayload = Schema.Struct({ · 14 unmodified lines
24 introspectionJson: Schema.optional(Schema.String),24 introspectionJson: Schema.optional(Schema.String),
25 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),25 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
26 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),26 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
27 authenticationTemplate: Schema.optional(Schema.Array(AuthTemplate)),27 authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
28});28});
29 29
30// The `configure` payload — the custom auth methods to merge-append onto the30// The `configure` payload — the custom auth methods to merge-append onto the
31// integration's `authenticationTemplate`. Reuses the same `AuthTemplate` schema31// integration's `authenticationTemplate`. Reuses the same input schema as
32// as `addIntegration` so a custom apiKey method round-trips identically.32// `addIntegration` (slug optional — the backend backfills it) so a custom
33// apikey method round-trips identically.
33const ConfigurePayload = Schema.Struct({34const ConfigurePayload = Schema.Struct({
34 authenticationTemplate: Schema.Array(AuthTemplate),35 authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),
35 mode: Schema.optional(Schema.Literals(["merge", "replace"])),36 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
36});37});
37 38
@@ -52,13 +53,13 @@ const GraphqlConfigView = Schema.Struct({ · 14 unmodified lines
52 introspectionJson: Schema.optional(Schema.String),53 introspectionJson: Schema.optional(Schema.String),
53 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),54 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
54 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),55 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
55 authenticationTemplate: Schema.Array(AuthTemplate),56 authenticationTemplate: Schema.Array(GraphqlAuthMethod),
56});57});
57 58
58// The configure result — the merged `authenticationTemplate` after the new59// The configure result — the merged `authenticationTemplate` after the new
59// custom methods were appended/replaced.60// custom methods were appended/replaced.
60const ConfigureResponse = Schema.Struct({61const ConfigureResponse = Schema.Struct({
61 authenticationTemplate: Schema.Array(AuthTemplate),62 authenticationTemplate: Schema.Array(GraphqlAuthMethod),
62});63});
63 64
64// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
packages/plugins/graphql/src/api/handlers.test.ts+52−19
@@ -17,8 +17,13 @@ import { Effect, Layer } from "effect"; · 16 unmodified lines
17import { addGroup, observabilityMiddleware } from "@executor-js/api";17import { addGroup, observabilityMiddleware } from "@executor-js/api";
18import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";18import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";
19 19
20import { expandGraphqlAuthMethodInputs } from "../sdk/types";
20import type { GraphqlPluginExtension } from "../sdk/plugin";21import type { GraphqlPluginExtension } from "../sdk/plugin";
21import type { AuthTemplate, GraphqlIntegrationConfig } from "../sdk/types";22import type {
23 GraphqlAuthMethod,
24 GraphqlAuthMethodInput,
25 GraphqlIntegrationConfig,
26} from "../sdk/types";
22import { GraphqlExtensionService, GraphqlHandlers } from "./handlers";27import { GraphqlExtensionService, GraphqlHandlers } from "./handlers";
23import { GraphqlGroup } from "./group";28import { GraphqlGroup } from "./group";
24 29
@@ -31,22 +36,22 @@ const makeStubExtension = ( · 6 unmodified lines
31): GraphqlPluginExtension => {36): GraphqlPluginExtension => {
32 let counter = 0;37 let counter = 0;
33 const merge = (38 const merge = (
34 existing: readonly AuthTemplate[],39 existing: readonly GraphqlAuthMethod[],
35 incoming: readonly AuthTemplate[],40 incoming: readonly GraphqlAuthMethodInput[],
36 ): readonly AuthTemplate[] => {41 ): readonly GraphqlAuthMethod[] => {
37 const result: AuthTemplate[] = existing.map((entry: AuthTemplate) => entry);42 const result: GraphqlAuthMethod[] = existing.map((entry: GraphqlAuthMethod) => entry);
38 const taken = new Set<string>(result.map((entry: AuthTemplate) => entry.slug));43 const taken = new Set<string>(result.map((entry: GraphqlAuthMethod) => entry.slug));
39 for (const entry of incoming) {44 for (const entry of incoming) {
40 const requested = entry.slug.trim();45 const requested = entry.slug?.trim() ?? "";
41 const index = result.findIndex((current: AuthTemplate) => current.slug === requested);46 const index = result.findIndex((current: GraphqlAuthMethod) => current.slug === requested);
42 if (requested.length > 0 && index >= 0) {47 if (requested.length > 0 && index >= 0) {
43 result[index] = entry;48 result[index] = entry as GraphqlAuthMethod;
44 continue;49 continue;
45 }50 }
46 const slug =51 const slug =
47 requested.length > 0 && !taken.has(requested) ? requested : `custom_${counter++}`;52 requested.length > 0 && !taken.has(requested) ? requested : `custom_${counter++}`;
48 taken.add(slug);53 taken.add(slug);
49 result.push({ ...entry, slug } as AuthTemplate);54 result.push({ ...entry, slug } as GraphqlAuthMethod);
50 }55 }
51 return result;56 return result;
52 };57 };
@@ -59,12 +64,17 @@ const makeStubExtension = ( · 6 unmodified lines
59 getConfig: (slug: string) => Effect.sync(() => store.get(slug) ?? null),64 getConfig: (slug: string) => Effect.sync(() => store.get(slug) ?? null),
60 configureAuth: (65 configureAuth: (
61 slug: string,66 slug: string,
62 input: { readonly authenticationTemplate: readonly AuthTemplate[] },67 input: { readonly authenticationTemplate: readonly GraphqlAuthMethodInput[] },
63 ) =>68 ) =>
64 Effect.sync((): readonly AuthTemplate[] => {69 Effect.sync((): readonly GraphqlAuthMethod[] => {
65 const current = store.get(slug);70 const current = store.get(slug);
66 if (!current) return [];71 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 );
68 store.set(slug, { ...current, authenticationTemplate: merged });78 store.set(slug, { ...current, authenticationTemplate: merged });
69 return merged;79 return merged;
70 }),80 }),
@@ -102,7 +112,9 @@ const seededStore = (): Map<string, GraphqlIntegrationConfig> => { · 31 unmodified lines
102 store.set("gql", {112 store.set("gql", {
103 endpoint: "https://x.example/graphql",113 endpoint: "https://x.example/graphql",
104 name: "GraphQL",114 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 ],
106 });118 });
107 return store;119 return store;
108};120};
@@ -134,7 +146,13 @@ describe("GraphqlHandlers — config surface", () => { · 25 unmodified lines
134 };146 };
135 147
136 const configureRes = yield* post(web, "http://localhost/graphql/integrations/gql/config", {148 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 ],
138 });156 });
139 expect(configureRes.status).toBe(200);157 expect(configureRes.status).toBe(200);
140 const configureBody = (yield* Effect.promise(() => configureRes.json())) as {158 const configureBody = (yield* Effect.promise(() => configureRes.json())) as {
@@ -159,15 +177,24 @@ describe("GraphqlHandlers — config surface", () => { · 18 unmodified lines
159 };177 };
160 178
161 const res = yield* post(web, "http://localhost/graphql/integrations/gql/config", {179 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 ],
163 });187 });
164 expect(res.status).toBe(200);188 expect(res.status).toBe(200);
165 const body = (yield* Effect.promise(() => res.json())) as {189 const body = (yield* Effect.promise(() => res.json())) as {
166 authenticationTemplate: { slug: string; name: string }[];190 authenticationTemplate: {
191 slug: string;
192 placements: { name: string }[];
193 }[];
167 };194 };
168 expect(body.authenticationTemplate).toHaveLength(1);195 expect(body.authenticationTemplate).toHaveLength(1);
169 expect(body.authenticationTemplate[0]!.slug).toBe("seed");196 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");
171 }),198 }),
172 );199 );
173 200
@@ -179,7 +206,13 @@ describe("GraphqlHandlers — config surface", () => { · 5 unmodified lines
179 };206 };
180 207
181 const res = yield* post(web, "http://localhost/graphql/integrations/nope/config", {208 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 ],
183 });216 });
184 expect(res.status).toBe(200);217 expect(res.status).toBe(200);
185 const body = (yield* Effect.promise(() => res.json())) as {218 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
26 26
27import { addGraphqlIntegrationOptimistic } from "./atoms";27import { addGraphqlIntegrationOptimistic } from "./atoms";
28import { GraphqlSourceFields } from "./GraphqlSourceFields";28import { GraphqlSourceFields } from "./GraphqlSourceFields";
29import { graphqlTemplatesFromPlacements } from "./auth-method-config";29import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
30import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";30import type { GraphqlAuthMethodInput } from "../sdk/types";
31import type { AuthTemplate } from "../sdk/types";
32 31
33// v2 GraphQL add flow: register the integration with its declared auth-method32// v2 GraphQL add flow: register the integration with its declared auth-method
34// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;33// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;
@@ -57,25 +56,19 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
57 mode: "promiseExit",56 mode: "promiseExit",
58 });57 });
59 58
60 // The templates to register: each apikey row emits one template per named59 // The methods to register: each apikey row declares ONE method carrying
61 // placement (GraphQL templates carry one header/query slot each). The first60 // every named placement (header + query mix in a single method). Inputs
62 // keeps the primary `apikey` slug (matching the prior single-method flow);61 // omit slugs — the backend assigns carrier-derived ones. `none` rows
63 // the rest get deterministic `apikey-<n>` slugs. `none` rows register62 // register nothing.
64 // nothing.63 const authenticationTemplate = useMemo<readonly GraphqlAuthMethodInput[]>(
65 const authenticationTemplate = useMemo<readonly AuthTemplate[]>(() => {64 () =>
66 const templates: AuthTemplate[] = [];65 authMethodList.rows.flatMap((row: AuthMethodRow) =>
67 for (const row of authMethodList.rows) {66 row.value.kind === "apikey"
68 if (row.value.kind !== "apikey") continue;67 ? graphqlAuthMethodInputsFromPlacements(row.value.placements)
69 for (const template of graphqlTemplatesFromPlacements(row.value.placements, "")) {68 : [],
70 const index = templates.length;69 ),
71 templates.push({70 [authMethodList.rows],
72 ...template,71 );
73 slug: index === 0 ? GRAPHQL_APIKEY_TEMPLATE : `apikey-${index + 1}`,
74 });
75 }
76 }
77 return templates;
78 }, [authMethodList.rows]);
79 72
80 // Every apikey row needs at least one named placement; `none` rows are73 // Every apikey row needs at least one named placement; `none` rows are
81 // always valid.74 // always valid.
packages/plugins/graphql/src/react/GraphqlAccountsPanel.tsx+19−15
@@ -15,8 +15,12 @@ import { · 14 unmodified lines
15} from "@executor-js/react/lib/custom-auth-methods";15} from "@executor-js/react/lib/custom-auth-methods";
16 16
17import { graphqlConfigAtom, graphqlConfigure } from "./atoms";17import { graphqlConfigAtom, graphqlConfigure } from "./atoms";
18import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";18import {
19import type { AuthTemplate } from "../sdk/types";19 authMethodsFromConfig,
20 graphqlAuthMethodInputsFromPlacements,
21 graphqlWireAuthInput,
22} from "./auth-method-config";
23import type { GraphqlAuthMethod } from "../sdk/types";
20 24
21// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
22// GraphQL Accounts hub — fills the generic detail page's `accounts` slot.26// GraphQL Accounts hub — fills the generic detail page's `accounts` slot.
@@ -24,11 +28,11 @@ import type { AuthTemplate } from "../sdk/types"; · 1 unmodified line
24// Reads the integration's real `authenticationTemplate` (via `getConfig`),28// Reads the integration's real `authenticationTemplate` (via `getConfig`),
25// converts it to generic `AuthMethod[]`, and composes the generic29// converts it to generic `AuthMethod[]`, and composes the generic
26// `AccountsSection` — whose Add-account offers those methods plus a "+ Custom30// `AccountsSection` — whose Add-account offers those methods plus a "+ Custom
27// method" row (apiKey-only). The custom-method create is INJECTED here31// method" row (apikey-only). The custom-method create is INJECTED here
28// (`createCustomMethod`): generic placements → graphql `apiKey` templates32// (`createCustomMethod`): generic placements → ONE apikey method
29// (`graphqlTemplatesFromPlacements`, slug omitted → backend `custom_<id>`)33// (`graphqlAuthMethodInputsFromPlacements`, slug omitted → backend
30// merge-appended via `configure`. Stays plugin-side because it touches the34// `custom_<id>`) merge-appended via `configure`. Stays plugin-side because it
31// graphql sdk `AuthTemplate` types.35// touches the graphql sdk method types.
32// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
33 37
34export default function GraphqlAccountsPanel(props: {38export default function GraphqlAccountsPanel(props: {
@@ -41,7 +45,7 @@ export default function GraphqlAccountsPanel(props: { · 6 unmodified lines
41 const configResult = useAtomValue(graphqlConfigAtom(slug));45 const configResult = useAtomValue(graphqlConfigAtom(slug));
42 const doConfigure = useAtomSet(graphqlConfigure, { mode: "promiseExit" });46 const doConfigure = useAtomSet(graphqlConfigure, { mode: "promiseExit" });
43 47
44 const existingTemplate = useMemo<readonly AuthTemplate[]>(() => {48 const existingTemplate = useMemo<readonly GraphqlAuthMethod[]>(() => {
45 if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];49 if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
46 return configResult.value.authenticationTemplate ?? [];50 return configResult.value.authenticationTemplate ?? [];
47 }, [configResult]);51 }, [configResult]);
@@ -53,13 +57,13 @@ export default function GraphqlAccountsPanel(props: { · 5 unmodified lines
53 57
54 // Custom-method create/remove: the shared skeleton (merge-append → diff out58 // Custom-method create/remove: the shared skeleton (merge-append → diff out
55 // the created method; filter → replace) parameterized by the GraphQL codec.59 // the created method; filter → replace) parameterized by the GraphQL codec.
56 // Stays plugin-side only where it touches the graphql `AuthTemplate` types.60 // Stays plugin-side only where it touches the graphql method types.
57 const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>(61 const configure = useCallback<ConfigureAuthMethods<GraphqlAuthMethod>>(
58 async (input) => {62 async (input) => {
59 const exit = await doConfigure({63 const exit = await doConfigure({
60 params: { slug: String(slug) },64 params: { slug: String(slug) },
61 payload: {65 payload: {
62 authenticationTemplate: input.authenticationTemplate,66 authenticationTemplate: input.authenticationTemplate.map(graphqlWireAuthInput),
63 ...(input.mode ? { mode: input.mode } : {}),67 ...(input.mode ? { mode: input.mode } : {}),
64 },68 },
65 reactivityKeys: integrationWriteKeys,69 reactivityKeys: integrationWriteKeys,
@@ -69,13 +73,13 @@ export default function GraphqlAccountsPanel(props: { · 3 unmodified lines
69 [doConfigure, slug],73 [doConfigure, slug],
70 );74 );
71 75
72 const codec = useMemo<AuthMethodsCodec<AuthTemplate>>(76 const codec = useMemo<AuthMethodsCodec<GraphqlAuthMethod>>(
73 () => ({77 () => ({
74 toAuthMethods: authMethodsFromConfig,78 toAuthMethods: authMethodsFromConfig,
75 // Slug blank → backend backfills `custom_<id>` per emitted template.79 // Inputs omit slugs — the backend merge backfills `custom_<id>`.
76 templatesFromPlacements: (placements: readonly Placement[]) =>80 templatesFromPlacements: (placements: readonly Placement[]) =>
77 graphqlTemplatesFromPlacements(placements, ""),81 graphqlAuthMethodInputsFromPlacements(placements) as readonly GraphqlAuthMethod[],
78 slugOf: (template: AuthTemplate) => template.slug,82 slugOf: (template: GraphqlAuthMethod) => template.slug,
79 }),83 }),
80 [],84 [],
81 );85 );
packages/plugins/graphql/src/react/auth-method-config.test.ts+164−71
@@ -1,98 +1,191 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { AuthTemplateSlug } from "@executor-js/sdk/shared";2import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
3 3
4import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config";4import {
5import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";5 authMethodsFromConfig,
6import type { AuthTemplate } from "../sdk/types";6 editorValueFromGraphqlAuthMethod,
7 graphqlAuthMethodInputFromEditorValue,
8 graphqlAuthMethodInputsFromPlacements,
9} from "./auth-method-config";
7 10
8describe("graphqlTemplatesFromPlacements", () => {11describe("graphqlAuthMethodInputFromEditorValue", () => {
9 it("maps a single named header placement → one apiKey template (primary slug)", () => {12 it("maps 'none' → { kind: 'none' }", () => {
10 const templates = graphqlTemplatesFromPlacements([13 expect(graphqlAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
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 ]);
22 });14 });
23 15
24 it("emits one template per named placement; only the first keeps the slug", () => {16 it("maps 'oauth' → { kind: 'oauth2' } (graphql oauth stores no endpoints)", () => {
25 const templates = graphqlTemplatesFromPlacements(17 const value: AuthTemplateEditorValue = {
26 [18 kind: "oauth",
27 { carrier: "header", name: "Authorization", prefix: "" },19 authorizationUrl: "https://a.example.com/auth",
28 { carrier: "query", name: "api_key", prefix: "" },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: "" },
29 ],43 ],
30 "",44 };
31 );45 expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({
32 expect(templates).toEqual([46 kind: "apikey",
33 { kind: "apiKey", slug: "", in: "header", name: "Authorization" },47 placements: [
34 { kind: "apiKey", slug: "", in: "query", name: "api_key" },48 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" },
35 ]);49 { carrier: "query", name: "team_id", variable: "team_id" },
50 ],
51 });
36 });52 });
37 53
38 it("drops unnamed placements", () => {54 it("drops unnamed placements and degrades to none when nothing is usable", () => {
39 const templates = graphqlTemplatesFromPlacements([55 expect(
40 { carrier: "header", name: "", prefix: "" },56 graphqlAuthMethodInputFromEditorValue({
41 { carrier: "header", name: "X-Token", prefix: "" },57 kind: "apikey",
42 ]);58 placements: [{ carrier: "header", name: " ", prefix: "" }],
43 expect(templates).toEqual([59 }),
44 { kind: "apiKey", slug: GRAPHQL_APIKEY_TEMPLATE, in: "header", name: "X-Token" },60 ).toEqual({ kind: "none" });
45 ]);61 });
62});
63
64describe("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 });
46 });107 });
47});108});
48 109
49describe("authMethodsFromConfig", () => {110describe("authMethodsFromConfig", () => {
50 it("maps a header apiKey template → a generic apikey method with a placement", () => {111 it("projects every declared method and marks custom_ slugs as custom", () => {
51 const config: AuthTemplate[] = [112 const methods = authMethodsFromConfig([
52 { kind: "apiKey", slug: "apiKey", in: "header", name: "Authorization", prefix: "Bearer " },113 { slug: "oauth2", kind: "oauth2" },
53 ];
54 expect(authMethodsFromConfig(config)).toEqual([
55 {114 {
56 id: "apiKey",115 slug: "custom_abc123",
57 label: "API key (Authorization)",
58 kind: "apikey",116 kind: "apikey",
59 source: "spec",117 placements: [{ carrier: "header", name: "X-Api-Key" }],
60 template: AuthTemplateSlug.make("apiKey"),
61 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
62 },118 },
119 { slug: "none", kind: "none" },
63 ]);120 ]);
64 });
65 121
66 it("marks a custom_ slug as a custom method", () => {122 expect(
67 const config: AuthTemplate[] = [123 methods.map((method) => ({
68 { kind: "apiKey", slug: "custom_abc", in: "query", name: "token" },124 id: method.id,
69 ];125 kind: method.kind,
70 const [method] = authMethodsFromConfig(config);126 source: method.source,
71 expect(method?.source).toBe("custom");127 template: String(method.template),
72 expect(method?.placements).toEqual([{ carrier: "query", name: "token", prefix: "" }]);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({});
73 });135 });
74 136
75 it("maps an oauth2 template → an oauth method", () => {137 it("carries multi-placement methods through to the hub", () => {
76 const config: AuthTemplate[] = [{ kind: "oauth2", slug: "oauth2" }];138 const methods = authMethodsFromConfig([
77 expect(authMethodsFromConfig(config)).toEqual([
78 {139 {
79 id: "oauth2",140 slug: "custom_mix",
80 label: "OAuth2",141 kind: "apikey",
81 kind: "oauth",142 placements: [
82 source: "spec",143 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" },
83 template: AuthTemplateSlug.make("oauth2"),144 { carrier: "query", name: "team_id", variable: "team_id" },
84 placements: [],145 ],
85 oauth: {},
86 },146 },
87 ]);147 ]);
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 ]);
88 });152 });
89});153});
90 154
91describe("round-trip (placements → templates → methods)", () => {155describe("graphqlAuthMethodInputsFromPlacements", () => {
92 it("a header placement survives the round-trip into a generic method", () => {156 it("builds ONE method carrying every named placement", () => {
93 const placements = [{ carrier: "header" as const, name: "X-Api-Key", prefix: "" }];157 expect(
94 const templates = graphqlTemplatesFromPlacements(placements, "");158 graphqlAuthMethodInputsFromPlacements([
95 const [method] = authMethodsFromConfig(templates);159 { carrier: "header", name: "X-Token", prefix: "Bearer " },
96 expect(method?.placements).toEqual(placements);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([]);
97 });190 });
98});191});
packages/plugins/graphql/src/react/auth-method-config.ts+71−70
@@ -1,84 +1,85 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// GraphQL ↔ generic auth-method converters.2// GraphQL ↔ generic auth-method converters — a thin oauth adapter over the
3//3// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4// The generic Accounts hub + add-time auth editor speak in plugin-agnostic4// apikey/none paths (multi-placement, multi-variable) live in the shared
5// `AuthMethod` / `Placement` values (`@executor-js/react/lib/auth-placements`).5// codec; GraphQL only contributes its oauth flavor: endpoint-less methods that
6// The GraphQL plugin stores auth as its own `AuthTemplate` wire shape6// render the connection's access token as a bearer header at invoke time
7// (`{ kind:"apiKey", slug, in, name, prefix? }` / `{ kind:"oauth2", slug, … }`).7// (optionally overriding the header name / prefix).
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.
11// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
12 9
13import { AuthTemplateSlug } from "@executor-js/sdk/shared";10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";12import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
13import {
14 wireAuthInputFromShared,
15 authMethodFromSharedTemplate,
16 editorValueFromSharedMethod,
17 sharedMethodInputFromEditorValue,
18 wirePlacementsFromEditor,
19} from "@executor-js/react/lib/shared-auth-method-codec";
15 20
16import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults";21import type {
17import type { AuthTemplate } from "../sdk/types";22 GraphqlAuthMethod,
23 GraphqlAuthMethodInput,
24 GraphqlCanonicalAuthMethodInput,
25} from "../sdk/types";
18 26
19// ---------------------------------------------------------------------------27const oauthAuthMethod = (slug: string): AuthMethod => ({
20// Templates → generic methods.28 id: slug,
21// ---------------------------------------------------------------------------29 label: "OAuth",
30 kind: "oauth",
31 source: slug.startsWith("custom_") ? "custom" : "spec",
32 template: AuthTemplateSlug.make(slug),
33 placements: [],
34 oauth: {},
35});
22 36
23const 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`. */
41export function graphqlAuthMethodInputFromEditorValue(
42 value: AuthTemplateEditorValue,
43): GraphqlAuthMethodInput {
44 if (value.kind === "oauth") return { kind: "oauth2" };
45 return (sharedMethodInputFromEditorValue(value) ?? { kind: "none" }) as GraphqlAuthMethodInput;
46}
24 47
25/** Map each stored GraphQL auth template to a generic `AuthMethod`. A `custom_`48/** Convert one stored GraphQL method into the generic editor value. */
26 * slug marks a user-defined method; everything else is spec-declared. */49export function editorValueFromGraphqlAuthMethod(
27export function authMethodsFromConfig(templates: readonly AuthTemplate[]): AuthMethod[] {50 method: GraphqlAuthMethod,
28 return templates.map((template: AuthTemplate): AuthMethod => {51): AuthTemplateEditorValue {
29 const slug = String(template.slug);52 if (method.kind === "oauth2") {
30 const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";53 // GraphQL oauth methods store no endpoints — only the bearer rendering.
31 if (template.kind === "oauth2") {54 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
32 return {55 }
33 id: slug,56 return editorValueFromSharedMethod(method);
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 });
56}57}
57 58
58// ---------------------------------------------------------------------------59/** Project the stored methods into the generic `AuthMethod[]` the hub renders.
59// Generic placements → graphql apiKey templates (inverse).60 * Mirrors the server's `describeGraphqlAuthMethods`; `custom_` slugs mark
60//61 * user-created methods (removable from the hub). */
61// GraphQL's `AuthTemplate` carries ONE header/query slot per template, so a62export function authMethodsFromConfig(methods: readonly GraphqlAuthMethod[]): AuthMethod[] {
62// multi-placement method emits one template per named placement. When `slug` is63 return methods.map((method: GraphqlAuthMethod): AuthMethod => {
63// omitted the backend backfills `custom_<id>`; the first template keeps the64 if (method.kind === "oauth2") return oauthAuthMethod(method.slug);
64// integration's primary `apiKey` slug so the add flow stays stable.65 return authMethodFromSharedTemplate(method);
65// ---------------------------------------------------------------------------66 });
67}
66 68
67/** Build GraphQL `apiKey` templates from generic placements. The optional69/** Build the GraphQL method input for a custom method from generic placements
68 * `slug` names the FIRST emitted template (subsequent placements get an empty70 * — ONE method carrying every named placement (header + query mix in a single
69 * slug so the backend assigns a `custom_<id>` each). */71 * method; each placement renders from its own input variable, or shares one).
70export function graphqlTemplatesFromPlacements(72 * Empty when no placement is usable. */
73export function graphqlAuthMethodInputsFromPlacements(
71 placements: readonly Placement[],74 placements: readonly Placement[],
72 slug?: string,75): GraphqlAuthMethodInput[] {
73): AuthTemplate[] {76 const wire = wirePlacementsFromEditor(placements);
74 const named = placements.filter((placement: Placement) => placement.name.trim().length > 0);77 if (wire.length === 0) return [];
75 return named.map(78 return [graphqlWireAuthInput({ kind: "apikey", placements: wire })];
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 );
84}79}
80
81/** Serialize a canonical method into the wire input union (apikey → the
82 * request-shaped dialect; none/oauth2 pass through). */
83export 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import {3import { graphqlConnectionName } from "./defaults";
4 GRAPHQL_APIKEY_TEMPLATE,
5 graphqlApiKeyAuthTemplate,
6 graphqlConnectionName,
7} from "./defaults";
8
9describe("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});
25 4
26describe("graphqlConnectionName", () => {5describe("graphqlConnectionName", () => {
27 it("is deterministic per integration + owner", () => {6 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
7// connection IS the credential, applied to the integration's auth template.7// connection IS the credential, applied to the integration's auth template.
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9 9
10/** The slug of the apiKey auth template the add flow declares + targets. */
11export 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_…`). */
16export 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
30/** Deterministic connection name for a GraphQL integration + owner. Keeps a10/** Deterministic connection name for a GraphQL integration + owner. Keeps a
31 * single owner-scoped credential per integration so re-adding overwrites11 * single owner-scoped credential per integration so re-adding overwrites
32 * rather than accumulating duplicates. */12 * rather than accumulating duplicates. */
packages/plugins/graphql/src/sdk/configure.test.ts+31−24
@@ -1,10 +1,10 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// GraphQL plugin — `configureAuth` (custom auth method merge-append) coverage.2// GraphQL plugin — `configureAuth` (custom auth method merge-append) coverage.
3//3//
4// `configureAuth` merge-appends apiKey/oauth2 templates onto an existing4// `configureAuth` merge-appends apikey/oauth2 methods onto an existing
5// integration's opaque `authenticationTemplate`. These tests exercise the5// integration's opaque `authenticationTemplate`. These tests exercise the
6// extension method directly (the same path the HTTP `configure` handler calls):6// 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,
8// - append: a configured method does not drop the integration's existing8// - append: a configured method does not drop the integration's existing
9// declared methods,9// declared methods,
10// - slug generation: a method with a blank slug is assigned `custom_<id>`,10// - 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
20import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";20import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
21 21
22import { graphqlPlugin } from "./plugin";22import { graphqlPlugin } from "./plugin";
23import type { AuthTemplate } from "./types";23import type { GraphqlAuthMethod } from "./types";
24 24
25const makeExecutor = () =>25const makeExecutor = () =>
26 createExecutor(26 createExecutor(
@@ -29,11 +29,10 @@ const makeExecutor = () => · 2 unmodified lines
29 }),29 }),
30 );30 );
31 31
32const customApiKey: AuthTemplate = {32const customApiKey: GraphqlAuthMethod = {
33 kind: "apiKey",
34 slug: "my_custom",33 slug: "my_custom",
35 in: "header",34 kind: "apikey",
36 name: "X-Api-Key",35 placements: [{ carrier: "header", name: "X-Api-Key" }],
37};36};
38 37
39describe("GraphQL Plugin — configureAuth (custom auth method)", () => {38describe("GraphQL Plugin — configureAuth (custom auth method)", () => {
@@ -56,7 +55,11 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 16 unmodified lines
56 const config = yield* executor.graphql.getConfig("cfg_api");55 const config = yield* executor.graphql.getConfig("cfg_api");
57 const template = config?.authenticationTemplate ?? [];56 const template = config?.authenticationTemplate ?? [];
58 expect(template).toEqual([57 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 },
60 ]);63 ]);
61 }),64 }),
62 );65 );
@@ -68,14 +71,16 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 5 unmodified lines
68 yield* executor.graphql.addIntegration({71 yield* executor.graphql.addIntegration({
69 endpoint: "https://x.example/graphql",72 endpoint: "https://x.example/graphql",
70 slug: "cfg_append",73 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 ],
72 });77 });
73 78
74 const merged = yield* executor.graphql.configureAuth("cfg_append", {79 const merged = yield* executor.graphql.configureAuth("cfg_append", {
75 authenticationTemplate: [customApiKey],80 authenticationTemplate: [customApiKey],
76 });81 });
77 82
78 expect(merged.map((m: AuthTemplate) => m.slug)).toEqual(["seed", "my_custom"]);83 expect(merged.map((m: GraphqlAuthMethod) => m.slug)).toEqual(["seed", "my_custom"]);
79 }),84 }),
80 );85 );
81 86
@@ -89,7 +94,9 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 7 unmodified lines
89 });94 });
90 95
91 const merged = yield* executor.graphql.configureAuth("cfg_genslug", {96 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 ],
93 });100 });
94 101
95 expect(merged).toHaveLength(1);102 expect(merged).toHaveLength(1);
@@ -111,29 +118,27 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 15 unmodified lines
111 authenticationTemplate: [118 authenticationTemplate: [
112 // Re-submit the same slug with a different placement → replace in place.119 // Re-submit the same slug with a different placement → replace in place.
113 {120 {
114 kind: "apiKey",
115 slug: "my_custom",121 slug: "my_custom",
116 in: "header",122 kind: "apikey",
117 name: "X-Other",123 placements: [{ carrier: "header", name: "X-Other" }],
118 },124 },
119 { kind: "apiKey", slug: "", in: "query", name: "api_key" },125 { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "api_key" }] },
120 { kind: "apiKey", slug: "", in: "query", name: "token" },126 { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
121 ],127 ],
122 });128 });
123 129
124 const slugs = merged.map((m: AuthTemplate) => m.slug);130 const slugs = merged.map((m: GraphqlAuthMethod) => m.slug);
125 expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1);131 expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1);
126 expect(merged).toHaveLength(3);132 expect(merged).toHaveLength(3);
127 const generated = slugs.filter((s: string) => s.startsWith("custom_"));133 const generated = slugs.filter((s: string) => s.startsWith("custom_"));
128 expect(generated).toHaveLength(2);134 expect(generated).toHaveLength(2);
129 expect(new Set(generated).size).toBe(2);135 expect(new Set(generated).size).toBe(2);
130 136
131 const replaced = merged.find((m: AuthTemplate) => m.slug === "my_custom")!;137 const replaced = merged.find((m: GraphqlAuthMethod) => m.slug === "my_custom")!;
132 expect(replaced).toEqual({138 expect(replaced).toEqual({
133 kind: "apiKey",
134 slug: "my_custom",139 slug: "my_custom",
135 in: "header",140 kind: "apikey",
136 name: "X-Other",141 placements: [{ carrier: "header", name: "X-Other" }],
137 });142 });
138 }),143 }),
139 );144 );
@@ -146,17 +151,19 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 6 unmodified lines
146 endpoint: "https://x.example/graphql",151 endpoint: "https://x.example/graphql",
147 slug: "cfg_replace",152 slug: "cfg_replace",
148 authenticationTemplate: [153 authenticationTemplate: [
149 { kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" },154 { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] },
150 customApiKey,155 customApiKey,
151 ],156 ],
152 });157 });
153 158
154 const merged = yield* executor.graphql.configureAuth("cfg_replace", {159 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 ],
156 mode: "replace",163 mode: "replace",
157 });164 });
158 165
159 expect(merged.map((m: AuthTemplate) => m.slug)).toEqual(["seed"]);166 expect(merged.map((m: GraphqlAuthMethod) => m.slug)).toEqual(["seed"]);
160 }),167 }),
161 );168 );
162 169
packages/plugins/graphql/src/sdk/describe-auth-methods.test.ts+57−8
@@ -22,13 +22,17 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 21 unmodified lines
22});22});
23 23
24describe("describeGraphqlAuthMethods", () => {24describe("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", () => {
26 const methods = describeGraphqlAuthMethods(26 const methods = describeGraphqlAuthMethods(
27 recordWith({27 recordWith({
28 endpoint: "https://x.example/graphql",28 endpoint: "https://x.example/graphql",
29 name: "x",29 name: "x",
30 authenticationTemplate: [30 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 },
32 ],36 ],
33 }),37 }),
34 );38 );
@@ -44,12 +48,14 @@ describe("describeGraphqlAuthMethods", () => { · 9 unmodified lines
44 ]);48 ]);
45 });49 });
46 50
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", () => {
48 const methods = describeGraphqlAuthMethods(52 const methods = describeGraphqlAuthMethods(
49 recordWith({53 recordWith({
50 endpoint: "https://x.example/graphql",54 endpoint: "https://x.example/graphql",
51 name: "x",55 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 ],
53 }),59 }),
54 );60 );
55 61
@@ -64,13 +70,42 @@ describe("describeGraphqlAuthMethods", () => { · 8 unmodified lines
64 ]);70 ]);
65 });71 });
66 72
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
67 it("defaults the placement prefix to an empty string when unset", () => {102 it("defaults the placement prefix to an empty string when unset", () => {
68 const methods = describeGraphqlAuthMethods(103 const methods = describeGraphqlAuthMethods(
69 recordWith({104 recordWith({
70 endpoint: "https://x.example/graphql",105 endpoint: "https://x.example/graphql",
71 name: "x",106 name: "x",
72 authenticationTemplate: [107 authenticationTemplate: [
73 { kind: "apiKey", slug: "h", in: "header", name: "Authorization" },108 { slug: "h", kind: "apikey", placements: [{ carrier: "header", name: "Authorization" }] },
74 ],109 ],
75 }),110 }),
76 );111 );
@@ -80,7 +115,7 @@ describe("describeGraphqlAuthMethods", () => { · 3 unmodified lines
80 ]);115 ]);
81 });116 });
82 117
83 it("projects an oauth2 template to one oauth method", () => {118 it("projects an oauth2 method to one oauth descriptor", () => {
84 const methods = describeGraphqlAuthMethods(119 const methods = describeGraphqlAuthMethods(
85 recordWith({120 recordWith({
86 endpoint: "https://x.example/graphql",121 endpoint: "https://x.example/graphql",
@@ -100,14 +135,28 @@ describe("describeGraphqlAuthMethods", () => { · 13 unmodified lines
100 ]);135 ]);
101 });136 });
102 137
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
103 it("projects every declared method (multi-method specs)", () => {152 it("projects every declared method (multi-method specs)", () => {
104 const methods = describeGraphqlAuthMethods(153 const methods = describeGraphqlAuthMethods(
105 recordWith({154 recordWith({
106 endpoint: "https://x.example/graphql",155 endpoint: "https://x.example/graphql",
107 name: "x",156 name: "x",
108 authenticationTemplate: [157 authenticationTemplate: [
109 { kind: "apiKey", slug: "a", in: "header", name: "X-Api-Key" },158 { slug: "a", kind: "apikey", placements: [{ carrier: "header", name: "X-Api-Key" }] },
110 { kind: "apiKey", slug: "b", in: "query", name: "token" },159 { slug: "b", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
111 ],160 ],
112 }),161 }),
113 );162 );
packages/plugins/graphql/src/sdk/index.ts+9−4
@@ -20,17 +20,22 @@ export { · 19 unmodified lines
20} from "./errors";20} from "./errors";
21 21
22export {22export {
23 ApiKeyAuthTemplate,
24 AuthTemplate,
25 decodeGraphqlIntegrationConfig,23 decodeGraphqlIntegrationConfig,
26 decodeGraphqlIntegrationConfigOption,24 decodeGraphqlIntegrationConfigOption,
27 ExtractedField,25 ExtractedField,
28 ExtractionResult,26 ExtractionResult,
29 GraphqlArgument,27 GraphqlArgument,
28 GraphqlAuthMethod,
29 GraphqlAuthMethodInput,
30 GraphqlIntegrationConfig,30 GraphqlIntegrationConfig,
31 GraphqlOAuthMethod,
31 GraphqlOperationKind,32 GraphqlOperationKind,
32 InvocationResult,33 InvocationResult,
33 OAuthAuthTemplate,34 normalizeGraphqlAuthMethods,
34 OperationBinding,35 OperationBinding,
35 type ApiKeyAuthTemplate as ApiKeyAuthTemplateType,
36} from "./types";36} from "./types";
37
38export { migrateGraphqlAuthConfig } from "./migrate-config";
39
40// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
41export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
packages/plugins/graphql/src/sdk/migrate-config.test.ts+202−0
@@ -0,0 +1,202 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Option } from "effect";
3
4import { migrateGraphqlAuthConfig } from "./migrate-config";
5import { 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
14const BASE = { endpoint: "https://x.example/graphql", name: "x" } as const;
15
16const decodes = (config: unknown): boolean =>
17 Option.isSome(decodeGraphqlIntegrationConfigOption(config));
18
19describe("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
22import { Option, Schema } from "effect";
23import {
24 apiKeyMethodFromLegacyTemplate,
25 decodeLegacyApiKeyTemplate,
26} from "@executor-js/sdk/http-auth";
27
28import { decodeGraphqlIntegrationConfigOption, type GraphqlAuthMethod } from "./types";
29
30const LegacySingleAuth = Schema.Union([
31 Schema.Struct({ kind: Schema.Literal("none") }),
32 Schema.Struct({ kind: Schema.Literal("oauth2") }),
33]);
34const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth);
35
36const 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});
43const LegacyOAuthTemplate = Schema.Struct({
44 slug: Schema.String,
45 type: Schema.Literal("oauth"),
46});
47const decodeLegacyNativeApiKey = Schema.decodeUnknownOption(LegacyNativeApiKey);
48const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate);
49
50const 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. */
63const 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
91const 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`. */
99export 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
11import { describe, expect, it } from "@effect/vitest";
12import { Effect } from "effect";
13
14import {
15 AuthTemplateSlug,
16 ConnectionName,
17 IntegrationSlug,
18 ToolAddress,
19 createExecutor,
20} from "@executor-js/sdk";
21import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
22
23import { graphqlPlugin } from "./plugin";
24import { variable } from "@executor-js/sdk/http-auth";
25import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
26
27const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
28
29const makeExecutor = () =>
30 createExecutor(
31 makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),
32 );
33
34const toolAddr = (integration: string, connection: string, tool: string): ToolAddress =>
35 ToolAddress.make(`tools.${integration}.org.${connection}.${tool}`);
36
37describe("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
328 slug: "live_invoke",328 slug: "live_invoke",
329 queryParams: { trace: "on" },329 queryParams: { trace: "on" },
330 authenticationTemplate: [330 authenticationTemplate: [
331 { kind: "apiKey", slug: "header", in: "header", name: "x-static" },331 { slug: "header", kind: "apikey", placements: [{ carrier: "header", name: "x-static" }] },
332 ],332 ],
333 });333 });
334 yield* createOrgConnection(executor, {334 yield* createOrgConnection(executor, {
@@ -768,11 +768,9 @@ describe("graphqlPlugin", () => { · 433 unmodified lines
768 introspectionJson,768 introspectionJson,
769 authenticationTemplate: [769 authenticationTemplate: [
770 {770 {
771 kind: "apiKey",
772 slug: "header",771 slug: "header",
773 in: "header",772 kind: "apikey",
774 name: "Authorization",773 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
775 prefix: "Bearer ",
776 },774 },
777 ],775 ],
778 });776 });
packages/plugins/graphql/src/sdk/plugin.ts+107−96
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
13 ToolName,13 ToolName,
14 ToolResult,14 ToolResult,
15 type AuthMethodDescriptor,15 type AuthMethodDescriptor,
16 type AuthPlacementDescriptor,
17 type IntegrationConfig,16 type IntegrationConfig,
18 type IntegrationRecord,17 type IntegrationRecord,
19 type PluginCtx,18 type PluginCtx,
@@ -22,6 +21,16 @@ import { · 2 unmodified lines
22 type ToolDef,21 type ToolDef,
23} from "@executor-js/sdk/core";22} from "@executor-js/sdk/core";
24 23
24import {
25 TOKEN_VARIABLE,
26 describeApiKeyAuthMethod,
27 describeNoneAuthMethod,
28 oauthBearerPlacement,
29 renderAuthPlacements,
30 requiredPlacementVariables,
31 type RenderedAuthPlacements,
32} from "@executor-js/sdk/http-auth";
33
25import {34import {
26 introspect,35 introspect,
27 parseIntrospectionJson,36 parseIntrospectionJson,
@@ -40,12 +49,15 @@ import { invokeWithLayer } from "./invoke"; · 12 unmodified lines
40import { graphqlPresets } from "./presets";49import { graphqlPresets } from "./presets";
41import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store";50import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store";
42import {51import {
43 AuthTemplate,52 GraphqlAuthMethodInput,
44 decodeGraphqlIntegrationConfig,53 decodeGraphqlIntegrationConfig,
45 decodeGraphqlIntegrationConfigOption,54 decodeGraphqlIntegrationConfigOption,
46 ExtractedField,55 ExtractedField,
47 GraphqlIntegrationConfig,56 GraphqlIntegrationConfig,
57 expandGraphqlAuthMethodInputs,
58 normalizeGraphqlAuthMethods,
48 OperationBinding,59 OperationBinding,
60 type GraphqlAuthMethod,
49 type GraphqlOperationKind,61 type GraphqlOperationKind,
50} from "./types";62} from "./types";
51 63
@@ -72,8 +84,6 @@ const GRAPHQL_PLUGIN_ID = "graphql"; · 20 unmodified lines
72// Extension input shapes84// Extension input shapes
73// ---------------------------------------------------------------------------85// ---------------------------------------------------------------------------
74 86
75const AuthTemplateSchema = AuthTemplate;
76
77/** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL;87/** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL;
78 * `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson`88 * `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson`
79 * supplies the schema when the endpoint disables live introspection; `headers`89 * supplies the schema when the endpoint disables live introspection; `headers`
@@ -87,7 +97,7 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 7 unmodified lines
87 introspectionJson: Schema.optional(Schema.String),97 introspectionJson: Schema.optional(Schema.String),
88 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),98 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
89 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),99 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
90 authenticationTemplate: Schema.optional(Schema.Array(AuthTemplateSchema)),100 authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
91});101});
92export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type;102export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type;
93 103
@@ -96,17 +106,17 @@ const GraphqlConfigureInputSchema = Schema.Struct({ · 2 unmodified lines
96 endpoint: Schema.optional(Schema.String),106 endpoint: Schema.optional(Schema.String),
97 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),107 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
98 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),108 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
99 authenticationTemplate: Schema.optional(Schema.Array(AuthTemplateSchema)),109 authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),
100});110});
101export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type;111export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type;
102 112
103/** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/113/** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/
104 * :slug/config`). Unlike `configure` (which REPLACES the whole config for the114 * :slug/config`). Unlike `configure` (which REPLACES the whole config for the
105 * generic repair path), `configureAuth` MERGE-APPENDS these templates onto the115 * generic repair path), `configureAuth` MERGE-APPENDS these methods onto the
106 * integration's existing `authenticationTemplate`, mirroring OpenAPI's116 * integration's existing `authenticationTemplate`, mirroring OpenAPI's
107 * `configure`. */117 * `configure`. */
108const GraphqlConfigureAuthInputSchema = Schema.Struct({118const GraphqlConfigureAuthInputSchema = Schema.Struct({
109 authenticationTemplate: Schema.Array(AuthTemplateSchema),119 authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),
110 mode: Schema.optional(Schema.Literals(["merge", "replace"])),120 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
111});121});
112export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type;122export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type;
@@ -337,26 +347,21 @@ const annotationsFor = (binding: OperationBinding): ToolAnnotations => { · 224 unmodified lines
337};347};
338 348
339// ---------------------------------------------------------------------------349// ---------------------------------------------------------------------------
340// Auth template rendering (D11) — apply the resolved credential value through350// Auth method rendering (D11) — apply the connection's resolved values through
341// the template the connection references, exactly like an apiKey bearer.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.
342// ---------------------------------------------------------------------------354// ---------------------------------------------------------------------------
343 355
344interface RenderedAuth {356const renderGraphqlAuthMethod = (
345 readonly headers: Record<string, string>;357 method: GraphqlAuthMethod,
346 readonly queryParams: Record<string, string>;358 values: Record<string, string | null>,
347}359): RenderedAuthPlacements => {
348 360 if (method.kind === "apikey") return renderAuthPlacements(method.placements, values);
349const renderAuthTemplate = (template: AuthTemplate, value: string): RenderedAuth => {361 if (method.kind === "oauth2") {
350 if (template.kind === "oauth2") {362 return renderAuthPlacements([oauthBearerPlacement(method.header, method.prefix)], values);
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 } };
358 }363 }
359 return { headers: { [template.name]: rendered }, queryParams: {} };364 return { headers: {}, queryParams: {} };
360};365};
361 366
362// ---------------------------------------------------------------------------367// ---------------------------------------------------------------------------
@@ -387,23 +392,23 @@ const toStoredOperations = ( · 24 unmodified lines
387 * auth-required endpoint introspects successfully here rather than at add-time. */392 * auth-required endpoint introspects successfully here rather than at add-time. */
388const introspectHeadersForConnection = (393const introspectHeadersForConnection = (
389 config: GraphqlIntegrationConfig,394 config: GraphqlIntegrationConfig,
390 credentialValue: string | null,395 values: Record<string, string | null>,
391 templateSlug: AuthTemplateSlug | null,396 templateSlug: AuthTemplateSlug | null,
392): RenderedAuth => {397): RenderedAuthPlacements => {
393 const headers: Record<string, string> = { ...(config.headers ?? {}) };398 const headers: Record<string, string> = { ...(config.headers ?? {}) };
394 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };399 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
395 if (credentialValue !== null) {400 // Render the exact method the connection references; with no slug
396 // Render the exact template the connection references; with no slug401 // (connection row not yet persisted) fall back to the first declared.
397 // (connection row not yet persisted) fall back to the first declared.402 const method =
398 const template =403 (templateSlug !== null
399 (templateSlug !== null404 ? config.authenticationTemplate.find(
400 ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug))405 (m: GraphqlAuthMethod) => m.slug === String(templateSlug),
401 : undefined) ?? config.authenticationTemplate[0];406 )
402 if (template) {407 : undefined) ?? config.authenticationTemplate[0];
403 const rendered = renderAuthTemplate(template, credentialValue);408 if (method) {
404 Object.assign(headers, rendered.headers);409 const rendered = renderGraphqlAuthMethod(method, values);
405 Object.assign(queryParams, rendered.queryParams);410 Object.assign(headers, rendered.headers);
406 }411 Object.assign(queryParams, rendered.queryParams);
407 }412 }
408 return { headers, queryParams };413 return { headers, queryParams };
409};414};
@@ -413,14 +418,14 @@ const introspectHeadersForConnection = ( · 3 unmodified lines
413 * present; otherwise this introspects the endpoint with the rendered credential. */418 * present; otherwise this introspects the endpoint with the rendered credential. */
414const introspectForConnection = (419const introspectForConnection = (
415 config: GraphqlIntegrationConfig,420 config: GraphqlIntegrationConfig,
416 credentialValue: string | null,421 values: Record<string, string | null>,
417 templateSlug: AuthTemplateSlug | null,422 templateSlug: AuthTemplateSlug | null,
418 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,423 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
419): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {424): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {
420 if (config.introspectionJson) {425 if (config.introspectionJson) {
421 return parseIntrospectionJson(config.introspectionJson);426 return parseIntrospectionJson(config.introspectionJson);
422 }427 }
423 const auth = introspectHeadersForConnection(config, credentialValue, templateSlug);428 const auth = introspectHeadersForConnection(config, values, templateSlug);
424 return introspect(429 return introspect(
425 config.endpoint,430 config.endpoint,
426 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,431 Object.keys(auth.headers).length > 0 ? auth.headers : undefined,
@@ -438,22 +443,22 @@ const materializeOperations = ( · 11 unmodified lines
438 config: GraphqlIntegrationConfig,443 config: GraphqlIntegrationConfig,
439 credential: {444 credential: {
440 readonly template: AuthTemplateSlug;445 readonly template: AuthTemplateSlug;
441 readonly value: string | null;446 readonly values: Record<string, string | null>;
442 },447 },
443 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,448 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
444): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>449): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>
445 Effect.gen(function* () {450 Effect.gen(function* () {
446 // Render the exact template this connection references (we have its slug451 // Render the exact method this connection references (we have its slug
447 // here, unlike `resolveTools`) so an auth-required endpoint introspects.452 // here, unlike `resolveTools`) so an auth-required endpoint introspects.
448 const template = config.authenticationTemplate.find(453 const method = config.authenticationTemplate.find(
449 (t: AuthTemplate) => t.slug === String(credential.template),454 (m: GraphqlAuthMethod) => m.slug === String(credential.template),
450 );455 );
451 const headers: Record<string, string> = { ...(config.headers ?? {}) };456 const headers: Record<string, string> = { ...(config.headers ?? {}) };
452 const queryParams: Record<string, string> = {457 const queryParams: Record<string, string> = {
453 ...(config.queryParams ?? {}),458 ...(config.queryParams ?? {}),
454 };459 };
455 if (template && credential.value !== null) {460 if (method) {
456 const rendered = renderAuthTemplate(template, credential.value);461 const rendered = renderGraphqlAuthMethod(method, credential.values);
457 Object.assign(headers, rendered.headers);462 Object.assign(headers, rendered.headers);
458 Object.assign(queryParams, rendered.queryParams);463 Object.assign(queryParams, rendered.queryParams);
459 }464 }
@@ -489,44 +494,29 @@ const materializeOperations = ( · 29 unmodified lines
489// through the catalog's `authMethods` to the hub / Add-account flows. Exported494// through the catalog's `authMethods` to the hub / Add-account flows. Exported
490// for tests.495// for tests.
491//496//
492// apiKey → one apikey method carrying a single header/query placement (the497// none → a no-auth method carrying no credential inputs
493// `name` from the template, with the template's `prefix` defaulted to498// apikey → carried placements (headers / query params) verbatim
494// `""`).
495// oauth2 → one oauth method (no resolved endpoints; graphql renders the499// oauth2 → one oauth method (no resolved endpoints; graphql renders the
496// connection value as a bearer at invoke time).500// connection value as a bearer at invoke time).
497// ---------------------------------------------------------------------------501// ---------------------------------------------------------------------------
498 502
499const graphqlApiKeyLabel = (placement: AuthPlacementDescriptor): string =>
500 `API key (${placement.name || (placement.carrier === "header" ? "header" : "query")})`;
501
502export const describeGraphqlAuthMethods = (503export const describeGraphqlAuthMethods = (
503 record: IntegrationRecord,504 record: IntegrationRecord,
504): readonly AuthMethodDescriptor[] => {505): readonly AuthMethodDescriptor[] => {
505 const config = Option.getOrUndefined(decodeGraphqlIntegrationConfigOption(record.config));506 const config = Option.getOrUndefined(decodeGraphqlIntegrationConfigOption(record.config));
506 if (!config) return [];507 if (!config) return [];
507 return config.authenticationTemplate.map((template: AuthTemplate): AuthMethodDescriptor => {508 return config.authenticationTemplate.map((method: GraphqlAuthMethod): AuthMethodDescriptor => {
508 const slug = template.slug;509 if (method.kind === "apikey") return describeApiKeyAuthMethod(method);
509 if (template.kind === "oauth2") {510 if (method.kind === "oauth2") {
510 return {511 return {
511 id: slug,512 id: method.slug,
512 label: "OAuth",513 label: "OAuth",
513 kind: "oauth",514 kind: "oauth",
514 template: slug,515 template: method.slug,
515 oauth: {},516 oauth: {},
516 };517 };
517 }518 }
518 const placement: AuthPlacementDescriptor = {519 return describeNoneAuthMethod(method.slug);
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 };
530 });520 });
531};521};
532 522
@@ -555,7 +545,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 22 unmodified lines
555 : {}),545 : {}),
556 ...(input.headers !== undefined ? { headers: input.headers } : {}),546 ...(input.headers !== undefined ? { headers: input.headers } : {}),
557 ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}),547 ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}),
558 authenticationTemplate: input.authenticationTemplate ?? [],548 authenticationTemplate: input.authenticationTemplate
549 ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
550 : [],
559 });551 });
560 552
561 /** Register the integration in the catalog. Registering a source is a553 /** Register the integration in the catalog. Registering a source is a
@@ -663,7 +655,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 101 unmodified lines
663 ...((input.queryParams ?? current.queryParams) !== undefined655 ...((input.queryParams ?? current.queryParams) !== undefined
664 ? { queryParams: input.queryParams ?? current.queryParams }656 ? { queryParams: input.queryParams ?? current.queryParams }
665 : {}),657 : {}),
666 authenticationTemplate: input.authenticationTemplate ?? current.authenticationTemplate,658 authenticationTemplate: input.authenticationTemplate
659 ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
660 : current.authenticationTemplate,
667 });661 });
668 662
669 yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {663 yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
@@ -691,18 +685,27 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines
691 const configureAuthMethods = (685 const configureAuthMethods = (
692 slug: string,686 slug: string,
693 input: GraphqlConfigureAuthInput,687 input: GraphqlConfigureAuthInput,
694 ): Effect.Effect<readonly AuthTemplate[], StorageFailure> =>688 ): Effect.Effect<readonly GraphqlAuthMethod[], StorageFailure> =>
695 ctx.transaction(689 ctx.transaction(
696 Effect.gen(function* () {690 Effect.gen(function* () {
697 const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));691 const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
698 if (!record) return [] as readonly AuthTemplate[];692 if (!record) return [] as readonly GraphqlAuthMethod[];
699 const current = Option.getOrNull(decodeGraphqlIntegrationConfigOption(record.config));693 const current = Option.getOrNull(decodeGraphqlIntegrationConfigOption(record.config));
700 if (!current) return [] as readonly AuthTemplate[];694 if (!current) return [] as readonly GraphqlAuthMethod[];
701 695
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.
702 const merged =700 const merged =
703 input.mode === "replace"701 input.mode === "replace"
704 ? input.authenticationTemplate702 ? normalizeGraphqlAuthMethods(input.authenticationTemplate)
705 : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate);703 : mergeAuthTemplates(
704 current.authenticationTemplate,
705 expandGraphqlAuthMethodInputs(
706 input.authenticationTemplate,
707 ) as readonly GraphqlAuthMethod[],
708 );
706 709
707 const next = GraphqlIntegrationConfig.make({710 const next = GraphqlIntegrationConfig.make({
708 ...current,711 ...current,
@@ -849,25 +852,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 140 unmodified lines
849 resolveTools: ({852 resolveTools: ({
850 config,853 config,
851 template,854 template,
852 getValue,855 getValues,
853 }: {856 }: {
854 readonly config: IntegrationConfig;857 readonly config: IntegrationConfig;
855 readonly template: AuthTemplateSlug | null;858 readonly template: AuthTemplateSlug | null;
856 readonly getValue: () => Effect.Effect<string | null, unknown>;859 readonly getValues: () => Effect.Effect<Record<string, string | null>, unknown>;
857 }) =>860 }) =>
858 Effect.gen(function* () {861 Effect.gen(function* () {
859 const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);862 const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);
860 if (Option.isNone(decoded)) return { tools: [] };863 if (Option.isNone(decoded)) return { tools: [] };
861 const graphqlConfig = decoded.value;864 const graphqlConfig = decoded.value;
862 // Live introspection (no stored snapshot) needs the connection's865 // Live introspection (no stored snapshot) needs the connection's
863 // credential for auth-required endpoints; resolve it lazily.866 // credential inputs for auth-required endpoints; resolve them lazily.
864 const credentialValue =867 const values =
865 graphqlConfig.introspectionJson === undefined868 graphqlConfig.introspectionJson === undefined
866 ? yield* getValue().pipe(Effect.catch(() => Effect.succeed(null)))869 ? yield* getValues().pipe(
867 : null;870 Effect.catch(() => Effect.succeed({} as Record<string, string | null>)),
871 )
872 : ({} as Record<string, string | null>);
868 const introspection = yield* introspectForConnection(873 const introspection = yield* introspectForConnection(
869 graphqlConfig,874 graphqlConfig,
870 credentialValue,875 values,
871 template,876 template,
872 options?.httpClientLayer ?? httpClientLayerFallback,877 options?.httpClientLayer ?? httpClientLayerFallback,
873 ).pipe(Effect.option);878 ).pipe(Effect.option);
@@ -929,29 +934,35 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 55 unmodified lines
929 ...(config.queryParams ?? {}),934 ...(config.queryParams ?? {}),
930 };935 };
931 936
932 const template = config.authenticationTemplate.find(937 const method = config.authenticationTemplate.find(
933 (t: AuthTemplate) => t.slug === String(credential.template),938 (m: GraphqlAuthMethod) => m.slug === String(credential.template),
934 );939 );
935 if (template) {940 if (method && method.kind !== "none") {
936 if (credential.value === null) {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) {
937 return yield* new GraphqlAuthRequiredError({950 return yield* new GraphqlAuthRequiredError({
938 code:951 code:
939 template.kind === "oauth2"952 method.kind === "oauth2" ? "oauth_connection_missing" : "connection_value_missing",
940 ? "oauth_connection_missing"
941 : "connection_value_missing",
942 message:953 message:
943 template.kind === "oauth2"954 method.kind === "oauth2"
944 ? `Missing OAuth connection value for GraphQL integration "${integration}" (connection "${credential.connection}")`955 ? `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(", ")}`,
946 owner: credential.owner,957 owner: credential.owner,
947 integration,958 integration,
948 connection: String(credential.connection),959 connection: String(credential.connection),
949 credentialKind: template.kind === "oauth2" ? "oauth" : "secret",960 credentialKind: method.kind === "oauth2" ? "oauth" : "secret",
950 credentialLabel: template.kind === "oauth2" ? "OAuth sign-in" : "API key",961 credentialLabel: method.kind === "oauth2" ? "OAuth sign-in" : "API key",
951 template: String(credential.template),962 template: String(credential.template),
952 });963 });
953 }964 }
954 const rendered = renderAuthTemplate(template, credential.value);965 const rendered = renderGraphqlAuthMethod(method, credential.values);
955 Object.assign(headers, rendered.headers);966 Object.assign(headers, rendered.headers);
956 Object.assign(queryParams, rendered.queryParams);967 Object.assign(queryParams, rendered.queryParams);
957 }968 }
packages/plugins/graphql/src/sdk/types.ts+101−32
@@ -1,4 +1,12 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import {
3 ApiKeyAuthTemplate,
4 apiKeyMethodFromAuthTemplate,
5 isApiKeyAuthTemplate,
6 ApiKeyAuthMethod,
7 NoneAuthMethod,
8 normalizeAuthMethodSlugs,
9} from "@executor-js/sdk/http-auth";
2 10
3// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
4// GraphQL operation kind12// GraphQL operation kind
@@ -55,35 +63,27 @@ export const OperationBinding = Schema.Struct({ · 50 unmodified lines
55export type OperationBinding = typeof OperationBinding.Type;63export type OperationBinding = typeof OperationBinding.Type;
56 64
57// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
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).
59//73//
60// The integration's `config.authenticationTemplate` describes WHERE a74// none — no credential (open endpoint)
61// connection's resolved value is applied: an apiKey header / query param (with75// apikey — render the connection's values through the method's header/query
62// an optional prefix like `Bearer `) or an OAuth bearer header. There are no76// placements (one credential input per distinct placement
63// secret slots and no credential bindings — a connection IS the credential, and77// `variable`; a method may mix carriers — e.g. a bearer header
64// the plugin renders `credential.value` onto the request through the template78// plus a team-id query param)
65// matched by `credential.template` (D11: "auth state derived into the79// oauth2 — the value is an OAuth access token, applied as a bearer header
66// auth-template format" — an OAuth access token renders exactly like an apiKey80// (optionally overriding the header name / prefix). GraphQL oauth
67// bearer).81// stores no endpoints — only how the token is rendered.
68// ---------------------------------------------------------------------------82// ---------------------------------------------------------------------------
69 83
70/** An apiKey-style template: place the value in a header or query parameter,84/** An OAuth bearer method: write `<header>: <prefix><access-token>`. The
71 * optionally prefixed (e.g. `Bearer `). */85 * resolved (and refreshed) access token is the connection's `token` value. */
72export const ApiKeyAuthTemplate = Schema.Struct({86export const GraphqlOAuthMethod = 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});
82export 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`. */
86export const OAuthAuthTemplate = Schema.Struct({
87 kind: Schema.Literal("oauth2"),87 kind: Schema.Literal("oauth2"),
88 slug: Schema.String,88 slug: Schema.String,
89 /** The header to write the bearer token to. Defaults to `Authorization`. */89 /** The header to write the bearer token to. Defaults to `Authorization`. */
@@ -91,10 +91,75 @@ export const OAuthAuthTemplate = Schema.Struct({ · 1 unmodified line
91 /** The token prefix. Defaults to `Bearer `. */91 /** The token prefix. Defaults to `Bearer `. */
92 prefix: Schema.optional(Schema.String),92 prefix: Schema.optional(Schema.String),
93});93});
94export type OAuthAuthTemplate = typeof OAuthAuthTemplate.Type;94export type GraphqlOAuthMethod = typeof GraphqlOAuthMethod.Type;
95 95
96export const AuthTemplate = Schema.Union([ApiKeyAuthTemplate, OAuthAuthTemplate]);96export const GraphqlAuthMethod = Schema.Union([
97export type AuthTemplate = typeof AuthTemplate.Type;97 NoneAuthMethod,
98 ApiKeyAuthMethod,
99 GraphqlOAuthMethod,
100]);
101export type GraphqlAuthMethod = typeof GraphqlAuthMethod.Type;
102
103/** Input variant of `GraphqlAuthMethod` — callers (UI, agents) may omit the
104 * slug; `normalizeGraphqlAuthMethods` backfills it. */
105export 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]);
120export 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). */
124export 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. */
131const 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). */
143export 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. */
156export const normalizeGraphqlAuthMethods = (
157 methods: readonly GraphqlAuthMethodInput[],
158): readonly GraphqlAuthMethod[] =>
159 normalizeAuthMethodSlugs(
160 expandGraphqlAuthMethodInputs(methods),
161 defaultGraphqlAuthSlug,
162 ) as readonly GraphqlAuthMethod[];
98 163
99// ---------------------------------------------------------------------------164// ---------------------------------------------------------------------------
100// Integration config — the opaque-to-core blob the graphql plugin stores on the165// 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
114 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),179 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
115 /** Static query parameters applied to every request. */180 /** Static query parameters applied to every request. */
116 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),181 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
117 /** Auth methods a connection can be applied through. */182 /** Declared auth methods — how a connection's values are rendered onto
118 authenticationTemplate: Schema.Array(AuthTemplate),183 * requests. A connection's `template` picks one by slug. */
184 authenticationTemplate: Schema.Array(GraphqlAuthMethod),
119});185});
120export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type;186export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type;
121 187
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.
122export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig);191export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig);
123export const decodeGraphqlIntegrationConfigOption =192export const decodeGraphqlIntegrationConfigOption =
124 Schema.decodeUnknownOption(GraphqlIntegrationConfig);193 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 @@
1export * 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
16export {
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
14import { describe, expect, it } from "@effect/vitest";
15import { Effect, Layer } from "effect";
16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17
18import {
19 createExecutor,
20 AuthTemplateSlug,
21 ConnectionName,
22 IntegrationSlug,
23 ToolAddress,
24} from "@executor-js/sdk";
25import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
26
27import { httpSourcePlugin } from "./plugin";
28import { variable, type APIKeyAuthentication, type Authentication } from "./types";
29
30const HTTP_SLUG = "http-source" as const;
31
32const 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
44const 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`.
54const 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.
63const 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.
68interface 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
79const 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
90describe("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 @@
1import { 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`. */
6export 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`). */
16export 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 @@
1export {
2 httpSourcePlugin,
3 HTTP_SOURCE_PLUGIN_ID,
4 REQUEST_TOOL_NAME,
5 type HttpSourceExtension,
6 type HttpSourceConfigureInput,
7 type RegisterHttpIntegrationInput,
8} from "./plugin";
9
10export {
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
24export {
25 applyAuthTemplate,
26 findAuthTemplate,
27 renderAuthTemplate,
28 type RenderedAuth,
29} from "./template";
30
31export { issueRequest, type IssueRequestInput } from "./request";
32
33export { HttpConfigError, HttpRequestError } from "./errors";
packages/plugins/http-source/src/sdk/plugin.test.ts+0−210
@@ -1,210 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
5import {
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
17import { httpSourcePlugin, REQUEST_TOOL_NAME } from "./plugin";
18import { variable, type HttpSourceConfig } from "./types";
19
20const 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.
24type Store = ReturnType<typeof plugin.storage>;
25type InvokeInput = Parameters<NonNullable<typeof plugin.invokeTool>>[0];
26
27const 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
38const 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
50const 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.
60const 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
64const 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
74const 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
82const noElicit = (() => Effect.die("no elicit")) as InvokeInput["elicit"];
83
84// Assert an invokeTool result is a failed ToolResult and return its error code.
85const 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
92describe("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
121describe("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
184describe("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 @@
1import { Effect, type Layer } from "effect";
2import type { HttpClient } from "effect/unstable/http";
3
4import {
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
22import { HttpRequestError } from "./errors";
23import { applyAuthTemplate, findAuthTemplate, requiredVariables } from "./template";
24import { issueRequest } from "./request";
25import {
26 type Authentication,
27 type HttpMethod,
28 type HttpRequestArgs,
29 type HttpSourceConfig,
30} from "./types";
31
32// ---------------------------------------------------------------------------
33// Plugin identity
34// ---------------------------------------------------------------------------
35
36export const HTTP_SOURCE_PLUGIN_ID = "http-source";
37export 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
44const 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
64const shortId = (): string => Math.random().toString(36).slice(2, 8);
65
66const 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
72const 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
104const 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
131const 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
161const 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
171const 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
189export 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`. */
198export interface HttpSourceConfigureInput {
199 readonly authenticationTemplate: readonly Authentication[];
200}
201
202// ---------------------------------------------------------------------------
203// Plugin
204// ---------------------------------------------------------------------------
205
206export 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. */
402export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/unstable/http";
4
5import { issueRequest } from "./request";
6import 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. */
10const 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
22const noAuth: RenderedAuth = { headers: {}, queryParams: {} };
23
24describe("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 @@
1import { Effect, Layer } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
4import { HttpRequestError } from "./errors";
5import type { RenderedAuth } from "./template";
6import 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
12const normalizeContentType = (ct: string | null | undefined): string =>
13 ct?.split(";")[0]?.trim().toLowerCase() ?? "";
14
15const 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
21const 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
28const 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
39export 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
52const 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). */
79export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3
4import {
5 applyAuthTemplate,
6 findAuthTemplate,
7 renderAuthTemplate,
8 requiredVariables,
9} from "./template";
10import { 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
18const apiKeyHeader: Authentication = {
19 slug: AuthTemplateSlug.make("apiKey-header"),
20 type: "apiKey",
21 headers: { Authorization: ["Bearer ", variable("token")] },
22};
23
24const apiKeyQuery: Authentication = {
25 slug: AuthTemplateSlug.make("apiKey-query"),
26 type: "apiKey",
27 queryParams: { api_key: [variable("token")] },
28};
29
30const 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.
40const 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
49describe("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
84describe("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
94describe("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 @@
1import type { AuthTemplateSlug } from "@executor-js/sdk/shared";
2
3import {
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
20export interface RenderedAuth {
21 readonly headers: Record<string, string>;
22 readonly queryParams: Record<string, string>;
23}
24
25const EMPTY_AUTH: RenderedAuth = { headers: {}, queryParams: {} };
26
27const 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. */
45export 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. */
54export 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. */
73export 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. */
102export 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 @@
1import { Schema } from "effect";
2
3import 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
15export 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. */
21export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
22
23export 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. */
30export const CREDENTIAL_VARIABLE = "token";
31
32export interface APIKeyAuthentication {
33 readonly slug: AuthTemplateSlug;
34 readonly type: "apiKey";
35 readonly headers?: Record<string, AuthenticationTemplateValue>;
36 readonly queryParams?: Record<string, AuthenticationTemplateValue>;
37}
38
39export 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
46export 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
62export const HttpMethod = Schema.Literals([
63 "GET",
64 "POST",
65 "PUT",
66 "PATCH",
67 "DELETE",
68 "HEAD",
69 "OPTIONS",
70]);
71export type HttpMethod = typeof HttpMethod.Type;
72
73export 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" });
82export type HttpRequestArgs = typeof HttpRequestArgs.Type;
83
84export const HttpResponse = Schema.Struct({
85 status: Schema.Number,
86 headers: Schema.Record(Schema.String, Schema.String),
87 body: Schema.Unknown,
88}).annotate({ identifier: "HttpResponse" });
89export 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
95export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1import { defineConfig } from "vitest/config";
2
3export 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
10import {10import {
11 McpAuthMethod,11 McpAuthMethod,
12 McpAuthMethodInput,12 McpAuthMethodInput,
13 McpAuthTemplate,13 McpAuthShorthand,
14 McpIntegrationConfig,14 McpIntegrationConfig,
15} from "../sdk/types";15} from "../sdk/types";
16 16
@@ -40,7 +40,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 23 unmodified lines
40 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),40 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
41 /** Single-method shorthand (legacy callers); ignored when41 /** Single-method shorthand (legacy callers); ignored when
42 * `authenticationTemplate` is present. */42 * `authenticationTemplate` is present. */
43 auth: Schema.optional(McpAuthTemplate),43 auth: Schema.optional(McpAuthShorthand),
44});44});
45 45
46const AddStdioServerPayload = Schema.Struct({46const 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
37import type { McpAuthMethodInput } from "../sdk/types";37import type { McpAuthMethodInput } from "../sdk/types";
38import { probeMcpEndpoint, addMcpServer } from "./atoms";38import { probeMcpEndpoint, addMcpServer } from "./atoms";
39import { McpRemoteSourceFields } from "./McpRemoteSourceFields";39import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
40import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config";40import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
41import { mcpPresets, type McpPreset } from "../sdk/presets";41import { mcpPresets, type McpPreset } from "../sdk/presets";
42 42
43// The remote add flow REGISTERS the server's declared auth methods through the43// 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
322 // Every row registers as a declared method (a lone no-auth row registers322 // Every row registers as a declared method (a lone no-auth row registers
323 // the open-server method). Slugs are assigned server-side by kind.323 // the open-server method). Slugs are assigned server-side by kind.
324 const methods = authMethodList.rows.map((row: AuthMethodRow) =>324 const methods = authMethodList.rows.map((row: AuthMethodRow) =>
325 mcpAuthMethodInputFromEditorValue(row.value),325 mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(row.value)),
326 );326 );
327 const slug = await registerIntegration(327 const slug = await registerIntegration(
328 methods.length > 0 ? methods : [{ kind: "none" as const }],328 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
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5 5
6import { IntegrationSlug } from "@executor-js/sdk/shared";6import { IntegrationSlug } from "@executor-js/sdk/shared";
7import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth";
7import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";8import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
8import {9import {
9 AuthMethodListEditor,10 AuthMethodListEditor,
@@ -24,10 +25,15 @@ import { Badge } from "@executor-js/react/components/badge"; · 14 unmodified lines
24import { FormErrorAlert } from "@executor-js/react/lib/integration-add";25import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
25 26
26import { configureMcpAuth, mcpServerAtom } from "./atoms";27import { configureMcpAuth, mcpServerAtom } from "./atoms";
27import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types";28import type {
29 McpAuthMethod,
30 McpCanonicalAuthMethodInput,
31 McpIntegrationConfig,
32} from "../sdk/types";
28import {33import {
29 editorValueFromMcpAuthMethod,34 editorValueFromMcpAuthMethod,
30 mcpAuthMethodInputFromEditorValue,35 mcpAuthMethodInputFromEditorValue,
36 mcpWireAuthInput,
31} from "./auth-method-config";37} from "./auth-method-config";
32 38
33type McpServer = {39type McpServer = {
@@ -43,10 +49,30 @@ type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; · 9 unmodified lines
43 49
44const methodSeedLabel = (method: McpAuthMethod): string => {50const methodSeedLabel = (method: McpAuthMethod): string => {
45 if (method.kind === "oauth2") return "OAuth";51 if (method.kind === "oauth2") return "OAuth";
46 if (method.kind === "header") return `API key (${method.headerName})`;52 if (method.kind === "apikey") return apiKeyMethodLabel(method);
47 return "No authentication";53 return "No authentication";
48};54};
49 55
56const 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
50// ---------------------------------------------------------------------------76// ---------------------------------------------------------------------------
51// Remote edit — v2: the integration's endpoint is part of its identity77// Remote edit — v2: the integration's endpoint is part of its identity
52// (opaque-to-core config); the editable surface is the declared auth-method78// (opaque-to-core config); the editable surface is the declared auth-method
@@ -80,9 +106,9 @@ function RemoteEdit(props: { · 27 unmodified lines
80 // The edited methods, slugs preserved for seeded rows so existing106 // The edited methods, slugs preserved for seeded rows so existing
81 // connections (bound by template slug) stay attached. New rows omit the107 // connections (bound by template slug) stay attached. New rows omit the
82 // slug — the backend assigns kind-based ones.108 // slug — the backend assigns kind-based ones.
83 const editedMethods = useMemo<readonly McpAuthMethodInput[]>(109 const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>(
84 () =>110 () =>
85 list.rows.map((row: AuthMethodRow): McpAuthMethodInput => {111 list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {
86 const input = mcpAuthMethodInputFromEditorValue(row.value);112 const input = mcpAuthMethodInputFromEditorValue(row.value);
87 return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;113 return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;
88 }),114 }),
@@ -92,16 +118,13 @@ function RemoteEdit(props: { · 3 unmodified lines
92 const methodsChanged = useMemo(() => {118 const methodsChanged = useMemo(() => {
93 const stored = server.config.authenticationTemplate;119 const stored = server.config.authenticationTemplate;
94 if (editedMethods.length !== stored.length) return true;120 if (editedMethods.length !== stored.length) return true;
95 return editedMethods.some((method: McpAuthMethodInput, index: number) => {121 return editedMethods.some((method: McpCanonicalAuthMethodInput, index: number) => {
96 const current = stored[index];122 const current = stored[index];
97 if (!current) return true;123 if (!current) return true;
98 if ((method.slug ?? "") !== current.slug) return true;124 if ((method.slug ?? "") !== current.slug) return true;
99 if (method.kind !== current.kind) return true;125 if (method.kind !== current.kind) return true;
100 if (method.kind === "header" && current.kind === "header") {126 if (method.kind === "apikey" && current.kind === "apikey") {
101 return (127 return !samePlacements(method.placements, current.placements);
102 method.headerName !== current.headerName ||
103 (method.prefix ?? "") !== (current.prefix ?? "")
104 );
105 }128 }
106 return false;129 return false;
107 });130 });
@@ -114,7 +137,9 @@ function RemoteEdit(props: { · 6 unmodified lines
114 params: { slug: server.slug },137 params: { slug: server.slug },
115 payload: {138 payload: {
116 authenticationTemplate:139 authenticationTemplate:
117 editedMethods.length > 0 ? editedMethods : [{ kind: "none" as const }],140 editedMethods.length > 0
141 ? editedMethods.map(mcpWireAuthInput)
142 : [{ kind: "none" as const }],
118 mode: "replace",143 mode: "replace",
119 },144 },
120 reactivityKeys: integrationWriteKeys,145 reactivityKeys: integrationWriteKeys,
packages/plugins/mcp/src/react/McpAccountsPanel.tsx+6−2
@@ -15,7 +15,11 @@ import { · 14 unmodified lines
15import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";15import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
16 16
17import { configureMcpAuth, mcpServerAtom } from "./atoms";17import { configureMcpAuth, mcpServerAtom } from "./atoms";
18import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config";18import {
19 authMethodsFromConfig,
20 mcpAuthMethodInputsFromPlacements,
21 mcpWireAuthInput,
22} from "./auth-method-config";
19import type { McpAuthMethod } from "../sdk/types";23import type { McpAuthMethod } from "../sdk/types";
20 24
21// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
@@ -60,7 +64,7 @@ export default function McpAccountsPanel(props: { · 38 unmodified lines
60 const exit = await doConfigureAuth({64 const exit = await doConfigureAuth({
61 params: { slug },65 params: { slug },
62 payload: {66 payload: {
63 authenticationTemplate: input.authenticationTemplate,67 authenticationTemplate: input.authenticationTemplate.map(mcpWireAuthInput),
64 ...(input.mode ? { mode: input.mode } : {}),68 ...(input.mode ? { mode: input.mode } : {}),
65 },69 },
66 reactivityKeys: integrationWriteKeys,70 reactivityKeys: integrationWriteKeys,
packages/plugins/mcp/src/react/auth-method-config.test.ts+103−32
@@ -23,64 +23,88 @@ describe("mcpAuthMethodInputFromEditorValue", () => { · 22 unmodified lines
23 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });23 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" });
24 });24 });
25 25
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)", () => {
27 const value: AuthTemplateEditorValue = {27 const value: AuthTemplateEditorValue = {
28 kind: "apikey",28 kind: "apikey",
29 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],29 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
30 };30 };
31 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({31 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
32 kind: "header",32 kind: "apikey",
33 headerName: "Authorization",33 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
34 prefix: "Bearer ",
35 });34 });
36 });35 });
37 36
38 it("omits the prefix when blank", () => {37 it("maps a query placement to an apikey method (servers like ui.sh use ?token=)", () => {
39 const value: AuthTemplateEditorValue = {38 const value: AuthTemplateEditorValue = {
40 kind: "apikey",39 kind: "apikey",
41 placements: [{ carrier: "header", name: "X-Token", prefix: "" }],40 placements: [{ carrier: "query", name: "token", prefix: "" }],
42 };41 };
43 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({42 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 = {
51 kind: "apikey",43 kind: "apikey",
52 placements: [{ carrier: "query", name: "api_key", prefix: "" }],44 placements: [{ carrier: "query", name: "token" }],
53 };45 });
54 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "none" });
55 });46 });
56 47
57 it("uses the first NAMED header placement (skips unnamed)", () => {48 it("keeps EVERY named placement — header + query mix in one method", () => {
58 const value: AuthTemplateEditorValue = {49 const value: AuthTemplateEditorValue = {
59 kind: "apikey",50 kind: "apikey",
60 placements: [51 placements: [
61 { carrier: "header", name: "", prefix: "" },52 { carrier: "header", name: "Authorization", prefix: "Bearer " },
62 { carrier: "header", name: "X-Token", prefix: "" },53 { carrier: "query", name: "team_id", prefix: "" },
63 ],54 ],
64 };55 };
65 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({56 expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({
66 kind: "header",57 kind: "apikey",
67 headerName: "X-Token",58 placements: [
59 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" },
60 { carrier: "query", name: "team_id", variable: "team_id" },
61 ],
68 });62 });
69 });63 });
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 });
70});73});
71 74
72describe("editorValueFromMcpAuthMethod", () => {75describe("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", () => {
74 expect(77 expect(
75 editorValueFromMcpAuthMethod({78 editorValueFromMcpAuthMethod({
76 slug: "header",79 slug: "header",
77 kind: "header",80 kind: "apikey",
78 headerName: "X-Api-Key",81 placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }],
79 prefix: "Bearer ",
80 }),82 }),
81 ).toEqual({83 ).toEqual({
82 kind: "apikey",84 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 ],
84 });108 });
85 });109 });
86 110
@@ -99,7 +123,11 @@ describe("authMethodsFromConfig", () => { · 12 unmodified lines
99 const methods = authMethodsFromConfig(123 const methods = authMethodsFromConfig(
100 [124 [
101 { slug: "oauth2", kind: "oauth2" },125 { 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 },
103 { slug: "none", kind: "none" },131 { slug: "none", kind: "none" },
104 ],132 ],
105 "https://mcp.example.com/mcp",133 "https://mcp.example.com/mcp",
@@ -119,21 +147,64 @@ describe("authMethodsFromConfig", () => { · 13 unmodified lines
119 ]);147 ]);
120 expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");148 expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
121 });149 });
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 });
122});170});
123 171
124describe("mcpAuthMethodInputsFromPlacements", () => {172describe("mcpAuthMethodInputsFromPlacements", () => {
125 it("builds one header method from the first named header placement", () => {173 it("builds ONE method carrying every named placement", () => {
126 expect(174 expect(
127 mcpAuthMethodInputsFromPlacements([175 mcpAuthMethodInputsFromPlacements([
128 { carrier: "query", name: "api_key", prefix: "" },
129 { carrier: "header", name: "X-Token", prefix: "Bearer " },176 { 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: "" },
130 ]),201 ]),
131 ).toEqual([{ kind: "header", headerName: "X-Token", prefix: "Bearer " }]);202 ).toEqual([{ kind: "apikey", placements: [{ carrier: "query", name: "token" }] }]);
132 });203 });
133 204
134 it("is empty when no usable header placement exists", () => {205 it("is empty when no placement has a usable name", () => {
135 expect(206 expect(
136 mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }]),207 mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: " ", prefix: "" }]),
137 ).toEqual([]);208 ).toEqual([]);
138 });209 });
139});210});
packages/plugins/mcp/src/react/auth-method-config.ts+47−79
@@ -1,40 +1,51 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// MCP ↔ generic auth-template converter.2// MCP ↔ generic auth-method converters — a thin oauth adapter over the shared
3//3// codec (`@executor-js/react/lib/shared-auth-method-codec`). The apikey/none
4// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the4// paths (multi-placement, multi-variable) live in the shared codec; MCP only
5// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP5// contributes its oauth flavor: endpoint-less methods whose metadata is
6// stores a slugged `authenticationTemplate` array (`none` / `header` /6// discovered at connect time (`discoveryUrl` = the MCP endpoint).
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.
10// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
11 8
12import { AuthTemplateSlug } from "@executor-js/sdk/shared";9import { AuthTemplateSlug } from "@executor-js/sdk/shared";
13import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";10import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";11import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
12import {
13 authMethodFromSharedTemplate,
14 editorValueFromSharedMethod,
15 sharedMethodInputFromEditorValue,
16 wirePlacementsFromEditor,
17} from "@executor-js/react/lib/shared-auth-method-codec";
15 18
16import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types";19import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec";
20import 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). */
24export const mcpWireAuthInput = (
25 method: McpAuthMethod | McpCanonicalAuthMethodInput,
26): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;
27
28const 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});
17 37
18/** Convert a generic editor value into one MCP auth-method input (no slug —38/** 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`39 * the backend assigns carrier-derived slugs). An apikey value keeps every
20 * method using its FIRST named header placement, preserving the prefix; an40 * named placement (headers and query params mix freely); one with no usable
21 * apiKey value with no usable header placement falls back to `none`. */41 * placement falls back to `none`. */
22export function mcpAuthMethodInputFromEditorValue(42export function mcpAuthMethodInputFromEditorValue(
23 value: AuthTemplateEditorValue,43 value: AuthTemplateEditorValue,
24): McpAuthMethodInput {44): McpCanonicalAuthMethodInput {
25 if (value.kind === "oauth") return { kind: "oauth2" };45 if (value.kind === "oauth") return { kind: "oauth2" };
26 if (value.kind === "apikey") {46 return (sharedMethodInputFromEditorValue(value) ?? {
27 const header = value.placements.find(47 kind: "none",
28 (placement) => placement.carrier === "header" && placement.name.trim().length > 0,48 }) as McpCanonicalAuthMethodInput;
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" };
38}49}
39 50
40/** Convert one stored MCP method into the generic editor value. */51/** Convert one stored MCP method into the generic editor value. */
@@ -42,13 +53,7 @@ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplat · 1 unmodified line
42 if (method.kind === "oauth2") {53 if (method.kind === "oauth2") {
43 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };54 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
44 }55 }
45 if (method.kind === "header") {56 return editorValueFromSharedMethod(method);
46 return {
47 kind: "apikey",
48 placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }],
49 };
50 }
51 return { kind: "none" };
52}57}
53 58
54/** Project the stored methods into the generic `AuthMethod[]` the hub renders.59/** Project the stored methods into the generic `AuthMethod[]` the hub renders.
@@ -60,56 +65,19 @@ export function authMethodsFromConfig( · 5 unmodified lines
60 endpoint: string,65 endpoint: string,
61): AuthMethod[] {66): AuthMethod[] {
62 return methods.map((method: McpAuthMethod): AuthMethod => {67 return methods.map((method: McpAuthMethod): AuthMethod => {
63 const source: "spec" | "custom" = method.slug.startsWith("custom_") ? "custom" : "spec";68 if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);
64 const template = AuthTemplateSlug.make(method.slug);69 return authMethodFromSharedTemplate(method);
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 };
94 });70 });
95}71}
96 72
97/** Build MCP header-method inputs for a custom method from generic placements.73/** Build the MCP method input for a custom method from generic placements —
98 * MCP carries one header per method, so only the FIRST named header placement74 * ONE method carrying every named placement (header + query mix in a single
99 * is used (query placements have no MCP credential carrier). Empty when no75 * method; each placement renders from its own input variable, or shares one).
100 * usable header placement exists. */76 * Empty when no placement is usable. */
101export function mcpAuthMethodInputsFromPlacements(77export function mcpAuthMethodInputsFromPlacements(
102 placements: readonly Placement[],78 placements: readonly Placement[],
103): McpAuthMethodInput[] {79): McpCanonicalAuthMethodInput[] {
104 const header = placements.find(80 const wire = wirePlacementsFromEditor(placements);
105 (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0,81 if (wire.length === 0) return [];
106 );82 return [{ kind: "apikey", placements: wire }];
107 if (!header) return [];
108 return [
109 {
110 kind: "header",
111 headerName: header.name.trim(),
112 ...(header.prefix ? { prefix: header.prefix } : {}),
113 },
114 ];
115}83}
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+63−46
@@ -7,8 +7,9 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 6 unmodified lines
7// `describeMcpAuthMethods` projects the stored MCP config into the catalog's7// `describeMcpAuthMethods` projects the stored MCP config into the catalog's
8// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is8// plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is
9// pure/sync and must tolerate a malformed or foreign config blob by returning9// 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 lifted10// `[]`. Pre-canonical blobs are rewritten by the one-off config migration
11// into a one-element `authenticationTemplate` whose slug is the method kind.11// (see migrate-config.test.ts), so this projection only ever sees the
12// canonical placements model.
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13 14
14const recordWith = (config: IntegrationConfig): IntegrationRecord => ({15const recordWith = (config: IntegrationConfig): IntegrationRecord => ({
@@ -45,13 +46,17 @@ describe("describeMcpAuthMethods", () => { · 30 unmodified lines
45 ]);46 ]);
46 });47 });
47 48
48 it("projects a header method to an apikey method carrying the header placement", () => {49 it("projects an apikey header method carrying the placement", () => {
49 const methods = describeMcpAuthMethods(50 const methods = describeMcpAuthMethods(
50 recordWith({51 recordWith({
51 transport: "remote",52 transport: "remote",
52 endpoint: "https://x.example/mcp",53 endpoint: "https://x.example/mcp",
53 authenticationTemplate: [54 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 },
55 ],60 ],
56 }),61 }),
57 );62 );
@@ -67,83 +72,85 @@ describe("describeMcpAuthMethods", () => { · 9 unmodified lines
67 ]);72 ]);
68 });73 });
69 74
70 it("projects every declared method (multi-method configs)", () => {75 it("projects an apikey query method (the ui.sh '?token=' shape)", () => {
71 const methods = describeMcpAuthMethods(76 const methods = describeMcpAuthMethods(
72 recordWith({77 recordWith({
73 transport: "remote",78 transport: "remote",
74 endpoint: "https://x.example/mcp",79 endpoint: "https://ui.sh/mcp",
75 authenticationTemplate: [80 authenticationTemplate: [
76 { slug: "oauth2", kind: "oauth2" },81 { slug: "query", kind: "apikey", placements: [{ carrier: "query", name: "token" }] },
77 { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" },
78 ],82 ],
79 }),83 }),
80 );84 );
81 85
82 expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([86 expect(methods).toEqual([
83 { id: "oauth2", kind: "oauth", template: "oauth2" },87 {
84 { id: "custom_abc123", kind: "apikey", template: "custom_abc123" },88 id: "query",
89 label: "API key (token)",
90 kind: "apikey",
91 template: "query",
92 placements: [{ carrier: "query", name: "token", prefix: "" }],
93 },
85 ]);94 ]);
86 });95 });
87 96
88 it("defaults the header prefix to an empty string when unset", () => {97 it("projects a mixed header+query method with per-placement variables", () => {
89 const methods = describeMcpAuthMethods(98 const methods = describeMcpAuthMethods(
90 recordWith({99 recordWith({
91 transport: "remote",100 transport: "remote",
92 endpoint: "https://x.example/mcp",101 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 ],
94 }),117 }),
95 );118 );
96 119
97 expect(methods[0]?.placements).toEqual([120 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" },
99 ]);123 ]);
100 });124 });
101 125
102 it("projects an open (none) method to a no-auth method", () => {126 it("projects every declared method (multi-method configs)", () => {
103 const methods = describeMcpAuthMethods(127 const methods = describeMcpAuthMethods(
104 recordWith({128 recordWith({
105 transport: "remote",129 transport: "remote",
106 endpoint: "https://x.example/mcp",130 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 ],
108 }),139 }),
109 );140 );
110 expect(methods).toEqual([
111 {
112 id: "none",
113 label: "No authentication",
114 kind: "none",
115 template: "none",
116 },
117 ]);
118 });
119 141
120 it("lifts a legacy singular `auth` config into one method slugged by kind", () => {142 expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([
121 const methods = describeMcpAuthMethods(143 { id: "oauth2", kind: "oauth", template: "oauth2" },
122 recordWith({144 { id: "custom_abc123", kind: "apikey", template: "custom_abc123" },
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 },
139 ]);145 ]);
140 });146 });
141 147
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", () => {
143 const methods = describeMcpAuthMethods(149 const methods = describeMcpAuthMethods(
144 recordWith({150 recordWith({
145 transport: "remote",151 transport: "remote",
146 endpoint: "https://x.example/mcp",152 endpoint: "https://x.example/mcp",
153 authenticationTemplate: [{ slug: "none", kind: "none" }],
147 }),154 }),
148 );155 );
149 expect(methods).toEqual([156 expect(methods).toEqual([
@@ -163,10 +170,20 @@ describe("describeMcpAuthMethods", () => { · 13 unmodified lines
163 expect(methods).toEqual([]);170 expect(methods).toEqual([]);
164 });171 });
165 172
166 it("returns [] for a malformed / foreign config blob", () => {173 it("returns [] for a malformed / foreign / pre-migration config blob", () => {
167 expect(describeMcpAuthMethods(recordWith({ not: "an mcp config" }))).toEqual([]);174 expect(describeMcpAuthMethods(recordWith({ not: "an mcp config" }))).toEqual([]);
168 expect(describeMcpAuthMethods(recordWith(null))).toEqual([]);175 expect(describeMcpAuthMethods(recordWith(null))).toEqual([]);
169 expect(describeMcpAuthMethods(recordWith("garbage"))).toEqual([]);176 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([]);
170 });187 });
171 188
172 it("projects remote endpoint as display metadata", () => {189 it("projects remote endpoint as display metadata", () => {
packages/plugins/mcp/src/sdk/index.ts+8−1
@@ -12,7 +12,9 @@ export { · 11 unmodified lines
12} from "./plugin";12} from "./plugin";
13 13
14export {14export {
15 McpAuthTemplate,15 McpAuthMethod,
16 McpAuthMethodInput,
17 McpAuthShorthand,
16 McpIntegrationConfig,18 McpIntegrationConfig,
17 McpRemoteIntegrationConfig,19 McpRemoteIntegrationConfig,
18 McpStdioIntegrationConfig,20 McpStdioIntegrationConfig,
@@ -23,6 +25,11 @@ export { · 4 unmodified lines
23 parseMcpIntegrationConfig,25 parseMcpIntegrationConfig,
24} from "./types";26} from "./types";
25 27
28export { migrateMcpAuthConfig } from "./migrate-config";
29
30// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
31export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
32
26export {33export {
27 McpConnectionError,34 McpConnectionError,
28 McpToolDiscoveryError,35 McpToolDiscoveryError,
packages/plugins/mcp/src/sdk/migrate-config.test.ts+165−0
@@ -0,0 +1,165 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { migrateMcpAuthConfig } from "./migrate-config";
4import { 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
13const REMOTE = { transport: "remote", endpoint: "https://x.example/mcp" } as const;
14
15describe("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
22import { Option, Schema } from "effect";
23import {
24 apiKeyMethodFromLegacyTemplate,
25 decodeLegacyApiKeyTemplate,
26} from "@executor-js/sdk/http-auth";
27
28import { parseMcpIntegrationConfig, type McpAuthMethod } from "./types";
29
30const 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]);
44const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth);
45
46const LegacyHeaderMethod = Schema.Struct({
47 slug: Schema.String,
48 kind: Schema.Literal("header"),
49 headerName: Schema.String,
50 prefix: Schema.optional(Schema.String),
51});
52const LegacyQueryMethod = Schema.Struct({
53 slug: Schema.String,
54 kind: Schema.Literal("query"),
55 paramName: Schema.String,
56 prefix: Schema.optional(Schema.String),
57});
58const LegacyOAuthTemplate = Schema.Struct({
59 slug: Schema.String,
60 type: Schema.Literal("oauth"),
61});
62const decodeLegacyHeaderMethod = Schema.decodeUnknownOption(LegacyHeaderMethod);
63const decodeLegacyQueryMethod = Schema.decodeUnknownOption(LegacyQueryMethod);
64const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate);
65
66const 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. */
79const 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
122const slugFromSingleAuth = (auth: typeof LegacySingleAuth.Type): string => auth.kind;
123
124const 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`. */
159export 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
12import { describe, expect, it } from "@effect/vitest";
13import { Effect } from "effect";
14
15import {
16 AuthTemplateSlug,
17 ConnectionName,
18 IntegrationSlug,
19 ToolAddress,
20 createExecutor,
21} from "@executor-js/sdk";
22import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
23
24import { mcpPlugin } from "./plugin";
25import { variable } from "@executor-js/sdk/http-auth";
26import { makeEchoMcpServer, serveMcpServer } from "../testing";
27
28const 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
38describe("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
315 });315 });
316 316
317 const merged = yield* executor.mcp.configureAuth("oauth_mcp", {317 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 ],
319 });321 });
320 322
321 expect(merged.map((method) => method.kind)).toEqual(["oauth2", "header"]);323 expect(merged.map((method) => method.kind)).toEqual(["oauth2", "apikey"]);
322 expect(merged[0]?.slug).toBe("oauth2");324 expect(merged[0]?.slug).toBe("oauth2");
323 expect(merged[1]?.slug).toMatch(/^custom_/);325 expect(merged[1]?.slug).toMatch(/^custom_/);
324 326
@@ -341,7 +343,10 @@ describe("mcpPlugin", () => { · 16 unmodified lines
341 const merged = yield* executor.mcp.configureAuth("open_mcp", {343 const merged = yield* executor.mcp.configureAuth("open_mcp", {
342 authenticationTemplate: [344 authenticationTemplate: [
343 { kind: "oauth2" },345 { kind: "oauth2" },
344 { kind: "header", headerName: "Authorization", prefix: "Bearer " },346 {
347 type: "apiKey",
348 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
349 },
345 ],350 ],
346 mode: "replace",351 mode: "replace",
347 });352 });
packages/plugins/mcp/src/sdk/plugin.ts+58−34
@@ -28,6 +28,14 @@ import { · 27 unmodified lines
28 ToolName,28 ToolName,
29} from "@executor-js/sdk";29} from "@executor-js/sdk";
30 30
31import {
32 TOKEN_VARIABLE,
33 describeApiKeyAuthMethod,
34 describeNoneAuthMethod,
35 renderAuthPlacements,
36 requiredPlacementVariables,
37} from "@executor-js/sdk/http-auth";
38
31import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";39import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
32import { discoverTools } from "./discover";40import { discoverTools } from "./discover";
33import { McpConnectionError, McpToolDiscoveryError } from "./errors";41import { McpConnectionError, McpToolDiscoveryError } from "./errors";
@@ -37,11 +45,12 @@ import { mcpPresets } from "./presets"; · 3 unmodified lines
37import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";45import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
38import {46import {
39 McpAuthMethodInput,47 McpAuthMethodInput,
40 McpAuthTemplate,48 McpAuthShorthand,
41 McpRemoteTransport,49 McpRemoteTransport,
42 type McpAuthMethod,50 type McpAuthMethod,
43 type McpToolAnnotations,51 type McpToolAnnotations,
44 mcpAuthMethodFromLegacy,52 expandMcpAuthMethodInputs,
53 mcpAuthMethodFromShorthand,
45 normalizeMcpAuthMethods,54 normalizeMcpAuthMethods,
46 parseMcpIntegrationConfig,55 parseMcpIntegrationConfig,
47 type McpIntegrationConfig as McpIntegrationConfigType,56 type McpIntegrationConfig as McpIntegrationConfigType,
@@ -145,7 +154,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 97 unmodified lines
145 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),154 authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)),
146 /** Single-method shorthand (legacy callers). Ignored when155 /** Single-method shorthand (legacy callers). Ignored when
147 * `authenticationTemplate` is present. Defaults to none. */156 * `authenticationTemplate` is present. Defaults to none. */
148 auth: Schema.optional(McpAuthTemplate),157 auth: Schema.optional(McpAuthShorthand),
149});158});
150 159
151const McpStdioServerInputSchema = Schema.Struct({160const McpStdioServerInputSchema = Schema.Struct({
@@ -266,7 +275,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 114 unmodified lines
266 headers: input.headers,275 headers: input.headers,
267 authenticationTemplate: input.authenticationTemplate276 authenticationTemplate: input.authenticationTemplate
268 ? normalizeMcpAuthMethods(input.authenticationTemplate)277 ? normalizeMcpAuthMethods(input.authenticationTemplate)
269 : [mcpAuthMethodFromLegacy(input.auth ?? { kind: "none" })],278 : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],
270 };279 };
271};280};
272 281
@@ -429,7 +438,7 @@ const selectAuthMethod = ( · 156 unmodified lines
429 438
430const buildConnectorInput = (439const buildConnectorInput = (
431 config: McpIntegrationConfigType,440 config: McpIntegrationConfigType,
432 value: string | null,441 values: Record<string, string | null>,
433 templateSlug: string | null,442 templateSlug: string | null,
434 allowStdio: boolean,443 allowStdio: boolean,
435): Effect.Effect<ConnectorInput, McpConnectionError> => {444): Effect.Effect<ConnectorInput, McpConnectionError> => {
@@ -452,24 +461,27 @@ const buildConnectorInput = ( · 16 unmodified lines
452 } satisfies McpStdioIntegrationConfig);461 } satisfies McpStdioIntegrationConfig);
453 }462 }
454 463
464 // Credential placements render OVER the integration's static headers /
465 // query params — a same-named static entry is overwritten.
455 const headers: Record<string, string> = { ...(config.headers ?? {}) };466 const headers: Record<string, string> = { ...(config.headers ?? {}) };
467 const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };
456 let authProvider: OAuthClientProvider | undefined;468 let authProvider: OAuthClientProvider | undefined;
457 469
458 const auth = selectAuthMethod(config, templateSlug);470 const auth = selectAuthMethod(config, templateSlug);
459 if (auth?.kind === "header" && value !== null) {471 if (auth?.kind === "apikey") {
460 headers[auth.headerName] = auth.prefix ? `${auth.prefix}${value}` : value;472 const rendered = renderAuthPlacements(auth.placements, values);
461 } else if (auth?.kind === "oauth2" && value !== null) {473 Object.assign(headers, rendered.headers);
462 authProvider = makeOAuthProvider(value);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);
463 }478 }
464 479
465 return Effect.succeed({480 return Effect.succeed({
466 transport: "remote" as const,481 transport: "remote" as const,
467 endpoint: config.endpoint,482 endpoint: config.endpoint,
468 remoteTransport: config.remoteTransport ?? "auto",483 remoteTransport: config.remoteTransport ?? "auto",
469 queryParams:484 queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
470 config.queryParams && Object.keys(config.queryParams).length > 0
471 ? config.queryParams
472 : undefined,
473 headers: Object.keys(headers).length > 0 ? headers : undefined,485 headers: Object.keys(headers).length > 0 ? headers : undefined,
474 authProvider,486 authProvider,
475 });487 });
@@ -483,7 +495,7 @@ const buildConnectorInput = ( · 7 unmodified lines
483//495//
484// none → a no-auth method carrying no credential inputs496// none → a no-auth method carrying no credential inputs
485// stdio → [] (no remote connection to configure)497// stdio → [] (no remote connection to configure)
486// header → an apikey method carrying the header placement498// apikey → carried placements (headers / query params) verbatim
487// oauth2 → an oauth method carrying the MCP endpoint to probe499// oauth2 → an oauth method carrying the MCP endpoint to probe
488// (`discoveryUrl`); endpoints are discovered live at500// (`discoveryUrl`); endpoints are discovered live at
489// connect time, so they are NOT pre-resolved here. We501// connect time, so they are NOT pre-resolved here. We
@@ -499,15 +511,7 @@ export const describeMcpAuthMethods = ( · 9 unmodified lines
499 if (!config || config.transport === "stdio") return [];511 if (!config || config.transport === "stdio") return [];
500 512
501 return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {513 return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {
502 if (method.kind === "header") {514 if (method.kind === "apikey") return describeApiKeyAuthMethod(method);
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 }
511 if (method.kind === "oauth2") {515 if (method.kind === "oauth2") {
512 return {516 return {
513 id: method.slug,517 id: method.slug,
@@ -517,12 +521,7 @@ export const describeMcpAuthMethods = ( · 3 unmodified lines
517 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },521 oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true },
518 };522 };
519 }523 }
520 return {524 return describeNoneAuthMethod(method.slug);
521 id: method.slug,
522 label: "No authentication",
523 kind: "none",
524 template: method.slug,
525 };
526 });525 });
527};526};
528 527
@@ -837,7 +836,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 308 unmodified lines
837 ? normalizeMcpAuthMethods(input.authenticationTemplate)836 ? normalizeMcpAuthMethods(input.authenticationTemplate)
838 : mergeAuthTemplates(837 : mergeAuthTemplates(
839 current.authenticationTemplate,838 current.authenticationTemplate,
840 input.authenticationTemplate as readonly McpAuthMethod[],839 expandMcpAuthMethodInputs(
840 input.authenticationTemplate,
841 ) as readonly McpAuthMethod[],
841 );842 );
842 843
843 yield* ctx.core.integrations.update(slugFrom(slug), {844 yield* ctx.core.integrations.update(slugFrom(slug), {
@@ -869,16 +870,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 25 unmodified lines
869 // Discovery failures (auth not ready, server down) yield an empty tool set870 // Discovery failures (auth not ready, server down) yield an empty tool set
870 // rather than failing — the connection still lands and can be refreshed.871 // rather than failing — the connection still lands and can be refreshed.
871 // -----------------------------------------------------------------------872 // -----------------------------------------------------------------------
872 resolveTools: ({ config, connection, template, getValue }) =>873 resolveTools: ({ config, connection, template, getValues }) =>
873 Effect.gen(function* () {874 Effect.gen(function* () {
874 const parsed = parseMcpIntegrationConfig(config);875 const parsed = parseMcpIntegrationConfig(config);
875 if (!parsed) return { tools: [] as readonly ToolDef[] };876 if (!parsed) return { tools: [] as readonly ToolDef[] };
876 877
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 );
878 883
879 const built = yield* buildConnectorInput(884 const built = yield* buildConnectorInput(
880 parsed,885 parsed,
881 value,886 values,
882 template === null ? null : String(template),887 template === null ? null : String(template),
883 allowStdio,888 allowStdio,
884 ).pipe(889 ).pipe(
@@ -925,9 +930,28 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines
925 const transport: string =930 const transport: string =
926 parsed.transport === "stdio" ? "stdio" : (parsed.remoteTransport ?? "auto");931 parsed.transport === "stdio" ? "stdio" : (parsed.remoteTransport ?? "auto");
927 932
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
928 const connector: McpConnector = yield* buildConnectorInput(952 const connector: McpConnector = yield* buildConnectorInput(
929 parsed,953 parsed,
930 credential.value,954 credential.values,
931 String(credential.template),955 String(credential.template),
932 allowStdio,956 allowStdio,
933 ).pipe(Effect.map((ci) => createMcpConnector(ci)));957 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
packages/plugins/mcp/src/sdk/types.ts+110−83
@@ -1,16 +1,23 @@
1import { Effect, Option, Schema } from "effect";1import { Effect, Option, Schema } from "effect";
2import {
3 ApiKeyAuthMethod,
4 ApiKeyAuthTemplate,
5 NoneAuthMethod,
6 apiKeyMethodFromAuthTemplate,
7 isApiKeyAuthTemplate,
8 normalizeAuthMethodSlugs,
9} from "@executor-js/sdk/http-auth";
2 10
3// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
4// MCP plugin v2 data model.12// MCP plugin v2 data model.
5//13//
6// An MCP integration is one server. Its `config` blob (opaque to core, stored14// 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 an15// on the integration row) carries everything needed to dial the server plus
8// `auth` *template* describing how a connection's resolved value is applied to16// the declared auth methods describing how a connection's resolved credential
9// the request. A connection IS the credential: at execute time core resolves17// values are applied to the request. A connection IS the credential: at
10// the connection's value through its provider (refreshing OAuth tokens), and18// execute time core resolves the connection's values through its provider
11// the plugin renders it onto the request per the template (D11). The same path19// (refreshing OAuth tokens), and the plugin renders them onto the request per
12// covers an API key bearer and an OAuth access token — both resolve to a value20// the method the connection binds (D11).
13// and render through their template.
14// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
15 22
16// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
@@ -25,35 +32,35 @@ export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", · 8 unmodified lines
25export type McpTransport = typeof McpTransport.Type;32export type McpTransport = typeof McpTransport.Type;
26 33
27// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
28// Auth methods — the ways a connection's resolved value can be applied to the35// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`)
29// request. An integration declares zero or more methods, each with a stable36// plus MCP's own oauth variant. An integration declares zero or more methods,
30// `slug` a connection binds against (`connection.template`), mirroring the37// each with a stable `slug` a connection binds against (`connection.template`),
31// OpenAPI/GraphQL `authenticationTemplate` arrays.38// mirroring the OpenAPI/GraphQL `authenticationTemplate` arrays.
32//39//
33// none — no credential (open server)40// none — no credential (open server)
34// header — render the value into a request header (e.g. `Authorization:41// apikey — render the connection's values through the method's header/query
35// Bearer <value>`); `prefix` is prepended to the value42// 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)
36// oauth2 — the value is an OAuth access token, applied as a Bearer header46// oauth2 — the value is an OAuth access token, applied as a Bearer header
37// via the MCP SDK's OAuthClientProvider47// via the MCP SDK's OAuthClientProvider. MCP oauth carries no
48// stored endpoints: metadata is discovered live at connect time.
38// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
39 50
40export const McpAuthMethod = Schema.Union([51export const McpOAuthMethod = Schema.Struct({
41 Schema.Struct({ slug: Schema.String, kind: Schema.Literal("none") }),52 slug: Schema.String,
42 Schema.Struct({53 kind: Schema.Literal("oauth2"),
43 slug: Schema.String,54});
44 kind: Schema.Literal("header"),55export type McpOAuthMethod = typeof McpOAuthMethod.Type;
45 headerName: Schema.String,56
46 prefix: Schema.optional(Schema.String),57export const McpAuthMethod = Schema.Union([NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod]);
47 }),
48 Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }),
49]);
50export type McpAuthMethod = typeof McpAuthMethod.Type;58export type McpAuthMethod = typeof McpAuthMethod.Type;
51 59
52/** Legacy single-method template (pre-array configs and the `auth` input60/** Single-method `auth` shorthand on `addServer` — agent convenience for the
53 * shorthand on `addServer`): the method without a slug. Lifted into a61 * common cases. Normalized into `authenticationTemplate` at the boundary;
54 * one-element `authenticationTemplate` whose slug is the method kind, which62 * never stored. */
55 * matches the template slugs existing connections were created against. */63export const McpAuthShorthand = Schema.Union([
56export const McpAuthTemplate = Schema.Union([
57 Schema.Struct({ kind: Schema.Literal("none") }),64 Schema.Struct({ kind: Schema.Literal("none") }),
58 Schema.Struct({65 Schema.Struct({
59 kind: Schema.Literal("header"),66 kind: Schema.Literal("header"),
@@ -62,41 +69,84 @@ export const McpAuthTemplate = Schema.Union([ · 2 unmodified lines
62 }),69 }),
63 Schema.Struct({ kind: Schema.Literal("oauth2") }),70 Schema.Struct({ kind: Schema.Literal("oauth2") }),
64]);71]);
65export type McpAuthTemplate = typeof McpAuthTemplate.Type;72export type McpAuthShorthand = typeof McpAuthShorthand.Type;
66 73
67export const mcpAuthMethodFromLegacy = (auth: McpAuthTemplate): McpAuthMethod => ({74/** Expand the `auth` shorthand into a declared method. Slugs match what the
68 ...auth,75 * shorthand has always produced (`none` / `header` / `oauth2`) so existing
69 slug: auth.kind,76 * connections bound to them keep matching. */
70});77export 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};
71 93
72/** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug;94/** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug;
73 * `normalizeMcpAuthMethods` backfills it from the kind. */95 * `normalizeMcpAuthMethods` backfills it. */
74export const McpAuthMethodInput = Schema.Union([96export const McpAuthMethodInput = Schema.Union([
75 Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }),97 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 }),
82 Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }),98 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,
83]);105]);
84export type McpAuthMethodInput = typeof McpAuthMethodInput.Type;106export type McpAuthMethodInput = typeof McpAuthMethodInput.Type;
85 107
86/** Assign each method a stable slug: a caller-provided one wins, otherwise the108/** The expansion target: input arms with the dialect resolved to canonical
87 * kind (`oauth2`, `header`, `none`), suffixed `_2`, `_3`, … on collision. */109 * placements (slug still optional — backfill is a separate pass). */
110export 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. */
118const 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). */
130export 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. */
88export const normalizeMcpAuthMethods = (143export const normalizeMcpAuthMethods = (
89 methods: readonly McpAuthMethodInput[],144 methods: readonly McpAuthMethodInput[],
90): readonly McpAuthMethod[] => {145): readonly McpAuthMethod[] =>
91 const taken = new Set<string>();146 normalizeAuthMethodSlugs(
92 return methods.map((method: McpAuthMethodInput): McpAuthMethod => {147 expandMcpAuthMethodInputs(methods),
93 const requested = method.slug?.trim() || method.kind;148 defaultMcpAuthSlug,
94 let slug = requested;149 ) as readonly McpAuthMethod[];
95 for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`;
96 taken.add(slug);
97 return { ...method, slug };
98 });
99};
100 150
101// ---------------------------------------------------------------------------151// ---------------------------------------------------------------------------
102// Integration config — the opaque blob stored on the integration row. A152// Integration config — the opaque blob stored on the integration row. A
@@ -118,7 +168,7 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 15 unmodified lines
118 queryParams: Schema.optional(StringMap),168 queryParams: Schema.optional(StringMap),
119 /** Static headers sent on every request (non-credential) */169 /** Static headers sent on every request (non-credential) */
120 headers: Schema.optional(StringMap),170 headers: Schema.optional(StringMap),
121 /** Declared auth methods — how a connection's value is rendered onto171 /** Declared auth methods — how a connection's values are rendered onto
122 * requests. A connection's `template` picks one by slug. */172 * requests. A connection's `template` picks one by slug. */
123 authenticationTemplate: Schema.Array(McpAuthMethod),173 authenticationTemplate: Schema.Array(McpAuthMethod),
124});174});
@@ -144,36 +194,13 @@ export const McpIntegrationConfig = Schema.Union([ · 19 unmodified lines
144export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;194export type McpIntegrationConfig = typeof McpIntegrationConfig.Type;
145 195
146const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);196const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig);
147const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate);
148 197
149/** Parse an opaque integration `config` blob into a typed MCP config, or null198/** 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:199 * if it isn't this plugin's (canonical) shape. Pre-canonical stored shapes
151 * a singular `auth` becomes a one-element `authenticationTemplate` (slug =200 * are rewritten by the one-off config migration (`migrate-config.ts`), not
152 * kind, matching the template slugs existing connections carry), and a remote201 * decoded here — runtime code knows only the canonical model. */
153 * config with neither field is an open server (`[{ slug: "none", … }]`). */202export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null =>
154export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => {203 Option.getOrNull(decodeIntegrationConfig(config));
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};
177 204
178// ---------------------------------------------------------------------------205// ---------------------------------------------------------------------------
179// Tool annotations — upstream MCP ToolAnnotations we honour (destructiveHint206// Tool annotations — upstream MCP ToolAnnotations we honour (destructiveHint
packages/plugins/openapi/src/api/group.ts+15−24
@@ -1,5 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
3import {4import {
4 InternalError,5 InternalError,
5 IntegrationAlreadyExistsError,6 IntegrationAlreadyExistsError,
@@ -46,29 +47,19 @@ const OpenApiSpecInputPayload = Schema.Union([ · 40 unmodified lines
46 }),47 }),
47]);48]);
48 49
49const AuthenticationVariablePayload = Schema.Struct({50const OAuthTemplatePayload = Schema.Struct({
50 type: Schema.Literal("variable"),51 slug: Schema.String,
51 name: Schema.String,52 kind: Schema.Literal("oauth2"),
53 authorizationUrl: Schema.String,
54 tokenUrl: Schema.String,
55 scopes: Schema.Array(Schema.String),
52});56});
53const AuthenticationTemplateValuePayload = Schema.Union([57
54 Schema.String,58/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */
55 Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])),59const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
56]);60
57const AuthenticationPayload = Schema.Union([61/** Auth in RESPONSES: the canonical stored shapes (placements). */
58 Schema.Struct({62const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
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]);
72 63
73const AddSpecPayload = Schema.Struct({64const AddSpecPayload = Schema.Struct({
74 spec: OpenApiSpecInputPayload,65 spec: OpenApiSpecInputPayload,
@@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines
119 baseUrl: Schema.optional(Schema.String),110 baseUrl: Schema.optional(Schema.String),
120 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),111 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
121 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),112 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
122 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),113 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
123});114});
124 115
125// The configure result — the merged `authenticationTemplate` after the new116// The configure result — the merged `authenticationTemplate` after the new
126// custom methods were appended/replaced.117// custom methods were appended/replaced.
127const ConfigureResponse = Schema.Struct({118const ConfigureResponse = Schema.Struct({
128 authenticationTemplate: Schema.Array(AuthenticationPayload),119 authenticationTemplate: Schema.Array(AuthenticationResponse),
129});120});
130 121
131// ---------------------------------------------------------------------------122// ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+2−14
@@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup, capture } from "@executor-js/api";4import { addGroup, capture } from "@executor-js/api";
5import { AuthTemplateSlug } from "@executor-js/sdk/shared";
6import type { Authentication } from "../sdk";
7import type { OpenApiPluginExtension } from "../sdk/plugin";5import type { OpenApiPluginExtension } from "../sdk/plugin";
8import { OpenApiGroup } from "./group";6import { OpenApiGroup } from "./group";
9 7
@@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines
60 baseUrl: payload.baseUrl,58 baseUrl: payload.baseUrl,
61 headers: payload.headers ? { ...payload.headers } : undefined,59 headers: payload.headers ? { ...payload.headers } : undefined,
62 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,60 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
63 authenticationTemplate: payload.authenticationTemplate?.map(61 authenticationTemplate: payload.authenticationTemplate,
64 (entry): Authentication => ({
65 ...entry,
66 slug: AuthTemplateSlug.make(entry.slug),
67 }),
68 ),
69 });62 });
70 }),63 }),
71 ),64 ),
@@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
115 Effect.gen(function* () {108 Effect.gen(function* () {
116 const ext = yield* OpenApiExtensionService;109 const ext = yield* OpenApiExtensionService;
117 const authenticationTemplate = yield* ext.configure(params.slug, {110 const authenticationTemplate = yield* ext.configure(params.slug, {
118 authenticationTemplate: payload.authenticationTemplate.map(111 authenticationTemplate: payload.authenticationTemplate,
119 (entry): Authentication => ({
120 ...entry,
121 slug: AuthTemplateSlug.make(entry.slug),
122 }),
123 ),
124 mode: payload.mode ?? "merge",112 mode: payload.mode ?? "merge",
125 });113 });
126 return { authenticationTemplate: [...authenticationTemplate] };114 return { authenticationTemplate: [...authenticationTemplate] };
packages/plugins/openapi/src/promise.ts+6−9
@@ -7,12 +7,9 @@ export type { · 6 unmodified lines
7 OpenApiPreviewInput,7 OpenApiPreviewInput,
8} from "./sdk/plugin";8} from "./sdk/plugin";
9 9
10// Auth-template authoring helpers. `variable("token")` marks where a10// Auth-template authoring helpers. Author apikey methods as canonical
11// connection's resolved credential renders into an `apiKey` template.11// placements, or request-shaped: `headers: { Authorization: ["Bearer ",
12export { variable, TOKEN_VARIABLE } from "./sdk/types";12// variable("token")] }` — both normalize to the same stored model.
13export type {13export { TOKEN_VARIABLE } from "./sdk/types";
14 Authentication,14export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types";
15 APIKeyAuthentication,15export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
16 AuthenticationVariable,
17 AuthenticationTemplateValue,
18} from "./sdk/types";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−20
@@ -34,7 +34,11 @@ import { · 33 unmodified lines
34 useSlugAlreadyExists,34 useSlugAlreadyExists,
35} from "@executor-js/react/lib/integration-add";35} from "@executor-js/react/lib/integration-add";
36 36
37import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";37import {
38 authenticationFromEditorValue,
39 editorValueFromAuthentication,
40 openApiWireAuthInput,
41} from "./auth-method-config";
38import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";42import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
39import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";43import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
40import { GoogleProductPicker } from "./GoogleProductPicker";44import { GoogleProductPicker } from "./GoogleProductPicker";
@@ -45,13 +49,7 @@ import { · 4 unmodified lines
45 type GoogleOpenApiPreset,49 type GoogleOpenApiPreset,
46} from "../sdk/google-presets";50} from "../sdk/google-presets";
47import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";51import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
48import {52import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
49 type APIKeyAuthentication,
50 type Authentication,
51 type ServerInfo,
52 TOKEN_VARIABLE,
53 variable,
54} from "../sdk/types";
55import { expandServerUrlOptions } from "../sdk/openapi-utils";53import { expandServerUrlOptions } from "../sdk/openapi-utils";
56 54
57const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";55const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
@@ -172,14 +170,16 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi · 114 unmodified lines
172const apiKeyTemplateFromHeaderPreset = (170const apiKeyTemplateFromHeaderPreset = (
173 preset: HeaderPreset,171 preset: HeaderPreset,
174 slug: AuthTemplateSlug,172 slug: AuthTemplateSlug,
175): APIKeyAuthentication => {173): APIKeyAuthentication => ({
176 const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {};174 slug,
177 for (const headerName of preset.secretHeaders) {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) => {
178 const prefix = headerPrefix(preset, headerName);179 const prefix = headerPrefix(preset, headerName);
179 headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)];180 return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
180 }181 }),
181 return { slug, type: "apiKey", headers };182});
182};
183 183
184const oauthTemplateFromPreset = (184const oauthTemplateFromPreset = (
185 preset: OAuth2Preset,185 preset: OAuth2Preset,
@@ -188,7 +188,7 @@ const oauthTemplateFromPreset = ( · 2 unmodified lines
188 scopes: readonly string[],188 scopes: readonly string[],
189): OAuthAuthentication => ({189): OAuthAuthentication => ({
190 slug,190 slug,
191 type: "oauth",191 kind: "oauth2",
192 authorizationUrl: resolveOAuthUrl(192 authorizationUrl: resolveOAuthUrl(
193 Option.getOrElse(preset.authorizationUrl, () => ""),193 Option.getOrElse(preset.authorizationUrl, () => ""),
194 baseUrl,194 baseUrl,
@@ -454,10 +454,8 @@ export default function AddOpenApiSource(props: { · 259 unmodified lines
454 baseUrl: resolvedBaseUrl,454 baseUrl: resolvedBaseUrl,
455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
456 ? {456 ? {
457 authenticationTemplate: editedAuthenticationTemplate.map((entry) => ({457 // Serialize to the wire input dialect (apikey → request-shaped).
458 ...entry,458 authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),
459 slug: String(entry.slug),
460 })),
461 }459 }
462 : {}),460 : {}),
463 },461 },
packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx+6−2
@@ -18,7 +18,11 @@ import { · 17 unmodified lines
18} from "@executor-js/react/lib/custom-auth-methods";18} from "@executor-js/react/lib/custom-auth-methods";
19 19
20import { openApiConfigAtom, openapiConfigure } from "./atoms";20import { openApiConfigAtom, openapiConfigure } from "./atoms";
21import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";21import {
22 authMethodsFromConfig,
23 templateFromPlacements,
24 openApiWireAuthInput,
25} from "./auth-method-config";
22import { googleAudienceWarningsForUrls } from "../sdk/google-presets";26import { googleAudienceWarningsForUrls } from "../sdk/google-presets";
23import type { Authentication } from "../sdk/types";27import type { Authentication } from "../sdk/types";
24 28
@@ -82,7 +86,7 @@ export default function OpenApiAccountsPanel(props: { · 57 unmodified lines
82 const exit = await doConfigure({86 const exit = await doConfigure({
83 params: { slug },87 params: { slug },
84 payload: {88 payload: {
85 authenticationTemplate: input.authenticationTemplate,89 authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
86 ...(input.mode ? { mode: input.mode } : {}),90 ...(input.mode ? { mode: input.mode } : {}),
87 },91 },
88 reactivityKeys: integrationWriteKeys,92 reactivityKeys: integrationWriteKeys,
packages/plugins/openapi/src/react/auth-method-config.test.ts+90−83
@@ -1,111 +1,118 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { AuthTemplateSlug } from "@executor-js/sdk/shared";2import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
4 3
5import {4import {
5 authMethodsFromConfig,
6 authenticationFromEditorValue,6 authenticationFromEditorValue,
7 editorValueFromAuthentication,7 editorValueFromAuthentication,
8 templateFromPlacements,8 templateFromPlacements,
9} from "./auth-method-config";9} from "./auth-method-config";
10import { TOKEN_VARIABLE, variable, type Authentication } from "../sdk/types";10import type { Authentication } from "../sdk/types";
11 11
12describe("editorValueFromAuthentication", () => {12describe("authMethodsFromConfig", () => {
13 it("maps a bearer apiKey header template → an apikey editor value with prefix", () => {13 it("projects oauth templates with their stored endpoints + scopes", () => {
14 const template: Authentication = {14 const methods = authMethodsFromConfig([
15 slug: AuthTemplateSlug.make("apikey-0"),15 {
16 type: "apiKey",16 slug: AuthTemplateSlug.make("oauth"),
17 headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },17 kind: "oauth2",
18 };18 authorizationUrl: "https://x.example/auth",
19 expect(editorValueFromAuthentication(template)).toEqual({19 tokenUrl: "https://x.example/token",
20 kind: "apikey",20 scopes: ["read"],
21 placements: [21 },
22 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },22 ]);
23 ],23 expect(methods[0]).toMatchObject({
24 });24 id: "oauth",
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({
48 kind: "oauth",25 kind: "oauth",
49 authorizationUrl: "https://accounts.example.com/o/oauth2/auth",26 source: "spec",
50 tokenUrl: "https://oauth2.example.com/token",27 oauth: {
51 scopes: ["openid", "email"],28 authorizationUrl: "https://x.example/auth",
29 tokenUrl: "https://x.example/token",
30 scopes: ["read"],
31 },
52 });32 });
53 });33 });
54});
55 34
56describe("authenticationFromEditorValue", () => {35 it("projects apikey methods, multi-placement and multi-variable intact", () => {
57 it("returns null for a 'none' value (nothing to register)", () => {36 const methods = authMethodsFromConfig([
58 expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();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 ]);
59 });51 });
52});
60 53
61 it("builds an apiKey template from placements, stamping the slug", () => {54describe("editor round-trip", () => {
62 const value: AuthTemplateEditorValue = {55 it("apikey stored → editor → stored preserves placements and shared token input", () => {
56 const stored: Authentication = {
57 slug: "bearer",
63 kind: "apikey",58 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 ],
65 };63 };
66 expect(authenticationFromEditorValue(value, "custom_x")).toEqual(64 const editor = editorValueFromAuthentication(stored);
67 templateFromPlacements(value.placements, "custom_x"),65 const back = authenticationFromEditorValue(editor, "bearer");
68 );66 expect(back).toEqual(stored);
69 });67 });
70 68
71 it("builds an oauth template from an oauth value", () => {69 it("oauth stored → editor carries endpoints + scopes", () => {
72 const value: AuthTemplateEditorValue = {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({
73 kind: "oauth",79 kind: "oauth",
74 authorizationUrl: "https://a.example.com/auth",80 authorizationUrl: "https://x.example/auth",
75 tokenUrl: "https://a.example.com/token",81 tokenUrl: "https://x.example/token",
76 scopes: ["read"],82 scopes: ["a", "b"],
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"],
84 });83 });
85 });84 });
85
86 it("none editor value yields no method", () => {
87 expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
88 });
86});89});
87 90
88describe("round-trip (apiKey + oauth)", () => {91describe("templateFromPlacements", () => {
89 it("apiKey: editorValue ∘ authentication is identity for the placement shape", () => {92 it("multi-placement custom methods assign distinct name-derived variables", () => {
90 const template: Authentication = {93 expect(
91 slug: AuthTemplateSlug.make("apikey-0"),94 templateFromPlacements(
92 type: "apiKey",95 [
93 headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },96 { carrier: "header", name: "DD-API-KEY", prefix: "" },
94 };97 { carrier: "header", name: "DD-APPLICATION-KEY", prefix: "" },
95 const value = editorValueFromAuthentication(template);98 ],
96 const back = authenticationFromEditorValue(value, "apikey-0");99 "custom_dd",
97 expect(back).toEqual(template);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 });
98 });109 });
99 110
100 it("oauth: editorValue ∘ authentication preserves endpoints + scopes", () => {111 it("a lone placement keeps the canonical token input (stored absent)", () => {
101 const template: Authentication = {112 expect(templateFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }])).toEqual({
102 slug: AuthTemplateSlug.make("oauth-x"),113 slug: "",
103 type: "oauth",114 kind: "apikey",
104 authorizationUrl: "https://a.example.com/auth",115 placements: [{ carrier: "query", name: "api_key" }],
105 tokenUrl: "https://a.example.com/token",116 });
106 scopes: ["read", "write"],
107 };
108 const back = authenticationFromEditorValue(editorValueFromAuthentication(template), "oauth-x");
109 expect(back).toEqual(template);
110 });117 });
111});118});
packages/plugins/openapi/src/react/auth-method-config.ts+45−158
@@ -1,192 +1,79 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// OpenAPI ↔ generic auth-method converters.2// OpenAPI ↔ generic auth-method converters — a thin oauth adapter over the
3//3// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4// The generic Accounts hub speaks in plugin-agnostic `AuthMethod` /4// apikey/none paths (multi-placement, multi-variable) live in the shared
5// `Placement` values (`@executor-js/react/lib/auth-placements`). The OpenAPI5// codec; OpenAPI only contributes its oauth flavor: stored endpoints + scopes
6// plugin stores auth as the HTTP-transport-specific `APIKeyAuthentication`6// (`kind: "oauth2"`, the core `OAuthAuthentication` shape) that pre-fill the client-registration form.
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.
11// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
12 8
13import { AuthTemplateSlug } from "@executor-js/sdk/shared";9import { AuthTemplateSlug } from "@executor-js/sdk/shared";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";10import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
15import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
16
17import {12import {
18 TOKEN_VARIABLE,13 authMethodFromSharedTemplate,
19 variable,14 editorPlacementsFromWire,
20 type APIKeyAuthentication,15 editorValueFromSharedMethod,
21 type Authentication,16 wireAuthInputFromShared,
22 type AuthenticationTemplateValue,17 wirePlacementsFromEditor,
23} from "../sdk/types";18} from "@executor-js/react/lib/shared-auth-method-codec";
24 19
25// ---------------------------------------------------------------------------20import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types";
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// ---------------------------------------------------------------------------
32 21
33const isVariablePart = (part: string | { readonly type: "variable"; readonly name: string }) =>22/** Serialize a canonical method into the wire input union (apikey → the
34 typeof part !== "string" && part.type === "variable";23 * request-shaped dialect; oauth passes through). */
24export const openApiWireAuthInput = (method: Authentication): AuthenticationInput =>
25 method.kind === "oauth2" ? method : (wireAuthInputFromShared(method) as AuthenticationInput);
35 26
36/** Extract the literal prefix preceding the credential variable, plus the27export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] =>
37 * variable name that placement renders from (`token` for single-input methods,28 editorPlacementsFromWire(template.placements);
38 * a distinct name per input for multi-input ones). */
39const 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};
52 29
53export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => {30const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): AuthMethod => {
54 const placements: Placement[] = [];31 const slug = String(template.slug);
55 for (const [name, value] of Object.entries(template.headers ?? {})) {32 return {
56 const { prefix, variable } = parseTemplateValue(value);33 id: slug,
57 placements.push({ carrier: "header", name, prefix, variable });34 label: "OAuth2",
58 }35 kind: "oauth",
59 for (const [name, value] of Object.entries(template.queryParams ?? {})) {36 source: slug.startsWith("custom_") ? "custom" : "spec",
60 const { prefix, variable } = parseTemplateValue(value);37 template: AuthTemplateSlug.make(slug),
61 placements.push({ carrier: "query", name, prefix, variable });38 placements: [],
62 }39 // Carry the integration's declared endpoints/scopes so the
63 return placements;40 // client-registration form pre-fills them.
64};41 oauth: {
65 42 authorizationUrl: template.authorizationUrl,
66// ---------------------------------------------------------------------------43 tokenUrl: template.tokenUrl,
67// Templates → generic methods.44 scopes: template.scopes,
68// ---------------------------------------------------------------------------45 },
69 46 };
70const 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})`;
74};47};
75 48
76/** Map each stored auth template to a generic `AuthMethod`. */49/** Map each stored auth template to a generic `AuthMethod`. */
77export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {50export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {
78 return templates.map((template: Authentication): AuthMethod => {51 return templates.map((template: Authentication): AuthMethod => {
79 const slug = String(template.slug);52 if (template.kind === "oauth2") return oauthAuthMethod(template);
80 const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";53 return authMethodFromSharedTemplate(template);
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 };
107 });54 });
108}55}
109 56
110// ---------------------------------------------------------------------------57/** Build an apikey method from generic placements. When `slug` is omitted the
111// Generic placements → apiKey template (inverse).58 * backend assigns a `custom_<id>` slug. */
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`. */
120const 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. */
130const 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
149const 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. */
154export function templateFromPlacements(59export function templateFromPlacements(
155 placements: readonly Placement[],60 placements: readonly Placement[],
156 slug?: string,61 slug?: string,
157): APIKeyAuthentication {62): 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 }
170 return {63 return {
171 slug: AuthTemplateSlug.make(slug ?? ""),64 slug: slug ?? "",
172 type: "apiKey",65 kind: "apikey",
173 ...(Object.keys(headers).length > 0 ? { headers } : {}),66 placements: wirePlacementsFromEditor(placements),
174 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
175 };67 };
176}68}
177 69
178// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
179// Stored `Authentication` ⇆ generic `AuthTemplateEditorValue`.71// 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.
185// ---------------------------------------------------------------------------72// ---------------------------------------------------------------------------
186 73
187/** Convert one stored `Authentication` template into a generic editor value. */74/** Convert one stored `Authentication` template into a generic editor value. */
188export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {75export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {
189 if (template.type === "oauth") {76 if (template.kind === "oauth2") {
190 return {77 return {
191 kind: "oauth",78 kind: "oauth",
192 authorizationUrl: template.authorizationUrl ?? "",79 authorizationUrl: template.authorizationUrl ?? "",
@@ -194,7 +81,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 1 unmodified line
194 scopes: template.scopes ?? [],81 scopes: template.scopes ?? [],
195 };82 };
196 }83 }
197 return { kind: "apikey", placements: placementsFromApiKey(template) };84 return editorValueFromSharedMethod(template);
198}85}
199 86
200/** Build an `OAuthAuthentication` template from a generic oauth editor value. */87/** Build an `OAuthAuthentication` template from a generic oauth editor value. */
@@ -203,7 +90,7 @@ const oauthTemplateFromEditorValue = ( · 2 unmodified lines
203 slug?: string,90 slug?: string,
204): Authentication => ({91): Authentication => ({
205 slug: AuthTemplateSlug.make(slug ?? ""),92 slug: AuthTemplateSlug.make(slug ?? ""),
206 type: "oauth",93 kind: "oauth2",
207 authorizationUrl: value.authorizationUrl,94 authorizationUrl: value.authorizationUrl,
208 tokenUrl: value.tokenUrl,95 tokenUrl: value.tokenUrl,
209 scopes: [...value.scopes],96 scopes: [...value.scopes],
packages/plugins/openapi/src/sdk/config.ts+21−65
@@ -1,7 +1,12 @@
1import { Option, Schema } from "effect";1import { Option, Schema } from "effect";
2import {
3 ApiKeyAuthMethod,
4 TOKEN_VARIABLE,
5 renderAuthPlacements,
6 requiredPlacementVariables,
7} from "@executor-js/sdk/http-auth";
2 8
3import type { Authentication, AuthenticationTemplateValue, AuthenticationVariable } from "./types";9import type { Authentication } from "./types";
4import { TOKEN_VARIABLE } from "./types";
5 10
6// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
7// OpenAPI integration config — the opaque blob stored on the catalog12// OpenAPI integration config — the opaque blob stored on the catalog
@@ -15,35 +20,15 @@ import { TOKEN_VARIABLE } from "./types"; · 7 unmodified lines
15// - the auth templates a connection's value is rendered through.20// - the auth templates a connection's value is rendered through.
16// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
17 22
18const AuthenticationVariableSchema = Schema.Struct({
19 type: Schema.Literal("variable"),
20 name: Schema.String,
21});
22
23const AuthenticationTemplateValueSchema = Schema.Union([
24 Schema.String,
25 Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
26]);
27
28const 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
35const OAuthAuthenticationSchema = Schema.Struct({23const OAuthAuthenticationSchema = Schema.Struct({
36 slug: Schema.String,24 slug: Schema.String,
37 type: Schema.Literal("oauth"),25 kind: Schema.Literal("oauth2"),
38 authorizationUrl: Schema.String,26 authorizationUrl: Schema.String,
39 tokenUrl: Schema.String,27 tokenUrl: Schema.String,
40 scopes: Schema.Array(Schema.String),28 scopes: Schema.Array(Schema.String),
41});29});
42 30
43export const AuthenticationSchema = Schema.Union([31export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]);
44 OAuthAuthenticationSchema,
45 APIKeyAuthenticationSchema,
46]);
47 32
48export const OpenApiIntegrationConfigSchema = Schema.Struct({33export const OpenApiIntegrationConfigSchema = Schema.Struct({
49 /** Inlined OpenAPI document text (resolved + parsed source of truth). */34 /** Inlined OpenAPI document text (resolved + parsed source of truth). */
@@ -80,66 +65,37 @@ export const decodeOpenApiIntegrationConfig = (value: unknown): OpenApiIntegrati · 30 unmodified lines
80 65
81// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
82// Template rendering — "auth state derived into the auth-template format"67// Template rendering — "auth state derived into the auth-template format"
83// (D11). The resolved credential value renders into the template's68// (D11). An apiKey method renders through the shared placements renderer; an
84// `variable("token")` slots, identically for apiKey and oauth (the oauth value69// oauth template (no explicit placement) renders a bearer `authorization`
85// IS the access token). Returns the headers + query params to apply.70// header from the `token` input (the access token).
86// ---------------------------------------------------------------------------71// ---------------------------------------------------------------------------
87 72
88const isVariable = (part: string | AuthenticationVariable): part is AuthenticationVariable =>
89 typeof part !== "string";
90
91const 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
99export interface RenderedAuth {73export interface RenderedAuth {
100 readonly headers: Record<string, string>;74 readonly headers: Record<string, string>;
101 readonly queryParams: Record<string, string>;75 readonly queryParams: Record<string, string>;
102}76}
103 77
104/** Render an auth template against a connection's resolved input `values`78/** Render an auth template against a connection's resolved input `values`
105 * (`variable → value`). For an apiKey template, each `variable("<name>")` is79 * (`variable → value`). Each placement substitutes from its own entry, so a
106 * substituted from its own entry, so a method with two distinct inputs (e.g.80 * method with two distinct inputs (e.g. Datadog) fills each header from a
107 * Datadog) fills each header from a different value. For an oauth template (no81 * different value. */
108 * explicit placement), render a bearer Authorization header from `token`. */
109export const renderAuthTemplate = (82export const renderAuthTemplate = (
110 template: Authentication,83 template: Authentication,
111 values: Record<string, string | null>,84 values: Record<string, string | null>,
112): RenderedAuth => {85): RenderedAuth => {
113 if (template.type === "oauth") {86 if (template.kind === "oauth2") {
114 return {87 return {
115 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },88 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },
116 queryParams: {},89 queryParams: {},
117 };90 };
118 }91 }
119 const headers: Record<string, string> = {};92 return renderAuthPlacements(template.placements, values);
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 };
128};93};
129 94
130/** The distinct input variables a template references — the inputs a connection95/** The distinct input variables a template references — the inputs a connection
131 * must supply. An oauth template needs `token`; an apiKey template needs every96 * must supply. An oauth template needs `token`; an apiKey method needs every
132 * `variable("<name>")` across its placements. */97 * variable across its placements. */
133export const requiredTemplateVariables = (template: Authentication): readonly string[] => {98export const requiredTemplateVariables = (template: Authentication): readonly string[] => {
134 if (template.type === "oauth") return [TOKEN_VARIABLE];99 if (template.kind === "oauth2") return [TOKEN_VARIABLE];
135 const names = new Set<string>();100 return requiredPlacementVariables(template.placements);
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];
145};101};
packages/plugins/openapi/src/sdk/configure.test.ts+72−27
@@ -5,7 +5,7 @@ · 4 unmodified lines
5// integration's opaque config. These tests exercise the extension method5// integration's opaque config. These tests exercise the extension method
6// directly (the same path the HTTP `configure` handler calls):6// directly (the same path the HTTP `configure` handler calls):
7// - round-trip: add a custom apiKey method → `getConfig` shows it with the7// - 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,
9// - slug generation: a method with no slug is assigned `custom_<id>`,9// - slug generation: a method with no slug is assigned `custom_<id>`,
10// - slug uniqueness/dedupe: a method whose slug matches an existing entry10// - slug uniqueness/dedupe: a method whose slug matches an existing entry
11// replaces it rather than duplicating, and two slug-less methods in one call11// replaces it rather than duplicating, and two slug-less methods in one call
@@ -28,7 +28,8 @@ import { · 16 unmodified lines
28import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";28import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
29 29
30import { openApiPlugin } from "./plugin";30import { openApiPlugin } from "./plugin";
31import { variable, type APIKeyAuthentication, type Authentication } from "./types";31import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types";
32import { variable } from "@executor-js/sdk/http-auth";
32import {33import {
33 makeOpenApiHttpApiTestSourceConfig,34 makeOpenApiHttpApiTestSourceConfig,
34 serveOpenApiHttpApiTestServer,35 serveOpenApiHttpApiTestServer,
@@ -71,9 +72,10 @@ const specText = () => { · 36 unmodified lines
71 return spec.url;72 return spec.url;
72};73};
73 74
74// A custom apiKey method that places the connection value into `x-api-key`.75// A custom apiKey method that places the connection value into `x-api-key`,
75const customApiKey: APIKeyAuthentication = {76// authored request-shaped (the one apikey input dialect).
76 slug: AuthTemplateSlug.make("my_custom"),77const customApiKey: AuthenticationInput = {
78 slug: "my_custom",
77 type: "apiKey",79 type: "apiKey",
78 headers: { "x-api-key": [variable("token")] },80 headers: { "x-api-key": [variable("token")] },
79};81};
@@ -81,9 +83,9 @@ const customApiKey: APIKeyAuthentication = { · 1 unmodified line
81// Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that83// Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that
82// omits it, which `configure` should backfill with a generated `custom_<id>`.84// omits it, which `configure` should backfill with a generated `custom_<id>`.
83// The cast is confined to this one boundary helper.85// The cast is confined to this one boundary helper.
84const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication =>86const sluglessApiKey = (
85 // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it87 template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
86 ({ type: "apiKey", ...placement }) as unknown as Authentication;88): AuthenticationInput => ({ type: "apiKey", ...template });
87 89
88describe("OpenAPI Plugin — configure (custom auth method)", () => {90describe("OpenAPI Plugin — configure (custom auth method)", () => {
89 it.effect("adds a custom apiKey method and getConfig reflects it", () =>91 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
107 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];109 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];
108 expect(template).toHaveLength(1);110 expect(template).toHaveLength(1);
109 const entry = template[0] as APIKeyAuthentication;111 const entry = template[0] as APIKeyAuthentication;
110 expect(entry.type).toBe("apiKey");112 expect(entry.kind).toBe("apikey");
111 expect(String(entry.slug)).toBe("my_custom");113 expect(String(entry.slug)).toBe("my_custom");
112 // The header placement preserves the `variable("token")` slot verbatim.114 // The placement preserves the canonical `token` input (stored absent).
113 expect(entry.headers).toEqual({ "x-api-key": [variable("token")] });115 expect(entry.placements).toEqual([{ carrier: "header", name: "x-api-key" }]);
114 }),116 }),
115 ),117 ),
116 );118 );
@@ -120,8 +122,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
120 Effect.gen(function* () {122 Effect.gen(function* () {
121 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));123 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
122 124
123 const seedTemplate: Authentication = {125 const seedTemplate: AuthenticationInput = {
124 slug: AuthTemplateSlug.make("seed"),126 slug: "seed",
125 type: "apiKey",127 type: "apiKey",
126 headers: { authorization: ["Bearer ", variable("token")] },128 headers: { authorization: ["Bearer ", variable("token")] },
127 };129 };
@@ -151,9 +153,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 23 unmodified lines
151 slug: "cfg_genslug",153 slug: "cfg_genslug",
152 });154 });
153 155
154 const slugless = sluglessApiKey({156 const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } });
155 headers: { "x-api-key": [variable("token")] },
156 });
157 157
158 const merged = yield* executor.openapi.configure("cfg_genslug", {158 const merged = yield* executor.openapi.configure("cfg_genslug", {
159 authenticationTemplate: [slugless],159 authenticationTemplate: [slugless],
@@ -177,17 +177,13 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
177 });177 });
178 178
179 // Re-submit the same slug with a different placement → replace in place.179 // Re-submit the same slug with a different placement → replace in place.
180 const replacement: APIKeyAuthentication = {180 const replacement: AuthenticationInput = {
181 slug: AuthTemplateSlug.make("my_custom"),181 slug: "my_custom",
182 type: "apiKey",182 type: "apiKey",
183 headers: { "x-other": [variable("token")] },183 headers: { "x-other": [variable("token")] },
184 };184 };
185 const slugless = sluglessApiKey({185 const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } });
186 queryParams: { api_key: [variable("token")] },186 const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } });
187 });
188 const slugless2 = sluglessApiKey({
189 queryParams: { token: [variable("token")] },
190 });
191 187
192 const merged = yield* executor.openapi.configure("cfg_dedupe", {188 const merged = yield* executor.openapi.configure("cfg_dedupe", {
193 authenticationTemplate: [replacement, slugless, slugless2],189 authenticationTemplate: [replacement, slugless, slugless2],
@@ -205,7 +201,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 11 unmodified lines
205 const replaced = merged.find(201 const replaced = merged.find(
206 (m: Authentication) => String(m.slug) === "my_custom",202 (m: Authentication) => String(m.slug) === "my_custom",
207 ) as APIKeyAuthentication;203 ) as APIKeyAuthentication;
208 expect(replaced.headers).toEqual({ "x-other": [variable("token")] });204 expect(replaced.placements).toEqual([{ carrier: "header", name: "x-other" }]);
209 }),205 }),
210 ),206 ),
211 );207 );
@@ -215,8 +211,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
215 Effect.gen(function* () {211 Effect.gen(function* () {
216 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));212 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
217 213
218 const seedTemplate: Authentication = {214 const seedTemplate: AuthenticationInput = {
219 slug: AuthTemplateSlug.make("seed"),215 slug: "seed",
220 type: "apiKey",216 type: "apiKey",
221 headers: { authorization: ["Bearer ", variable("token")] },217 headers: { authorization: ["Bearer ", variable("token")] },
222 };218 };
@@ -273,6 +269,55 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 50 unmodified lines
273 ),269 ),
274 );270 );
275 271
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
276 it.effect("getConfig returns null for an unknown integration", () =>321 it.effect("getConfig returns null for an unknown integration", () =>
277 Effect.scoped(322 Effect.scoped(
278 Effect.gen(function* () {323 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+6−7
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7} from "@executor-js/sdk/core";7} from "@executor-js/sdk/core";
8 8
9import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";9import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";
10import { variable, type Authentication } from "./types";10import { type Authentication } from "./types";
11 11
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13// `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`13// `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`
@@ -33,8 +33,8 @@ describe("describeOpenApiAuthMethods", () => { · 19 unmodified lines
33 recordWith([33 recordWith([
34 {34 {
35 slug: AuthTemplateSlug.make("bearer"),35 slug: AuthTemplateSlug.make("bearer"),
36 type: "apiKey",36 kind: "apikey",
37 headers: { Authorization: ["Bearer ", variable("token")] },37 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
38 },38 },
39 ]),39 ]),
40 );40 );
@@ -45,9 +45,8 @@ describe("describeOpenApiAuthMethods", () => { · 4 unmodified lines
45 label: "API key (Authorization)",45 label: "API key (Authorization)",
46 kind: "apikey",46 kind: "apikey",
47 template: "bearer",47 template: "bearer",
48 placements: [48 // The canonical `token` input is stored (and projected) as absent.
49 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },49 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
50 ],
51 },50 },
52 ]);51 ]);
53 });52 });
@@ -57,7 +56,7 @@ describe("describeOpenApiAuthMethods", () => { · 3 unmodified lines
57 recordWith([56 recordWith([
58 {57 {
59 slug: AuthTemplateSlug.make("oauth"),58 slug: AuthTemplateSlug.make("oauth"),
60 type: "oauth",59 kind: "oauth2",
61 authorizationUrl: "https://auth.example/authorize",60 authorizationUrl: "https://auth.example/authorize",
62 tokenUrl: "https://auth.example/token",61 tokenUrl: "https://auth.example/token",
63 scopes: ["read", "write"],62 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
205 // collapses under `https://mail.google.com/`, so the requested consent205 // collapses under `https://mail.google.com/`, so the requested consent
206 // is clean rather than the raw per-method union.206 // is clean rather than the raw per-method union.
207 const config = yield* executor.openapi.getConfig("google");207 const config = yield* executor.openapi.getConfig("google");
208 const oauth = config?.authenticationTemplate?.find((entry) => entry.type === "oauth");208 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209 expect(oauth?.type === "oauth" ? [...oauth.scopes].sort() : undefined).toEqual(209 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210 [210 [
211 "https://mail.google.com/",211 "https://mail.google.com/",
212 "https://www.googleapis.com/auth/calendar",212 "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
243 // than v1's `oauth2` source config.243 // than v1's `oauth2` source config.
244 // removed: identityScopes assertion — that field belonged to the v1244 // removed: identityScopes assertion — that field belonged to the v1
245 // OAuth2SourceConfig slot model, which no longer exists in v2.245 // 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");
247 expect(oauthTemplate).toBeDefined();247 expect(oauthTemplate).toBeDefined();
248 }),248 }),
249);249);
@@ -440,8 +440,8 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 190 unmodified lines
440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");
442 // v2: the bundled oauth scopes are carried on the oauth auth template.442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444 expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445 "https://www.googleapis.com/auth/gmail.metadata",445 "https://www.googleapis.com/auth/gmail.metadata",
446 "https://www.googleapis.com/auth/chat.spaces.readonly",446 "https://www.googleapis.com/auth/chat.spaces.readonly",
447 ]);447 ]);
@@ -579,8 +579,8 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent · 131 unmodified lines
579 579
580 // The derived oauth auth template carries the compacted/filtered set580 // The derived oauth auth template carries the compacted/filtered set
581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");582 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583 expect(oauthTemplate?.type === "oauth" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(583 expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584 [...expectedConsentScopes].sort(),584 [...expectedConsentScopes].sort(),
585 );585 );
586 586
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
623 [623 [
624 {624 {
625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626 type: "oauth",626 kind: "oauth2",
627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629 scopes: Object.keys(scopes),629 scopes: Object.keys(scopes),
packages/plugins/openapi/src/sdk/index.ts+7−3
@@ -67,9 +67,13 @@ export { · 66 unmodified lines
67 OperationId,67 OperationId,
68 HttpMethod,68 HttpMethod,
69 ParameterLocation,69 ParameterLocation,
70 variable,70 TOKEN_VARIABLE,
71 normalizeOpenApiAuthInputs,
71 type Authentication,72 type Authentication,
73 type AuthenticationInput,
72 type APIKeyAuthentication,74 type APIKeyAuthentication,
73 type AuthenticationVariable,
74 type AuthenticationTemplateValue,
75} from "./types";75} from "./types";
76// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
77export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
78
79export { migrateOpenApiAuthConfig } from "./migrate-config";
packages/plugins/openapi/src/sdk/migrate-config.test.ts+225−0
@@ -0,0 +1,225 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { renderAuthTemplate } from "./config";
4import { migrateOpenApiAuthConfig } from "./migrate-config";
5import 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
15const BASE = { spec: "{}", baseUrl: "https://api.example.com" } as const;
16
17describe("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
160describe("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
17import { Option } from "effect";
18import {
19 apiKeyMethodFromLegacyTemplate,
20 decodeLegacyApiKeyTemplate,
21} from "@executor-js/sdk/http-auth";
22
23import { decodeOpenApiIntegrationConfig } from "./config";
24
25const 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"`. */
33const 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
41const 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. */
55export 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
36} from "@executor-js/plugin-openapi/testing";36} from "@executor-js/plugin-openapi/testing";
37 37
38import { openApiPlugin } from "./plugin";38import { openApiPlugin } from "./plugin";
39import { variable, type Authentication } from "./types";39import { type AuthenticationInput } from "./types";
40 40
41// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
42// Test API — a single endpoint that echoes the Authorization header so the42// Test API — a single endpoint that echoes the Authorization header so the
@@ -68,10 +68,10 @@ const serveVercel = () => · 25 unmodified lines
68 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });68 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });
69 69
70// Bearer template: the connection value renders into `Authorization: Bearer …`.70// Bearer template: the connection value renders into `Authorization: Bearer …`.
71const bearerTemplate: Authentication = {71const bearerTemplate: AuthenticationInput = {
72 slug: AuthTemplateSlug.make("bearer"),72 slug: AuthTemplateSlug.make("bearer"),
73 type: "apiKey",73 type: "apiKey",
74 headers: { authorization: ["Bearer ", variable("token")] },74 headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
75};75};
76 76
77const testPlugins = () =>77const testPlugins = () =>
packages/plugins/openapi/src/sdk/plugin.test.ts+5−5
@@ -32,7 +32,7 @@ import { · 31 unmodified lines
32} from "@executor-js/sdk/testing";32} from "@executor-js/sdk/testing";
33 33
34import { openApiPlugin } from "./plugin";34import { openApiPlugin } from "./plugin";
35import { variable, type Authentication } from "./types";35import { type AuthenticationInput } from "./types";
36import {36import {
37 addOpenApiTestConnection,37 addOpenApiTestConnection,
38 makeOpenApiHttpApiTestSourceConfig,38 makeOpenApiHttpApiTestSourceConfig,
@@ -154,16 +154,16 @@ const servePluginTestApi = () => · 115 unmodified lines
154 });154 });
155 155
156// An apiKey auth template that places the connection value into `x-api-key`.156// An apiKey auth template that places the connection value into `x-api-key`.
157const apiKeyTemplate: Authentication = {157const apiKeyTemplate: AuthenticationInput = {
158 slug: AuthTemplateSlug.make("apiKey"),158 slug: AuthTemplateSlug.make("apiKey"),
159 type: "apiKey",159 type: "apiKey",
160 headers: { "x-api-key": [variable("token")] },160 headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] },
161};161};
162 162
163// An oauth template — the connection value renders as a bearer token.163// An oauth template — the connection value renders as a bearer token.
164const oauthTemplate: Authentication = {164const oauthTemplate: AuthenticationInput = {
165 slug: AuthTemplateSlug.make("oauth"),165 slug: AuthTemplateSlug.make("oauth"),
166 type: "oauth",166 kind: "oauth2",
167 authorizationUrl: "https://auth.example.test/authorize",167 authorizationUrl: "https://auth.example.test/authorize",
168 tokenUrl: "https://auth.example.test/token",168 tokenUrl: "https://auth.example.test/token",
169 scopes: ["read"],169 scopes: ["read"],
packages/plugins/openapi/src/sdk/plugin.ts+29−87
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
13 mergeAuthTemplates,13 mergeAuthTemplates,
14 tool,14 tool,
15 type AuthMethodDescriptor,15 type AuthMethodDescriptor,
16 type AuthPlacementDescriptor,
17 type Integration,16 type Integration,
18 type IntegrationConfig,17 type IntegrationConfig,
19 type IntegrationRecord,18 type IntegrationRecord,
@@ -44,8 +43,9 @@ import { annotationsForOperation, invokeWithLayer } from "./invoke"; · 24 unmodified lines
44import { previewSpec, type SpecPreview } from "./preview";43import { previewSpec, type SpecPreview } from "./preview";
45import { openApiPresets } from "./presets";44import { openApiPresets } from "./presets";
46import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";45import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
47import type { Authentication, AuthenticationTemplateValue } from "./types";46import type { Authentication } from "./types";
48import { OperationBinding, TOKEN_VARIABLE } from "./types";47import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
48import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
49 49
50// ---------------------------------------------------------------------------50// ---------------------------------------------------------------------------
51// Plugin config51// Plugin config
@@ -154,8 +154,9 @@ export interface OpenApiSpecConfig { · 102 unmodified lines
154 readonly headers?: Record<string, string>;154 readonly headers?: Record<string, string>;
155 /** Static query params applied to every request. */155 /** Static query params applied to every request. */
156 readonly queryParams?: Record<string, string>;156 readonly queryParams?: Record<string, string>;
157 /** Auth methods a connection's value renders through. */157 /** Auth methods a connection's value renders through — canonical
158 readonly authenticationTemplate?: readonly Authentication[];158 * placements or the request-shaped authoring dialect. */
159 readonly authenticationTemplate?: readonly AuthenticationInput[];
159}160}
160 161
161export interface OpenApiExtensionFailure {162export interface OpenApiExtensionFailure {
@@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines
169 * already exists, replaces) the integration's existing template array. A170 * already exists, replaces) the integration's existing template array. A
170 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`171 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
171 * slug that is collision-checked against the existing template. */172 * slug that is collision-checked against the existing template. */
172 readonly authenticationTemplate: readonly Authentication[];173 readonly authenticationTemplate: readonly AuthenticationInput[];
173 readonly mode?: "merge" | "replace";174 readonly mode?: "merge" | "replace";
174}175}
175 176
@@ -295,28 +296,18 @@ const OpenApiSpecInputSchema = Schema.Union([ · 119 unmodified lines
295 }),296 }),
296]);297]);
297 298
298const AuthenticationVariableSchema = Schema.Struct({
299 type: Schema.Literal("variable"),
300 name: Schema.String,
301});
302const AuthenticationTemplateValueSchema = Schema.Union([
303 Schema.String,
304 Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
305]);
306const AuthenticationSchema = Schema.Union([299const AuthenticationSchema = Schema.Union([
307 Schema.Struct({300 Schema.Struct({
308 slug: Schema.String,301 slug: Schema.String,
309 type: Schema.Literal("apiKey"),302 kind: Schema.Literal("oauth2"),
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"),
316 authorizationUrl: Schema.String,303 authorizationUrl: Schema.String,
317 tokenUrl: Schema.String,304 tokenUrl: Schema.String,
318 scopes: Schema.Array(Schema.String),305 scopes: Schema.Array(Schema.String),
319 }),306 }),
307 // Credential methods are authored request-shaped — the ONE apikey input
308 // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
309 // variable("token")] }, queryParams: { … } }`.
310 ApiKeyAuthTemplate,
320]);311]);
321 312
322const AddSourceInputSchema = Schema.Struct({313const AddSourceInputSchema = Schema.Struct({
@@ -525,53 +516,10 @@ const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | un · 202 unmodified lines
525// Declared auth methods — project the stored `authenticationTemplate` into the516// Declared auth methods — project the stored `authenticationTemplate` into the
526// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's517// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
527// `authMethodsFromConfig` (in the React auth-method-config module) on the518// `authMethodsFromConfig` (in the React auth-method-config module) on the
528// server so the catalog field is consistent. OpenAPI also renders its own519// server so the catalog field is consistent. apikey/none projection comes from
529// accounts slot, so this projection is consistency work, not a fix; an absent /520// the shared model; the oauth method carries the stored endpoints + scopes.
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]`).
535// ---------------------------------------------------------------------------521// ---------------------------------------------------------------------------
536 522
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). */
540const 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
554const 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
569const 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
575export const describeOpenApiAuthMethods = (523export const describeOpenApiAuthMethods = (
576 record: IntegrationRecord,524 record: IntegrationRecord,
577): readonly AuthMethodDescriptor[] => {525): readonly AuthMethodDescriptor[] => {
@@ -579,13 +527,12 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
579 if (!config) return [];527 if (!config) return [];
580 return (config.authenticationTemplate ?? []).map(528 return (config.authenticationTemplate ?? []).map(
581 (template: Authentication): AuthMethodDescriptor => {529 (template: Authentication): AuthMethodDescriptor => {
582 const slug = String(template.slug);530 if (template.kind === "oauth2") {
583 if (template.type === "oauth") {
584 return {531 return {
585 id: slug,532 id: String(template.slug),
586 label: "OAuth2",533 label: "OAuth2",
587 kind: "oauth",534 kind: "oauth",
588 template: slug,535 template: String(template.slug),
589 oauth: {536 oauth: {
590 authorizationUrl: template.authorizationUrl,537 authorizationUrl: template.authorizationUrl,
591 tokenUrl: template.tokenUrl,538 tokenUrl: template.tokenUrl,
@@ -593,14 +540,7 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
593 },540 },
594 };541 };
595 }542 }
596 const placements = placementsFromAuthentication(template);543 return describeApiKeyAuthMethod(template);
597 return {
598 id: slug,
599 label: apiKeyLabel(slug, placements),
600 kind: "apikey",
601 template: slug,
602 placements,
603 };
604 },544 },
605 );545 );
606};546};
@@ -798,7 +738,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 191 unmodified lines
798 // Google Discovery converter derived from the spec (the bundle add738 // Google Discovery converter derived from the spec (the bundle add
799 // path relies on this — it has no preview to detect auth from).739 // path relies on this — it has no preview to detect auth from).
800 ...(config.authenticationTemplate740 ...(config.authenticationTemplate
801 ? { authenticationTemplate: config.authenticationTemplate }741 ? {
742 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
743 }
802 : resolved.authenticationTemplate744 : resolved.authenticationTemplate
803 ? { authenticationTemplate: resolved.authenticationTemplate }745 ? { authenticationTemplate: resolved.authenticationTemplate }
804 : {}),746 : {}),
@@ -891,13 +833,11 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 86 unmodified lines
891 const current = decodeOpenApiIntegrationConfig(record.config);833 const current = decodeOpenApiIntegrationConfig(record.config);
892 if (!current) return [] as readonly Authentication[];834 if (!current) return [] as readonly Authentication[];
893 835
836 const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
894 const merged =837 const merged =
895 input.mode === "replace"838 input.mode === "replace"
896 ? input.authenticationTemplate839 ? incoming
897 : mergeAuthTemplates(840 : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
898 current.authenticationTemplate ?? [],
899 input.authenticationTemplate,
900 );
901 841
902 const next: OpenApiIntegrationConfig = {842 const next: OpenApiIntegrationConfig = {
903 ...current,843 ...current,
@@ -959,7 +899,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 55 unmodified lines
959 headers: input.headers,899 headers: input.headers,
960 queryParams: input.queryParams,900 queryParams: input.queryParams,
961 authenticationTemplate: input.authenticationTemplate as901 authenticationTemplate: input.authenticationTemplate as
962 | readonly Authentication[]902 | readonly AuthenticationInput[]
963 | undefined,903 | undefined,
964 })904 })
965 .pipe(905 .pipe(
@@ -1074,12 +1014,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 108 unmodified lines
1074 if (missing.length > 0) {1014 if (missing.length > 0) {
1075 return openApiAuthToolFailure({1015 return openApiAuthToolFailure({
1076 code:1016 code:
1077 template.type === "oauth" ? "oauth_connection_missing" : "connection_value_missing",1017 template.kind === "oauth2"
1018 ? "oauth_connection_missing"
1019 : "connection_value_missing",
1078 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,1020 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
1079 owner: credential.owner,1021 owner: credential.owner,
1080 integration,1022 integration,
1081 connection: String(credential.connection),1023 connection: String(credential.connection),
1082 credentialKind: template.type === "oauth" ? "oauth" : "secret",1024 credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
1083 });1025 });
1084 }1026 }
1085 const rendered = renderAuthTemplate(template, credential.values);1027 const rendered = renderAuthTemplate(template, credential.values);
packages/plugins/openapi/src/sdk/types.ts+45−30
@@ -1,43 +1,58 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import type { AuthTemplateSlug } from "@executor-js/sdk/shared";2import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
3import type { OAuthAuthentication } from "@executor-js/sdk/shared";3import {
4 apiKeyMethodFromAuthTemplate,
5 isApiKeyAuthTemplate,
6 type ApiKeyAuthMethod,
7 type ApiKeyAuthTemplate,
8} from "@executor-js/sdk/http-auth";
4 9
5// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
6// Auth-template model (ported from the v2 scaffold `openapi/types.ts`).11// Auth-template model.
7//12//
8// The apiKey template is HTTP-transport-specific: it declares where the user's13// The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`,
9// credential goes on the outbound request (headers / query params) via the14// the same shape the graphql/mcp plugins store): N header/query placements,
10// `variable()` templating below. That placement is why it lives with the15// each rendered from its own credential input. The oauth template is
11// openapi plugin rather than in core. The oauth template is mechanism-intrinsic16// mechanism-intrinsic and comes from core (`OAuthAuthentication`, keyed
12// and comes from core (`OAuthAuthentication`); an integration's `Authentication`17// `kind: "oauth2"` with stored endpoints+scopes); an integration's
13// union composes the two. Client credentials (clientId/secret) live on the core18// `Authentication` union composes the two. Client credentials
14// `OAuthClient`, not here.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.
15// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
16 25
17export type AuthenticationVariable = {26export { TOKEN_VARIABLE } from "@executor-js/sdk/http-auth";
18 readonly type: "variable";
19 readonly name: string;
20};
21
22/** A literal string, or a parts-array mixing literals and variable refs. */
23export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
24 27
25export const variable = (name: string): AuthenticationVariable => ({28export type APIKeyAuthentication = ApiKeyAuthMethod;
26 type: "variable",
27 name,
28});
29 29
30/** The variable name the resolved credential value renders into. */30/** Every method is keyed by `kind` — `kind: "oauth2"` | `kind: "apikey"`. */
31export const TOKEN_VARIABLE = "token" as const;31export type Authentication = OAuthAuthentication | APIKeyAuthentication;
32 32
33export type APIKeyAuthentication = {33/** What auth inputs accept: oauth templates (wire-typed: plain slug) plus the
34 readonly slug: AuthTemplateSlug;34 * request-shaped apikey dialect (`type: "apiKey"`, headers/queryParams
35 readonly type: "apiKey";35 * records) — the ONE apikey authoring shape. Stored configs and the catalog
36 readonly headers?: Record<string, AuthenticationTemplateValue>;36 * read as canonical placements; `apiKeyAuthTemplateFromMethod` serializes
37 readonly queryParams?: Record<string, AuthenticationTemplateValue>;37 * them back for read-modify-write flows. */
38export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & {
39 readonly slug: string;
38};40};
39 41export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate;
40export type Authentication = OAuthAuthentication | APIKeyAuthentication;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>`. */
46export 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 });
41 56
42// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
43// Branded IDs58// Branded IDs
packages/react/src/lib/auth-placements.tsx+6−1
@@ -31,6 +31,10 @@ export interface Placement { · 30 unmodified lines
31 * each get a distinct variable. Two placements sharing a variable share one31 * each get a distinct variable. Two placements sharing a variable share one
32 * value. */32 * value. */
33 readonly variable?: string;33 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;
34}38}
35 39
36/** A fresh, empty header placement — the default first row in an editor. */40/** 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
42 * `source` distinguishes integration-declared ("spec") methods from46 * `source` distinguishes integration-declared ("spec") methods from
43 * user-defined ("custom") ones. `template` is the auth-template slug the47 * user-defined ("custom") ones. `template` is the auth-template slug the
44 * method applies a connection through. */48 * method applies a connection through. */
45export type AuthMethodKind = "oauth" | "apikey" | "custom" | "none";49export type AuthMethodKind = "oauth" | "apikey" | "none";
46 50
47/** Provider OAuth endpoints/scopes an `oauth` method declares, used to pre-fill51/** Provider OAuth endpoints/scopes an `oauth` method declares, used to pre-fill
48 * the client-registration form so the user only pastes their client id/secret.52 * 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
167 name: placement.name,171 name: placement.name,
168 prefix: placement.prefix,172 prefix: placement.prefix,
169 ...(placement.variable ? { variable: placement.variable } : {}),173 ...(placement.variable ? { variable: placement.variable } : {}),
174 ...(placement.literal !== undefined ? { literal: placement.literal } : {}),
170 }),175 }),
171 )176 )
172 : DEFAULT_PLACEMENTS;177 : 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
16import { AuthTemplateSlug } from "@executor-js/sdk/shared";
17import {
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
27import type { AuthTemplateEditorValue } from "../components/auth-template-editor";
28import type { AuthMethod, Placement } from "./auth-placements";
29import type { AuthMethodsCodec } from "./custom-auth-methods";
30
31export type SharedAuthMethod = NoneAuthMethod | ApiKeyAuthMethod;
32
33/** A method input the backend slugs itself (custom-method create flow). */
34export 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
43export 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. */
53export 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`. */
69const 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. */
80const 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`). */
111export 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. */
137export 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`. */
146export 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. */
159export 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
170const 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). */
175export 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
201export interface SharedCodecOAuthAdapter<TOAuth extends { readonly slug: string }> {
202 readonly isOAuth: (template: SharedAuthMethod | TOAuth) => template is TOAuth;
203 readonly toAuthMethod: (template: TOAuth) => AuthMethod;
204}
205
206export 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}