* 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.
Unified · Split
| @@ -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. | ||
| @@ -47,7 +47,10 @@ before using it. · 46 unmodified lines | |||
| 47 | ## Package Roles | 47 | ## 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-specific | 55 | - `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. |
| @@ -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", |
| @@ -0,0 +1,101 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: out-of-band migration script over a raw postgres connection */ | ||
| 2 | // --------------------------------------------------------------------------- | ||
| 3 | // One-off data migration: rewrite pre-canonical integration auth configs | ||
| 4 | // into the shared placements model. Run OUT-OF-BAND against the database | ||
| 5 | // BEFORE deploying code that only decodes the canonical shape (the same | ||
| 6 | // rule as schema migrations — Workers never migrate at request time): | ||
| 7 | // | ||
| 8 | // bun run db:migrate-auth:prod # op run --env-file=.env.production | ||
| 9 | // bun run db:migrate-auth:dev # against the local PGlite dev db | ||
| 10 | // | ||
| 11 | // Idempotent — canonical rows plan zero updates, so re-running is safe. | ||
| 12 | // Pass --dry-run to print the plan without writing. | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | import postgres from "postgres"; | ||
| 16 | import { Option } from "effect"; | ||
| 17 | import { planAuthConfigMigration, type AuthConfigMigrationRow } from "@executor-js/sdk/http-auth"; | ||
| 18 | import { | ||
| 19 | decodeGraphqlIntegrationConfigOption, | ||
| 20 | migrateGraphqlAuthConfig, | ||
| 21 | } from "@executor-js/plugin-graphql"; | ||
| 22 | import { migrateMcpAuthConfig, parseMcpIntegrationConfig } from "@executor-js/plugin-mcp"; | ||
| 23 | import { | ||
| 24 | decodeOpenApiIntegrationConfig, | ||
| 25 | migrateOpenApiAuthConfig, | ||
| 26 | } from "@executor-js/plugin-openapi"; | ||
| 27 | |||
| 28 | const transforms = { | ||
| 29 | mcp: migrateMcpAuthConfig, | ||
| 30 | graphql: migrateGraphqlAuthConfig, | ||
| 31 | openapi: migrateOpenApiAuthConfig, | ||
| 32 | }; | ||
| 33 | |||
| 34 | // Whether a config decodes under the NEW runtime (the canonical shapes). | ||
| 35 | // Used by the post-migration audit: every row the new code will read must | ||
| 36 | // decode, whether the migration rewrote it or left it untouched. | ||
| 37 | const decodesCanonically: Record<string, (config: unknown) => boolean> = { | ||
| 38 | mcp: (config) => parseMcpIntegrationConfig(config) !== null, | ||
| 39 | graphql: (config) => Option.isSome(decodeGraphqlIntegrationConfigOption(config)), | ||
| 40 | openapi: (config) => decodeOpenApiIntegrationConfig(config) !== null, | ||
| 41 | }; | ||
| 42 | |||
| 43 | const connectionString = process.env.DATABASE_URL; | ||
| 44 | if (!connectionString) { | ||
| 45 | console.error("DATABASE_URL is not set"); | ||
| 46 | process.exit(1); | ||
| 47 | } | ||
| 48 | const dryRun = process.argv.includes("--dry-run"); | ||
| 49 | |||
| 50 | // Direct (non-Hyperdrive) connection — PlanetScale requires TLS. | ||
| 51 | const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); | ||
| 52 | |||
| 53 | try { | ||
| 54 | const rows = await sql<{ row_id: string; plugin_id: string; config: unknown }[]>` | ||
| 55 | SELECT row_id, plugin_id, config FROM integration | ||
| 56 | `; | ||
| 57 | |||
| 58 | const inputs: AuthConfigMigrationRow[] = rows.map((row) => ({ | ||
| 59 | rowId: row.row_id, | ||
| 60 | pluginId: row.plugin_id, | ||
| 61 | // postgres-js parses json columns; tolerate text just in case. | ||
| 62 | config: typeof row.config === "string" ? JSON.parse(row.config) : row.config, | ||
| 63 | })); | ||
| 64 | |||
| 65 | const updates = planAuthConfigMigration(inputs, transforms); | ||
| 66 | console.log(`${rows.length} integration row(s), ${updates.length} to rewrite`); | ||
| 67 | |||
| 68 | // Post-migration audit: simulate the final state of every row this plugin | ||
| 69 | // set owns and check it decodes under the new runtime. A row that neither | ||
| 70 | // rewrites nor decodes would read as "no usable config" after deploy — | ||
| 71 | // surface those BEFORE writing anything. | ||
| 72 | const planned = new Map(updates.map((update) => [update.rowId, update.config])); | ||
| 73 | const undecodable = inputs.filter((row) => { | ||
| 74 | const decodes = decodesCanonically[row.pluginId]; | ||
| 75 | if (!decodes) return false; // not a protocol-plugin row (no auth config) | ||
| 76 | return !decodes(planned.get(row.rowId) ?? row.config); | ||
| 77 | }); | ||
| 78 | if (undecodable.length > 0) { | ||
| 79 | console.error(`${undecodable.length} row(s) would NOT decode after migration:`); | ||
| 80 | for (const row of undecodable) console.error(` ${row.pluginId} ${row.rowId}`); | ||
| 81 | process.exit(1); | ||
| 82 | } | ||
| 83 | console.log("audit: every row decodes canonically after migration"); | ||
| 84 | |||
| 85 | if (dryRun) { | ||
| 86 | for (const update of updates) console.log(` would rewrite ${update.rowId}`); | ||
| 87 | } else if (updates.length > 0) { | ||
| 88 | await sql.begin(async (tx) => { | ||
| 89 | for (const update of updates) { | ||
| 90 | await tx` | ||
| 91 | UPDATE integration | ||
| 92 | SET config = ${tx.json(update.config as never)} | ||
| 93 | WHERE row_id = ${update.rowId} | ||
| 94 | `; | ||
| 95 | } | ||
| 96 | }); | ||
| 97 | console.log(`rewrote ${updates.length} row(s)`); | ||
| 98 | } | ||
| 99 | } finally { | ||
| 100 | await sql.end(); | ||
| 101 | } | ||
| @@ -4,7 +4,10 @@ import { Layer } from "effect"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; | 5 | import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; |
| 6 | 6 | ||
| 7 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; | ||
| 8 | |||
| 7 | import { resolveAuthProviders } from "./auth"; | 9 | import { resolveAuthProviders } from "./auth"; |
| 10 | import { authConfigTransforms } from "./db/auth-config-migration"; | ||
| 8 | import { makeSelfHostAdminApiLayer } from "./admin/handlers"; | 11 | import { makeSelfHostAdminApiLayer } from "./admin/handlers"; |
| 9 | import { makeSelfHostSystemApiLayer } from "./system/handlers"; | 12 | import { makeSelfHostSystemApiLayer } from "./system/handlers"; |
| 10 | import { selfHostAccountMiddleware } from "./account"; | 13 | import { 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 + account | 66 | // 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. |
| @@ -0,0 +1,17 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The per-plugin auth-config transforms the one-off boot migration runs over | ||
| 3 | // the `integration` table (see `runSqliteAuthConfigMigration`). Each plugin | ||
| 4 | // owns the rewrite of its own legacy config shapes into the canonical | ||
| 5 | // placements model; this map only routes by `plugin_id`. | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | import type { AuthConfigTransform } from "@executor-js/sdk/http-auth"; | ||
| 9 | import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql"; | ||
| 10 | import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp"; | ||
| 11 | import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi"; | ||
| 12 | |||
| 13 | export const authConfigTransforms: Record<string, AuthConfigTransform> = { | ||
| 14 | mcp: migrateMcpAuthConfig, | ||
| 15 | graphql: migrateGraphqlAuthConfig, | ||
| 16 | openapi: migrateOpenApiAuthConfig, | ||
| 17 | }; | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The per-plugin auth-config transforms the one-off boot migration runs over | ||
| 3 | // the `integration` table (see `runSqliteAuthConfigMigration`). Each plugin | ||
| 4 | // owns the rewrite of its own legacy config shapes into the canonical | ||
| 5 | // placements model; this map only routes by `plugin_id`. | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | import type { AuthConfigTransform } from "@executor-js/sdk/http-auth"; | ||
| 9 | import { migrateGraphqlAuthConfig } from "@executor-js/plugin-graphql"; | ||
| 10 | import { migrateMcpAuthConfig } from "@executor-js/plugin-mcp"; | ||
| 11 | import { migrateOpenApiAuthConfig } from "@executor-js/plugin-openapi"; | ||
| 12 | |||
| 13 | export const authConfigTransforms: Record<string, AuthConfigTransform> = { | ||
| 14 | mcp: migrateMcpAuthConfig, | ||
| 15 | graphql: migrateGraphqlAuthConfig, | ||
| 16 | openapi: migrateOpenApiAuthConfig, | ||
| 17 | }; | ||
| @@ -5,10 +5,12 @@ import { basename, join } from "node:path"; · 4 unmodified lines | |||
| 5 | import { createHash } from "node:crypto"; | 5 | import { createHash } from "node:crypto"; |
| 6 | 6 | ||
| 7 | import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk"; | 7 | import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk"; |
| 8 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; | ||
| 8 | import { collectTables } from "@executor-js/api/server"; | 9 | import { collectTables } from "@executor-js/api/server"; |
| 9 | import { loadPluginsFromJsonc } from "@executor-js/config"; | 10 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 10 | 11 | ||
| 11 | import executorConfig from "../executor.config"; | 12 | import executorConfig from "../executor.config"; |
| 13 | import { authConfigTransforms } from "./db/auth-config-migration"; | ||
| 12 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; | 14 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| 13 | import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; | 15 | import { 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 the | 181 | // 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 port | 182 | // daemon API since the daemon serves both. Mirrors serve.ts's port |
| 172 | // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL | 183 | // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL |
| @@ -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"], |
| @@ -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 | ```ts | 6 | ```ts |
| 7 | // An integration is the API surface. The apiKey template declares where a | 7 | // An integration is the API surface. The auth template declares where a |
| 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. |
| 10 | await executor.openapi.addSpec({ | 10 | await executor.openapi.addSpec({ |
| @@ -1,26 +1,33 @@ | |||
| 1 | // Cross-target: multi-method authentication — an integration declares SEVERAL | 1 | // Cross-target: multi-method authentication — an integration declares SEVERAL |
| 2 | // auth methods and a connection picks one by template slug. Covers the model | 2 | // auth methods and a connection picks one by template slug. Covers the unified |
| 3 | // rework: MCP's slugged `authenticationTemplate` array, the merge-append | 3 | // placements model: one apikey method can mix header and query placements, |
| 4 | // configureAuth flow (a custom API key must never displace a detected OAuth | 4 | // each rendered from its own credential input; the merge-append configureAuth |
| 5 | // method), declaring a method on a server that advertises none, and GraphQL's | 5 | // flow (a custom API key must never displace a detected OAuth method); |
| 6 | // multi-method add. Entirely through the typed clients — no MCP server is | 6 | // 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. | ||
| 8 | import { randomBytes } from "node:crypto"; | 11 | import { randomBytes } from "node:crypto"; |
| 9 | 12 | ||
| 10 | import { expect } from "@effect/vitest"; | 13 | import { expect } from "@effect/vitest"; |
| 11 | import { Effect } from "effect"; | 14 | import { Effect } from "effect"; |
| 12 | import { composePluginApi } from "@executor-js/api/server"; | 15 | import { composePluginApi } from "@executor-js/api/server"; |
| 13 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 16 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 17 | import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 14 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 18 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 15 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | 19 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 20 | |||
| 21 | import { variable } from "@executor-js/sdk/http-auth"; | ||
| 16 | 22 | ||
| 17 | import { scenario } from "../src/scenario"; | 23 | import { scenario } from "../src/scenario"; |
| 18 | 24 | ||
| 19 | const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const); | 25 | const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const); |
| 20 | 26 | ||
| 21 | const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; | 27 | const 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. | ||
| 24 | const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp"; | 31 | const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp"; |
| 25 | 32 | ||
| 26 | scenario( | 33 | scenario( |
| @@ -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 the | 60 | // 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 | |||
| 245 | scenario( | ||
| 246 | "Auth methods · one source mixes oauth, a 2-input header+query method, a bearer header, and a query token", | ||
| 247 | { needs: ["api", "mcp-oauth"] }, | ||
| 248 | (ctx) => | ||
| 249 | Effect.gen(function* () { | ||
| 250 | const identity = yield* ctx.target.newIdentity(); | ||
| 251 | const client = yield* ctx.api.client(api, identity); | ||
| 252 | const slug = freshSlug("mcp_extreme"); | ||
| 253 | |||
| 254 | // A real MCP server that records every request it receives. | ||
| 255 | const server = yield* serveMcpServer(() => | ||
| 256 | makeEchoMcpServer({ | ||
| 257 | name: "extreme-auth", | ||
| 258 | toolName: "whoami", | ||
| 259 | toolDescription: "Echoes a marker so the test can prove the invoke reached the server", | ||
| 260 | inputName: "marker", | ||
| 261 | text: (marker) => `ok:${marker}`, | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | |||
| 265 | yield* client.mcp.addServer({ | ||
| 266 | payload: { | ||
| 267 | transport: "remote", | ||
| 268 | name: "Extreme auth MCP", | ||
| 269 | endpoint: server.url, | ||
| 270 | slug, | ||
| 271 | authenticationTemplate: [ | ||
| 272 | { kind: "oauth2" }, | ||
| 273 | { | ||
| 274 | slug: "token_and_team", | ||
| 275 | type: "apiKey", | ||
| 276 | headers: { Authorization: ["Bearer ", variable("api_token")] }, | ||
| 277 | queryParams: { team_id: [variable("team_id")] }, | ||
| 278 | }, | ||
| 279 | { | ||
| 280 | slug: "bearer", | ||
| 281 | type: "apiKey", | ||
| 282 | headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 283 | }, | ||
| 284 | { | ||
| 285 | slug: "query_token", | ||
| 286 | type: "apiKey", | ||
| 287 | queryParams: { auth_token: [variable("token")] }, | ||
| 288 | }, | ||
| 289 | ], | ||
| 290 | }, | ||
| 291 | }); | ||
| 292 | |||
| 293 | yield* Effect.gen(function* () { | ||
| 294 | // All four methods project into the catalog, the 2-input method with | ||
| 295 | // both placements and their distinct variables (what drives the two | ||
| 296 | // credential fields in the connect modal). | ||
| 297 | const integration = yield* client.integrations.get({ | ||
| 298 | params: { slug: IntegrationSlug.make(slug) }, | ||
| 299 | }); | ||
| 300 | expect( | ||
| 301 | integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })), | ||
| 302 | "the catalog lists all four methods", | ||
| 303 | ).toEqual([ | ||
| 304 | { kind: "oauth", template: "oauth2" }, | ||
| 305 | { kind: "apikey", template: "token_and_team" }, | ||
| 306 | { kind: "apikey", template: "bearer" }, | ||
| 307 | { kind: "apikey", template: "query_token" }, | ||
| 308 | ]); | ||
| 309 | expect( | ||
| 310 | integration.authMethods[1]?.placements, | ||
| 311 | "the 2-input method carries both placements with their variables", | ||
| 312 | ).toEqual([ | ||
| 313 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | ||
| 314 | { carrier: "query", name: "team_id", prefix: "", variable: "team_id" }, | ||
| 315 | ]); | ||
| 316 | |||
| 317 | // One connection per credential method. The 2-input method takes a | ||
| 318 | // `values` map (one entry per placement variable). | ||
| 319 | yield* client.connections.create({ | ||
| 320 | payload: { | ||
| 321 | owner: "org", | ||
| 322 | name: ConnectionName.make("mixed"), | ||
| 323 | integration: IntegrationSlug.make(slug), | ||
| 324 | template: AuthTemplateSlug.make("token_and_team"), | ||
| 325 | values: { api_token: "tok_mixed", team_id: "team_42" }, | ||
| 326 | }, | ||
| 327 | }); | ||
| 328 | yield* client.connections.create({ | ||
| 329 | payload: { | ||
| 330 | owner: "org", | ||
| 331 | name: ConnectionName.make("bearer"), | ||
| 332 | integration: IntegrationSlug.make(slug), | ||
| 333 | template: AuthTemplateSlug.make("bearer"), | ||
| 334 | value: "tok_bearer", | ||
| 335 | }, | ||
| 336 | }); | ||
| 337 | yield* client.connections.create({ | ||
| 338 | payload: { | ||
| 339 | owner: "org", | ||
| 340 | name: ConnectionName.make("token"), | ||
| 341 | integration: IntegrationSlug.make(slug), | ||
| 342 | template: AuthTemplateSlug.make("query_token"), | ||
| 343 | value: "tok_query", | ||
| 344 | }, | ||
| 345 | }); | ||
| 346 | |||
| 347 | // Discovery dialed the server per connection; every connection | ||
| 348 | // produced its own addressed tool. | ||
| 349 | const tools = yield* client.tools.list(); | ||
| 350 | const names = tools | ||
| 351 | .filter((tool) => String(tool.integration) === slug) | ||
| 352 | .map((tool) => String(tool.address)); | ||
| 353 | for (const conn of ["mixed", "bearer", "token"]) { | ||
| 354 | expect(names, `connection "${conn}" resolved the server's tool`).toContain( | ||
| 355 | `tools.${slug}.org.${conn}.whoami`, | ||
| 356 | ); | ||
| 357 | } | ||
| 358 | |||
| 359 | // Invoke through the MCP surface (the real agent path: execute runs | ||
| 360 | // sandbox code that calls the addressed tool), then assert the WIRE: | ||
| 361 | // what the recording server received for each connection. | ||
| 362 | const session = ctx.mcp.session(identity); | ||
| 363 | |||
| 364 | const invokeAndCapture = (conn: string, marker: string) => | ||
| 365 | Effect.gen(function* () { | ||
| 366 | const before = (yield* server.requests).length; | ||
| 367 | const result = yield* session.call("execute", { | ||
| 368 | // Return the whole result — the assertion only needs the echoed | ||
| 369 | // marker to prove the call reached the server. | ||
| 370 | code: `return JSON.stringify(await tools.${slug}.org.${conn}.whoami({ marker: ${JSON.stringify(marker)} }));`, | ||
| 371 | }); | ||
| 372 | expect(result.text, `the ${conn} invocation reached the server`).toContain( | ||
| 373 | `ok:${marker}`, | ||
| 374 | ); | ||
| 375 | return (yield* server.requests).slice(before); | ||
| 376 | }); | ||
| 377 | |||
| 378 | const mixedRequests = yield* invokeAndCapture("mixed", "m1"); | ||
| 379 | expect( | ||
| 380 | mixedRequests.every((r) => r.authorization === "Bearer tok_mixed"), | ||
| 381 | "the 2-input method rendered its bearer header", | ||
| 382 | ).toBe(true); | ||
| 383 | expect( | ||
| 384 | mixedRequests.every((r) => r.url.includes("team_id=team_42")), | ||
| 385 | "…and its team-id query param, from the second input", | ||
| 386 | ).toBe(true); | ||
| 387 | |||
| 388 | const bearerRequests = yield* invokeAndCapture("bearer", "b1"); | ||
| 389 | expect( | ||
| 390 | bearerRequests.every((r) => r.authorization === "Bearer tok_bearer"), | ||
| 391 | "the bearer method rendered only its header", | ||
| 392 | ).toBe(true); | ||
| 393 | expect( | ||
| 394 | bearerRequests.every( | ||
| 395 | (r) => !r.url.includes("team_id=") && !r.url.includes("auth_token="), | ||
| 396 | ), | ||
| 397 | "…with no query credential bleeding in from sibling methods", | ||
| 398 | ).toBe(true); | ||
| 399 | |||
| 400 | const queryRequests = yield* invokeAndCapture("token", "q1"); | ||
| 401 | expect( | ||
| 402 | queryRequests.every((r) => r.url.includes("auth_token=tok_query")), | ||
| 403 | "the query method rendered its token param", | ||
| 404 | ).toBe(true); | ||
| 405 | expect( | ||
| 406 | queryRequests.every((r) => r.authorization === undefined), | ||
| 407 | "…and no Authorization header", | ||
| 408 | ).toBe(true); | ||
| 409 | }).pipe( | ||
| 410 | Effect.ensuring( | ||
| 411 | Effect.gen(function* () { | ||
| 412 | for (const name of ["mixed", "bearer", "token"]) { | ||
| 413 | yield* client.connections | ||
| 414 | .remove({ | ||
| 415 | params: { | ||
| 416 | owner: "org", | ||
| 417 | integration: IntegrationSlug.make(slug), | ||
| 418 | name: ConnectionName.make(name), | ||
| 419 | }, | ||
| 420 | }) | ||
| 421 | .pipe(Effect.ignore); | ||
| 422 | } | ||
| 423 | yield* client.mcp | ||
| 424 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 425 | .pipe(Effect.ignore); | ||
| 426 | }), | ||
| 427 | ), | ||
| 428 | ); | ||
| 429 | }), | ||
| 430 | ); | ||
| @@ -154,3 +154,86 @@ scenario( · 153 unmodified lines | |||
| 154 | }), | 154 | }), |
| 155 | ), | 155 | ), |
| 156 | ); | 156 | ); |
| 157 | |||
| 158 | scenario( | ||
| 159 | "Auth methods · the connect modal collects one value per input of a 2-input method", | ||
| 160 | { needs: ["browser", "api"] }, | ||
| 161 | (ctx) => | ||
| 162 | Effect.scoped( | ||
| 163 | Effect.gen(function* () { | ||
| 164 | // A method with TWO credential inputs: a bearer header rendered from | ||
| 165 | // `api_token` and a team-id query param rendered from `team_id`. The | ||
| 166 | // server requires both on every request, so the connect-time | ||
| 167 | // discovery dial only succeeds when the modal collected both values. | ||
| 168 | const apiToken = `e2e-two-input-${randomBytes(4).toString("hex")}`; | ||
| 169 | const teamId = `team-${randomBytes(3).toString("hex")}`; | ||
| 170 | const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { | ||
| 171 | auth: { | ||
| 172 | validateAuthorization: (authorization) => | ||
| 173 | Effect.succeed(authorization === `Bearer ${apiToken}`), | ||
| 174 | }, | ||
| 175 | }); | ||
| 176 | |||
| 177 | const identity = yield* ctx.target.newIdentity(); | ||
| 178 | const client = yield* ctx.api.client(api, identity); | ||
| 179 | const slug = `mcp-two-input-${randomBytes(3).toString("hex")}`; | ||
| 180 | |||
| 181 | yield* client.mcp.addServer({ | ||
| 182 | payload: { | ||
| 183 | transport: "remote", | ||
| 184 | name: "Two-input MCP", | ||
| 185 | endpoint: server.endpoint, | ||
| 186 | slug, | ||
| 187 | authenticationTemplate: [ | ||
| 188 | { | ||
| 189 | slug: "token_and_team", | ||
| 190 | type: "apiKey", | ||
| 191 | headers: { Authorization: ["Bearer ", { type: "variable", name: "api_token" }] }, | ||
| 192 | queryParams: { team_id: [{ type: "variable", name: "team_id" }] }, | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | }, | ||
| 196 | }); | ||
| 197 | |||
| 198 | yield* Effect.gen(function* () { | ||
| 199 | yield* ctx.browser.session(identity, async ({ page, step }) => { | ||
| 200 | await step("Open the integration's connect modal", async () => { | ||
| 201 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | ||
| 202 | await page.getByRole("button", { name: "Add connection" }).first().click(); | ||
| 203 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); | ||
| 204 | }); | ||
| 205 | |||
| 206 | await step("The method renders one field per credential input", async () => { | ||
| 207 | await page.getByPlaceholder("paste Authorization").waitFor(); | ||
| 208 | await page.getByPlaceholder("paste team_id").waitFor(); | ||
| 209 | }); | ||
| 210 | |||
| 211 | await step("Fill both values and connect", async () => { | ||
| 212 | await page.getByPlaceholder("paste Authorization").fill(apiToken); | ||
| 213 | await page.getByPlaceholder("paste team_id").fill(teamId); | ||
| 214 | await page.getByRole("button", { name: "Add connection" }).click(); | ||
| 215 | await page.getByText("Connection added").waitFor(); | ||
| 216 | }); | ||
| 217 | }); | ||
| 218 | |||
| 219 | // Wire proof: the discovery dial rendered BOTH inputs — the bearer | ||
| 220 | // header (the server rejects anything else) and the team-id query. | ||
| 221 | const requests = yield* server.requests; | ||
| 222 | expect( | ||
| 223 | requests.some( | ||
| 224 | (request) => | ||
| 225 | request.authorization === `Bearer ${apiToken}` && | ||
| 226 | request.url.includes(`team_id=${teamId}`), | ||
| 227 | ), | ||
| 228 | "the server saw the bearer header and the team-id query param together", | ||
| 229 | ).toBe(true); | ||
| 230 | }).pipe( | ||
| 231 | Effect.ensuring( | ||
| 232 | client.mcp | ||
| 233 | .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) | ||
| 234 | .pipe(Effect.ignore), | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| @@ -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 | }); |
| @@ -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: [], |
| @@ -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 | }, |
| @@ -99,7 +99,7 @@ const executor = await createExecutor({ · 98 unmodified lines | |||
| 99 | // docs:end create-executor | 99 | // docs:end create-executor |
| 100 | 100 | ||
| 101 | // docs:start add-integration | 101 | // docs:start add-integration |
| 102 | // An integration is the API surface. The apiKey template declares where a | 102 | // An integration is the API surface. The auth template declares where a |
| 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. |
| 105 | await executor.openapi.addSpec({ | 105 | await executor.openapi.addSpec({ |
| @@ -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 | // }); |
| 133 | void variable; | 133 | void 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. |
| @@ -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`. */ |
| @@ -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 | }, |
| @@ -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 | // Integrations | 1591 | // 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) => |
| @@ -0,0 +1,223 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type ApiKeyAuthMethod, | ||
| 5 | type AuthPlacement, | ||
| 6 | apiKeyMethodLabel, | ||
| 7 | describeApiKeyAuthMethod, | ||
| 8 | describeNoneAuthMethod, | ||
| 9 | normalizeAuthMethodSlugs, | ||
| 10 | oauthBearerPlacement, | ||
| 11 | renderAuthPlacements, | ||
| 12 | requiredPlacementVariables, | ||
| 13 | } from "./auth-method"; | ||
| 14 | |||
| 15 | describe("renderAuthPlacements", () => { | ||
| 16 | it("renders a bearer header from the implicit token variable", () => { | ||
| 17 | expect( | ||
| 18 | renderAuthPlacements([{ carrier: "header", name: "Authorization", prefix: "Bearer " }], { | ||
| 19 | token: "tok_1", | ||
| 20 | }), | ||
| 21 | ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("renders a query param (the ui.sh '?token=' case)", () => { | ||
| 25 | expect(renderAuthPlacements([{ carrier: "query", name: "token" }], { token: "tok_1" })).toEqual( | ||
| 26 | { headers: {}, queryParams: { token: "tok_1" } }, | ||
| 27 | ); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("mixes header and query placements in one method", () => { | ||
| 31 | expect( | ||
| 32 | renderAuthPlacements( | ||
| 33 | [ | ||
| 34 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | ||
| 35 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 36 | ], | ||
| 37 | { api_token: "tok_1", team_id: "team_9" }, | ||
| 38 | ), | ||
| 39 | ).toEqual({ | ||
| 40 | headers: { Authorization: "Bearer tok_1" }, | ||
| 41 | queryParams: { team_id: "team_9" }, | ||
| 42 | }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("renders distinct variables into their own placements (Datadog's two keys)", () => { | ||
| 46 | expect( | ||
| 47 | renderAuthPlacements( | ||
| 48 | [ | ||
| 49 | { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | ||
| 50 | { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" }, | ||
| 51 | ], | ||
| 52 | { dd_api_key: "a", dd_application_key: "b" }, | ||
| 53 | ), | ||
| 54 | ).toEqual({ | ||
| 55 | headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" }, | ||
| 56 | queryParams: {}, | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("lets two placements share one variable (same value in two spots)", () => { | ||
| 61 | expect( | ||
| 62 | renderAuthPlacements( | ||
| 63 | [ | ||
| 64 | { carrier: "header", name: "X-Token" }, | ||
| 65 | { carrier: "query", name: "token" }, | ||
| 66 | ], | ||
| 67 | { token: "shared" }, | ||
| 68 | ), | ||
| 69 | ).toEqual({ headers: { "X-Token": "shared" }, queryParams: { token: "shared" } }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("renders literal placements verbatim, with no credential", () => { | ||
| 73 | expect( | ||
| 74 | renderAuthPlacements( | ||
| 75 | [ | ||
| 76 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 77 | { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 78 | ], | ||
| 79 | { token: "tok_1" }, | ||
| 80 | ), | ||
| 81 | ).toEqual({ | ||
| 82 | headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" }, | ||
| 83 | queryParams: {}, | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("skips a credential placement whose variable resolved to nothing", () => { | ||
| 88 | expect( | ||
| 89 | renderAuthPlacements( | ||
| 90 | [ | ||
| 91 | { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 92 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 93 | ], | ||
| 94 | { token: "tok_1", team_id: null }, | ||
| 95 | ), | ||
| 96 | ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} }); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("renders nothing for an empty values map", () => { | ||
| 100 | expect( | ||
| 101 | renderAuthPlacements([{ carrier: "header", name: "Authorization", prefix: "Bearer " }], {}), | ||
| 102 | ).toEqual({ headers: {}, queryParams: {} }); | ||
| 103 | }); | ||
| 104 | }); | ||
| 105 | |||
| 106 | describe("requiredPlacementVariables", () => { | ||
| 107 | it("dedupes shared variables and defaults absent ones to token", () => { | ||
| 108 | const placements: readonly AuthPlacement[] = [ | ||
| 109 | { carrier: "header", name: "X-Token" }, | ||
| 110 | { carrier: "query", name: "token" }, | ||
| 111 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 112 | ]; | ||
| 113 | expect(requiredPlacementVariables(placements)).toEqual(["token", "team_id"]); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("excludes literal placements (they reference no credential)", () => { | ||
| 117 | expect( | ||
| 118 | requiredPlacementVariables([ | ||
| 119 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 120 | ]), | ||
| 121 | ).toEqual([]); | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | describe("oauthBearerPlacement", () => { | ||
| 126 | it("defaults to the conventional Authorization: Bearer header", () => { | ||
| 127 | expect(renderAuthPlacements([oauthBearerPlacement()], { token: "at_1" })).toEqual({ | ||
| 128 | headers: { Authorization: "Bearer at_1" }, | ||
| 129 | queryParams: {}, | ||
| 130 | }); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("honors a custom header and prefix (graphql oauth override)", () => { | ||
| 134 | expect( | ||
| 135 | renderAuthPlacements([oauthBearerPlacement("X-Access-Token", "")], { token: "at_1" }), | ||
| 136 | ).toEqual({ headers: { "X-Access-Token": "at_1" }, queryParams: {} }); | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | describe("normalizeAuthMethodSlugs", () => { | ||
| 141 | type Input = { readonly slug?: string | undefined; readonly kind: string }; | ||
| 142 | const byKind = (method: Input) => method.kind; | ||
| 143 | |||
| 144 | it("keeps caller-provided slugs and defaults the rest", () => { | ||
| 145 | expect( | ||
| 146 | normalizeAuthMethodSlugs( | ||
| 147 | [{ slug: "custom_abc", kind: "apikey" }, { kind: "none" }], | ||
| 148 | byKind, | ||
| 149 | ).map((m) => m.slug), | ||
| 150 | ).toEqual(["custom_abc", "none"]); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("suffixes collisions deterministically", () => { | ||
| 154 | expect( | ||
| 155 | normalizeAuthMethodSlugs( | ||
| 156 | [{ kind: "apikey" }, { kind: "apikey" }, { slug: "apikey", kind: "apikey" }], | ||
| 157 | byKind, | ||
| 158 | ).map((m) => m.slug), | ||
| 159 | ).toEqual(["apikey", "apikey_2", "apikey_3"]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("treats a blank slug as absent", () => { | ||
| 163 | expect(normalizeAuthMethodSlugs([{ slug: " ", kind: "oauth2" }], byKind)[0]?.slug).toBe( | ||
| 164 | "oauth2", | ||
| 165 | ); | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | |||
| 169 | describe("catalog projection", () => { | ||
| 170 | it("projects an apikey method with placements, deriving the label", () => { | ||
| 171 | const method: ApiKeyAuthMethod = { | ||
| 172 | slug: "header", | ||
| 173 | kind: "apikey", | ||
| 174 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 175 | }; | ||
| 176 | expect(describeApiKeyAuthMethod(method)).toEqual({ | ||
| 177 | id: "header", | ||
| 178 | label: "API key (X-Api-Key)", | ||
| 179 | kind: "apikey", | ||
| 180 | template: "header", | ||
| 181 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 182 | }); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("carries explicit variables and literals through to the descriptor", () => { | ||
| 186 | const method: ApiKeyAuthMethod = { | ||
| 187 | slug: "custom_x", | ||
| 188 | kind: "apikey", | ||
| 189 | label: "Token + team", | ||
| 190 | placements: [ | ||
| 191 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | ||
| 192 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 193 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 194 | ], | ||
| 195 | }; | ||
| 196 | expect(describeApiKeyAuthMethod(method)).toEqual({ | ||
| 197 | id: "custom_x", | ||
| 198 | label: "Token + team", | ||
| 199 | kind: "apikey", | ||
| 200 | template: "custom_x", | ||
| 201 | placements: [ | ||
| 202 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | ||
| 203 | { carrier: "query", name: "team_id", prefix: "", variable: "team_id" }, | ||
| 204 | { carrier: "header", name: "X-Api-Version", prefix: "", literal: "2023-01-01" }, | ||
| 205 | ], | ||
| 206 | }); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("falls back to the slug when no placement is named", () => { | ||
| 210 | expect(apiKeyMethodLabel({ slug: "custom_y", kind: "apikey", placements: [] })).toBe( | ||
| 211 | "API key (custom_y)", | ||
| 212 | ); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("projects a none method", () => { | ||
| 216 | expect(describeNoneAuthMethod("none")).toEqual({ | ||
| 217 | id: "none", | ||
| 218 | label: "No authentication", | ||
| 219 | kind: "none", | ||
| 220 | template: "none", | ||
| 221 | }); | ||
| 222 | }); | ||
| 223 | }); | ||
| @@ -0,0 +1,188 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | import type { AuthMethodDescriptor, AuthPlacementDescriptor } from "../integration"; | ||
| 3 | |||
| 4 | // --------------------------------------------------------------------------- | ||
| 5 | // The shared HTTP auth-method vocabulary for protocol plugins (openapi, | ||
| 6 | // graphql, mcp). An integration declares a LIST of auth methods; a connection | ||
| 7 | // binds one by its `slug` (`connection.template`) and supplies one credential | ||
| 8 | // value per `variable` the method's placements reference (D11: core resolves | ||
| 9 | // `values: Record<variable, string|null>`, the plugin renders them onto the | ||
| 10 | // request). | ||
| 11 | // | ||
| 12 | // Core itself never imports this module — core stays carrier-agnostic (a | ||
| 13 | // connection could be a CLI login or a DB URL). It ships as the | ||
| 14 | // `@executor-js/sdk/http-auth` subpath purely as a home: composition, not | ||
| 15 | // location, is what keeps headers/query params an HTTP-plugin concern. | ||
| 16 | // | ||
| 17 | // OAuth methods are NOT modeled here — their config genuinely differs per | ||
| 18 | // plugin (openapi stores endpoints+scopes, graphql an optional header | ||
| 19 | // override, mcp discovers everything at connect time). Each plugin's method | ||
| 20 | // union is `NoneAuthMethod | ApiKeyAuthMethod | <its own oauth variant>`. | ||
| 21 | // OAuth-refreshed connections resolve only the `token` input, so oauth | ||
| 22 | // values must never be mixed into a placements method. | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | /** The canonical input variable of single-credential methods. A placement | ||
| 26 | * with no `variable` renders from it, and core resolves a connection created | ||
| 27 | * with a bare `{ value }` into `{ token: value }`. */ | ||
| 28 | export const TOKEN_VARIABLE = "token"; | ||
| 29 | |||
| 30 | export const AuthCarrier = Schema.Literals(["header", "query"]); | ||
| 31 | export type AuthCarrier = typeof AuthCarrier.Type; | ||
| 32 | |||
| 33 | /** One spot on the outbound request a method writes to. | ||
| 34 | * | ||
| 35 | * Credential placement: renders `prefix + values[variable ?? "token"]`. | ||
| 36 | * Two placements naming the same variable share one credential input; a | ||
| 37 | * placement naming its own variable gets its own input (e.g. Datadog's two | ||
| 38 | * keys). | ||
| 39 | * | ||
| 40 | * Literal placement (`literal` set): renders the literal verbatim and | ||
| 41 | * references no credential — static values a method carries alongside its | ||
| 42 | * credential (e.g. a fixed version header). */ | ||
| 43 | export const AuthPlacement = Schema.Struct({ | ||
| 44 | carrier: AuthCarrier, | ||
| 45 | /** Header name (e.g. `Authorization`) or query-param name (e.g. `token`). */ | ||
| 46 | name: Schema.String, | ||
| 47 | /** Literal prepended to the credential value, e.g. `Bearer `. */ | ||
| 48 | prefix: Schema.optional(Schema.String), | ||
| 49 | /** The credential input this placement renders from. Absent ⇒ `token`. */ | ||
| 50 | variable: Schema.optional(Schema.String), | ||
| 51 | /** Render this exact value instead of a credential. */ | ||
| 52 | literal: Schema.optional(Schema.String), | ||
| 53 | }); | ||
| 54 | export type AuthPlacement = typeof AuthPlacement.Type; | ||
| 55 | |||
| 56 | export const ApiKeyAuthMethod = Schema.Struct({ | ||
| 57 | slug: Schema.String, | ||
| 58 | kind: Schema.Literal("apikey"), | ||
| 59 | /** Display label; derived from the first placement when absent. */ | ||
| 60 | label: Schema.optional(Schema.String), | ||
| 61 | placements: Schema.Array(AuthPlacement), | ||
| 62 | }); | ||
| 63 | export type ApiKeyAuthMethod = typeof ApiKeyAuthMethod.Type; | ||
| 64 | |||
| 65 | /** An open integration — connections carry no credential. */ | ||
| 66 | export const NoneAuthMethod = Schema.Struct({ | ||
| 67 | slug: Schema.String, | ||
| 68 | kind: Schema.Literal("none"), | ||
| 69 | }); | ||
| 70 | export type NoneAuthMethod = typeof NoneAuthMethod.Type; | ||
| 71 | |||
| 72 | // --------------------------------------------------------------------------- | ||
| 73 | // Rendering | ||
| 74 | // --------------------------------------------------------------------------- | ||
| 75 | |||
| 76 | export interface RenderedAuthPlacements { | ||
| 77 | readonly headers: Record<string, string>; | ||
| 78 | readonly queryParams: Record<string, string>; | ||
| 79 | } | ||
| 80 | |||
| 81 | const renderPlacementValue = ( | ||
| 82 | placement: AuthPlacement, | ||
| 83 | values: Record<string, string | null>, | ||
| 84 | ): string | null => { | ||
| 85 | if (placement.literal !== undefined) return placement.literal; | ||
| 86 | const value = values[placement.variable ?? TOKEN_VARIABLE]; | ||
| 87 | if (value == null) return null; | ||
| 88 | return placement.prefix ? `${placement.prefix}${value}` : value; | ||
| 89 | }; | ||
| 90 | |||
| 91 | /** Render a method's placements with a connection's resolved values. Total: | ||
| 92 | * a credential placement whose variable resolved to nothing is skipped — | ||
| 93 | * callers own their missing-value policy (fail the invocation, dial | ||
| 94 | * unauthenticated, …) and should gate on `requiredPlacementVariables`. */ | ||
| 95 | export const renderAuthPlacements = ( | ||
| 96 | placements: readonly AuthPlacement[], | ||
| 97 | values: Record<string, string | null>, | ||
| 98 | ): RenderedAuthPlacements => { | ||
| 99 | const headers: Record<string, string> = {}; | ||
| 100 | const queryParams: Record<string, string> = {}; | ||
| 101 | for (const placement of placements) { | ||
| 102 | const rendered = renderPlacementValue(placement, values); | ||
| 103 | if (rendered === null) continue; | ||
| 104 | if (placement.carrier === "header") headers[placement.name] = rendered; | ||
| 105 | else queryParams[placement.name] = rendered; | ||
| 106 | } | ||
| 107 | return { headers, queryParams }; | ||
| 108 | }; | ||
| 109 | |||
| 110 | /** The distinct credential inputs a method's placements reference — what a | ||
| 111 | * connection must supply. Literal placements reference none. */ | ||
| 112 | export const requiredPlacementVariables = ( | ||
| 113 | placements: readonly AuthPlacement[], | ||
| 114 | ): readonly string[] => { | ||
| 115 | const names = new Set<string>(); | ||
| 116 | for (const placement of placements) { | ||
| 117 | if (placement.literal !== undefined) continue; | ||
| 118 | names.add(placement.variable ?? TOKEN_VARIABLE); | ||
| 119 | } | ||
| 120 | return [...names]; | ||
| 121 | }; | ||
| 122 | |||
| 123 | /** The conventional rendering of an OAuth access token, as a placement — for | ||
| 124 | * plugins whose oauth method applies the token as a plain (possibly | ||
| 125 | * customized) header. The token is always the `token` input. */ | ||
| 126 | export const oauthBearerPlacement = (header?: string, prefix?: string): AuthPlacement => ({ | ||
| 127 | carrier: "header", | ||
| 128 | name: header ?? "Authorization", | ||
| 129 | prefix: prefix ?? "Bearer ", | ||
| 130 | }); | ||
| 131 | |||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Slug normalization — methods are input without slugs from UIs/agents; every | ||
| 134 | // stored method needs a stable one (`connection.template` binds by it). | ||
| 135 | // --------------------------------------------------------------------------- | ||
| 136 | |||
| 137 | /** Assign each method a stable slug: a caller-provided one wins, otherwise | ||
| 138 | * `defaultSlugFor(method)`, suffixed `_2`, `_3`, … on collision. */ | ||
| 139 | export const normalizeAuthMethodSlugs = <T extends { readonly slug?: string | undefined }>( | ||
| 140 | methods: readonly T[], | ||
| 141 | defaultSlugFor: (method: T) => string, | ||
| 142 | ): readonly (T & { readonly slug: string })[] => { | ||
| 143 | const taken = new Set<string>(); | ||
| 144 | return methods.map((method: T): T & { readonly slug: string } => { | ||
| 145 | const requested = method.slug?.trim() || defaultSlugFor(method); | ||
| 146 | let slug = requested; | ||
| 147 | for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`; | ||
| 148 | taken.add(slug); | ||
| 149 | return { ...method, slug }; | ||
| 150 | }); | ||
| 151 | }; | ||
| 152 | |||
| 153 | // --------------------------------------------------------------------------- | ||
| 154 | // Catalog projection — the plugin-agnostic `AuthMethodDescriptor` every | ||
| 155 | // plugin's `describeAuthMethods` emits (the descriptor TYPE stays in | ||
| 156 | // @executor-js/sdk: core carries it as opaque catalog metadata). | ||
| 157 | // --------------------------------------------------------------------------- | ||
| 158 | |||
| 159 | export const apiKeyMethodLabel = (method: ApiKeyAuthMethod): string => { | ||
| 160 | if (method.label !== undefined && method.label.trim().length > 0) return method.label; | ||
| 161 | const first = method.placements.find( | ||
| 162 | (placement: AuthPlacement) => placement.name.trim().length > 0, | ||
| 163 | ); | ||
| 164 | return first ? `API key (${first.name})` : `API key (${method.slug})`; | ||
| 165 | }; | ||
| 166 | |||
| 167 | const describePlacement = (placement: AuthPlacement): AuthPlacementDescriptor => ({ | ||
| 168 | carrier: placement.carrier, | ||
| 169 | name: placement.name, | ||
| 170 | prefix: placement.prefix ?? "", | ||
| 171 | ...(placement.variable !== undefined ? { variable: placement.variable } : {}), | ||
| 172 | ...(placement.literal !== undefined ? { literal: placement.literal } : {}), | ||
| 173 | }); | ||
| 174 | |||
| 175 | export const describeApiKeyAuthMethod = (method: ApiKeyAuthMethod): AuthMethodDescriptor => ({ | ||
| 176 | id: method.slug, | ||
| 177 | label: apiKeyMethodLabel(method), | ||
| 178 | kind: "apikey", | ||
| 179 | template: method.slug, | ||
| 180 | placements: method.placements.map(describePlacement), | ||
| 181 | }); | ||
| 182 | |||
| 183 | export const describeNoneAuthMethod = (slug: string): AuthMethodDescriptor => ({ | ||
| 184 | id: slug, | ||
| 185 | label: "No authentication", | ||
| 186 | kind: "none", | ||
| 187 | template: slug, | ||
| 188 | }); | ||
| @@ -0,0 +1,97 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Option, Schema } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyAuthTemplate, apiKeyMethodFromAuthTemplate, variable } from "./authoring"; | ||
| 5 | import { renderAuthPlacements } from "./auth-method"; | ||
| 6 | |||
| 7 | const decode = Schema.decodeUnknownOption(ApiKeyAuthTemplate); | ||
| 8 | |||
| 9 | describe("request-shaped authoring", () => { | ||
| 10 | it("a bearer header template expands to the canonical placement", () => { | ||
| 11 | expect( | ||
| 12 | apiKeyMethodFromAuthTemplate({ | ||
| 13 | slug: "bearer", | ||
| 14 | type: "apiKey", | ||
| 15 | headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 16 | }), | ||
| 17 | ).toEqual({ | ||
| 18 | slug: "bearer", | ||
| 19 | kind: "apikey", | ||
| 20 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("headers and query params mix, each input keeping its variable", () => { | ||
| 25 | const method = apiKeyMethodFromAuthTemplate({ | ||
| 26 | type: "apiKey", | ||
| 27 | headers: { Authorization: ["Bearer ", variable("api_token")] }, | ||
| 28 | queryParams: { team_id: [variable("team_id")] }, | ||
| 29 | }); | ||
| 30 | expect(method.placements).toEqual([ | ||
| 31 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | ||
| 32 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 33 | ]); | ||
| 34 | // …and the expansion renders exactly like the template reads. | ||
| 35 | expect(renderAuthPlacements(method.placements, { api_token: "tok", team_id: "t42" })).toEqual({ | ||
| 36 | headers: { Authorization: "Bearer tok" }, | ||
| 37 | queryParams: { team_id: "t42" }, | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("a plain-string value is a static literal (no credential input)", () => { | ||
| 42 | expect( | ||
| 43 | apiKeyMethodFromAuthTemplate({ | ||
| 44 | type: "apiKey", | ||
| 45 | headers: { "X-Api-Version": "2023-01-01", "X-Api-Key": [variable("token")] }, | ||
| 46 | }).placements, | ||
| 47 | ).toEqual([ | ||
| 48 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 49 | { carrier: "header", name: "X-Api-Key" }, | ||
| 50 | ]); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("slug and label are optional and carried through", () => { | ||
| 54 | const method = apiKeyMethodFromAuthTemplate({ | ||
| 55 | type: "apiKey", | ||
| 56 | label: "Team key", | ||
| 57 | queryParams: { token: [variable("token")] }, | ||
| 58 | }); | ||
| 59 | expect(method.slug).toBeUndefined(); | ||
| 60 | expect(method.label).toBe("Team key"); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("rejects a variable that is not the final part (authoring is strict)", () => { | ||
| 64 | expect( | ||
| 65 | Option.isNone( | ||
| 66 | decode({ | ||
| 67 | type: "apiKey", | ||
| 68 | headers: { Authorization: [variable("token"), "-suffix"] }, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | ).toBe(true); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("rejects two variables in one value", () => { | ||
| 75 | expect( | ||
| 76 | Option.isNone( | ||
| 77 | decode({ | ||
| 78 | type: "apiKey", | ||
| 79 | headers: { Authorization: [variable("a"), ":", variable("b")] }, | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | ).toBe(true); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("accepts the strict shapes it should", () => { | ||
| 86 | expect( | ||
| 87 | Option.isSome( | ||
| 88 | decode({ | ||
| 89 | slug: "ok", | ||
| 90 | type: "apiKey", | ||
| 91 | headers: { A: ["Bearer ", variable("token")], B: "literal" }, | ||
| 92 | queryParams: { q: [variable("q")] }, | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | ).toBe(true); | ||
| 96 | }); | ||
| 97 | }); | ||
| @@ -0,0 +1,148 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Request-shaped authoring — the second accepted INPUT dialect for apikey | ||
| 3 | // methods, alongside canonical placements. It reads like the HTTP request it | ||
| 4 | // produces: | ||
| 5 | // | ||
| 6 | // { | ||
| 7 | // slug: "bearer", | ||
| 8 | // type: "apiKey", | ||
| 9 | // headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 10 | // queryParams: { team_id: [variable("team_id")] }, | ||
| 11 | // } | ||
| 12 | // | ||
| 13 | // A record value is either a parts-array (`prefix literals… + one variable, | ||
| 14 | // last`) rendering a credential input, or a plain string rendering a static | ||
| 15 | // literal. Normalized to canonical placements at the boundary — STORED | ||
| 16 | // configs carry only the canonical shape. | ||
| 17 | // | ||
| 18 | // Authoring is strict where the migration is tolerant: a parts-array with a | ||
| 19 | // variable anywhere but last, or with more than one variable, is rejected at | ||
| 20 | // decode time with a pointed message (the renderer and UI honor exactly | ||
| 21 | // prefix+variable, so accepting more would silently lose information — | ||
| 22 | // `migrate-config` only flattens that way for pre-existing stored data). | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | import { Schema } from "effect"; | ||
| 26 | |||
| 27 | import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method"; | ||
| 28 | |||
| 29 | export interface AuthTemplateVariable { | ||
| 30 | readonly type: "variable"; | ||
| 31 | readonly name: string; | ||
| 32 | } | ||
| 33 | |||
| 34 | /** Mark where a credential input renders inside a template value: | ||
| 35 | * `["Bearer ", variable("token")]`. */ | ||
| 36 | export const variable = (name: string): AuthTemplateVariable => ({ type: "variable", name }); | ||
| 37 | |||
| 38 | const VariablePart = Schema.Struct({ | ||
| 39 | type: Schema.Literal("variable"), | ||
| 40 | name: Schema.String, | ||
| 41 | }); | ||
| 42 | |||
| 43 | const isVariablePart = (part: string | AuthTemplateVariable): part is AuthTemplateVariable => | ||
| 44 | typeof part !== "string"; | ||
| 45 | |||
| 46 | /** `string` (a static literal) or `prefix literals… + one variable, last`. */ | ||
| 47 | export const AuthTemplateValue = Schema.Union([ | ||
| 48 | Schema.String, | ||
| 49 | Schema.Array(Schema.Union([Schema.String, VariablePart])).check( | ||
| 50 | Schema.makeFilter((parts) => { | ||
| 51 | const variableIndexes = parts.flatMap((part, index) => (isVariablePart(part) ? [index] : [])); | ||
| 52 | if ( | ||
| 53 | variableIndexes.length > 1 || | ||
| 54 | (variableIndexes.length === 1 && variableIndexes[0] !== parts.length - 1) | ||
| 55 | ) { | ||
| 56 | return "a template value renders at most ONE variable, as the FINAL part — split extra variables/suffixes into separate header or query entries"; | ||
| 57 | } | ||
| 58 | return undefined; | ||
| 59 | }), | ||
| 60 | ), | ||
| 61 | ]); | ||
| 62 | export type AuthTemplateValue = typeof AuthTemplateValue.Type; | ||
| 63 | |||
| 64 | /** The request-shaped apikey authoring dialect. `slug` optional (backfilled | ||
| 65 | * like every other input); `label` optional. */ | ||
| 66 | export const ApiKeyAuthTemplate = Schema.Struct({ | ||
| 67 | slug: Schema.optional(Schema.String), | ||
| 68 | type: Schema.Literal("apiKey"), | ||
| 69 | label: Schema.optional(Schema.String), | ||
| 70 | headers: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)), | ||
| 71 | queryParams: Schema.optional(Schema.Record(Schema.String, AuthTemplateValue)), | ||
| 72 | }); | ||
| 73 | export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type; | ||
| 74 | |||
| 75 | const placementFromValue = ( | ||
| 76 | carrier: "header" | "query", | ||
| 77 | name: string, | ||
| 78 | value: AuthTemplateValue, | ||
| 79 | ): AuthPlacement => { | ||
| 80 | if (typeof value === "string") return { carrier, name, literal: value }; | ||
| 81 | const variablePart = value.find(isVariablePart); | ||
| 82 | if (variablePart === undefined) { | ||
| 83 | return { carrier, name, literal: value.filter((p) => typeof p === "string").join("") }; | ||
| 84 | } | ||
| 85 | const prefix = value.filter((part): part is string => typeof part === "string").join(""); | ||
| 86 | return { | ||
| 87 | carrier, | ||
| 88 | name, | ||
| 89 | ...(prefix ? { prefix } : {}), | ||
| 90 | ...(variablePart.name !== TOKEN_VARIABLE ? { variable: variablePart.name } : {}), | ||
| 91 | }; | ||
| 92 | }; | ||
| 93 | |||
| 94 | /** Normalize the request-shaped dialect into the canonical apikey method | ||
| 95 | * (sans slug backfill — the caller's normalize pass owns slugs). */ | ||
| 96 | export const apiKeyMethodFromAuthTemplate = ( | ||
| 97 | template: ApiKeyAuthTemplate, | ||
| 98 | ): Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string } => { | ||
| 99 | const placements: AuthPlacement[] = []; | ||
| 100 | for (const [name, value] of Object.entries(template.headers ?? {})) { | ||
| 101 | placements.push(placementFromValue("header", name, value)); | ||
| 102 | } | ||
| 103 | for (const [name, value] of Object.entries(template.queryParams ?? {})) { | ||
| 104 | placements.push(placementFromValue("query", name, value)); | ||
| 105 | } | ||
| 106 | return { | ||
| 107 | ...(template.slug !== undefined ? { slug: template.slug } : {}), | ||
| 108 | kind: "apikey", | ||
| 109 | ...(template.label !== undefined ? { label: template.label } : {}), | ||
| 110 | placements, | ||
| 111 | }; | ||
| 112 | }; | ||
| 113 | |||
| 114 | /** Serialize a canonical method back into the request-shaped dialect — the | ||
| 115 | * write side of read-modify-write flows (stored configs and the catalog | ||
| 116 | * read as placements; auth INPUTS accept only this dialect). Same-named | ||
| 117 | * same-carrier placements collapse, exactly as the renderer's header/query | ||
| 118 | * records do. */ | ||
| 119 | export const apiKeyAuthTemplateFromMethod = (method: { | ||
| 120 | readonly slug?: string; | ||
| 121 | readonly label?: string; | ||
| 122 | readonly placements: readonly AuthPlacement[]; | ||
| 123 | }): ApiKeyAuthTemplate => { | ||
| 124 | const headers: Record<string, AuthTemplateValue> = {}; | ||
| 125 | const queryParams: Record<string, AuthTemplateValue> = {}; | ||
| 126 | for (const placement of method.placements) { | ||
| 127 | const target = placement.carrier === "header" ? headers : queryParams; | ||
| 128 | target[placement.name] = | ||
| 129 | placement.literal !== undefined | ||
| 130 | ? placement.literal | ||
| 131 | : placement.prefix | ||
| 132 | ? [placement.prefix, variable(placement.variable ?? TOKEN_VARIABLE)] | ||
| 133 | : [variable(placement.variable ?? TOKEN_VARIABLE)]; | ||
| 134 | } | ||
| 135 | return { | ||
| 136 | ...(method.slug !== undefined ? { slug: method.slug } : {}), | ||
| 137 | type: "apiKey", | ||
| 138 | ...(method.label !== undefined ? { label: method.label } : {}), | ||
| 139 | ...(Object.keys(headers).length > 0 ? { headers } : {}), | ||
| 140 | ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), | ||
| 141 | }; | ||
| 142 | }; | ||
| 143 | |||
| 144 | /** Whether an input union member is the request-shaped dialect. */ | ||
| 145 | export const isApiKeyAuthTemplate = (input: { | ||
| 146 | readonly kind?: string; | ||
| 147 | readonly type?: string; | ||
| 148 | }): input is ApiKeyAuthTemplate => input.type === "apiKey"; | ||
| @@ -0,0 +1,43 @@ | |||
| 1 | export { | ||
| 2 | ApiKeyAuthMethod, | ||
| 3 | AuthCarrier, | ||
| 4 | AuthPlacement, | ||
| 5 | NoneAuthMethod, | ||
| 6 | TOKEN_VARIABLE, | ||
| 7 | apiKeyMethodLabel, | ||
| 8 | describeApiKeyAuthMethod, | ||
| 9 | describeNoneAuthMethod, | ||
| 10 | normalizeAuthMethodSlugs, | ||
| 11 | oauthBearerPlacement, | ||
| 12 | renderAuthPlacements, | ||
| 13 | requiredPlacementVariables, | ||
| 14 | type RenderedAuthPlacements, | ||
| 15 | } from "./auth-method"; | ||
| 16 | |||
| 17 | // Request-shaped authoring dialect — accepted on every plugin's auth inputs, | ||
| 18 | // normalized to canonical placements at the boundary. | ||
| 19 | export { | ||
| 20 | ApiKeyAuthTemplate, | ||
| 21 | AuthTemplateValue, | ||
| 22 | apiKeyAuthTemplateFromMethod, | ||
| 23 | apiKeyMethodFromAuthTemplate, | ||
| 24 | isApiKeyAuthTemplate, | ||
| 25 | variable, | ||
| 26 | type AuthTemplateVariable, | ||
| 27 | } from "./authoring"; | ||
| 28 | |||
| 29 | // Migration-only legacy vocabulary — runtime code must not use these. | ||
| 30 | export { | ||
| 31 | LegacyApiKeyTemplate, | ||
| 32 | LegacyTemplateValue, | ||
| 33 | apiKeyMethodFromLegacyTemplate, | ||
| 34 | decodeLegacyApiKeyTemplate, | ||
| 35 | } from "./legacy"; | ||
| 36 | export { | ||
| 37 | planAuthConfigMigration, | ||
| 38 | runSqliteAuthConfigMigration, | ||
| 39 | type AuthConfigMigrationRow, | ||
| 40 | type AuthConfigMigrationUpdate, | ||
| 41 | type AuthConfigTransform, | ||
| 42 | type SqliteAuthConfigClient, | ||
| 43 | } from "./migrate"; | ||
| @@ -0,0 +1,82 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // FROZEN legacy shapes — input vocabulary for the one-off config migrations. | ||
| 3 | // | ||
| 4 | // Before the canonical placements model, the OpenAPI plugin (and the v1→v2 | ||
| 5 | // migration, which wrote this shape into MCP and GraphQL configs too) stored | ||
| 6 | // apiKey methods as header/query records of TEMPLATE VALUES: a string literal | ||
| 7 | // or a part-array interleaving literals with `{ type: "variable", name }` | ||
| 8 | // references. | ||
| 9 | // | ||
| 10 | // Runtime code must not import this module — it exists so every plugin's | ||
| 11 | // `migrate-config` rewrites stored blobs the same way, and the rules below are | ||
| 12 | // the contract: variable names are preserved VERBATIM (they key the | ||
| 13 | // connections' stored `item_ids`), the placement prefix is the literal run | ||
| 14 | // before the first variable, a literal-only value becomes a `literal` | ||
| 15 | // placement, and anything after the first variable is dropped (matching what | ||
| 16 | // the descriptor projection has always shown). | ||
| 17 | // --------------------------------------------------------------------------- | ||
| 18 | |||
| 19 | import { Schema } from "effect"; | ||
| 20 | |||
| 21 | import { TOKEN_VARIABLE, type ApiKeyAuthMethod, type AuthPlacement } from "./auth-method"; | ||
| 22 | |||
| 23 | const LegacyVariablePart = Schema.Struct({ | ||
| 24 | type: Schema.Literal("variable"), | ||
| 25 | name: Schema.String, | ||
| 26 | }); | ||
| 27 | |||
| 28 | export const LegacyTemplateValue = Schema.Union([ | ||
| 29 | Schema.String, | ||
| 30 | Schema.Array(Schema.Union([Schema.String, LegacyVariablePart])), | ||
| 31 | ]); | ||
| 32 | export type LegacyTemplateValue = typeof LegacyTemplateValue.Type; | ||
| 33 | |||
| 34 | /** The pre-canonical apiKey template shape (OpenAPI native + what the v1→v2 | ||
| 35 | * migration wrote into MCP/GraphQL configs). */ | ||
| 36 | export const LegacyApiKeyTemplate = Schema.Struct({ | ||
| 37 | slug: Schema.String, | ||
| 38 | type: Schema.Literal("apiKey"), | ||
| 39 | headers: Schema.optional(Schema.Record(Schema.String, LegacyTemplateValue)), | ||
| 40 | queryParams: Schema.optional(Schema.Record(Schema.String, LegacyTemplateValue)), | ||
| 41 | }); | ||
| 42 | export type LegacyApiKeyTemplate = typeof LegacyApiKeyTemplate.Type; | ||
| 43 | |||
| 44 | export const decodeLegacyApiKeyTemplate = Schema.decodeUnknownOption(LegacyApiKeyTemplate); | ||
| 45 | |||
| 46 | const placementFromTemplateValue = ( | ||
| 47 | carrier: "header" | "query", | ||
| 48 | name: string, | ||
| 49 | value: LegacyTemplateValue, | ||
| 50 | ): AuthPlacement => { | ||
| 51 | if (typeof value === "string") return { carrier, name, literal: value }; | ||
| 52 | const literals: string[] = []; | ||
| 53 | for (const part of value) { | ||
| 54 | if (typeof part === "string") { | ||
| 55 | literals.push(part); | ||
| 56 | continue; | ||
| 57 | } | ||
| 58 | const prefix = literals.join(""); | ||
| 59 | return { | ||
| 60 | carrier, | ||
| 61 | name, | ||
| 62 | ...(prefix ? { prefix } : {}), | ||
| 63 | ...(part.name !== TOKEN_VARIABLE ? { variable: part.name } : {}), | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | return { carrier, name, literal: literals.join("") }; | ||
| 67 | }; | ||
| 68 | |||
| 69 | /** Rewrite a legacy apiKey template into the canonical placements method. | ||
| 70 | * Slug and variable names are preserved verbatim. */ | ||
| 71 | export const apiKeyMethodFromLegacyTemplate = ( | ||
| 72 | template: LegacyApiKeyTemplate, | ||
| 73 | ): ApiKeyAuthMethod => { | ||
| 74 | const placements: AuthPlacement[] = []; | ||
| 75 | for (const [name, value] of Object.entries(template.headers ?? {})) { | ||
| 76 | placements.push(placementFromTemplateValue("header", name, value)); | ||
| 77 | } | ||
| 78 | for (const [name, value] of Object.entries(template.queryParams ?? {})) { | ||
| 79 | placements.push(placementFromTemplateValue("query", name, value)); | ||
| 80 | } | ||
| 81 | return { slug: template.slug, kind: "apikey", placements }; | ||
| 82 | }; | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | planAuthConfigMigration, | ||
| 5 | runSqliteAuthConfigMigration, | ||
| 6 | type AuthConfigTransform, | ||
| 7 | type SqliteAuthConfigClient, | ||
| 8 | } from "./migrate"; | ||
| 9 | |||
| 10 | const transforms: Record<string, AuthConfigTransform> = { | ||
| 11 | mcp: (config) => { | ||
| 12 | const blob = config as { legacy?: boolean }; | ||
| 13 | return blob.legacy ? { migrated: true } : null; | ||
| 14 | }, | ||
| 15 | }; | ||
| 16 | |||
| 17 | describe("planAuthConfigMigration", () => { | ||
| 18 | it("plans rewrites only for known plugins whose transform rewrites", () => { | ||
| 19 | const updates = planAuthConfigMigration( | ||
| 20 | [ | ||
| 21 | { rowId: "a", pluginId: "mcp", config: { legacy: true } }, | ||
| 22 | { rowId: "b", pluginId: "mcp", config: { legacy: false } }, | ||
| 23 | { rowId: "c", pluginId: "keychain", config: { legacy: true } }, | ||
| 24 | ], | ||
| 25 | transforms, | ||
| 26 | ); | ||
| 27 | expect(updates).toEqual([{ rowId: "a", config: { migrated: true } }]); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | // A tiny scripted fake standing in for a libSQL client. | ||
| 32 | const makeFakeClient = (rows: Record<string, unknown>[]) => { | ||
| 33 | const log: unknown[] = []; | ||
| 34 | const client: SqliteAuthConfigClient = { | ||
| 35 | execute: (stmt) => { | ||
| 36 | log.push(stmt); | ||
| 37 | if (typeof stmt === "string" && stmt.includes("sqlite_master")) { | ||
| 38 | return Promise.resolve({ rows: [{ name: "integration" }] }); | ||
| 39 | } | ||
| 40 | if (typeof stmt === "string" && stmt.startsWith("SELECT row_id")) { | ||
| 41 | return Promise.resolve({ rows }); | ||
| 42 | } | ||
| 43 | return Promise.resolve({ rows: [] }); | ||
| 44 | }, | ||
| 45 | }; | ||
| 46 | return { client, log }; | ||
| 47 | }; | ||
| 48 | |||
| 49 | describe("runSqliteAuthConfigMigration", () => { | ||
| 50 | it("rewrites legacy rows in a transaction and reports the count", async () => { | ||
| 51 | const { client, log } = makeFakeClient([ | ||
| 52 | { row_id: "a", plugin_id: "mcp", config: JSON.stringify({ legacy: true }) }, | ||
| 53 | { row_id: "b", plugin_id: "mcp", config: JSON.stringify({ legacy: false }) }, | ||
| 54 | { row_id: "c", plugin_id: "mcp", config: "not json" }, | ||
| 55 | ]); | ||
| 56 | const count = await runSqliteAuthConfigMigration(client, transforms); | ||
| 57 | expect(count).toBe(1); | ||
| 58 | expect(log).toContainEqual("BEGIN"); | ||
| 59 | expect(log).toContainEqual({ | ||
| 60 | sql: "UPDATE integration SET config = ? WHERE row_id = ?", | ||
| 61 | args: [JSON.stringify({ migrated: true }), "a"], | ||
| 62 | }); | ||
| 63 | expect(log).toContainEqual("COMMIT"); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("plans nothing on a canonical database (idempotent re-run)", async () => { | ||
| 67 | const { client, log } = makeFakeClient([ | ||
| 68 | { row_id: "a", plugin_id: "mcp", config: JSON.stringify({ legacy: false }) }, | ||
| 69 | ]); | ||
| 70 | expect(await runSqliteAuthConfigMigration(client, transforms)).toBe(0); | ||
| 71 | expect(log).not.toContainEqual("BEGIN"); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("treats a database without the integration table as nothing to migrate", async () => { | ||
| 75 | const client: SqliteAuthConfigClient = { | ||
| 76 | execute: () => Promise.resolve({ rows: [] }), | ||
| 77 | }; | ||
| 78 | expect(await runSqliteAuthConfigMigration(client, transforms)).toBe(0); | ||
| 79 | }); | ||
| 80 | }); | ||
| @@ -0,0 +1,99 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: one-shot config migration drives a raw SQL client (JSON text columns, transaction + rollback) */ | ||
| 2 | // --------------------------------------------------------------------------- | ||
| 3 | // One-off auth-config migration planner — the driver-agnostic core every app | ||
| 4 | // wires its own SQL around. Each protocol plugin exports a pure | ||
| 5 | // `migrate<Plugin>AuthConfig(config) → config | null` (null = leave the row | ||
| 6 | // untouched); this plans which integration rows to rewrite. Idempotent by | ||
| 7 | // construction: re-running over migrated rows plans zero updates. | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | export type AuthConfigTransform = (config: unknown) => unknown | null; | ||
| 11 | |||
| 12 | export interface AuthConfigMigrationRow { | ||
| 13 | /** The integration row's primary key (`row_id`). */ | ||
| 14 | readonly rowId: string; | ||
| 15 | readonly pluginId: string; | ||
| 16 | readonly config: unknown; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface AuthConfigMigrationUpdate { | ||
| 20 | readonly rowId: string; | ||
| 21 | readonly config: unknown; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** Plan the config rewrites for a set of integration rows. `transforms` maps | ||
| 25 | * plugin ids (`mcp` / `graphql` / `openapi`) to their migration functions; | ||
| 26 | * rows of other plugins, and rows whose transform returns null, are left | ||
| 27 | * untouched. */ | ||
| 28 | export const planAuthConfigMigration = ( | ||
| 29 | rows: readonly AuthConfigMigrationRow[], | ||
| 30 | transforms: Record<string, AuthConfigTransform>, | ||
| 31 | ): readonly AuthConfigMigrationUpdate[] => { | ||
| 32 | const updates: AuthConfigMigrationUpdate[] = []; | ||
| 33 | for (const row of rows) { | ||
| 34 | const transform = transforms[row.pluginId]; | ||
| 35 | if (!transform) continue; | ||
| 36 | const migrated = transform(row.config); | ||
| 37 | if (migrated !== null) updates.push({ rowId: row.rowId, config: migrated }); | ||
| 38 | } | ||
| 39 | return updates; | ||
| 40 | }; | ||
| 41 | |||
| 42 | // --------------------------------------------------------------------------- | ||
| 43 | // SQLite runner — shared by the libSQL-backed apps (local boot, selfhost | ||
| 44 | // boot). Structural client interface so this package stays dependency-free; | ||
| 45 | // `@libsql/client` satisfies it. Postgres apps (cloud) wire their own SQL | ||
| 46 | // around `planAuthConfigMigration`. | ||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | |||
| 49 | export interface SqliteAuthConfigClient { | ||
| 50 | execute( | ||
| 51 | stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, | ||
| 52 | ): Promise<{ readonly rows: readonly Record<string, unknown>[] }>; | ||
| 53 | } | ||
| 54 | |||
| 55 | /** Rewrite pre-canonical integration `config` blobs in a SQLite database. | ||
| 56 | * Idempotent (canonical rows plan no updates); returns the number of rows | ||
| 57 | * rewritten. The `integration` table may not exist yet on a fresh database — | ||
| 58 | * that counts as nothing to migrate. */ | ||
| 59 | export const runSqliteAuthConfigMigration = async ( | ||
| 60 | client: SqliteAuthConfigClient, | ||
| 61 | transforms: Record<string, AuthConfigTransform>, | ||
| 62 | ): Promise<number> => { | ||
| 63 | const exists = await client.execute( | ||
| 64 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'", | ||
| 65 | ); | ||
| 66 | if (exists.rows.length === 0) return 0; | ||
| 67 | |||
| 68 | const result = await client.execute("SELECT row_id, plugin_id, config FROM integration"); | ||
| 69 | const rows: AuthConfigMigrationRow[] = []; | ||
| 70 | for (const row of result.rows) { | ||
| 71 | if (typeof row.row_id !== "string" || typeof row.plugin_id !== "string") continue; | ||
| 72 | if (typeof row.config !== "string") continue; | ||
| 73 | let config: unknown; | ||
| 74 | try { | ||
| 75 | config = JSON.parse(row.config); | ||
| 76 | } catch { | ||
| 77 | continue; | ||
| 78 | } | ||
| 79 | rows.push({ rowId: row.row_id, pluginId: row.plugin_id, config }); | ||
| 80 | } | ||
| 81 | |||
| 82 | const updates = planAuthConfigMigration(rows, transforms); | ||
| 83 | if (updates.length === 0) return 0; | ||
| 84 | |||
| 85 | await client.execute("BEGIN"); | ||
| 86 | try { | ||
| 87 | for (const update of updates) { | ||
| 88 | await client.execute({ | ||
| 89 | sql: "UPDATE integration SET config = ? WHERE row_id = ?", | ||
| 90 | args: [JSON.stringify(update.config), update.rowId], | ||
| 91 | }); | ||
| 92 | } | ||
| 93 | await client.execute("COMMIT"); | ||
| 94 | } catch (cause) { | ||
| 95 | await client.execute("ROLLBACK"); | ||
| 96 | throw cause; | ||
| 97 | } | ||
| 98 | return updates.length; | ||
| 99 | }; | ||
| @@ -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 providers | 44 | /** OAuth specifics for an `oauth` auth method. For probe-at-connect providers |
| @@ -25,10 +25,11 @@ import { · 24 unmodified lines | |||
| 25 | export type OAuthGrant = "authorization_code" | "client_credentials"; | 25 | export 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. */ | ||
| 29 | export interface OAuthAuthentication { | 30 | export 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[]; |
| @@ -201,6 +201,10 @@ export interface ResolveToolsInput { · 200 unmodified lines | |||
| 201 | /** Lazily resolve the connection's credential value via its provider — only | 201 | /** 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 | ||
| 206 | export interface ResolveToolsResult { | 210 | export interface ResolveToolsResult { |
| @@ -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"], |
| @@ -43,7 +43,7 @@ import { · 42 unmodified lines | |||
| 43 | type FumaTables, | 43 | type FumaTables, |
| 44 | } from "@executor-js/sdk"; | 44 | } from "@executor-js/sdk"; |
| 45 | import { makeExecutorToolInvoker } from "@executor-js/execution"; | 45 | import { makeExecutorToolInvoker } from "@executor-js/execution"; |
| 46 | import { openApiPlugin, variable, type Authentication } from "@executor-js/plugin-openapi"; | 46 | import { openApiPlugin, variable, type AuthenticationInput } from "@executor-js/plugin-openapi"; |
| 47 | 47 | ||
| 48 | import { makeDynamicWorkerExecutor } from "./executor"; | 48 | import { 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 an | 85 | // 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 a | 86 | // `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. |
| 88 | const apiKeyTemplate: Authentication = { | 88 | const 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 | }; |
| @@ -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 | ||
| @@ -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 | } | ||
| @@ -1,83 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 5 | import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; | ||
| 6 | import { AuthTemplate } from "../sdk/types"; | ||
| 7 | |||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | // Params | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | const IntegrationParams = { | ||
| 13 | slug: Schema.String, | ||
| 14 | }; | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // Payloads | ||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | |||
| 20 | const AddIntegrationPayload = Schema.Struct({ | ||
| 21 | slug: Schema.String, | ||
| 22 | endpoint: Schema.String, | ||
| 23 | description: Schema.optional(Schema.String), | ||
| 24 | introspectionJson: Schema.optional(Schema.String), | ||
| 25 | authentication: Schema.optional(Schema.Array(AuthTemplate)), | ||
| 26 | introspectionHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 27 | }); | ||
| 28 | |||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | // Responses | ||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | |||
| 33 | const AddIntegrationResponse = Schema.Struct({ | ||
| 34 | slug: Schema.String, | ||
| 35 | toolCount: Schema.Number, | ||
| 36 | }); | ||
| 37 | |||
| 38 | const GetIntegrationResponse = Schema.NullOr( | ||
| 39 | Schema.Struct({ | ||
| 40 | slug: Schema.String, | ||
| 41 | description: Schema.String, | ||
| 42 | kind: Schema.String, | ||
| 43 | canRemove: Schema.Boolean, | ||
| 44 | canRefresh: Schema.Boolean, | ||
| 45 | config: Schema.NullOr(Schema.Unknown), | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | |||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | // Errors with HTTP status | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | |||
| 53 | const IntrospectionError = GraphqlIntrospectionError.annotate({ httpApiStatus: 400 }); | ||
| 54 | const ExtractionError = GraphqlExtractionError.annotate({ httpApiStatus: 400 }); | ||
| 55 | |||
| 56 | const GraphqlErrors = [ | ||
| 57 | InternalError, | ||
| 58 | IntrospectionError, | ||
| 59 | ExtractionError, | ||
| 60 | IntegrationAlreadyExistsError, | ||
| 61 | ] as const; | ||
| 62 | |||
| 63 | // --------------------------------------------------------------------------- | ||
| 64 | // Group — addIntegration / getIntegration over the v2 integration catalog. | ||
| 65 | // (v1's scope-keyed addSource/getSource is replaced by catalog-keyed routes; | ||
| 66 | // connections are managed through the core connections API, not here.) | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | export const GraphqlGroup = HttpApiGroup.make("graphql-greenfield") | ||
| 70 | .add( | ||
| 71 | HttpApiEndpoint.post("addIntegration", "/graphql-greenfield/integrations", { | ||
| 72 | payload: AddIntegrationPayload, | ||
| 73 | success: AddIntegrationResponse, | ||
| 74 | error: GraphqlErrors, | ||
| 75 | }), | ||
| 76 | ) | ||
| 77 | .add( | ||
| 78 | HttpApiEndpoint.get("getIntegration", "/graphql-greenfield/integrations/:slug", { | ||
| 79 | params: IntegrationParams, | ||
| 80 | success: GetIntegrationResponse, | ||
| 81 | error: GraphqlErrors, | ||
| 82 | }), | ||
| 83 | ); | ||
| @@ -1,70 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { GraphqlPluginExtension } from "../sdk/plugin"; | ||
| 6 | import { GraphqlGroup } from "./group"; | ||
| 7 | |||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | // Service tag | ||
| 10 | // | ||
| 11 | // Holds the `Captured` shape — every method's `StorageFailure` channel has | ||
| 12 | // been swapped for `InternalError({ traceId })`. The host app provides an | ||
| 13 | // already-wrapped extension via | ||
| 14 | // `Layer.succeed(GraphqlExtensionService, withCapture(executor["graphql-greenfield"]))`. | ||
| 15 | // --------------------------------------------------------------------------- | ||
| 16 | |||
| 17 | export class GraphqlExtensionService extends Context.Service< | ||
| 18 | GraphqlExtensionService, | ||
| 19 | GraphqlPluginExtension | ||
| 20 | >()("GraphqlGreenfieldExtensionService") {} | ||
| 21 | |||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | // Composed API — core + graphql group | ||
| 24 | // --------------------------------------------------------------------------- | ||
| 25 | |||
| 26 | const ExecutorApiWithGraphql = addGroup(GraphqlGroup); | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // Handlers | ||
| 30 | // --------------------------------------------------------------------------- | ||
| 31 | |||
| 32 | export const GraphqlHandlers = HttpApiBuilder.group( | ||
| 33 | ExecutorApiWithGraphql, | ||
| 34 | "graphql-greenfield", | ||
| 35 | (handlers) => | ||
| 36 | handlers | ||
| 37 | .handle("addIntegration", ({ payload }) => | ||
| 38 | capture( | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const ext = yield* GraphqlExtensionService; | ||
| 41 | const result = yield* ext.addIntegration({ | ||
| 42 | slug: payload.slug, | ||
| 43 | endpoint: payload.endpoint, | ||
| 44 | description: payload.description, | ||
| 45 | introspectionJson: payload.introspectionJson, | ||
| 46 | authentication: payload.authentication, | ||
| 47 | introspectionHeaders: payload.introspectionHeaders, | ||
| 48 | }); | ||
| 49 | return { slug: result.slug, toolCount: result.toolCount }; | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | ) | ||
| 53 | .handle("getIntegration", ({ params: path }) => | ||
| 54 | capture( | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const ext = yield* GraphqlExtensionService; | ||
| 57 | const integration = yield* ext.getIntegration(path.slug); | ||
| 58 | if (integration === null) return null; | ||
| 59 | return { | ||
| 60 | slug: String(integration.slug), | ||
| 61 | description: integration.description, | ||
| 62 | kind: integration.kind, | ||
| 63 | canRemove: integration.canRemove, | ||
| 64 | canRefresh: integration.canRefresh, | ||
| 65 | config: integration.config, | ||
| 66 | }; | ||
| 67 | }), | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | ); | ||
| @@ -1,20 +0,0 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk"; | ||
| 2 | |||
| 3 | import { graphqlPlugin, type GraphqlPluginOptions } from "../sdk/plugin"; | ||
| 4 | import { GraphqlGroup } from "./group"; | ||
| 5 | import { GraphqlHandlers, GraphqlExtensionService } from "./handlers"; | ||
| 6 | |||
| 7 | export { GraphqlGroup } from "./group"; | ||
| 8 | export { GraphqlHandlers, GraphqlExtensionService } from "./handlers"; | ||
| 9 | |||
| 10 | // HTTP-augmented variant of `graphqlPlugin`. The returned plugin carries the | ||
| 11 | // HTTP `routes`, `handlers`, and `extensionService` so a host can mount the | ||
| 12 | // GraphQL HTTP surface. Hosts that compose an `HttpApi` import this; SDK-only | ||
| 13 | // consumers stay on `@executor-js/plugin-graphql-greenfield` and never load | ||
| 14 | // `@executor-js/api`. | ||
| 15 | export const graphqlHttpPlugin = definePlugin((options?: GraphqlPluginOptions) => ({ | ||
| 16 | ...graphqlPlugin(options), | ||
| 17 | routes: () => GraphqlGroup, | ||
| 18 | handlers: () => GraphqlHandlers, | ||
| 19 | extensionService: GraphqlExtensionService, | ||
| 20 | })); | ||
| @@ -1,7 +0,0 @@ | |||
| 1 | export { graphqlPlugin } from "./sdk/plugin"; | ||
| 2 | export type { | ||
| 3 | GraphqlPluginOptions, | ||
| 4 | GraphqlPluginExtension, | ||
| 5 | AddGraphqlIntegrationInput, | ||
| 6 | ConfigureGraphqlIntegrationInput, | ||
| 7 | } from "./sdk/plugin"; | ||
| @@ -1,40 +0,0 @@ | |||
| 1 | import { Data, Schema } from "effect"; | ||
| 2 | import type { Option } from "effect"; | ||
| 3 | import type { AuthToolFailureCode } from "@executor-js/sdk"; | ||
| 4 | |||
| 5 | export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIntrospectionError>()( | ||
| 6 | "GraphqlIntrospectionError", | ||
| 7 | { | ||
| 8 | message: Schema.String, | ||
| 9 | }, | ||
| 10 | ) {} | ||
| 11 | |||
| 12 | export class GraphqlExtractionError extends Schema.TaggedErrorClass<GraphqlExtractionError>()( | ||
| 13 | "GraphqlExtractionError", | ||
| 14 | { | ||
| 15 | message: Schema.String, | ||
| 16 | }, | ||
| 17 | ) {} | ||
| 18 | |||
| 19 | export class GraphqlInvocationError extends Data.TaggedError("GraphqlInvocationError")<{ | ||
| 20 | readonly message: string; | ||
| 21 | readonly statusCode: Option.Option<number>; | ||
| 22 | readonly cause?: unknown; | ||
| 23 | }> {} | ||
| 24 | |||
| 25 | /** Raised inside `invokeTool` when the connection's credential value could not | ||
| 26 | * be resolved (provider returned nothing / OAuth re-auth needed). v2 keys this | ||
| 27 | * by the connection (owner + integration + name) rather than v1's source/scope | ||
| 28 | * + credential-binding slot. */ | ||
| 29 | export class GraphqlAuthRequiredError extends Data.TaggedError("GraphqlAuthRequiredError")<{ | ||
| 30 | readonly code: AuthToolFailureCode; | ||
| 31 | readonly message: string; | ||
| 32 | readonly owner: "org" | "user"; | ||
| 33 | readonly integration: string; | ||
| 34 | readonly connection: string; | ||
| 35 | readonly credentialKind: "secret" | "oauth" | "upstream"; | ||
| 36 | readonly credentialLabel?: string; | ||
| 37 | readonly status?: number; | ||
| 38 | readonly details?: unknown; | ||
| 39 | readonly cause?: unknown; | ||
| 40 | }> {} | ||
| @@ -1,252 +0,0 @@ | |||
| 1 | import { Effect, Match, Option } from "effect"; | ||
| 2 | |||
| 3 | import { GraphqlExtractionError } from "./errors"; | ||
| 4 | import type { | ||
| 5 | IntrospectionResult, | ||
| 6 | IntrospectionSchema, | ||
| 7 | IntrospectionType, | ||
| 8 | IntrospectionTypeRef, | ||
| 9 | IntrospectionInputValue, | ||
| 10 | } from "./introspect"; | ||
| 11 | import { | ||
| 12 | ExtractedField, | ||
| 13 | ExtractionResult, | ||
| 14 | GraphqlArgument, | ||
| 15 | type GraphqlOperationKind, | ||
| 16 | } from "./types"; | ||
| 17 | |||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | // Type ref helpers | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /** Unwrap NON_NULL / LIST wrappers to get the leaf type name */ | ||
| 23 | const unwrapTypeName = (ref: IntrospectionTypeRef): string => { | ||
| 24 | if (ref.name) return ref.name; | ||
| 25 | if (ref.ofType) return unwrapTypeName(ref.ofType); | ||
| 26 | return "Unknown"; | ||
| 27 | }; | ||
| 28 | |||
| 29 | /** Check if a type ref is non-null (required) */ | ||
| 30 | const isNonNull = (ref: IntrospectionTypeRef): boolean => ref.kind === "NON_NULL"; | ||
| 31 | |||
| 32 | // --------------------------------------------------------------------------- | ||
| 33 | // Build shared definitions from all INPUT_OBJECT and ENUM types | ||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | |||
| 36 | const buildDefinitions = ( | ||
| 37 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 38 | ): Record<string, unknown> => { | ||
| 39 | const defs: Record<string, unknown> = {}; | ||
| 40 | |||
| 41 | for (const [name, type] of types) { | ||
| 42 | // Skip internal types | ||
| 43 | if (name.startsWith("__")) continue; | ||
| 44 | |||
| 45 | if (type.kind === "INPUT_OBJECT" && type.inputFields) { | ||
| 46 | const properties: Record<string, unknown> = {}; | ||
| 47 | const required: string[] = []; | ||
| 48 | |||
| 49 | for (const field of type.inputFields) { | ||
| 50 | const schema = typeRefToJsonSchema(field.type, types); | ||
| 51 | if (field.description) { | ||
| 52 | (schema as Record<string, unknown>).description = field.description; | ||
| 53 | } | ||
| 54 | properties[field.name] = schema; | ||
| 55 | if (isNonNull(field.type)) { | ||
| 56 | required.push(field.name); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | const def: Record<string, unknown> = { type: "object", properties }; | ||
| 61 | if (required.length > 0) def.required = required; | ||
| 62 | if (type.description) def.description = type.description; | ||
| 63 | defs[name] = def; | ||
| 64 | } | ||
| 65 | |||
| 66 | if (type.kind === "ENUM" && type.enumValues) { | ||
| 67 | defs[name] = { | ||
| 68 | type: "string", | ||
| 69 | enum: type.enumValues.map((v) => v.name), | ||
| 70 | ...(type.description ? { description: type.description } : {}), | ||
| 71 | }; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | return defs; | ||
| 76 | }; | ||
| 77 | |||
| 78 | // --------------------------------------------------------------------------- | ||
| 79 | // Convert a type ref to JSON Schema using $ref for complex types | ||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | |||
| 82 | const typeRefToJsonSchema = ( | ||
| 83 | ref: IntrospectionTypeRef, | ||
| 84 | // oxlint-disable-next-line only-used-in-recursion | ||
| 85 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 86 | ): Record<string, unknown> => | ||
| 87 | Match.value(ref.kind).pipe( | ||
| 88 | Match.when( | ||
| 89 | "NON_NULL", | ||
| 90 | (): Record<string, unknown> => (ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}), | ||
| 91 | ), | ||
| 92 | Match.when( | ||
| 93 | "LIST", | ||
| 94 | (): Record<string, unknown> => ({ | ||
| 95 | type: "array", | ||
| 96 | items: ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}, | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | Match.when("SCALAR", (): Record<string, unknown> => scalarToJsonSchema(ref.name ?? "String")), | ||
| 100 | Match.when( | ||
| 101 | "ENUM", | ||
| 102 | (): Record<string, unknown> => | ||
| 103 | ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "string" }, | ||
| 104 | ), | ||
| 105 | Match.when( | ||
| 106 | "INPUT_OBJECT", | ||
| 107 | (): Record<string, unknown> => | ||
| 108 | ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "object" }, | ||
| 109 | ), | ||
| 110 | Match.whenOr( | ||
| 111 | "OBJECT", | ||
| 112 | "INTERFACE", | ||
| 113 | "UNION", | ||
| 114 | (): Record<string, unknown> => ({ type: "object" }), | ||
| 115 | ), | ||
| 116 | Match.option, | ||
| 117 | Option.getOrElse((): Record<string, unknown> => ({})), | ||
| 118 | ); | ||
| 119 | |||
| 120 | const scalarToJsonSchema = (name: string): Record<string, unknown> => | ||
| 121 | Match.value(name).pipe( | ||
| 122 | Match.whenOr("String", "ID", (): Record<string, unknown> => ({ type: "string" })), | ||
| 123 | Match.when("Int", (): Record<string, unknown> => ({ type: "integer" })), | ||
| 124 | Match.when("Float", (): Record<string, unknown> => ({ type: "number" })), | ||
| 125 | Match.when("Boolean", (): Record<string, unknown> => ({ type: "boolean" })), | ||
| 126 | Match.option, | ||
| 127 | Option.getOrElse( | ||
| 128 | (): Record<string, unknown> => ({ type: "string", description: `Custom scalar: ${name}` }), | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | |||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Build input JSON Schema from field arguments | ||
| 134 | // --------------------------------------------------------------------------- | ||
| 135 | |||
| 136 | const buildInputSchema = ( | ||
| 137 | args: readonly IntrospectionInputValue[], | ||
| 138 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 139 | ): Record<string, unknown> | undefined => { | ||
| 140 | if (args.length === 0) return undefined; | ||
| 141 | |||
| 142 | const properties: Record<string, unknown> = {}; | ||
| 143 | const required: string[] = []; | ||
| 144 | |||
| 145 | for (const arg of args) { | ||
| 146 | const schema = typeRefToJsonSchema(arg.type, types); | ||
| 147 | if (arg.description) { | ||
| 148 | (schema as Record<string, unknown>).description = arg.description; | ||
| 149 | } | ||
| 150 | properties[arg.name] = schema; | ||
| 151 | if (isNonNull(arg.type)) { | ||
| 152 | required.push(arg.name); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | const inputSchema: Record<string, unknown> = { | ||
| 157 | type: "object", | ||
| 158 | properties, | ||
| 159 | }; | ||
| 160 | if (required.length > 0) inputSchema.required = required; | ||
| 161 | return inputSchema; | ||
| 162 | }; | ||
| 163 | |||
| 164 | /** Format a type ref back to GraphQL type notation (e.g. "[String!]!") */ | ||
| 165 | const formatTypeRef = (ref: IntrospectionTypeRef): string => | ||
| 166 | Match.value(ref.kind).pipe( | ||
| 167 | Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")), | ||
| 168 | Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")), | ||
| 169 | Match.option, | ||
| 170 | Option.getOrElse(() => ref.name ?? "Unknown"), | ||
| 171 | ); | ||
| 172 | |||
| 173 | // --------------------------------------------------------------------------- | ||
| 174 | // Extract fields from schema | ||
| 175 | // --------------------------------------------------------------------------- | ||
| 176 | |||
| 177 | const extractFields = ( | ||
| 178 | _schema: IntrospectionSchema, | ||
| 179 | kind: GraphqlOperationKind, | ||
| 180 | typeName: string | null | undefined, | ||
| 181 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 182 | ): ExtractedField[] => { | ||
| 183 | if (!typeName) return []; | ||
| 184 | |||
| 185 | const type = types.get(typeName); | ||
| 186 | if (!type?.fields) return []; | ||
| 187 | |||
| 188 | return type.fields | ||
| 189 | .filter((f) => !f.name.startsWith("__")) | ||
| 190 | .map((field) => { | ||
| 191 | const args = field.args.map((arg) => | ||
| 192 | GraphqlArgument.make({ | ||
| 193 | name: arg.name, | ||
| 194 | typeName: formatTypeRef(arg.type), | ||
| 195 | required: isNonNull(arg.type), | ||
| 196 | description: arg.description ? Option.some(arg.description) : Option.none(), | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | |||
| 200 | const inputSchema = buildInputSchema(field.args, types); | ||
| 201 | |||
| 202 | return ExtractedField.make({ | ||
| 203 | fieldName: field.name, | ||
| 204 | kind, | ||
| 205 | description: field.description ? Option.some(field.description) : Option.none(), | ||
| 206 | arguments: args, | ||
| 207 | inputSchema: inputSchema ? Option.some(inputSchema) : Option.none(), | ||
| 208 | returnTypeName: unwrapTypeName(field.type), | ||
| 209 | }); | ||
| 210 | }); | ||
| 211 | }; | ||
| 212 | |||
| 213 | // --------------------------------------------------------------------------- | ||
| 214 | // Public API | ||
| 215 | // --------------------------------------------------------------------------- | ||
| 216 | |||
| 217 | export interface ExtractionOutput { | ||
| 218 | readonly result: ExtractionResult; | ||
| 219 | /** Shared JSON Schema definitions for INPUT_OBJECT and ENUM types. | ||
| 220 | * Tool input schemas use `$ref` pointers into these. */ | ||
| 221 | readonly definitions: Record<string, unknown>; | ||
| 222 | } | ||
| 223 | |||
| 224 | export const extract = ( | ||
| 225 | introspection: IntrospectionResult, | ||
| 226 | ): Effect.Effect<ExtractionOutput, GraphqlExtractionError> => | ||
| 227 | Effect.try({ | ||
| 228 | try: () => { | ||
| 229 | const schema = introspection.__schema; | ||
| 230 | const typeMap = new Map<string, IntrospectionType>(); | ||
| 231 | for (const t of schema.types) { | ||
| 232 | typeMap.set(t.name, t); | ||
| 233 | } | ||
| 234 | |||
| 235 | const definitions = buildDefinitions(typeMap); | ||
| 236 | |||
| 237 | const queryFields = extractFields(schema, "query", schema.queryType?.name, typeMap); | ||
| 238 | const mutationFields = extractFields(schema, "mutation", schema.mutationType?.name, typeMap); | ||
| 239 | |||
| 240 | return { | ||
| 241 | result: ExtractionResult.make({ | ||
| 242 | schemaName: Option.none(), | ||
| 243 | fields: [...queryFields, ...mutationFields], | ||
| 244 | }), | ||
| 245 | definitions, | ||
| 246 | }; | ||
| 247 | }, | ||
| 248 | catch: () => | ||
| 249 | new GraphqlExtractionError({ | ||
| 250 | message: "Failed to extract GraphQL schema", | ||
| 251 | }), | ||
| 252 | }); | ||
| @@ -1,31 +0,0 @@ | |||
| 1 | export { introspect, parseIntrospectionJson } from "./introspect"; | ||
| 2 | export { extract, type ExtractionOutput } from "./extract"; | ||
| 3 | export { invoke, invokeWithLayer, endpointForTelemetry } from "./invoke"; | ||
| 4 | export { | ||
| 5 | graphqlPlugin, | ||
| 6 | type GraphqlPluginExtension, | ||
| 7 | type GraphqlPluginOptions, | ||
| 8 | type AddGraphqlIntegrationInput, | ||
| 9 | type ConfigureGraphqlIntegrationInput, | ||
| 10 | } from "./plugin"; | ||
| 11 | |||
| 12 | export { | ||
| 13 | GraphqlIntrospectionError, | ||
| 14 | GraphqlExtractionError, | ||
| 15 | GraphqlInvocationError, | ||
| 16 | GraphqlAuthRequiredError, | ||
| 17 | } from "./errors"; | ||
| 18 | |||
| 19 | export { | ||
| 20 | ApiKeyHeaderTemplate, | ||
| 21 | ApiKeyQueryTemplate, | ||
| 22 | AuthTemplate, | ||
| 23 | ExtractedField, | ||
| 24 | ExtractionResult, | ||
| 25 | GraphqlArgument, | ||
| 26 | GraphqlIntegrationConfig, | ||
| 27 | GraphqlOperationKind, | ||
| 28 | InvocationResult, | ||
| 29 | OAuthTemplate, | ||
| 30 | OperationBinding, | ||
| 31 | } from "./types"; | ||
| @@ -1,343 +0,0 @@ | |||
| 1 | import { Effect, Option, Schema } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | import { GraphqlIntrospectionError } from "./errors"; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // Introspection query — standard GraphQL introspection | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | const INTROSPECTION_QUERY = ` | ||
| 11 | query IntrospectionQuery { | ||
| 12 | __schema { | ||
| 13 | queryType { name } | ||
| 14 | mutationType { name } | ||
| 15 | types { | ||
| 16 | kind | ||
| 17 | name | ||
| 18 | description | ||
| 19 | fields(includeDeprecated: false) { | ||
| 20 | name | ||
| 21 | description | ||
| 22 | args { | ||
| 23 | name | ||
| 24 | description | ||
| 25 | type { | ||
| 26 | ...TypeRef | ||
| 27 | } | ||
| 28 | defaultValue | ||
| 29 | } | ||
| 30 | type { | ||
| 31 | ...TypeRef | ||
| 32 | } | ||
| 33 | } | ||
| 34 | inputFields { | ||
| 35 | name | ||
| 36 | description | ||
| 37 | type { | ||
| 38 | ...TypeRef | ||
| 39 | } | ||
| 40 | defaultValue | ||
| 41 | } | ||
| 42 | enumValues(includeDeprecated: false) { | ||
| 43 | name | ||
| 44 | description | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | fragment TypeRef on __Type { | ||
| 51 | kind | ||
| 52 | name | ||
| 53 | ofType { | ||
| 54 | kind | ||
| 55 | name | ||
| 56 | ofType { | ||
| 57 | kind | ||
| 58 | name | ||
| 59 | ofType { | ||
| 60 | kind | ||
| 61 | name | ||
| 62 | ofType { | ||
| 63 | kind | ||
| 64 | name | ||
| 65 | ofType { | ||
| 66 | kind | ||
| 67 | name | ||
| 68 | ofType { | ||
| 69 | kind | ||
| 70 | name | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | `; | ||
| 79 | |||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | // Introspection result types | ||
| 82 | // --------------------------------------------------------------------------- | ||
| 83 | |||
| 84 | const IntrospectionTypeRefLeaf = Schema.Struct({ | ||
| 85 | kind: Schema.String, | ||
| 86 | name: Schema.NullOr(Schema.String), | ||
| 87 | ofType: Schema.optional(Schema.Null), | ||
| 88 | }); | ||
| 89 | |||
| 90 | const IntrospectionTypeRef5 = Schema.Struct({ | ||
| 91 | kind: Schema.String, | ||
| 92 | name: Schema.NullOr(Schema.String), | ||
| 93 | ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)), | ||
| 94 | }); | ||
| 95 | |||
| 96 | const IntrospectionTypeRef4 = Schema.Struct({ | ||
| 97 | kind: Schema.String, | ||
| 98 | name: Schema.NullOr(Schema.String), | ||
| 99 | ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)), | ||
| 100 | }); | ||
| 101 | |||
| 102 | const IntrospectionTypeRef3 = Schema.Struct({ | ||
| 103 | kind: Schema.String, | ||
| 104 | name: Schema.NullOr(Schema.String), | ||
| 105 | ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)), | ||
| 106 | }); | ||
| 107 | |||
| 108 | const IntrospectionTypeRef2 = Schema.Struct({ | ||
| 109 | kind: Schema.String, | ||
| 110 | name: Schema.NullOr(Schema.String), | ||
| 111 | ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)), | ||
| 112 | }); | ||
| 113 | |||
| 114 | const IntrospectionTypeRefSchema = Schema.Struct({ | ||
| 115 | kind: Schema.String, | ||
| 116 | name: Schema.NullOr(Schema.String), | ||
| 117 | ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef2)), | ||
| 118 | }); | ||
| 119 | |||
| 120 | const IntrospectionInputValueSchema = Schema.Struct({ | ||
| 121 | name: Schema.String, | ||
| 122 | description: Schema.NullOr(Schema.String), | ||
| 123 | type: IntrospectionTypeRefSchema, | ||
| 124 | defaultValue: Schema.NullOr(Schema.String), | ||
| 125 | }); | ||
| 126 | |||
| 127 | const IntrospectionFieldSchema = Schema.Struct({ | ||
| 128 | name: Schema.String, | ||
| 129 | description: Schema.NullOr(Schema.String), | ||
| 130 | args: Schema.Array(IntrospectionInputValueSchema), | ||
| 131 | type: IntrospectionTypeRefSchema, | ||
| 132 | }); | ||
| 133 | |||
| 134 | const IntrospectionTypeSchema = Schema.Struct({ | ||
| 135 | kind: Schema.String, | ||
| 136 | name: Schema.String, | ||
| 137 | description: Schema.NullOr(Schema.String), | ||
| 138 | fields: Schema.NullOr(Schema.Array(IntrospectionFieldSchema)), | ||
| 139 | inputFields: Schema.NullOr(Schema.Array(IntrospectionInputValueSchema)), | ||
| 140 | enumValues: Schema.NullOr( | ||
| 141 | Schema.Array( | ||
| 142 | Schema.Struct({ | ||
| 143 | name: Schema.String, | ||
| 144 | description: Schema.NullOr(Schema.String), | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | }); | ||
| 149 | |||
| 150 | const IntrospectionResultSchema = Schema.Struct({ | ||
| 151 | __schema: Schema.Struct({ | ||
| 152 | queryType: Schema.NullOr(Schema.Struct({ name: Schema.String })), | ||
| 153 | mutationType: Schema.NullOr(Schema.Struct({ name: Schema.String })), | ||
| 154 | types: Schema.Array(IntrospectionTypeSchema), | ||
| 155 | }), | ||
| 156 | }); | ||
| 157 | |||
| 158 | const IntrospectionResponseSchema = Schema.Struct({ | ||
| 159 | data: Schema.optional(IntrospectionResultSchema), | ||
| 160 | errors: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 161 | }); | ||
| 162 | |||
| 163 | const UpstreamErrorResponseSchema = Schema.Struct({ | ||
| 164 | message: Schema.optional(Schema.String), | ||
| 165 | errors: Schema.optional( | ||
| 166 | Schema.Array( | ||
| 167 | Schema.Struct({ | ||
| 168 | message: Schema.optional(Schema.String), | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | }); | ||
| 173 | |||
| 174 | const IntrospectionJsonSchema = Schema.Union([ | ||
| 175 | Schema.Struct({ data: IntrospectionResultSchema }), | ||
| 176 | IntrospectionResultSchema, | ||
| 177 | ]); | ||
| 178 | const JsonTextSchema = Schema.fromJsonString(Schema.Unknown); | ||
| 179 | |||
| 180 | const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema); | ||
| 181 | |||
| 182 | export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type; | ||
| 183 | export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type; | ||
| 184 | export type IntrospectionField = typeof IntrospectionFieldSchema.Type; | ||
| 185 | export type IntrospectionEnumValue = NonNullable< | ||
| 186 | (typeof IntrospectionTypeSchema.Type)["enumValues"] | ||
| 187 | >[number]; | ||
| 188 | export type IntrospectionType = typeof IntrospectionTypeSchema.Type; | ||
| 189 | export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"]; | ||
| 190 | export type IntrospectionResult = typeof IntrospectionResultSchema.Type; | ||
| 191 | |||
| 192 | const firstUpstreamErrorMessage = (value: unknown): string | null => { | ||
| 193 | const decoded = decodeUpstreamErrorResponse(value); | ||
| 194 | return Option.match(decoded, { | ||
| 195 | onNone: () => null, | ||
| 196 | onSome: (response) => { | ||
| 197 | if (response.message) return response.message; | ||
| 198 | for (const entry of response.errors ?? []) { | ||
| 199 | const message = entry.message; | ||
| 200 | if (message) return message; | ||
| 201 | } | ||
| 202 | return null; | ||
| 203 | }, | ||
| 204 | }); | ||
| 205 | }; | ||
| 206 | |||
| 207 | const redactUpstreamBody = (body: string): string => | ||
| 208 | body | ||
| 209 | .replaceAll( | ||
| 210 | /("(?:access_token|refresh_token|id_token|client_secret|token|authorization)"\s*:\s*")[^"]*(")/gi, | ||
| 211 | "$1[redacted]$2", | ||
| 212 | ) | ||
| 213 | .replaceAll( | ||
| 214 | /((?:access_token|refresh_token|id_token|client_secret|token|authorization)=)[^&\s]*/gi, | ||
| 215 | "$1[redacted]", | ||
| 216 | ) | ||
| 217 | .replaceAll( | ||
| 218 | /((?:authorization|access-token|refresh-token|id-token|client-secret|token)\s*:\s*)(?:bearer\s+)?[^\s,;]+/gi, | ||
| 219 | "$1[redacted]", | ||
| 220 | ); | ||
| 221 | |||
| 222 | const upstreamTextMessage = (body: string): string | null => { | ||
| 223 | const text = redactUpstreamBody(body.replaceAll(/\s+/g, " ").trim()); | ||
| 224 | if (text.length === 0) return null; | ||
| 225 | return text.length > 500 ? `${text.slice(0, 500)}...` : text; | ||
| 226 | }; | ||
| 227 | |||
| 228 | // --------------------------------------------------------------------------- | ||
| 229 | // Introspect a GraphQL endpoint | ||
| 230 | // --------------------------------------------------------------------------- | ||
| 231 | |||
| 232 | export const introspect = Effect.fn("GraphQL.introspect")(function* ( | ||
| 233 | endpoint: string, | ||
| 234 | headers?: Record<string, string>, | ||
| 235 | queryParams?: Record<string, string>, | ||
| 236 | ) { | ||
| 237 | const client = yield* HttpClient.HttpClient; | ||
| 238 | const requestEndpoint = | ||
| 239 | queryParams && Object.keys(queryParams).length > 0 | ||
| 240 | ? (() => { | ||
| 241 | const url = new URL(endpoint); | ||
| 242 | for (const [name, value] of Object.entries(queryParams)) { | ||
| 243 | url.searchParams.set(name, value); | ||
| 244 | } | ||
| 245 | return url.toString(); | ||
| 246 | })() | ||
| 247 | : endpoint; | ||
| 248 | |||
| 249 | let request = HttpClientRequest.post(requestEndpoint).pipe( | ||
| 250 | HttpClientRequest.setHeader("Content-Type", "application/json"), | ||
| 251 | HttpClientRequest.setHeader("Accept", "application/json"), | ||
| 252 | HttpClientRequest.setHeader("User-Agent", "executor-graphql"), | ||
| 253 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 254 | query: INTROSPECTION_QUERY, | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | if (headers) { | ||
| 259 | for (const [k, v] of Object.entries(headers)) { | ||
| 260 | request = HttpClientRequest.setHeader(request, k, v); | ||
| 261 | } | ||
| 262 | } | ||
| 263 | |||
| 264 | const response = yield* client.execute(request).pipe( | ||
| 265 | Effect.tapCause((cause) => Effect.logError("graphql introspection request failed", cause)), | ||
| 266 | Effect.mapError( | ||
| 267 | () => | ||
| 268 | new GraphqlIntrospectionError({ | ||
| 269 | message: "Failed to reach GraphQL endpoint", | ||
| 270 | }), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | if (response.status !== 200) { | ||
| 275 | const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); | ||
| 276 | const raw = responseText | ||
| 277 | ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( | ||
| 278 | Effect.catch(() => Effect.succeed(null)), | ||
| 279 | ) | ||
| 280 | : null; | ||
| 281 | const upstreamMessage = upstreamTextMessage( | ||
| 282 | (raw === null ? null : firstUpstreamErrorMessage(raw)) ?? responseText, | ||
| 283 | ); | ||
| 284 | return yield* new GraphqlIntrospectionError({ | ||
| 285 | message: upstreamMessage | ||
| 286 | ? `Introspection failed with status ${response.status}: ${upstreamMessage}` | ||
| 287 | : `Introspection failed with status ${response.status}`, | ||
| 288 | }); | ||
| 289 | } | ||
| 290 | |||
| 291 | const raw = yield* response.json.pipe( | ||
| 292 | Effect.tapCause((cause) => Effect.logError("graphql introspection JSON parse failed", cause)), | ||
| 293 | Effect.mapError( | ||
| 294 | () => | ||
| 295 | new GraphqlIntrospectionError({ | ||
| 296 | message: `Failed to parse introspection response as JSON`, | ||
| 297 | }), | ||
| 298 | ), | ||
| 299 | ); | ||
| 300 | |||
| 301 | const json = yield* Schema.decodeUnknownEffect(IntrospectionResponseSchema)(raw).pipe( | ||
| 302 | Effect.mapError( | ||
| 303 | () => | ||
| 304 | new GraphqlIntrospectionError({ | ||
| 305 | message: "Introspection response has an invalid shape", | ||
| 306 | }), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | |||
| 310 | if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { | ||
| 311 | const upstreamMessage = firstUpstreamErrorMessage(json); | ||
| 312 | return yield* new GraphqlIntrospectionError({ | ||
| 313 | message: upstreamMessage | ||
| 314 | ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` | ||
| 315 | : `Introspection returned ${json.errors.length} error(s)`, | ||
| 316 | }); | ||
| 317 | } | ||
| 318 | |||
| 319 | if (!json.data?.__schema) { | ||
| 320 | return yield* new GraphqlIntrospectionError({ | ||
| 321 | message: "Introspection response missing __schema", | ||
| 322 | }); | ||
| 323 | } | ||
| 324 | |||
| 325 | return json.data; | ||
| 326 | }); | ||
| 327 | |||
| 328 | // --------------------------------------------------------------------------- | ||
| 329 | // Parse an introspection result from a JSON string (for offline/text input) | ||
| 330 | // --------------------------------------------------------------------------- | ||
| 331 | |||
| 332 | export const parseIntrospectionJson = ( | ||
| 333 | text: string, | ||
| 334 | ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => | ||
| 335 | Schema.decodeUnknownEffect(Schema.fromJsonString(IntrospectionJsonSchema))(text).pipe( | ||
| 336 | Effect.map((parsed) => ("data" in parsed ? parsed.data : parsed)), | ||
| 337 | Effect.mapError( | ||
| 338 | () => | ||
| 339 | new GraphqlIntrospectionError({ | ||
| 340 | message: "Failed to parse introspection JSON", | ||
| 341 | }), | ||
| 342 | ), | ||
| 343 | ); | ||
| @@ -1,147 +0,0 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | import { GraphqlInvocationError } from "./errors"; | ||
| 5 | import { type OperationBinding, InvocationResult } from "./types"; | ||
| 6 | |||
| 7 | // removed: v1 `resolveHeaders` (secret-backed header map via | ||
| 8 | // `resolveSecretBackedMap`). In v2 a connection resolves to a single credential | ||
| 9 | // value that the plugin renders through the integration's auth template at | ||
| 10 | // invoke time — there is no per-header secret map to resolve. | ||
| 11 | |||
| 12 | const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, string>): string => { | ||
| 13 | if (Object.keys(queryParams).length === 0) return endpoint; | ||
| 14 | const url = new URL(endpoint); | ||
| 15 | for (const [name, value] of Object.entries(queryParams)) { | ||
| 16 | url.searchParams.set(name, value); | ||
| 17 | } | ||
| 18 | return url.toString(); | ||
| 19 | }; | ||
| 20 | |||
| 21 | export const endpointForTelemetry = (endpoint: string): string => { | ||
| 22 | if (!URL.canParse(endpoint)) return endpoint; | ||
| 23 | const url = new URL(endpoint); | ||
| 24 | url.search = ""; | ||
| 25 | url.hash = ""; | ||
| 26 | return url.toString(); | ||
| 27 | }; | ||
| 28 | |||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | // Response helpers | ||
| 31 | // --------------------------------------------------------------------------- | ||
| 32 | |||
| 33 | const isJsonContentType = (ct: string | null | undefined): boolean => { | ||
| 34 | if (!ct) return false; | ||
| 35 | const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? ""; | ||
| 36 | return ( | ||
| 37 | normalized === "application/json" || normalized.includes("+json") || normalized.includes("json") | ||
| 38 | ); | ||
| 39 | }; | ||
| 40 | |||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | // Public API — execute a GraphQL operation | ||
| 43 | // --------------------------------------------------------------------------- | ||
| 44 | |||
| 45 | export const invoke = Effect.fn("GraphQL.invoke")(function* ( | ||
| 46 | operation: OperationBinding, | ||
| 47 | args: Record<string, unknown>, | ||
| 48 | endpoint: string, | ||
| 49 | resolvedHeaders: Record<string, string>, | ||
| 50 | resolvedQueryParams: Record<string, string> = {}, | ||
| 51 | ) { | ||
| 52 | const client = yield* HttpClient.HttpClient; | ||
| 53 | const requestEndpoint = endpointWithQueryParams(endpoint, resolvedQueryParams); | ||
| 54 | const telemetryEndpoint = endpointForTelemetry(endpoint); | ||
| 55 | |||
| 56 | yield* Effect.annotateCurrentSpan({ | ||
| 57 | "http.method": "POST", | ||
| 58 | "http.url": telemetryEndpoint, | ||
| 59 | "plugin.graphql.endpoint": telemetryEndpoint, | ||
| 60 | "plugin.graphql.operation_kind": operation.kind, | ||
| 61 | "plugin.graphql.field_name": operation.fieldName, | ||
| 62 | "plugin.graphql.headers.resolved_count": Object.keys(resolvedHeaders).length, | ||
| 63 | "plugin.graphql.query_params.resolved_count": Object.keys(resolvedQueryParams).length, | ||
| 64 | }); | ||
| 65 | |||
| 66 | // Build the GraphQL request body | ||
| 67 | const variables: Record<string, unknown> = {}; | ||
| 68 | for (const varName of operation.variableNames) { | ||
| 69 | if (args[varName] !== undefined) { | ||
| 70 | variables[varName] = args[varName]; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | // Also pick up any variables from a "variables" container | ||
| 75 | if (typeof args.variables === "object" && args.variables !== null) { | ||
| 76 | Object.assign(variables, args.variables); | ||
| 77 | } | ||
| 78 | |||
| 79 | let request = HttpClientRequest.post(requestEndpoint).pipe( | ||
| 80 | HttpClientRequest.setHeader("Content-Type", "application/json"), | ||
| 81 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 82 | query: operation.operationString, | ||
| 83 | variables: Object.keys(variables).length > 0 ? variables : undefined, | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | |||
| 87 | for (const [name, value] of Object.entries(resolvedHeaders)) { | ||
| 88 | request = HttpClientRequest.setHeader(request, name, value); | ||
| 89 | } | ||
| 90 | |||
| 91 | const response = yield* client.execute(request).pipe( | ||
| 92 | Effect.mapError( | ||
| 93 | (err: unknown) => | ||
| 94 | new GraphqlInvocationError({ | ||
| 95 | message: "GraphQL request failed", | ||
| 96 | statusCode: Option.none(), | ||
| 97 | cause: err, | ||
| 98 | }), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | |||
| 102 | const status = response.status; | ||
| 103 | const contentType = response.headers["content-type"] ?? null; | ||
| 104 | |||
| 105 | const body: unknown = isJsonContentType(contentType) | ||
| 106 | ? yield* response.json.pipe(Effect.catch(() => response.text)) | ||
| 107 | : yield* response.text; | ||
| 108 | |||
| 109 | // GraphQL responses are always 200 with { data, errors } | ||
| 110 | const gqlBody = body as { data?: unknown; errors?: unknown[] } | null; | ||
| 111 | const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0; | ||
| 112 | |||
| 113 | yield* Effect.annotateCurrentSpan({ | ||
| 114 | "http.status_code": status, | ||
| 115 | "plugin.graphql.has_errors": hasErrors, | ||
| 116 | "plugin.graphql.error_count": hasErrors ? gqlBody!.errors!.length : 0, | ||
| 117 | }); | ||
| 118 | |||
| 119 | return InvocationResult.make({ | ||
| 120 | status, | ||
| 121 | data: gqlBody?.data ?? null, | ||
| 122 | errors: hasErrors ? gqlBody!.errors : null, | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 126 | // --------------------------------------------------------------------------- | ||
| 127 | // Invoke a GraphQL operation with a provided HttpClient layer | ||
| 128 | // --------------------------------------------------------------------------- | ||
| 129 | |||
| 130 | export const invokeWithLayer = ( | ||
| 131 | operation: OperationBinding, | ||
| 132 | args: Record<string, unknown>, | ||
| 133 | endpoint: string, | ||
| 134 | resolvedHeaders: Record<string, string>, | ||
| 135 | resolvedQueryParams: Record<string, string>, | ||
| 136 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | ||
| 137 | ) => | ||
| 138 | invoke(operation, args, endpoint, resolvedHeaders, resolvedQueryParams).pipe( | ||
| 139 | Effect.provide(httpClientLayer), | ||
| 140 | Effect.withSpan("plugin.graphql.invoke", { | ||
| 141 | attributes: { | ||
| 142 | "plugin.graphql.endpoint": endpointForTelemetry(endpoint), | ||
| 143 | "plugin.graphql.operation_kind": operation.kind, | ||
| 144 | "plugin.graphql.field_name": operation.fieldName, | ||
| 145 | }, | ||
| 146 | }), | ||
| 147 | ); | ||
| @@ -1,512 +0,0 @@ | |||
| 1 | import { describe, it, expect } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | AuthTemplateSlug, | ||
| 7 | ConnectionName, | ||
| 8 | createExecutor, | ||
| 9 | IntegrationSlug, | ||
| 10 | ToolAddress, | ||
| 11 | } from "@executor-js/sdk"; | ||
| 12 | import { | ||
| 13 | makeTestConfig, | ||
| 14 | memoryCredentialsPlugin, | ||
| 15 | serveTestHttpApp, | ||
| 16 | } from "@executor-js/sdk/testing"; | ||
| 17 | |||
| 18 | import { graphqlPlugin } from "./plugin"; | ||
| 19 | import { endpointForTelemetry } from "./invoke"; | ||
| 20 | import { introspect } from "./introspect"; | ||
| 21 | import type { IntrospectionResult } from "./introspect"; | ||
| 22 | import { | ||
| 23 | makeGreetingGraphqlSchema, | ||
| 24 | serveGraphqlFailureTestServer, | ||
| 25 | serveGraphqlTestServer, | ||
| 26 | } from "../testing"; | ||
| 27 | |||
| 28 | // removed: v1's scope-stack, secret-table, and credential-binding tests | ||
| 29 | // (initial credential bindings, user-scoped org introspection, multi-scope | ||
| 30 | // shadowing add/remove/configure, org-vs-user secret/connection collision, | ||
| 31 | // `usagesForSecret`/`usagesForConnection`, `secrets.remove` refusal). v2 has no | ||
| 32 | // scope stack (owner is explicit in the address), no secret table, and no | ||
| 33 | // credential bindings — a connection IS the credential and resolves its value | ||
| 34 | // through a provider. The cases below cover the v2 surface: add integration, | ||
| 35 | // per-connection tool production, auth-template rendering at invoke, upstream | ||
| 36 | // failure reshaping, and URL detection. | ||
| 37 | |||
| 38 | const GRAPHQL = IntegrationSlug.make("greeting_graph"); | ||
| 39 | const BEARER = AuthTemplateSlug.make("oauth"); | ||
| 40 | |||
| 41 | const introspectionResult: IntrospectionResult = { | ||
| 42 | __schema: { | ||
| 43 | queryType: { name: "Query" }, | ||
| 44 | mutationType: { name: "Mutation" }, | ||
| 45 | types: [ | ||
| 46 | { | ||
| 47 | kind: "OBJECT", | ||
| 48 | name: "Query", | ||
| 49 | description: null, | ||
| 50 | fields: [ | ||
| 51 | { | ||
| 52 | name: "hello", | ||
| 53 | description: "Say hello", | ||
| 54 | args: [ | ||
| 55 | { | ||
| 56 | name: "name", | ||
| 57 | description: null, | ||
| 58 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 59 | defaultValue: null, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 63 | }, | ||
| 64 | ], | ||
| 65 | inputFields: null, | ||
| 66 | enumValues: null, | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | kind: "OBJECT", | ||
| 70 | name: "Mutation", | ||
| 71 | description: null, | ||
| 72 | fields: [ | ||
| 73 | { | ||
| 74 | name: "setGreeting", | ||
| 75 | description: "Set greeting message", | ||
| 76 | args: [ | ||
| 77 | { | ||
| 78 | name: "message", | ||
| 79 | description: null, | ||
| 80 | type: { | ||
| 81 | kind: "NON_NULL", | ||
| 82 | name: null, | ||
| 83 | ofType: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 84 | }, | ||
| 85 | defaultValue: null, | ||
| 86 | }, | ||
| 87 | ], | ||
| 88 | type: { kind: "SCALAR", name: "String", ofType: null }, | ||
| 89 | }, | ||
| 90 | ], | ||
| 91 | inputFields: null, | ||
| 92 | enumValues: null, | ||
| 93 | }, | ||
| 94 | { | ||
| 95 | kind: "SCALAR", | ||
| 96 | name: "String", | ||
| 97 | description: null, | ||
| 98 | fields: null, | ||
| 99 | inputFields: null, | ||
| 100 | enumValues: null, | ||
| 101 | }, | ||
| 102 | ], | ||
| 103 | }, | ||
| 104 | }; | ||
| 105 | |||
| 106 | const introspectionJson = JSON.stringify({ data: introspectionResult }); | ||
| 107 | const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); | ||
| 108 | |||
| 109 | describe("graphqlPlugin introspection error handling", () => { | ||
| 110 | it("uses query-free endpoints for invocation attributes", () => { | ||
| 111 | expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe( | ||
| 112 | "https://api.example.test/graphql", | ||
| 113 | ); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it.effect("includes redacted upstream text in introspection status errors", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const server = yield* serveGraphqlFailureTestServer({ | ||
| 119 | status: 500, | ||
| 120 | body: 'upstream failed {"access_token":"secret-value"} token=another-secret', | ||
| 121 | }); | ||
| 122 | |||
| 123 | const error = yield* introspect(server.endpoint).pipe( | ||
| 124 | Effect.provide(server.httpClientLayer), | ||
| 125 | Effect.flip, | ||
| 126 | ); | ||
| 127 | |||
| 128 | expect(error).toHaveProperty( | ||
| 129 | "message", | ||
| 130 | 'Introspection failed with status 500: upstream failed {"access_token":"[redacted]"} token=[redacted]', | ||
| 131 | ); | ||
| 132 | expect(error).not.toHaveProperty("message", expect.stringContaining("secret-value")); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("includes safe upstream JSON messages in introspection status errors", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const server = yield* serveTestHttpApp(() => | ||
| 139 | Effect.succeed( | ||
| 140 | HttpServerResponse.jsonUnsafe( | ||
| 141 | { message: "Resource protected by organization SSO" }, | ||
| 142 | { status: 403 }, | ||
| 143 | ), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | const error = yield* introspect(server.url("/graphql")).pipe( | ||
| 148 | Effect.provide(server.httpClientLayer), | ||
| 149 | Effect.flip, | ||
| 150 | ); | ||
| 151 | |||
| 152 | expect(error).toHaveProperty( | ||
| 153 | "message", | ||
| 154 | "Introspection failed with status 403: Resource protected by organization SSO", | ||
| 155 | ); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | }); | ||
| 159 | |||
| 160 | describe("graphqlPlugin integration + connection lifecycle", () => { | ||
| 161 | it.effect("adds an integration by introspecting the live GraphQL endpoint", () => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | const server = yield* serveGreetingServer; | ||
| 164 | const executor = yield* createExecutor( | ||
| 165 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | const result = yield* executor["graphql-greenfield"].addIntegration({ | ||
| 169 | slug: "live_graph", | ||
| 170 | endpoint: server.endpoint, | ||
| 171 | }); | ||
| 172 | expect(result).toEqual({ slug: "live_graph", toolCount: 2 }); | ||
| 173 | |||
| 174 | const requests = yield* server.requests; | ||
| 175 | expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true); | ||
| 176 | |||
| 177 | // A connection produces the integration's tools, addressed with its owner. | ||
| 178 | yield* executor.connections.create({ | ||
| 179 | owner: "org", | ||
| 180 | name: ConnectionName.make("main"), | ||
| 181 | integration: IntegrationSlug.make("live_graph"), | ||
| 182 | template: BEARER, | ||
| 183 | value: "token", | ||
| 184 | }); | ||
| 185 | |||
| 186 | const tools = yield* executor.tools.list(); | ||
| 187 | expect( | ||
| 188 | tools | ||
| 189 | .map((t) => String(t.address)) | ||
| 190 | .filter((address) => address.startsWith("tools.live_graph.")) | ||
| 191 | .sort(), | ||
| 192 | ).toEqual([ | ||
| 193 | "tools.live_graph.org.main.mutation.setGreeting", | ||
| 194 | "tools.live_graph.org.main.query.hello", | ||
| 195 | ]); | ||
| 196 | }), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("registers tools from introspection JSON", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const executor = yield* createExecutor( | ||
| 202 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 203 | ); | ||
| 204 | |||
| 205 | const result = yield* executor["graphql-greenfield"].addIntegration({ | ||
| 206 | slug: "test_api", | ||
| 207 | endpoint: "http://localhost:4000/graphql", | ||
| 208 | introspectionJson, | ||
| 209 | }); | ||
| 210 | expect(result).toEqual({ slug: "test_api", toolCount: 2 }); | ||
| 211 | |||
| 212 | yield* executor.connections.create({ | ||
| 213 | owner: "org", | ||
| 214 | name: ConnectionName.make("c"), | ||
| 215 | integration: IntegrationSlug.make("test_api"), | ||
| 216 | template: BEARER, | ||
| 217 | value: "token", | ||
| 218 | }); | ||
| 219 | |||
| 220 | const tools = yield* executor.tools.list(); | ||
| 221 | const byName = new Map(tools.map((t) => [String(t.name), t] as const)); | ||
| 222 | expect(byName.get("query.hello")?.description).toBe("Say hello"); | ||
| 223 | expect(byName.get("mutation.setGreeting")?.description).toBe("Set greeting message"); | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("mutations require approval; queries do not", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const executor = yield* createExecutor( | ||
| 230 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 231 | ); | ||
| 232 | |||
| 233 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 234 | slug: "approval_test", | ||
| 235 | endpoint: "http://localhost:4000/graphql", | ||
| 236 | introspectionJson, | ||
| 237 | }); | ||
| 238 | yield* executor.connections.create({ | ||
| 239 | owner: "org", | ||
| 240 | name: ConnectionName.make("c"), | ||
| 241 | integration: IntegrationSlug.make("approval_test"), | ||
| 242 | template: BEARER, | ||
| 243 | value: "token", | ||
| 244 | }); | ||
| 245 | |||
| 246 | const tools = yield* executor.tools.list(); | ||
| 247 | const mutationTool = tools.find((t) => String(t.name) === "mutation.setGreeting"); | ||
| 248 | expect(mutationTool?.annotations?.requiresApproval).toBe(true); | ||
| 249 | expect(mutationTool?.annotations?.approvalDescription).toBe("mutation setGreeting"); | ||
| 250 | const queryTool = tools.find((t) => String(t.name) === "query.hello"); | ||
| 251 | expect(queryTool?.annotations?.requiresApproval).toBeFalsy(); | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | it.effect("removing a connection removes its tools; the integration remains", () => | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const executor = yield* createExecutor( | ||
| 258 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 259 | ); | ||
| 260 | |||
| 261 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 262 | slug: "removable", | ||
| 263 | endpoint: "http://localhost:4000/graphql", | ||
| 264 | introspectionJson, | ||
| 265 | }); | ||
| 266 | yield* executor.connections.create({ | ||
| 267 | owner: "org", | ||
| 268 | name: ConnectionName.make("c"), | ||
| 269 | integration: IntegrationSlug.make("removable"), | ||
| 270 | template: BEARER, | ||
| 271 | value: "token", | ||
| 272 | }); | ||
| 273 | |||
| 274 | const listRemovableToolAddresses = Effect.map(executor.tools.list(), (tools) => | ||
| 275 | tools | ||
| 276 | .map((t) => String(t.address)) | ||
| 277 | .filter((address) => address.startsWith("tools.removable.")), | ||
| 278 | ); | ||
| 279 | |||
| 280 | expect((yield* listRemovableToolAddresses).length).toBe(2); | ||
| 281 | |||
| 282 | yield* executor.connections.remove({ | ||
| 283 | owner: "org", | ||
| 284 | integration: IntegrationSlug.make("removable"), | ||
| 285 | name: ConnectionName.make("c"), | ||
| 286 | }); | ||
| 287 | |||
| 288 | expect(yield* listRemovableToolAddresses).toEqual([]); | ||
| 289 | // Integration is still in the catalog. | ||
| 290 | const integration = yield* executor.integrations.get(IntegrationSlug.make("removable")); | ||
| 291 | expect(integration?.kind).toBe("graphql-greenfield"); | ||
| 292 | }), | ||
| 293 | ); | ||
| 294 | }); | ||
| 295 | |||
| 296 | describe("graphqlPlugin invocation", () => { | ||
| 297 | it.effect("renders the connection value as a bearer header and invokes the query", () => | ||
| 298 | Effect.gen(function* () { | ||
| 299 | const server = yield* serveGraphqlTestServer({ | ||
| 300 | schema: makeGreetingGraphqlSchema(), | ||
| 301 | auth: { | ||
| 302 | validateAuthorization: (authorization) => | ||
| 303 | Effect.succeed(authorization === "Bearer secret-token"), | ||
| 304 | }, | ||
| 305 | }); | ||
| 306 | const executor = yield* createExecutor( | ||
| 307 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 308 | ); | ||
| 309 | |||
| 310 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 311 | slug: GRAPHQL, | ||
| 312 | endpoint: server.endpoint, | ||
| 313 | introspectionHeaders: { Authorization: "Bearer secret-token" }, | ||
| 314 | }); | ||
| 315 | yield* executor.connections.create({ | ||
| 316 | owner: "org", | ||
| 317 | name: ConnectionName.make("main"), | ||
| 318 | integration: GRAPHQL, | ||
| 319 | template: BEARER, | ||
| 320 | value: "secret-token", | ||
| 321 | }); | ||
| 322 | yield* server.clearRequests; | ||
| 323 | |||
| 324 | const result = yield* executor.execute( | ||
| 325 | ToolAddress.make("tools.greeting_graph.org.main.query.hello"), | ||
| 326 | { name: "Ada" }, | ||
| 327 | ); | ||
| 328 | expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } }); | ||
| 329 | |||
| 330 | const requests = yield* server.requests; | ||
| 331 | expect(requests[0]?.headers.authorization).toBe("Bearer secret-token"); | ||
| 332 | expect(requests[0]?.payload.variables).toEqual({ name: "Ada" }); | ||
| 333 | }), | ||
| 334 | ); | ||
| 335 | |||
| 336 | it.effect("renders an apiKey header template with a prefix", () => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const server = yield* serveGraphqlTestServer({ | ||
| 339 | schema: makeGreetingGraphqlSchema(), | ||
| 340 | auth: { | ||
| 341 | validateAuthorization: (authorization) => | ||
| 342 | Effect.succeed(authorization === "token abc123"), | ||
| 343 | }, | ||
| 344 | }); | ||
| 345 | const executor = yield* createExecutor( | ||
| 346 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 347 | ); | ||
| 348 | |||
| 349 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 350 | slug: "apikey_graph", | ||
| 351 | endpoint: server.endpoint, | ||
| 352 | introspectionHeaders: { Authorization: "token abc123" }, | ||
| 353 | authentication: [ | ||
| 354 | { | ||
| 355 | slug: "apiKey", | ||
| 356 | type: "apiKey", | ||
| 357 | in: "header", | ||
| 358 | name: "Authorization", | ||
| 359 | prefix: "token ", | ||
| 360 | }, | ||
| 361 | ], | ||
| 362 | }); | ||
| 363 | yield* executor.connections.create({ | ||
| 364 | owner: "org", | ||
| 365 | name: ConnectionName.make("main"), | ||
| 366 | integration: IntegrationSlug.make("apikey_graph"), | ||
| 367 | template: AuthTemplateSlug.make("apiKey"), | ||
| 368 | value: "abc123", | ||
| 369 | }); | ||
| 370 | yield* server.clearRequests; | ||
| 371 | |||
| 372 | const result = yield* executor.execute( | ||
| 373 | ToolAddress.make("tools.apikey_graph.org.main.query.hello"), | ||
| 374 | { name: "Ada" }, | ||
| 375 | ); | ||
| 376 | expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } }); | ||
| 377 | const requests = yield* server.requests; | ||
| 378 | expect(requests[0]?.headers.authorization).toBe("token abc123"); | ||
| 379 | }), | ||
| 380 | ); | ||
| 381 | |||
| 382 | it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () => | ||
| 383 | Effect.gen(function* () { | ||
| 384 | const server = yield* serveTestHttpApp((request) => | ||
| 385 | Effect.gen(function* () { | ||
| 386 | const webRequest = yield* HttpServerRequest.toWeb(request); | ||
| 387 | const body = yield* Effect.promise(() => webRequest.text()); | ||
| 388 | if (body.includes("__schema")) { | ||
| 389 | return HttpServerResponse.jsonUnsafe({ data: introspectionResult }); | ||
| 390 | } | ||
| 391 | return HttpServerResponse.text("temporary upstream outage", { | ||
| 392 | status: 503, | ||
| 393 | contentType: "text/plain", | ||
| 394 | }); | ||
| 395 | }).pipe(Effect.orDie), | ||
| 396 | ); | ||
| 397 | const executor = yield* createExecutor( | ||
| 398 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 399 | ); | ||
| 400 | |||
| 401 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 402 | slug: "http_error_graph", | ||
| 403 | endpoint: server.url("/graphql"), | ||
| 404 | }); | ||
| 405 | yield* executor.connections.create({ | ||
| 406 | owner: "org", | ||
| 407 | name: ConnectionName.make("c"), | ||
| 408 | integration: IntegrationSlug.make("http_error_graph"), | ||
| 409 | template: BEARER, | ||
| 410 | value: "token", | ||
| 411 | }); | ||
| 412 | |||
| 413 | const result = yield* executor.execute( | ||
| 414 | ToolAddress.make("tools.http_error_graph.org.c.query.hello"), | ||
| 415 | { name: "Ada" }, | ||
| 416 | ); | ||
| 417 | |||
| 418 | expect(result).toMatchObject({ | ||
| 419 | ok: false, | ||
| 420 | error: { | ||
| 421 | code: "graphql_http_error", | ||
| 422 | status: 503, | ||
| 423 | message: "GraphQL request failed with HTTP 503", | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | }), | ||
| 427 | ); | ||
| 428 | |||
| 429 | it.effect("reshapes a 401 upstream rejection into an auth failure", () => | ||
| 430 | Effect.gen(function* () { | ||
| 431 | const server = yield* serveTestHttpApp((request) => | ||
| 432 | Effect.gen(function* () { | ||
| 433 | const webRequest = yield* HttpServerRequest.toWeb(request); | ||
| 434 | const body = yield* Effect.promise(() => webRequest.text()); | ||
| 435 | if (body.includes("__schema")) { | ||
| 436 | return HttpServerResponse.jsonUnsafe({ data: introspectionResult }); | ||
| 437 | } | ||
| 438 | // A non-GraphQL 401 (no `errors` array) so the upstream-rejection | ||
| 439 | // branch — not the GraphQL-errors branch — reshapes it. | ||
| 440 | return HttpServerResponse.text("Unauthorized", { | ||
| 441 | status: 401, | ||
| 442 | contentType: "text/plain", | ||
| 443 | }); | ||
| 444 | }).pipe(Effect.orDie), | ||
| 445 | ); | ||
| 446 | const executor = yield* createExecutor( | ||
| 447 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 448 | ); | ||
| 449 | |||
| 450 | yield* executor["graphql-greenfield"].addIntegration({ | ||
| 451 | slug: "rejected_graph", | ||
| 452 | endpoint: server.url("/graphql"), | ||
| 453 | }); | ||
| 454 | yield* executor.connections.create({ | ||
| 455 | owner: "org", | ||
| 456 | name: ConnectionName.make("c"), | ||
| 457 | integration: IntegrationSlug.make("rejected_graph"), | ||
| 458 | template: BEARER, | ||
| 459 | value: "stale-token", | ||
| 460 | }); | ||
| 461 | |||
| 462 | const result = yield* executor.execute( | ||
| 463 | ToolAddress.make("tools.rejected_graph.org.c.query.hello"), | ||
| 464 | { name: "Ada" }, | ||
| 465 | ); | ||
| 466 | |||
| 467 | expect(result).toMatchObject({ | ||
| 468 | ok: false, | ||
| 469 | error: { | ||
| 470 | code: "connection_rejected", | ||
| 471 | status: 401, | ||
| 472 | details: { category: "authentication" }, | ||
| 473 | }, | ||
| 474 | }); | ||
| 475 | }), | ||
| 476 | ); | ||
| 477 | }); | ||
| 478 | |||
| 479 | describe("graphqlPlugin detect URL-token fallback", () => { | ||
| 480 | // Port 1 connection-refuses immediately, so introspection always fails and | ||
| 481 | // the URL-token fallback is the only thing that can produce a candidate. | ||
| 482 | it.effect("returns low-confidence candidate when path has /graphql segment", () => | ||
| 483 | Effect.gen(function* () { | ||
| 484 | const executor = yield* createExecutor( | ||
| 485 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 486 | ); | ||
| 487 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/graphql"); | ||
| 488 | const gql = results.find((r) => r.kind === "graphql"); | ||
| 489 | expect(gql?.confidence).toBe("low"); | ||
| 490 | }), | ||
| 491 | ); | ||
| 492 | |||
| 493 | it.effect("does not match graphql as a substring", () => | ||
| 494 | Effect.gen(function* () { | ||
| 495 | const executor = yield* createExecutor( | ||
| 496 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 497 | ); | ||
| 498 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/graphqlite"); | ||
| 499 | expect(results.find((r) => r.kind === "graphql")).toBeUndefined(); | ||
| 500 | }), | ||
| 501 | ); | ||
| 502 | |||
| 503 | it.effect("returns null when no token match and introspection fails", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const executor = yield* createExecutor( | ||
| 506 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 507 | ); | ||
| 508 | const results = yield* executor.integrations.detect("http://127.0.0.1:1/api/v1"); | ||
| 509 | expect(results.find((r) => r.kind === "graphql")).toBeUndefined(); | ||
| 510 | }), | ||
| 511 | ); | ||
| 512 | }); | ||
| @@ -1,730 +0,0 @@ | |||
| 1 | import { Effect, Match, Option, Schema } from "effect"; | ||
| 2 | import type { Layer } from "effect"; | ||
| 3 | import { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | authToolFailure, | ||
| 7 | definePlugin, | ||
| 8 | IntegrationAlreadyExistsError, | ||
| 9 | IntegrationSlug, | ||
| 10 | ToolName, | ||
| 11 | ToolResult, | ||
| 12 | tool, | ||
| 13 | type IntegrationConfig, | ||
| 14 | type PluginCtx, | ||
| 15 | type ToolAnnotations, | ||
| 16 | type ToolDef, | ||
| 17 | type ToolInvocationCredential, | ||
| 18 | } from "@executor-js/sdk"; | ||
| 19 | |||
| 20 | import { | ||
| 21 | introspect, | ||
| 22 | parseIntrospectionJson, | ||
| 23 | type IntrospectionResult, | ||
| 24 | type IntrospectionType, | ||
| 25 | type IntrospectionField, | ||
| 26 | type IntrospectionTypeRef, | ||
| 27 | } from "./introspect"; | ||
| 28 | import { extract } from "./extract"; | ||
| 29 | import { | ||
| 30 | GraphqlAuthRequiredError, | ||
| 31 | GraphqlExtractionError, | ||
| 32 | GraphqlIntrospectionError, | ||
| 33 | GraphqlInvocationError, | ||
| 34 | } from "./errors"; | ||
| 35 | import { invokeWithLayer } from "./invoke"; | ||
| 36 | import { graphqlPresets } from "./presets"; | ||
| 37 | import { | ||
| 38 | type ApiKeyHeaderTemplate, | ||
| 39 | type ApiKeyQueryTemplate, | ||
| 40 | AuthTemplate, | ||
| 41 | ExtractedField, | ||
| 42 | GraphqlIntegrationConfig, | ||
| 43 | type GraphqlOperationKind, | ||
| 44 | type OAuthTemplate, | ||
| 45 | OperationBinding, | ||
| 46 | } from "./types"; | ||
| 47 | |||
| 48 | // --------------------------------------------------------------------------- | ||
| 49 | // Plugin config | ||
| 50 | // --------------------------------------------------------------------------- | ||
| 51 | |||
| 52 | const GraphqlErrorBody = Schema.Struct({ message: Schema.String }); | ||
| 53 | const GraphqlErrorsBody = Schema.Array(Schema.Unknown); | ||
| 54 | const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody); | ||
| 55 | const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); | ||
| 56 | const decodeIntegrationConfig = Schema.decodeUnknownOption(GraphqlIntegrationConfig); | ||
| 57 | |||
| 58 | const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => | ||
| 59 | Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); | ||
| 60 | |||
| 61 | const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => | ||
| 62 | errors | ||
| 63 | .map((error: unknown) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message) | ||
| 64 | .find((message: string | undefined) => message !== undefined && message.length > 0); | ||
| 65 | |||
| 66 | const GRAPHQL_PLUGIN_ID = "graphql-greenfield"; | ||
| 67 | |||
| 68 | /** The default auth template a connection applies through when the integration | ||
| 69 | * declares no explicit `authentication`. Renders the value as a bearer header. */ | ||
| 70 | const DEFAULT_OAUTH_TEMPLATE_SLUG = "oauth"; | ||
| 71 | |||
| 72 | // --------------------------------------------------------------------------- | ||
| 73 | // Public input contracts — derived from the Effect Schemas so the wire shape | ||
| 74 | // and the TypeScript type stay in lockstep. | ||
| 75 | // --------------------------------------------------------------------------- | ||
| 76 | |||
| 77 | const AddGraphqlIntegrationInputSchema = Schema.Struct({ | ||
| 78 | /** The integration slug (catalog identity). */ | ||
| 79 | slug: Schema.String, | ||
| 80 | /** The GraphQL endpoint URL. */ | ||
| 81 | endpoint: Schema.String, | ||
| 82 | /** Display description for the catalog. */ | ||
| 83 | description: Schema.optional(Schema.String), | ||
| 84 | /** Optional introspection JSON (when the endpoint can't be reached live). */ | ||
| 85 | introspectionJson: Schema.optional(Schema.String), | ||
| 86 | /** Auth methods a connection can apply through. Defaults to a single bearer | ||
| 87 | * oauth template if omitted. */ | ||
| 88 | authentication: Schema.optional(Schema.Array(AuthTemplate)), | ||
| 89 | /** Headers applied to the add-time introspection request only. */ | ||
| 90 | introspectionHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 91 | }); | ||
| 92 | export type AddGraphqlIntegrationInput = typeof AddGraphqlIntegrationInputSchema.Type; | ||
| 93 | |||
| 94 | const ConfigureGraphqlIntegrationInputSchema = Schema.Struct({ | ||
| 95 | slug: Schema.String, | ||
| 96 | endpoint: Schema.optional(Schema.String), | ||
| 97 | description: Schema.optional(Schema.String), | ||
| 98 | authentication: Schema.optional(Schema.Array(AuthTemplate)), | ||
| 99 | }); | ||
| 100 | /** Patch applied by `configureIntegration` (the `slug` identifies which one). */ | ||
| 101 | export type ConfigureGraphqlIntegrationInput = Omit< | ||
| 102 | typeof ConfigureGraphqlIntegrationInputSchema.Type, | ||
| 103 | "slug" | ||
| 104 | >; | ||
| 105 | |||
| 106 | const ConfigureGraphqlIntegrationOutputSchema = Schema.Struct({ | ||
| 107 | configured: Schema.Boolean, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const StaticAddIntegrationOutputSchema = Schema.Struct({ | ||
| 111 | slug: Schema.String, | ||
| 112 | toolCount: Schema.Number, | ||
| 113 | }); | ||
| 114 | const StaticGetIntegrationInputSchema = Schema.Struct({ slug: Schema.String }); | ||
| 115 | const StaticGetIntegrationOutputSchema = Schema.Struct({ | ||
| 116 | integration: Schema.NullOr(Schema.Unknown), | ||
| 117 | }); | ||
| 118 | |||
| 119 | const toStandard = <A, I>(schema: Schema.Codec<A, I>) => | ||
| 120 | Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema)); | ||
| 121 | |||
| 122 | const StaticAddIntegrationInputStandardSchema = toStandard(AddGraphqlIntegrationInputSchema); | ||
| 123 | const StaticAddIntegrationOutputStandardSchema = toStandard(StaticAddIntegrationOutputSchema); | ||
| 124 | const StaticGetIntegrationInputStandardSchema = toStandard(StaticGetIntegrationInputSchema); | ||
| 125 | const StaticGetIntegrationOutputStandardSchema = toStandard(StaticGetIntegrationOutputSchema); | ||
| 126 | const StaticConfigureIntegrationInputStandardSchema = toStandard( | ||
| 127 | ConfigureGraphqlIntegrationInputSchema, | ||
| 128 | ); | ||
| 129 | const StaticConfigureIntegrationOutputStandardSchema = toStandard( | ||
| 130 | ConfigureGraphqlIntegrationOutputSchema, | ||
| 131 | ); | ||
| 132 | |||
| 133 | const graphqlToolFailure = (code: string, message: string, details?: unknown) => | ||
| 134 | ToolResult.fail({ | ||
| 135 | code, | ||
| 136 | message, | ||
| 137 | ...(details === undefined ? {} : { details }), | ||
| 138 | }); | ||
| 139 | |||
| 140 | const graphqlAuthToolFailure = (failure: GraphqlAuthRequiredError) => | ||
| 141 | authToolFailure({ | ||
| 142 | code: failure.code, | ||
| 143 | message: failure.message, | ||
| 144 | source: { id: failure.integration }, | ||
| 145 | credential: { | ||
| 146 | kind: failure.credentialKind, | ||
| 147 | ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}), | ||
| 148 | }, | ||
| 149 | ...(failure.status !== undefined ? { status: failure.status } : {}), | ||
| 150 | ...(failure.details !== undefined | ||
| 151 | ? { | ||
| 152 | upstream: { | ||
| 153 | ...(failure.status !== undefined ? { status: failure.status } : {}), | ||
| 154 | details: failure.details, | ||
| 155 | }, | ||
| 156 | } | ||
| 157 | : {}), | ||
| 158 | recovery: { configureSourceTool: "executor.graphql-greenfield.configureIntegration" }, | ||
| 159 | }); | ||
| 160 | |||
| 161 | // --------------------------------------------------------------------------- | ||
| 162 | // Detection helpers | ||
| 163 | // --------------------------------------------------------------------------- | ||
| 164 | |||
| 165 | /** Match `token` as a separator-bounded run inside a URL hostname or path, | ||
| 166 | * used as a low-confidence detection hint when introspection fails. */ | ||
| 167 | const urlMatchesToken = (url: URL, token: string): boolean => { | ||
| 168 | const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i"); | ||
| 169 | return re.test(url.hostname) || re.test(url.pathname); | ||
| 170 | }; | ||
| 171 | |||
| 172 | /** Derive a slug from an endpoint URL. */ | ||
| 173 | const slugFromEndpoint = (endpoint: string): string => { | ||
| 174 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL construction throws; this helper intentionally falls back to the stable default slug | ||
| 175 | try { | ||
| 176 | const url = new URL(endpoint); | ||
| 177 | return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); | ||
| 178 | } catch { | ||
| 179 | return "graphql"; | ||
| 180 | } | ||
| 181 | }; | ||
| 182 | |||
| 183 | // --------------------------------------------------------------------------- | ||
| 184 | // Operation-string building | ||
| 185 | // --------------------------------------------------------------------------- | ||
| 186 | |||
| 187 | const formatTypeRef = (ref: IntrospectionTypeRef): string => | ||
| 188 | Match.value(ref.kind).pipe( | ||
| 189 | Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")), | ||
| 190 | Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")), | ||
| 191 | Match.option, | ||
| 192 | Option.getOrElse(() => ref.name ?? "Unknown"), | ||
| 193 | ); | ||
| 194 | |||
| 195 | const unwrapTypeName = (ref: IntrospectionTypeRef): string => { | ||
| 196 | if (ref.name) return ref.name; | ||
| 197 | if (ref.ofType) return unwrapTypeName(ref.ofType); | ||
| 198 | return "Unknown"; | ||
| 199 | }; | ||
| 200 | |||
| 201 | const buildSelectionSet = ( | ||
| 202 | ref: IntrospectionTypeRef, | ||
| 203 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 204 | depth: number, | ||
| 205 | seen: Set<string>, | ||
| 206 | ): string => { | ||
| 207 | if (depth > 2) return ""; | ||
| 208 | |||
| 209 | const leafName = unwrapTypeName(ref); | ||
| 210 | if (seen.has(leafName)) return ""; | ||
| 211 | |||
| 212 | const objectType = types.get(leafName); | ||
| 213 | if (!objectType?.fields) return ""; | ||
| 214 | |||
| 215 | const kind = objectType.kind; | ||
| 216 | if (kind === "SCALAR" || kind === "ENUM") return ""; | ||
| 217 | |||
| 218 | seen.add(leafName); | ||
| 219 | |||
| 220 | const subFields = objectType.fields | ||
| 221 | .filter((f: IntrospectionField) => !f.name.startsWith("__")) | ||
| 222 | .slice(0, 12) | ||
| 223 | .map((f: IntrospectionField) => { | ||
| 224 | const sub = buildSelectionSet(f.type, types, depth + 1, seen); | ||
| 225 | return sub ? `${f.name} ${sub}` : f.name; | ||
| 226 | }); | ||
| 227 | |||
| 228 | seen.delete(leafName); | ||
| 229 | |||
| 230 | return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : ""; | ||
| 231 | }; | ||
| 232 | |||
| 233 | const buildOperationStringForField = ( | ||
| 234 | kind: GraphqlOperationKind, | ||
| 235 | field: IntrospectionField, | ||
| 236 | types: ReadonlyMap<string, IntrospectionType>, | ||
| 237 | ): string => { | ||
| 238 | const opType = kind === "query" ? "query" : "mutation"; | ||
| 239 | |||
| 240 | const varDefs = field.args.map((arg) => { | ||
| 241 | const typeName = formatTypeRef(arg.type); | ||
| 242 | return `$${arg.name}: ${typeName}`; | ||
| 243 | }); | ||
| 244 | |||
| 245 | const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`); | ||
| 246 | const selectionSet = buildSelectionSet(field.type, types, 0, new Set()); | ||
| 247 | |||
| 248 | const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : ""; | ||
| 249 | const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : ""; | ||
| 250 | |||
| 251 | return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`; | ||
| 252 | }; | ||
| 253 | |||
| 254 | const prepareOperations = ( | ||
| 255 | fields: readonly ExtractedField[], | ||
| 256 | introspection: IntrospectionResult, | ||
| 257 | ): readonly OperationBinding[] => { | ||
| 258 | const typeMap = new Map<string, IntrospectionType>(); | ||
| 259 | for (const t of introspection.__schema.types) { | ||
| 260 | typeMap.set(t.name, t); | ||
| 261 | } | ||
| 262 | |||
| 263 | const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>(); | ||
| 264 | const schema = introspection.__schema; | ||
| 265 | for (const rootKind of ["query", "mutation"] as const) { | ||
| 266 | const typeName = rootKind === "query" ? schema.queryType?.name : schema.mutationType?.name; | ||
| 267 | if (!typeName) continue; | ||
| 268 | const rootType = typeMap.get(typeName); | ||
| 269 | if (!rootType?.fields) continue; | ||
| 270 | for (const f of rootType.fields) { | ||
| 271 | if (!f.name.startsWith("__")) { | ||
| 272 | fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f }); | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | return fields.map((extracted: ExtractedField) => { | ||
| 278 | const prefix = extracted.kind === "mutation" ? "mutation" : "query"; | ||
| 279 | // A tool's name keeps its `<kind>.<field>` path (e.g. `query.hello`, | ||
| 280 | // `mutation.setGreeting`). The address grammar treats `<tool>` as the | ||
| 281 | // trailing remainder (see parseToolAddress), so the dot nests naturally. | ||
| 282 | const toolName = `${prefix}.${extracted.fieldName}`; | ||
| 283 | const description = Option.getOrElse( | ||
| 284 | extracted.description, | ||
| 285 | () => `GraphQL ${extracted.kind}: ${extracted.fieldName} -> ${extracted.returnTypeName}`, | ||
| 286 | ); | ||
| 287 | |||
| 288 | const key = `${extracted.kind}.${extracted.fieldName}`; | ||
| 289 | const entry = fieldMap.get(key); | ||
| 290 | const operationString = entry | ||
| 291 | ? buildOperationStringForField(entry.kind, entry.field, typeMap) | ||
| 292 | : `${extracted.kind} { ${extracted.fieldName} }`; | ||
| 293 | |||
| 294 | return OperationBinding.make({ | ||
| 295 | toolName, | ||
| 296 | kind: extracted.kind, | ||
| 297 | fieldName: extracted.fieldName, | ||
| 298 | operationString, | ||
| 299 | variableNames: extracted.arguments.map((a) => a.name), | ||
| 300 | description, | ||
| 301 | inputSchema: Option.getOrUndefined(extracted.inputSchema), | ||
| 302 | }); | ||
| 303 | }); | ||
| 304 | }; | ||
| 305 | |||
| 306 | const annotationsFor = (binding: OperationBinding): ToolAnnotations | undefined => { | ||
| 307 | if (binding.kind === "mutation") { | ||
| 308 | return { | ||
| 309 | requiresApproval: true, | ||
| 310 | approvalDescription: `mutation ${binding.fieldName}`, | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | return undefined; | ||
| 314 | }; | ||
| 315 | |||
| 316 | // --------------------------------------------------------------------------- | ||
| 317 | // Auth-template rendering — D11: the plugin derives the resolved connection | ||
| 318 | // value into the integration's auth-template format. An OAuth access token and | ||
| 319 | // an apiKey both arrive as a single `value` and render identically. | ||
| 320 | // --------------------------------------------------------------------------- | ||
| 321 | |||
| 322 | interface RenderedAuth { | ||
| 323 | readonly headers: Record<string, string>; | ||
| 324 | readonly queryParams: Record<string, string>; | ||
| 325 | } | ||
| 326 | |||
| 327 | const selectAuthTemplate = ( | ||
| 328 | authentication: readonly AuthTemplate[], | ||
| 329 | templateSlug: string, | ||
| 330 | ): AuthTemplate | null => { | ||
| 331 | const match = authentication.find((t: AuthTemplate) => t.slug === templateSlug); | ||
| 332 | if (match) return match; | ||
| 333 | // Fall back to the first declared template, or a synthesized default oauth. | ||
| 334 | if (authentication.length > 0) return authentication[0] ?? null; | ||
| 335 | return null; | ||
| 336 | }; | ||
| 337 | |||
| 338 | const renderAuth = (template: AuthTemplate | null, value: string): RenderedAuth => { | ||
| 339 | const headers: Record<string, string> = {}; | ||
| 340 | const queryParams: Record<string, string> = {}; | ||
| 341 | |||
| 342 | if (template === null) { | ||
| 343 | // No declared template — default to a bearer Authorization header. | ||
| 344 | headers.Authorization = `Bearer ${value}`; | ||
| 345 | return { headers, queryParams }; | ||
| 346 | } | ||
| 347 | |||
| 348 | Match.value(template).pipe( | ||
| 349 | Match.when({ type: "apiKey", in: "header" }, (t: ApiKeyHeaderTemplate) => { | ||
| 350 | headers[t.name] = `${t.prefix ?? ""}${value}`; | ||
| 351 | }), | ||
| 352 | Match.when({ type: "apiKey", in: "query" }, (t: ApiKeyQueryTemplate) => { | ||
| 353 | queryParams[t.name] = `${t.prefix ?? ""}${value}`; | ||
| 354 | }), | ||
| 355 | Match.when({ type: "oauth" }, (t: OAuthTemplate) => { | ||
| 356 | const header = t.header ?? "Authorization"; | ||
| 357 | const prefix = t.prefix ?? "Bearer "; | ||
| 358 | headers[header] = `${prefix}${value}`; | ||
| 359 | }), | ||
| 360 | Match.exhaustive, | ||
| 361 | ); | ||
| 362 | |||
| 363 | return { headers, queryParams }; | ||
| 364 | }; | ||
| 365 | |||
| 366 | // --------------------------------------------------------------------------- | ||
| 367 | // Plugin factory | ||
| 368 | // --------------------------------------------------------------------------- | ||
| 369 | |||
| 370 | export interface GraphqlPluginOptions { | ||
| 371 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 372 | } | ||
| 373 | |||
| 374 | const defaultAuthentication = ( | ||
| 375 | authentication: readonly AuthTemplate[] | undefined, | ||
| 376 | ): readonly AuthTemplate[] => | ||
| 377 | authentication && authentication.length > 0 | ||
| 378 | ? authentication | ||
| 379 | : [AuthTemplate.make({ slug: DEFAULT_OAUTH_TEMPLATE_SLUG, type: "oauth" })]; | ||
| 380 | |||
| 381 | const integrationConfigFrom = (config: IntegrationConfig): GraphqlIntegrationConfig | null => | ||
| 382 | Option.getOrNull(decodeIntegrationConfig(config)); | ||
| 383 | |||
| 384 | const makeGraphqlExtension = ( | ||
| 385 | ctx: PluginCtx<Record<string, never>>, | ||
| 386 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | ||
| 387 | ) => { | ||
| 388 | const buildConfig = (input: AddGraphqlIntegrationInput) => | ||
| 389 | Effect.gen(function* () { | ||
| 390 | let introspectionResult: IntrospectionResult; | ||
| 391 | if (input.introspectionJson) { | ||
| 392 | introspectionResult = yield* parseIntrospectionJson(input.introspectionJson); | ||
| 393 | } else { | ||
| 394 | introspectionResult = yield* introspect(input.endpoint, input.introspectionHeaders).pipe( | ||
| 395 | Effect.provide(httpClientLayer), | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | |||
| 399 | const { result, definitions } = yield* extract(introspectionResult); | ||
| 400 | const operations = prepareOperations(result.fields, introspectionResult); | ||
| 401 | |||
| 402 | const config = GraphqlIntegrationConfig.make({ | ||
| 403 | endpoint: input.endpoint, | ||
| 404 | ...(input.introspectionJson ? { introspectionJson: input.introspectionJson } : {}), | ||
| 405 | authentication: defaultAuthentication(input.authentication), | ||
| 406 | operations, | ||
| 407 | ...(Object.keys(definitions).length > 0 ? { definitions } : {}), | ||
| 408 | }); | ||
| 409 | return { config, toolCount: operations.length }; | ||
| 410 | }); | ||
| 411 | |||
| 412 | const addIntegration = (input: AddGraphqlIntegrationInput) => | ||
| 413 | Effect.gen(function* () { | ||
| 414 | const slug = IntegrationSlug.make(input.slug); | ||
| 415 | |||
| 416 | // Block re-adding an existing slug. The core `integrations.register` | ||
| 417 | // primitive upserts (so boot re-registration is idempotent), but an | ||
| 418 | // explicit add must NOT silently clobber an existing integration's tools, | ||
| 419 | // connections, and policies. To add more auth, update the existing one. | ||
| 420 | const existing = yield* ctx.core.integrations.get(slug); | ||
| 421 | if (existing) { | ||
| 422 | return yield* new IntegrationAlreadyExistsError({ slug }); | ||
| 423 | } | ||
| 424 | |||
| 425 | return yield* ctx.transaction( | ||
| 426 | Effect.gen(function* () { | ||
| 427 | const { config, toolCount } = yield* buildConfig(input); | ||
| 428 | yield* ctx.core.integrations.register({ | ||
| 429 | slug, | ||
| 430 | description: input.description ?? input.slug, | ||
| 431 | config, | ||
| 432 | canRemove: true, | ||
| 433 | canRefresh: true, | ||
| 434 | }); | ||
| 435 | return { slug: input.slug, toolCount }; | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | }); | ||
| 439 | |||
| 440 | const getIntegration = (slug: string) => | ||
| 441 | ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( | ||
| 442 | Effect.map((record) => { | ||
| 443 | if (record === null) return null; | ||
| 444 | const config = integrationConfigFrom(record.config); | ||
| 445 | return { ...record, config }; | ||
| 446 | }), | ||
| 447 | ); | ||
| 448 | |||
| 449 | const configureIntegration = (slug: string, input: ConfigureGraphqlIntegrationInput) => | ||
| 450 | Effect.gen(function* () { | ||
| 451 | const integrationSlug = IntegrationSlug.make(slug); | ||
| 452 | const record = yield* ctx.core.integrations.get(integrationSlug); | ||
| 453 | if (record === null) return; | ||
| 454 | const existing = integrationConfigFrom(record.config); | ||
| 455 | if (existing === null) return; | ||
| 456 | const nextConfig = GraphqlIntegrationConfig.make({ | ||
| 457 | ...existing, | ||
| 458 | endpoint: input.endpoint ?? existing.endpoint, | ||
| 459 | authentication: input.authentication ?? existing.authentication, | ||
| 460 | }); | ||
| 461 | yield* ctx.core.integrations.update(integrationSlug, { | ||
| 462 | ...(input.description ? { description: input.description } : {}), | ||
| 463 | config: nextConfig, | ||
| 464 | }); | ||
| 465 | }); | ||
| 466 | |||
| 467 | const removeIntegration = (slug: string) => | ||
| 468 | ctx.core.integrations.remove(IntegrationSlug.make(slug)); | ||
| 469 | |||
| 470 | return { | ||
| 471 | addIntegration, | ||
| 472 | getIntegration, | ||
| 473 | configureIntegration, | ||
| 474 | removeIntegration, | ||
| 475 | }; | ||
| 476 | }; | ||
| 477 | |||
| 478 | export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; | ||
| 479 | |||
| 480 | export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { | ||
| 481 | return { | ||
| 482 | id: GRAPHQL_PLUGIN_ID as typeof GRAPHQL_PLUGIN_ID, | ||
| 483 | packageName: "@executor-js/plugin-graphql-greenfield", | ||
| 484 | integrationPresets: graphqlPresets.map((preset) => ({ | ||
| 485 | id: preset.id, | ||
| 486 | name: preset.name, | ||
| 487 | summary: preset.summary, | ||
| 488 | url: preset.url, | ||
| 489 | endpoint: preset.endpoint, | ||
| 490 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 491 | ...(preset.featured ? { featured: preset.featured } : {}), | ||
| 492 | })), | ||
| 493 | storage: (): Record<string, never> => ({}), | ||
| 494 | |||
| 495 | extension: (ctx) => makeGraphqlExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer), | ||
| 496 | |||
| 497 | staticSources: (self) => [ | ||
| 498 | { | ||
| 499 | id: GRAPHQL_PLUGIN_ID, | ||
| 500 | kind: "executor", | ||
| 501 | name: "GraphQL", | ||
| 502 | tools: [ | ||
| 503 | tool({ | ||
| 504 | name: "addIntegration", | ||
| 505 | description: | ||
| 506 | "Add a GraphQL endpoint as an integration and register its operations as tools. Introspects the endpoint (or uses `introspectionJson`) and stores the operation bindings plus auth templates in the integration catalog. After adding, create a connection for the integration (via `connections.create` or `oauth.start`) to make its tools callable.", | ||
| 507 | annotations: { | ||
| 508 | requiresApproval: true, | ||
| 509 | approvalDescription: "Add a GraphQL integration", | ||
| 510 | }, | ||
| 511 | inputSchema: StaticAddIntegrationInputStandardSchema, | ||
| 512 | outputSchema: StaticAddIntegrationOutputStandardSchema, | ||
| 513 | execute: (input) => | ||
| 514 | self.addIntegration(input as AddGraphqlIntegrationInput).pipe( | ||
| 515 | Effect.map((result) => ToolResult.ok(result)), | ||
| 516 | Effect.catchTags({ | ||
| 517 | GraphqlIntrospectionError: ({ message }: GraphqlIntrospectionError) => | ||
| 518 | Effect.succeed(graphqlToolFailure("graphql_introspection_failed", message)), | ||
| 519 | GraphqlExtractionError: ({ message }: GraphqlExtractionError) => | ||
| 520 | Effect.succeed(graphqlToolFailure("graphql_extraction_failed", message)), | ||
| 521 | IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) => | ||
| 522 | Effect.succeed( | ||
| 523 | graphqlToolFailure( | ||
| 524 | "integration_already_exists", | ||
| 525 | `Integration ${slug} already exists; update it instead of re-adding.`, | ||
| 526 | ), | ||
| 527 | ), | ||
| 528 | }), | ||
| 529 | ), | ||
| 530 | }), | ||
| 531 | tool({ | ||
| 532 | name: "getIntegration", | ||
| 533 | description: | ||
| 534 | "Inspect an existing GraphQL integration, including its endpoint, auth templates, and registered operations. Use before reconfiguring with `graphql-greenfield.configureIntegration`.", | ||
| 535 | inputSchema: StaticGetIntegrationInputStandardSchema, | ||
| 536 | outputSchema: StaticGetIntegrationOutputStandardSchema, | ||
| 537 | execute: (input) => | ||
| 538 | self | ||
| 539 | .getIntegration((input as { slug: string }).slug) | ||
| 540 | .pipe(Effect.map((integration) => ToolResult.ok({ integration }))), | ||
| 541 | }), | ||
| 542 | tool({ | ||
| 543 | name: "configureIntegration", | ||
| 544 | description: | ||
| 545 | "Reconfigure an existing GraphQL integration's endpoint, description, or auth templates. Does not re-introspect; existing connections keep their tools. Use `graphql-greenfield.addIntegration` (or `connections.refresh`) to re-derive tools from a new schema.", | ||
| 546 | annotations: { | ||
| 547 | requiresApproval: true, | ||
| 548 | approvalDescription: "Configure a GraphQL integration", | ||
| 549 | }, | ||
| 550 | inputSchema: StaticConfigureIntegrationInputStandardSchema, | ||
| 551 | outputSchema: StaticConfigureIntegrationOutputStandardSchema, | ||
| 552 | execute: (input) => { | ||
| 553 | const { slug, ...patch } = input as ConfigureGraphqlIntegrationInput & { | ||
| 554 | readonly slug: string; | ||
| 555 | }; | ||
| 556 | return self | ||
| 557 | .configureIntegration(slug, patch) | ||
| 558 | .pipe(Effect.as(ToolResult.ok({ configured: true }))); | ||
| 559 | }, | ||
| 560 | }), | ||
| 561 | ], | ||
| 562 | }, | ||
| 563 | ], | ||
| 564 | |||
| 565 | resolveTools: ({ config }) => | ||
| 566 | Effect.sync(() => { | ||
| 567 | const parsed = integrationConfigFrom(config); | ||
| 568 | if (parsed === null) return { tools: [] as readonly ToolDef[] }; | ||
| 569 | const tools: ToolDef[] = parsed.operations.map((op: OperationBinding) => { | ||
| 570 | const annotations = annotationsFor(op); | ||
| 571 | return { | ||
| 572 | name: ToolName.make(op.toolName), | ||
| 573 | ...(op.description ? { description: op.description } : {}), | ||
| 574 | ...(op.inputSchema !== undefined ? { inputSchema: op.inputSchema } : {}), | ||
| 575 | ...(annotations ? { annotations } : {}), | ||
| 576 | }; | ||
| 577 | }); | ||
| 578 | return { | ||
| 579 | tools, | ||
| 580 | ...(parsed.definitions ? { definitions: parsed.definitions } : {}), | ||
| 581 | }; | ||
| 582 | }), | ||
| 583 | |||
| 584 | invokeTool: ({ | ||
| 585 | ctx, | ||
| 586 | credential, | ||
| 587 | toolRow, | ||
| 588 | args, | ||
| 589 | }: { | ||
| 590 | ctx: PluginCtx<Record<string, never>>; | ||
| 591 | credential: ToolInvocationCredential; | ||
| 592 | toolRow: { name: string }; | ||
| 593 | args: unknown; | ||
| 594 | }) => | ||
| 595 | Effect.gen(function* () { | ||
| 596 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 597 | const config = integrationConfigFrom(credential.config); | ||
| 598 | if (config === null) { | ||
| 599 | return yield* new GraphqlInvocationError({ | ||
| 600 | message: `No GraphQL configuration found for integration "${credential.integration}"`, | ||
| 601 | statusCode: Option.none(), | ||
| 602 | }); | ||
| 603 | } | ||
| 604 | |||
| 605 | const op = config.operations.find( | ||
| 606 | (candidate: OperationBinding) => candidate.toolName === toolRow.name, | ||
| 607 | ); | ||
| 608 | if (!op) { | ||
| 609 | return yield* new GraphqlInvocationError({ | ||
| 610 | message: `No GraphQL operation found for tool "${toolRow.name}"`, | ||
| 611 | statusCode: Option.none(), | ||
| 612 | }); | ||
| 613 | } | ||
| 614 | |||
| 615 | if (credential.value === null) { | ||
| 616 | return yield* new GraphqlAuthRequiredError({ | ||
| 617 | code: "oauth_connection_missing", | ||
| 618 | owner: credential.owner, | ||
| 619 | integration: String(credential.integration), | ||
| 620 | connection: String(credential.connection), | ||
| 621 | credentialKind: "oauth", | ||
| 622 | credentialLabel: "GraphQL credential", | ||
| 623 | message: | ||
| 624 | `Missing credential value for GraphQL connection ` + | ||
| 625 | `"${credential.connection}" on integration "${credential.integration}". ` + | ||
| 626 | `Re-authenticate or update the connection before retrying this tool.`, | ||
| 627 | }); | ||
| 628 | } | ||
| 629 | |||
| 630 | const template = selectAuthTemplate(config.authentication, String(credential.template)); | ||
| 631 | const rendered = renderAuth(template, credential.value); | ||
| 632 | |||
| 633 | const result = yield* invokeWithLayer( | ||
| 634 | op, | ||
| 635 | (args ?? {}) as Record<string, unknown>, | ||
| 636 | config.endpoint, | ||
| 637 | rendered.headers, | ||
| 638 | rendered.queryParams, | ||
| 639 | httpClientLayer, | ||
| 640 | ); | ||
| 641 | |||
| 642 | const errors = decodeGraphqlErrors(result.errors); | ||
| 643 | if (errors !== undefined && errors.length > 0) { | ||
| 644 | const firstMessage = extractGraphqlErrorMessage(errors); | ||
| 645 | return ToolResult.fail({ | ||
| 646 | code: "graphql_errors", | ||
| 647 | message: firstMessage !== undefined ? firstMessage : "GraphQL request returned errors", | ||
| 648 | details: { errors }, | ||
| 649 | }); | ||
| 650 | } | ||
| 651 | if (result.status < 200 || result.status >= 300) { | ||
| 652 | if (result.status === 401 || result.status === 403) { | ||
| 653 | return authToolFailure({ | ||
| 654 | code: "connection_rejected", | ||
| 655 | status: result.status, | ||
| 656 | message: | ||
| 657 | `Upstream rejected credentials for GraphQL integration ` + | ||
| 658 | `"${credential.integration}" with HTTP ${result.status}. Re-authenticate or ` + | ||
| 659 | `update the connection before retrying this tool.`, | ||
| 660 | source: { id: String(credential.integration) }, | ||
| 661 | credential: { kind: "upstream", label: "Upstream authorization" }, | ||
| 662 | upstream: { | ||
| 663 | status: result.status, | ||
| 664 | details: { | ||
| 665 | data: result.data, | ||
| 666 | errors: result.errors, | ||
| 667 | }, | ||
| 668 | }, | ||
| 669 | recovery: { | ||
| 670 | configureSourceTool: "executor.graphql-greenfield.configureIntegration", | ||
| 671 | }, | ||
| 672 | }); | ||
| 673 | } | ||
| 674 | return ToolResult.fail({ | ||
| 675 | code: "graphql_http_error", | ||
| 676 | status: result.status, | ||
| 677 | message: `GraphQL request failed with HTTP ${result.status}`, | ||
| 678 | details: { | ||
| 679 | status: result.status, | ||
| 680 | data: result.data, | ||
| 681 | errors: result.errors, | ||
| 682 | }, | ||
| 683 | }); | ||
| 684 | } | ||
| 685 | return ToolResult.ok(result.data); | ||
| 686 | }).pipe( | ||
| 687 | Effect.catchTag("GraphqlAuthRequiredError", (error: GraphqlAuthRequiredError) => | ||
| 688 | Effect.succeed(graphqlAuthToolFailure(error)), | ||
| 689 | ), | ||
| 690 | ), | ||
| 691 | |||
| 692 | detect: ({ ctx, url }: { ctx: PluginCtx<Record<string, never>>; url: string }) => | ||
| 693 | Effect.gen(function* () { | ||
| 694 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 695 | const trimmed = url.trim(); | ||
| 696 | if (!trimmed || !URL.canParse(trimmed)) return null; | ||
| 697 | const parsedUrl = new URL(trimmed); | ||
| 698 | |||
| 699 | const ok = yield* introspect(trimmed).pipe( | ||
| 700 | Effect.provide(httpClientLayer), | ||
| 701 | Effect.map(() => true), | ||
| 702 | Effect.catch(() => Effect.succeed(false)), | ||
| 703 | ); | ||
| 704 | |||
| 705 | const slug = slugFromEndpoint(trimmed); | ||
| 706 | |||
| 707 | if (ok) { | ||
| 708 | return { | ||
| 709 | kind: "graphql", | ||
| 710 | confidence: "high" as const, | ||
| 711 | endpoint: trimmed, | ||
| 712 | name: slug, | ||
| 713 | slug, | ||
| 714 | }; | ||
| 715 | } | ||
| 716 | |||
| 717 | if (urlMatchesToken(parsedUrl, "graphql")) { | ||
| 718 | return { | ||
| 719 | kind: "graphql", | ||
| 720 | confidence: "low" as const, | ||
| 721 | endpoint: trimmed, | ||
| 722 | name: slug, | ||
| 723 | slug, | ||
| 724 | }; | ||
| 725 | } | ||
| 726 | |||
| 727 | return null; | ||
| 728 | }), | ||
| 729 | }; | ||
| 730 | }); | ||
| @@ -1,55 +0,0 @@ | |||
| 1 | export interface GraphqlPreset { | ||
| 2 | readonly id: string; | ||
| 3 | readonly name: string; | ||
| 4 | readonly summary: string; | ||
| 5 | readonly url: string; | ||
| 6 | readonly endpoint: string; | ||
| 7 | readonly icon?: string; | ||
| 8 | readonly featured?: boolean; | ||
| 9 | } | ||
| 10 | |||
| 11 | export const graphqlPresets: readonly GraphqlPreset[] = [ | ||
| 12 | { | ||
| 13 | id: "github-graphql", | ||
| 14 | name: "GitHub GraphQL", | ||
| 15 | summary: "Repos, issues, PRs, and users via GitHub's GraphQL API.", | ||
| 16 | url: "https://api.github.com/graphql", | ||
| 17 | endpoint: "https://api.github.com/graphql", | ||
| 18 | icon: "https://github.com/favicon.ico", | ||
| 19 | featured: true, | ||
| 20 | }, | ||
| 21 | { | ||
| 22 | id: "gitlab", | ||
| 23 | name: "GitLab", | ||
| 24 | summary: "Projects, merge requests, pipelines, and users.", | ||
| 25 | url: "https://gitlab.com/api/graphql", | ||
| 26 | endpoint: "https://gitlab.com/api/graphql", | ||
| 27 | icon: "https://gitlab.com/favicon.ico", | ||
| 28 | featured: true, | ||
| 29 | }, | ||
| 30 | { | ||
| 31 | id: "linear", | ||
| 32 | name: "Linear", | ||
| 33 | summary: "Issues, projects, teams, and cycles.", | ||
| 34 | url: "https://api.linear.app/graphql", | ||
| 35 | endpoint: "https://api.linear.app/graphql", | ||
| 36 | icon: "https://linear.app/favicon.ico", | ||
| 37 | featured: true, | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | id: "monday", | ||
| 41 | name: "Monday.com", | ||
| 42 | summary: "Boards, items, columns, and workspace automation.", | ||
| 43 | url: "https://api.monday.com/v2", | ||
| 44 | endpoint: "https://api.monday.com/v2", | ||
| 45 | icon: "https://monday.com/favicon.ico", | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | id: "anilist", | ||
| 49 | name: "AniList", | ||
| 50 | summary: "Anime and manga database — no auth required.", | ||
| 51 | url: "https://graphql.anilist.co", | ||
| 52 | endpoint: "https://graphql.anilist.co", | ||
| 53 | icon: "https://anilist.co/img/icons/favicon-32x32.png", | ||
| 54 | }, | ||
| 55 | ]; | ||
| @@ -1,147 +0,0 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // GraphQL operation kind | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | export const GraphqlOperationKind = Schema.Literals(["query", "mutation"]); | ||
| 8 | export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Extracted field (becomes a tool) | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | export const GraphqlArgument = Schema.Struct({ | ||
| 15 | name: Schema.String, | ||
| 16 | typeName: Schema.String, | ||
| 17 | required: Schema.Boolean, | ||
| 18 | description: Schema.OptionFromOptional(Schema.String), | ||
| 19 | }); | ||
| 20 | export type GraphqlArgument = typeof GraphqlArgument.Type; | ||
| 21 | |||
| 22 | export const ExtractedField = Schema.Struct({ | ||
| 23 | /** e.g. "user", "createUser" */ | ||
| 24 | fieldName: Schema.String, | ||
| 25 | /** "query" or "mutation" */ | ||
| 26 | kind: GraphqlOperationKind, | ||
| 27 | description: Schema.OptionFromOptional(Schema.String), | ||
| 28 | arguments: Schema.Array(GraphqlArgument), | ||
| 29 | /** JSON Schema for the input (built from arguments) */ | ||
| 30 | inputSchema: Schema.OptionFromOptional(Schema.Unknown), | ||
| 31 | /** The return type name for documentation */ | ||
| 32 | returnTypeName: Schema.String, | ||
| 33 | }); | ||
| 34 | export type ExtractedField = typeof ExtractedField.Type; | ||
| 35 | |||
| 36 | export const ExtractionResult = Schema.Struct({ | ||
| 37 | /** Schema name from introspection */ | ||
| 38 | schemaName: Schema.OptionFromOptional(Schema.String), | ||
| 39 | fields: Schema.Array(ExtractedField), | ||
| 40 | }); | ||
| 41 | export type ExtractionResult = typeof ExtractionResult.Type; | ||
| 42 | |||
| 43 | // --------------------------------------------------------------------------- | ||
| 44 | // Operation binding — minimal data needed to invoke. Stored inside the | ||
| 45 | // integration's opaque config (one entry per tool name). | ||
| 46 | // --------------------------------------------------------------------------- | ||
| 47 | |||
| 48 | export const OperationBinding = Schema.Struct({ | ||
| 49 | /** The tool name — `query.hello`, `mutation.setGreeting`. */ | ||
| 50 | toolName: Schema.String, | ||
| 51 | kind: GraphqlOperationKind, | ||
| 52 | fieldName: Schema.String, | ||
| 53 | /** The full GraphQL query/mutation string */ | ||
| 54 | operationString: Schema.String, | ||
| 55 | /** Ordered variable names for mapping */ | ||
| 56 | variableNames: Schema.Array(Schema.String), | ||
| 57 | description: Schema.optional(Schema.String), | ||
| 58 | inputSchema: Schema.optional(Schema.Unknown), | ||
| 59 | }); | ||
| 60 | export type OperationBinding = typeof OperationBinding.Type; | ||
| 61 | |||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | // Auth templates — v2 replaces v1's secret-backed header/query maps + | ||
| 64 | // credential bindings. An integration declares zero or more auth methods; a | ||
| 65 | // connection picks one by `template` slug, and `invokeTool` renders the | ||
| 66 | // connection's resolved value through it. (D11: "auth state derived into the | ||
| 67 | // auth-template format" — an OAuth access token renders exactly like an apiKey | ||
| 68 | // bearer because both arrive as a single resolved `value`.) | ||
| 69 | // --------------------------------------------------------------------------- | ||
| 70 | |||
| 71 | /** Apply the value as an HTTP header (e.g. `Authorization: Bearer <value>`). */ | ||
| 72 | export const ApiKeyHeaderTemplate = Schema.Struct({ | ||
| 73 | slug: Schema.String, | ||
| 74 | type: Schema.Literal("apiKey"), | ||
| 75 | in: Schema.Literal("header"), | ||
| 76 | /** The header name to set. */ | ||
| 77 | name: Schema.String, | ||
| 78 | /** Optional literal prefix prepended to the value (e.g. "Bearer "). */ | ||
| 79 | prefix: Schema.optional(Schema.String), | ||
| 80 | }); | ||
| 81 | export type ApiKeyHeaderTemplate = typeof ApiKeyHeaderTemplate.Type; | ||
| 82 | |||
| 83 | /** Apply the value as a URL query parameter. */ | ||
| 84 | export const ApiKeyQueryTemplate = Schema.Struct({ | ||
| 85 | slug: Schema.String, | ||
| 86 | type: Schema.Literal("apiKey"), | ||
| 87 | in: Schema.Literal("query"), | ||
| 88 | /** The query parameter name to set. */ | ||
| 89 | name: Schema.String, | ||
| 90 | prefix: Schema.optional(Schema.String), | ||
| 91 | }); | ||
| 92 | export type ApiKeyQueryTemplate = typeof ApiKeyQueryTemplate.Type; | ||
| 93 | |||
| 94 | /** Apply the (already-resolved, already-refreshed) OAuth access token as a | ||
| 95 | * bearer header. Renders identically to an apiKey header bound to the same | ||
| 96 | * header/prefix — the difference is only how the value was sourced. */ | ||
| 97 | export const OAuthTemplate = Schema.Struct({ | ||
| 98 | slug: Schema.String, | ||
| 99 | type: Schema.Literal("oauth"), | ||
| 100 | /** Header the token is written into. Defaults to `Authorization`. */ | ||
| 101 | header: Schema.optional(Schema.String), | ||
| 102 | /** Prefix for the token. Defaults to `Bearer `. */ | ||
| 103 | prefix: Schema.optional(Schema.String), | ||
| 104 | authorizationUrl: Schema.optional(Schema.String), | ||
| 105 | tokenUrl: Schema.optional(Schema.String), | ||
| 106 | scopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 107 | }); | ||
| 108 | export type OAuthTemplate = typeof OAuthTemplate.Type; | ||
| 109 | |||
| 110 | export const AuthTemplate = Schema.Union([ | ||
| 111 | ApiKeyHeaderTemplate, | ||
| 112 | ApiKeyQueryTemplate, | ||
| 113 | OAuthTemplate, | ||
| 114 | ]); | ||
| 115 | export type AuthTemplate = typeof AuthTemplate.Type; | ||
| 116 | |||
| 117 | // --------------------------------------------------------------------------- | ||
| 118 | // Integration config — the opaque blob the plugin stores on the integration | ||
| 119 | // row. Core never parses it; the plugin writes it at register time and reads | ||
| 120 | // it back in `resolveTools` (to produce tools) and `invokeTool` (to render | ||
| 121 | // auth + look up the operation). | ||
| 122 | // --------------------------------------------------------------------------- | ||
| 123 | |||
| 124 | export const GraphqlIntegrationConfig = Schema.Struct({ | ||
| 125 | /** The GraphQL endpoint URL. */ | ||
| 126 | endpoint: Schema.String, | ||
| 127 | /** Optional introspection JSON (when the endpoint can't be reached live). */ | ||
| 128 | introspectionJson: Schema.optional(Schema.String), | ||
| 129 | /** Auth methods a connection can apply through. */ | ||
| 130 | authentication: Schema.Array(AuthTemplate), | ||
| 131 | /** Spec-derived operations, one per produced tool. */ | ||
| 132 | operations: Schema.Array(OperationBinding), | ||
| 133 | /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */ | ||
| 134 | definitions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | ||
| 135 | }); | ||
| 136 | export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type; | ||
| 137 | |||
| 138 | // --------------------------------------------------------------------------- | ||
| 139 | // Invocation | ||
| 140 | // --------------------------------------------------------------------------- | ||
| 141 | |||
| 142 | export const InvocationResult = Schema.Struct({ | ||
| 143 | status: Schema.Number, | ||
| 144 | data: Schema.NullOr(Schema.Unknown), | ||
| 145 | errors: Schema.NullOr(Schema.Unknown), | ||
| 146 | }); | ||
| 147 | export type InvocationResult = typeof InvocationResult.Type; | ||
| @@ -1,258 +0,0 @@ | |||
| 1 | import { | ||
| 2 | Context, | ||
| 3 | Data, | ||
| 4 | Effect, | ||
| 5 | Layer, | ||
| 6 | Predicate, | ||
| 7 | Ref, | ||
| 8 | Schema as EffectSchema, | ||
| 9 | Scope, | ||
| 10 | } from "effect"; | ||
| 11 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 12 | import type { GraphQLSchema } from "graphql"; | ||
| 13 | import { | ||
| 14 | createSchema, | ||
| 15 | createYoga, | ||
| 16 | type GraphQLParams, | ||
| 17 | type YogaInitialContext, | ||
| 18 | } from "graphql-yoga"; | ||
| 19 | import { OAuthTestServer, serveTestHttpApp } from "@executor-js/sdk/testing"; | ||
| 20 | |||
| 21 | const GraphqlRequestPayload = EffectSchema.Struct({ | ||
| 22 | query: EffectSchema.optional(EffectSchema.String), | ||
| 23 | variables: EffectSchema.optional(EffectSchema.Record(EffectSchema.String, EffectSchema.Unknown)), | ||
| 24 | operationName: EffectSchema.optional(EffectSchema.NullOr(EffectSchema.String)), | ||
| 25 | }); | ||
| 26 | |||
| 27 | type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type; | ||
| 28 | |||
| 29 | export interface GraphqlTestRequest { | ||
| 30 | readonly url: string; | ||
| 31 | readonly method: string; | ||
| 32 | readonly path: string; | ||
| 33 | readonly headers: Readonly<Record<string, string>>; | ||
| 34 | readonly payload: GraphqlRequestPayload; | ||
| 35 | } | ||
| 36 | |||
| 37 | export interface GraphqlTestContext { | ||
| 38 | readonly request: GraphqlTestRequest; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface GraphqlTestServerOptions { | ||
| 42 | readonly schema: GraphQLSchema; | ||
| 43 | readonly path?: string; | ||
| 44 | readonly auth?: { | ||
| 45 | readonly validateAuthorization: (authorization: string | null) => Effect.Effect<boolean>; | ||
| 46 | readonly wwwAuthenticate?: string; | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface GraphqlTestServerShape { | ||
| 51 | readonly endpoint: string; | ||
| 52 | readonly schema: GraphQLSchema; | ||
| 53 | readonly requests: Effect.Effect<readonly GraphqlTestRequest[]>; | ||
| 54 | readonly clearRequests: Effect.Effect<void>; | ||
| 55 | } | ||
| 56 | |||
| 57 | class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{ | ||
| 58 | readonly address: unknown; | ||
| 59 | }> {} | ||
| 60 | |||
| 61 | class GraphqlTestServerHandlerError extends Data.TaggedError("GraphqlTestServerHandlerError")<{ | ||
| 62 | readonly cause: unknown; | ||
| 63 | }> {} | ||
| 64 | |||
| 65 | const headersFromRequest = (headers: Headers): Readonly<Record<string, string>> => | ||
| 66 | Object.fromEntries(headers.entries()); | ||
| 67 | |||
| 68 | const payloadFromParams = (params: GraphQLParams): GraphqlRequestPayload => ({ | ||
| 69 | query: params.query, | ||
| 70 | variables: | ||
| 71 | typeof params.variables === "object" && params.variables !== null | ||
| 72 | ? params.variables | ||
| 73 | : undefined, | ||
| 74 | operationName: params.operationName ?? null, | ||
| 75 | }); | ||
| 76 | |||
| 77 | const captureRequest = ( | ||
| 78 | initial: YogaInitialContext, | ||
| 79 | requests: Ref.Ref<readonly GraphqlTestRequest[]>, | ||
| 80 | ) => { | ||
| 81 | const url = new URL(initial.request.url); | ||
| 82 | const captured: GraphqlTestRequest = { | ||
| 83 | url: initial.request.url, | ||
| 84 | method: initial.request.method, | ||
| 85 | path: url.pathname, | ||
| 86 | headers: headersFromRequest(initial.request.headers), | ||
| 87 | payload: payloadFromParams(initial.params), | ||
| 88 | }; | ||
| 89 | return Effect.runPromise( | ||
| 90 | Ref.update(requests, (all) => [...all, captured]).pipe(Effect.as(captured)), | ||
| 91 | ); | ||
| 92 | }; | ||
| 93 | |||
| 94 | export const serveGraphqlTestServer = ( | ||
| 95 | options: GraphqlTestServerOptions, | ||
| 96 | ): Effect.Effect< | ||
| 97 | GraphqlTestServerShape, | ||
| 98 | GraphqlTestServerAddressError | GraphqlTestServerHandlerError, | ||
| 99 | Scope.Scope | ||
| 100 | > => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const requests = yield* Ref.make<readonly GraphqlTestRequest[]>([]); | ||
| 103 | const path = options.path ?? "/graphql"; | ||
| 104 | |||
| 105 | const yoga = createYoga<Record<string, never>, GraphqlTestContext>({ | ||
| 106 | schema: options.schema, | ||
| 107 | graphqlEndpoint: path, | ||
| 108 | graphiql: false, | ||
| 109 | landingPage: false, | ||
| 110 | logging: false, | ||
| 111 | maskedErrors: false, | ||
| 112 | context: (initial) => | ||
| 113 | captureRequest(initial, requests).then((request) => ({ | ||
| 114 | request, | ||
| 115 | })), | ||
| 116 | }); | ||
| 117 | |||
| 118 | const server = yield* serveTestHttpApp((request) => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | if (options.auth) { | ||
| 121 | const accepted = yield* options.auth.validateAuthorization( | ||
| 122 | request.headers.authorization ?? null, | ||
| 123 | ); | ||
| 124 | if (!accepted) { | ||
| 125 | const responseOptions = options.auth.wwwAuthenticate | ||
| 126 | ? { | ||
| 127 | status: 401, | ||
| 128 | headers: { "www-authenticate": options.auth.wwwAuthenticate }, | ||
| 129 | } | ||
| 130 | : { status: 401 }; | ||
| 131 | return HttpServerResponse.jsonUnsafe( | ||
| 132 | { errors: [{ message: "Unauthorized" }] }, | ||
| 133 | responseOptions, | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | } | ||
| 137 | const webRequest = yield* HttpServerRequest.toWeb(request); | ||
| 138 | const response = yield* Effect.promise(() => Promise.resolve(yoga.handle(webRequest, {}))); | ||
| 139 | return HttpServerResponse.fromWeb(response); | ||
| 140 | }).pipe( | ||
| 141 | Effect.catch(() => | ||
| 142 | Effect.succeed( | ||
| 143 | HttpServerResponse.text("GraphQL test server failed", { | ||
| 144 | status: 500, | ||
| 145 | contentType: "text/plain", | ||
| 146 | }), | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | ), | ||
| 150 | ).pipe( | ||
| 151 | Effect.mapError((error) => | ||
| 152 | Predicate.isTagged(error, "TestHttpServerAddressError") | ||
| 153 | ? new GraphqlTestServerAddressError({ address: error.address }) | ||
| 154 | : new GraphqlTestServerHandlerError({ cause: error.cause }), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | |||
| 158 | return { | ||
| 159 | endpoint: server.url(path), | ||
| 160 | schema: options.schema, | ||
| 161 | requests: Ref.get(requests), | ||
| 162 | clearRequests: Ref.set(requests, []), | ||
| 163 | }; | ||
| 164 | }); | ||
| 165 | |||
| 166 | export const serveGraphqlFailureTestServer = (options: { | ||
| 167 | readonly status: number; | ||
| 168 | readonly body: string; | ||
| 169 | readonly contentType?: string; | ||
| 170 | readonly path?: string; | ||
| 171 | }) => | ||
| 172 | serveTestHttpApp(() => | ||
| 173 | Effect.succeed( | ||
| 174 | HttpServerResponse.text(options.body, { | ||
| 175 | status: options.status, | ||
| 176 | contentType: options.contentType ?? "text/plain", | ||
| 177 | }), | ||
| 178 | ), | ||
| 179 | ).pipe( | ||
| 180 | Effect.map((server) => ({ | ||
| 181 | endpoint: server.url(options.path ?? "/graphql"), | ||
| 182 | httpClientLayer: server.httpClientLayer, | ||
| 183 | })), | ||
| 184 | ); | ||
| 185 | |||
| 186 | export class GraphqlTestServer extends Context.Service<GraphqlTestServer, GraphqlTestServerShape>()( | ||
| 187 | "@executor-js/plugin-graphql-greenfield/testing/GraphqlTestServer", | ||
| 188 | ) { | ||
| 189 | static readonly layer = (options: GraphqlTestServerOptions) => | ||
| 190 | Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options)); | ||
| 191 | |||
| 192 | static readonly layerWithOAuth = (options: Omit<GraphqlTestServerOptions, "auth">) => | ||
| 193 | Layer.effect( | ||
| 194 | GraphqlTestServer, | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const oauth = yield* OAuthTestServer; | ||
| 197 | return yield* serveGraphqlTestServer({ | ||
| 198 | ...options, | ||
| 199 | auth: { | ||
| 200 | validateAuthorization: oauth.acceptsAuthorizationHeader, | ||
| 201 | wwwAuthenticate: 'Bearer error="invalid_token"', | ||
| 202 | }, | ||
| 203 | }); | ||
| 204 | }), | ||
| 205 | ); | ||
| 206 | } | ||
| 207 | |||
| 208 | const stringArgument = ( | ||
| 209 | args: Readonly<Record<string, unknown>>, | ||
| 210 | key: string, | ||
| 211 | fallback: string, | ||
| 212 | ): string => { | ||
| 213 | const value = args[key]; | ||
| 214 | return typeof value === "string" ? value : fallback; | ||
| 215 | }; | ||
| 216 | |||
| 217 | export const makeGreetingGraphqlSchema = ( | ||
| 218 | options: { readonly includeMutation?: boolean } = {}, | ||
| 219 | ): GraphQLSchema => { | ||
| 220 | const includeMutation = options.includeMutation ?? true; | ||
| 221 | return createSchema<GraphqlTestContext>({ | ||
| 222 | typeDefs: /* GraphQL */ ` | ||
| 223 | type Query { | ||
| 224 | hello(name: String): String | ||
| 225 | } | ||
| 226 | |||
| 227 | ${ | ||
| 228 | includeMutation | ||
| 229 | ? /* GraphQL */ ` | ||
| 230 | type Mutation { | ||
| 231 | setGreeting(message: String!): String | ||
| 232 | } | ||
| 233 | ` | ||
| 234 | : "" | ||
| 235 | } | ||
| 236 | `, | ||
| 237 | resolvers: { | ||
| 238 | Query: { | ||
| 239 | hello: (_source: unknown, args: Readonly<Record<string, unknown>>) => | ||
| 240 | `Hello ${stringArgument(args, "name", "world")}`, | ||
| 241 | }, | ||
| 242 | ...(includeMutation | ||
| 243 | ? { | ||
| 244 | Mutation: { | ||
| 245 | setGreeting: (_source: unknown, args: Readonly<Record<string, unknown>>) => | ||
| 246 | stringArgument(args, "message", ""), | ||
| 247 | }, | ||
| 248 | } | ||
| 249 | : {}), | ||
| 250 | }, | ||
| 251 | }); | ||
| 252 | }; | ||
| 253 | |||
| 254 | export const TestLayers = { | ||
| 255 | greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }), | ||
| 256 | greetingWithOAuth: () => | ||
| 257 | GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }), | ||
| 258 | }; | ||
| @@ -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 | } | ||
| @@ -1,14 +0,0 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | index: "src/promise.ts", | ||
| 6 | core: "src/sdk/index.ts", | ||
| 7 | testing: "src/testing/index.ts", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: false, | ||
| 11 | sourcemap: true, | ||
| 12 | clean: true, | ||
| 13 | external: [/^@executor-js\//, /^effect/, /^@effect\//, /^graphql/, /^graphql-yoga/], | ||
| 14 | }); | ||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts"], | ||
| 6 | testTimeout: 15_000, | ||
| 7 | server: { | ||
| 8 | deps: { | ||
| 9 | inline: ["graphql", "graphql-yoga", "@graphql-tools", "@envelop", "@whatwg-node"], | ||
| 10 | }, | ||
| 11 | }, | ||
| 12 | }, | ||
| 13 | }); | ||
| @@ -3,7 +3,7 @@ import { Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; | 3 | import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; |
| 4 | 4 | ||
| 5 | import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; | 5 | import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; |
| 6 | import { AuthTemplate } from "../sdk/types"; | 6 | import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types"; |
| 7 | 7 | ||
| 8 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 9 | // Params | 9 | // 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 the | 30 | // The `configure` payload — the custom auth methods to merge-append onto the |
| 31 | // integration's `authenticationTemplate`. Reuses the same `AuthTemplate` schema | 31 | // 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. | ||
| 33 | const ConfigurePayload = Schema.Struct({ | 34 | const 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 new | 59 | // The configure result — the merged `authenticationTemplate` after the new |
| 59 | // custom methods were appended/replaced. | 60 | // custom methods were appended/replaced. |
| 60 | const ConfigureResponse = Schema.Struct({ | 61 | const ConfigureResponse = Schema.Struct({ |
| 61 | authenticationTemplate: Schema.Array(AuthTemplate), | 62 | authenticationTemplate: Schema.Array(GraphqlAuthMethod), |
| 62 | }); | 63 | }); |
| 63 | 64 | ||
| 64 | // --------------------------------------------------------------------------- | 65 | // --------------------------------------------------------------------------- |
| @@ -17,8 +17,13 @@ import { Effect, Layer } from "effect"; · 16 unmodified lines | |||
| 17 | import { addGroup, observabilityMiddleware } from "@executor-js/api"; | 17 | import { addGroup, observabilityMiddleware } from "@executor-js/api"; |
| 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; | 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; |
| 19 | 19 | ||
| 20 | import { expandGraphqlAuthMethodInputs } from "../sdk/types"; | ||
| 20 | import type { GraphqlPluginExtension } from "../sdk/plugin"; | 21 | import type { GraphqlPluginExtension } from "../sdk/plugin"; |
| 21 | import type { AuthTemplate, GraphqlIntegrationConfig } from "../sdk/types"; | 22 | import type { |
| 23 | GraphqlAuthMethod, | ||
| 24 | GraphqlAuthMethodInput, | ||
| 25 | GraphqlIntegrationConfig, | ||
| 26 | } from "../sdk/types"; | ||
| 22 | import { GraphqlExtensionService, GraphqlHandlers } from "./handlers"; | 27 | import { GraphqlExtensionService, GraphqlHandlers } from "./handlers"; |
| 23 | import { GraphqlGroup } from "./group"; | 28 | import { 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 { |
| @@ -26,9 +26,8 @@ import { · 25 unmodified lines | |||
| 26 | 26 | ||
| 27 | import { addGraphqlIntegrationOptimistic } from "./atoms"; | 27 | import { addGraphqlIntegrationOptimistic } from "./atoms"; |
| 28 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | 28 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 29 | import { graphqlTemplatesFromPlacements } from "./auth-method-config"; | 29 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 30 | import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | 30 | import type { GraphqlAuthMethodInput } from "../sdk/types"; |
| 31 | import type { AuthTemplate } from "../sdk/types"; | ||
| 32 | 31 | ||
| 33 | // v2 GraphQL add flow: register the integration with its declared auth-method | 32 | // 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 named | 59 | // The methods to register: each apikey row declares ONE method carrying |
| 61 | // placement (GraphQL templates carry one header/query slot each). The first | 60 | // 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 register | 62 | // 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 are | 73 | // Every apikey row needs at least one named placement; `none` rows are |
| 81 | // always valid. | 74 | // always valid. |
| @@ -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 | ||
| 17 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; | 17 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; |
| 18 | import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config"; | 18 | import { |
| 19 | import type { AuthTemplate } from "../sdk/types"; | 19 | authMethodsFromConfig, |
| 20 | graphqlAuthMethodInputsFromPlacements, | ||
| 21 | graphqlWireAuthInput, | ||
| 22 | } from "./auth-method-config"; | ||
| 23 | import 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 generic | 29 | // converts it to generic `AuthMethod[]`, and composes the generic |
| 26 | // `AccountsSection` — whose Add-account offers those methods plus a "+ Custom | 30 | // `AccountsSection` — whose Add-account offers those methods plus a "+ Custom |
| 27 | // method" row (apiKey-only). The custom-method create is INJECTED here | 31 | // method" row (apikey-only). The custom-method create is INJECTED here |
| 28 | // (`createCustomMethod`): generic placements → graphql `apiKey` templates | 32 | // (`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 the | 34 | // `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 | ||
| 34 | export default function GraphqlAccountsPanel(props: { | 38 | export 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 out | 58 | // 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 | ); |
| @@ -1,98 +1,191 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 2 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 3 | 3 | ||
| 4 | import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config"; | 4 | import { |
| 5 | import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | 5 | authMethodsFromConfig, |
| 6 | import type { AuthTemplate } from "../sdk/types"; | 6 | editorValueFromGraphqlAuthMethod, |
| 7 | graphqlAuthMethodInputFromEditorValue, | ||
| 8 | graphqlAuthMethodInputsFromPlacements, | ||
| 9 | } from "./auth-method-config"; | ||
| 7 | 10 | ||
| 8 | describe("graphqlTemplatesFromPlacements", () => { | 11 | describe("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 | |||
| 64 | describe("editorValueFromGraphqlAuthMethod", () => { | ||
| 65 | it("round-trips an apikey method, making the shared token variable explicit", () => { | ||
| 66 | expect( | ||
| 67 | editorValueFromGraphqlAuthMethod({ | ||
| 68 | slug: "header", | ||
| 69 | kind: "apikey", | ||
| 70 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 71 | }), | ||
| 72 | ).toEqual({ | ||
| 73 | kind: "apikey", | ||
| 74 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer ", variable: "token" }], | ||
| 75 | }); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("round-trip edit preserves placement variables (sharing survives)", () => { | ||
| 79 | const stored = { | ||
| 80 | slug: "custom_two_spots", | ||
| 81 | kind: "apikey", | ||
| 82 | placements: [ | ||
| 83 | { carrier: "header", name: "X-Token" }, | ||
| 84 | { carrier: "query", name: "token" }, | ||
| 85 | ], | ||
| 86 | } as const; | ||
| 87 | const editor = editorValueFromGraphqlAuthMethod(stored); | ||
| 88 | const back = graphqlAuthMethodInputFromEditorValue(editor); | ||
| 89 | // Both placements still share the canonical `token` input (stored as | ||
| 90 | // absent on the wire) — a round-trip must not split one credential in two. | ||
| 91 | expect(back).toEqual({ | ||
| 92 | kind: "apikey", | ||
| 93 | placements: [ | ||
| 94 | { carrier: "header", name: "X-Token" }, | ||
| 95 | { carrier: "query", name: "token" }, | ||
| 96 | ], | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("maps oauth2 to an oauth editor value with no endpoints", () => { | ||
| 101 | expect(editorValueFromGraphqlAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({ | ||
| 102 | kind: "oauth", | ||
| 103 | authorizationUrl: "", | ||
| 104 | tokenUrl: "", | ||
| 105 | scopes: [], | ||
| 106 | }); | ||
| 46 | }); | 107 | }); |
| 47 | }); | 108 | }); |
| 48 | 109 | ||
| 49 | describe("authMethodsFromConfig", () => { | 110 | describe("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 | ||
| 91 | describe("round-trip (placements → templates → methods)", () => { | 155 | describe("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 | }); |
| @@ -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-agnostic | 4 | // 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 shape | 6 | // 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 | ||
| 13 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | ||
| 14 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 12 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 13 | import { | ||
| 14 | wireAuthInputFromShared, | ||
| 15 | authMethodFromSharedTemplate, | ||
| 16 | editorValueFromSharedMethod, | ||
| 17 | sharedMethodInputFromEditorValue, | ||
| 18 | wirePlacementsFromEditor, | ||
| 19 | } from "@executor-js/react/lib/shared-auth-method-codec"; | ||
| 15 | 20 | ||
| 16 | import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | 21 | import type { |
| 17 | import type { AuthTemplate } from "../sdk/types"; | 22 | GraphqlAuthMethod, |
| 23 | GraphqlAuthMethodInput, | ||
| 24 | GraphqlCanonicalAuthMethodInput, | ||
| 25 | } from "../sdk/types"; | ||
| 18 | 26 | ||
| 19 | // --------------------------------------------------------------------------- | 27 | const 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 | ||
| 23 | const labelForApiKey = (slug: string, name: string): string => `API key (${name || slug})`; | 37 | /** Convert a generic editor value into one GraphQL auth-method input (no slug |
| 38 | * — the backend assigns carrier-derived slugs). An apikey value keeps every | ||
| 39 | * named placement (headers and query params mix freely); one with no usable | ||
| 40 | * placement falls back to `none`. */ | ||
| 41 | export function graphqlAuthMethodInputFromEditorValue( | ||
| 42 | value: AuthTemplateEditorValue, | ||
| 43 | ): GraphqlAuthMethodInput { | ||
| 44 | if (value.kind === "oauth") return { kind: "oauth2" }; | ||
| 45 | return (sharedMethodInputFromEditorValue(value) ?? { kind: "none" }) as GraphqlAuthMethodInput; | ||
| 46 | } | ||
| 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. */ | 49 | export function editorValueFromGraphqlAuthMethod( |
| 27 | export 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 a | 62 | export function authMethodsFromConfig(methods: readonly GraphqlAuthMethod[]): AuthMethod[] { |
| 62 | // multi-placement method emits one template per named placement. When `slug` is | 63 | return methods.map((method: GraphqlAuthMethod): AuthMethod => { |
| 63 | // omitted the backend backfills `custom_<id>`; the first template keeps the | 64 | 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 optional | 69 | /** Build the GraphQL method input for a custom method from generic placements |
| 68 | * `slug` names the FIRST emitted template (subsequent placements get an empty | 70 | * — 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). |
| 70 | export function graphqlTemplatesFromPlacements( | 72 | * Empty when no placement is usable. */ |
| 73 | export 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). */ | ||
| 83 | export const graphqlWireAuthInput = ( | ||
| 84 | method: GraphqlAuthMethod | GraphqlCanonicalAuthMethodInput, | ||
| 85 | ): GraphqlAuthMethodInput => wireAuthInputFromShared(method) as GraphqlAuthMethodInput; | ||
| @@ -1,27 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { graphqlConnectionName } from "./defaults"; |
| 4 | GRAPHQL_APIKEY_TEMPLATE, | ||
| 5 | graphqlApiKeyAuthTemplate, | ||
| 6 | graphqlConnectionName, | ||
| 7 | } from "./defaults"; | ||
| 8 | |||
| 9 | describe("graphqlApiKeyAuthTemplate", () => { | ||
| 10 | it("builds an apiKey header template using the template slug", () => { | ||
| 11 | const template = graphqlApiKeyAuthTemplate("X-Api-Key"); | ||
| 12 | |||
| 13 | expect(template).toEqual({ | ||
| 14 | kind: "apiKey", | ||
| 15 | slug: GRAPHQL_APIKEY_TEMPLATE, | ||
| 16 | in: "header", | ||
| 17 | name: "X-Api-Key", | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("falls back to Authorization when no header name is given", () => { | ||
| 22 | expect(graphqlApiKeyAuthTemplate("").name).toBe("Authorization"); | ||
| 23 | }); | ||
| 24 | }); | ||
| 25 | 4 | ||
| 26 | describe("graphqlConnectionName", () => { | 5 | describe("graphqlConnectionName", () => { |
| 27 | it("is deterministic per integration + owner", () => { | 6 | it("is deterministic per integration + owner", () => { |
| @@ -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. */ | ||
| 11 | export const GRAPHQL_APIKEY_TEMPLATE = "apiKey"; | ||
| 12 | |||
| 13 | /** Build the apiKey auth template the integration stores. The connection's | ||
| 14 | * value is written to `headerName` at invoke time (no prefix — the user pastes | ||
| 15 | * the full header value, e.g. `Bearer ghp_…`). */ | ||
| 16 | export const graphqlApiKeyAuthTemplate = ( | ||
| 17 | headerName: string, | ||
| 18 | ): { | ||
| 19 | readonly kind: "apiKey"; | ||
| 20 | readonly slug: string; | ||
| 21 | readonly in: "header"; | ||
| 22 | readonly name: string; | ||
| 23 | } => ({ | ||
| 24 | kind: "apiKey", | ||
| 25 | slug: GRAPHQL_APIKEY_TEMPLATE, | ||
| 26 | in: "header", | ||
| 27 | name: headerName || "Authorization", | ||
| 28 | }); | ||
| 29 | |||
| 30 | /** Deterministic connection name for a GraphQL integration + owner. Keeps a | 10 | /** Deterministic connection name for a GraphQL integration + owner. Keeps a |
| 31 | * single owner-scoped credential per integration so re-adding overwrites | 11 | * single owner-scoped credential per integration so re-adding overwrites |
| 32 | * rather than accumulating duplicates. */ | 12 | * rather than accumulating duplicates. */ |
| @@ -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 existing | 4 | // `configureAuth` merge-appends apikey/oauth2 methods onto an existing |
| 5 | // integration's opaque `authenticationTemplate`. These tests exercise the | 5 | // 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 existing | 8 | // - 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 | |||
| 20 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 20 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 21 | 21 | ||
| 22 | import { graphqlPlugin } from "./plugin"; | 22 | import { graphqlPlugin } from "./plugin"; |
| 23 | import type { AuthTemplate } from "./types"; | 23 | import type { GraphqlAuthMethod } from "./types"; |
| 24 | 24 | ||
| 25 | const makeExecutor = () => | 25 | const makeExecutor = () => |
| 26 | createExecutor( | 26 | createExecutor( |
| @@ -29,11 +29,10 @@ const makeExecutor = () => · 2 unmodified lines | |||
| 29 | }), | 29 | }), |
| 30 | ); | 30 | ); |
| 31 | 31 | ||
| 32 | const customApiKey: AuthTemplate = { | 32 | const 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 | ||
| 39 | describe("GraphQL Plugin — configureAuth (custom auth method)", () => { | 38 | describe("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 | ||
| @@ -22,13 +22,17 @@ const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ · 21 unmodified lines | |||
| 22 | }); | 22 | }); |
| 23 | 23 | ||
| 24 | describe("describeGraphqlAuthMethods", () => { | 24 | describe("describeGraphqlAuthMethods", () => { |
| 25 | it("projects a header apiKey template to one apikey method carrying the header placement", () => { | 25 | it("projects a header apikey method carrying the header placement", () => { |
| 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 | ); |
| @@ -20,17 +20,22 @@ export { · 19 unmodified lines | |||
| 20 | } from "./errors"; | 20 | } from "./errors"; |
| 21 | 21 | ||
| 22 | export { | 22 | export { |
| 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 | |||
| 38 | export { migrateGraphqlAuthConfig } from "./migrate-config"; | ||
| 39 | |||
| 40 | // Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`. | ||
| 41 | export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| @@ -0,0 +1,202 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Option } from "effect"; | ||
| 3 | |||
| 4 | import { migrateGraphqlAuthConfig } from "./migrate-config"; | ||
| 5 | import { decodeGraphqlIntegrationConfigOption } from "./types"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // The one-off config migration rewrites every pre-canonical GraphQL blob | ||
| 9 | // family into the canonical placements model. Two invariants are load-bearing: | ||
| 10 | // slugs are preserved verbatim (connections bind by `connection.template`) | ||
| 11 | // and variable names are preserved verbatim (they key stored `item_ids`). | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | const BASE = { endpoint: "https://x.example/graphql", name: "x" } as const; | ||
| 15 | |||
| 16 | const decodes = (config: unknown): boolean => | ||
| 17 | Option.isSome(decodeGraphqlIntegrationConfigOption(config)); | ||
| 18 | |||
| 19 | describe("migrateGraphqlAuthConfig", () => { | ||
| 20 | it("lifts the v1→v2 singular `auth: {kind:'none'}` into a one-method template, slug = kind", () => { | ||
| 21 | const migrated = migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "none" } }); | ||
| 22 | expect(migrated).toEqual({ | ||
| 23 | ...BASE, | ||
| 24 | authenticationTemplate: [{ slug: "none", kind: "none" }], | ||
| 25 | }); | ||
| 26 | expect(decodes(migrated)).toBe(true); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("lifts a singular `auth: {kind:'oauth2'}` (the v1→v2 migration's output)", () => { | ||
| 30 | expect(migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "oauth2" } })).toEqual({ | ||
| 31 | ...BASE, | ||
| 32 | authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("treats a config with no auth fields at all as an open endpoint", () => { | ||
| 37 | expect(migrateGraphqlAuthConfig({ ...BASE })).toEqual({ | ||
| 38 | ...BASE, | ||
| 39 | authenticationTemplate: [{ slug: "none", kind: "none" }], | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("rewrites the retired native `{kind:'apiKey', in, name}` shape, slugs preserved", () => { | ||
| 44 | const migrated = migrateGraphqlAuthConfig({ | ||
| 45 | ...BASE, | ||
| 46 | authenticationTemplate: [ | ||
| 47 | { kind: "apiKey", slug: "header", in: "header", name: "X-Api-Key", prefix: "Bearer " }, | ||
| 48 | { kind: "apiKey", slug: "custom_q1", in: "query", name: "token" }, | ||
| 49 | { kind: "oauth2", slug: "oauth2" }, | ||
| 50 | ], | ||
| 51 | }); | ||
| 52 | expect(migrated).toEqual({ | ||
| 53 | ...BASE, | ||
| 54 | authenticationTemplate: [ | ||
| 55 | { | ||
| 56 | slug: "header", | ||
| 57 | kind: "apikey", | ||
| 58 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 59 | }, | ||
| 60 | { | ||
| 61 | slug: "custom_q1", | ||
| 62 | kind: "apikey", | ||
| 63 | placements: [{ carrier: "query", name: "token" }], | ||
| 64 | }, | ||
| 65 | { kind: "oauth2", slug: "oauth2" }, | ||
| 66 | ], | ||
| 67 | }); | ||
| 68 | expect(decodes(migrated)).toBe(true); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("rewrites openapi-shaped entries the v1→v2 migration wrote, variables preserved", () => { | ||
| 72 | // Exactly what `migrateOpenApiAuthTemplate` emits for a multi-input source. | ||
| 73 | const migrated = migrateGraphqlAuthConfig({ | ||
| 74 | ...BASE, | ||
| 75 | authenticationTemplate: [ | ||
| 76 | { | ||
| 77 | slug: "apiKey", | ||
| 78 | type: "apiKey", | ||
| 79 | headers: { | ||
| 80 | "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }], | ||
| 81 | Authorization: ["Bearer ", { type: "variable", name: "dd_application_key" }], | ||
| 82 | }, | ||
| 83 | queryParams: { team: [{ type: "variable", name: "team" }] }, | ||
| 84 | }, | ||
| 85 | { slug: "github_oauth", type: "oauth" }, | ||
| 86 | ], | ||
| 87 | }); | ||
| 88 | expect(migrated).toEqual({ | ||
| 89 | ...BASE, | ||
| 90 | authenticationTemplate: [ | ||
| 91 | { | ||
| 92 | slug: "apiKey", | ||
| 93 | kind: "apikey", | ||
| 94 | placements: [ | ||
| 95 | { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | ||
| 96 | { | ||
| 97 | carrier: "header", | ||
| 98 | name: "Authorization", | ||
| 99 | prefix: "Bearer ", | ||
| 100 | variable: "dd_application_key", | ||
| 101 | }, | ||
| 102 | { carrier: "query", name: "team", variable: "team" }, | ||
| 103 | ], | ||
| 104 | }, | ||
| 105 | { slug: "github_oauth", kind: "oauth2" }, | ||
| 106 | ], | ||
| 107 | }); | ||
| 108 | expect(decodes(migrated)).toBe(true); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("stores the canonical `token` variable as absent and literal-only values as literals", () => { | ||
| 112 | const migrated = migrateGraphqlAuthConfig({ | ||
| 113 | ...BASE, | ||
| 114 | authenticationTemplate: [ | ||
| 115 | { | ||
| 116 | slug: "apiKey", | ||
| 117 | type: "apiKey", | ||
| 118 | headers: { | ||
| 119 | Authorization: ["Bearer ", { type: "variable", name: "token" }], | ||
| 120 | "X-Api-Version": "2023-01-01", | ||
| 121 | }, | ||
| 122 | }, | ||
| 123 | ], | ||
| 124 | }) as { authenticationTemplate: readonly unknown[] }; | ||
| 125 | expect(migrated.authenticationTemplate[0]).toEqual({ | ||
| 126 | slug: "apiKey", | ||
| 127 | kind: "apikey", | ||
| 128 | placements: [ | ||
| 129 | { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 130 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 131 | ], | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("folds a singular oauth2 `auth` alongside apiKey entries into the declared set", () => { | ||
| 136 | // The v1→v2 migration wrote BOTH when a source had placements and oauth. | ||
| 137 | const migrated = migrateGraphqlAuthConfig({ | ||
| 138 | ...BASE, | ||
| 139 | auth: { kind: "oauth2" }, | ||
| 140 | authenticationTemplate: [ | ||
| 141 | { | ||
| 142 | slug: "apiKey", | ||
| 143 | type: "apiKey", | ||
| 144 | headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, | ||
| 145 | }, | ||
| 146 | ], | ||
| 147 | }); | ||
| 148 | expect(migrated).toEqual({ | ||
| 149 | ...BASE, | ||
| 150 | authenticationTemplate: [ | ||
| 151 | { | ||
| 152 | slug: "apiKey", | ||
| 153 | kind: "apikey", | ||
| 154 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 155 | }, | ||
| 156 | { slug: "oauth2", kind: "oauth2" }, | ||
| 157 | ], | ||
| 158 | }); | ||
| 159 | expect(decodes(migrated)).toBe(true); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("is idempotent — a canonical config returns null (no rewrite)", () => { | ||
| 163 | const canonical = { | ||
| 164 | ...BASE, | ||
| 165 | authenticationTemplate: [ | ||
| 166 | { slug: "oauth2", kind: "oauth2" }, | ||
| 167 | { | ||
| 168 | slug: "header", | ||
| 169 | kind: "apikey", | ||
| 170 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 171 | }, | ||
| 172 | { slug: "none", kind: "none" }, | ||
| 173 | ], | ||
| 174 | }; | ||
| 175 | expect(migrateGraphqlAuthConfig(canonical)).toBeNull(); | ||
| 176 | // And the lifted output of every family is itself canonical. | ||
| 177 | const lifted = migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "oauth2" } }); | ||
| 178 | expect(migrateGraphqlAuthConfig(lifted)).toBeNull(); | ||
| 179 | const liftedBoth = migrateGraphqlAuthConfig({ | ||
| 180 | ...BASE, | ||
| 181 | auth: { kind: "oauth2" }, | ||
| 182 | authenticationTemplate: [{ kind: "apiKey", slug: "header", in: "header", name: "X-Api-Key" }], | ||
| 183 | }); | ||
| 184 | expect(migrateGraphqlAuthConfig(liftedBoth)).toBeNull(); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("leaves foreign and unmigratable blobs untouched", () => { | ||
| 188 | // MCP remote configs also carry an endpoint — `transport` marks them. | ||
| 189 | expect( | ||
| 190 | migrateGraphqlAuthConfig({ transport: "remote", endpoint: "https://x.example/mcp" }), | ||
| 191 | ).toBeNull(); | ||
| 192 | expect(migrateGraphqlAuthConfig({ not: "graphql" })).toBeNull(); | ||
| 193 | expect(migrateGraphqlAuthConfig(null)).toBeNull(); | ||
| 194 | expect( | ||
| 195 | migrateGraphqlAuthConfig({ | ||
| 196 | ...BASE, | ||
| 197 | authenticationTemplate: [{ slug: "x", kind: "mystery" }], | ||
| 198 | }), | ||
| 199 | ).toBeNull(); | ||
| 200 | expect(migrateGraphqlAuthConfig({ ...BASE, auth: { kind: "mystery" } })).toBeNull(); | ||
| 201 | }); | ||
| 202 | }); | ||
| @@ -0,0 +1,148 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // One-off config migration — rewrite pre-canonical GraphQL integration | ||
| 3 | // `config` blobs into the canonical placements model. Runs once per database | ||
| 4 | // (boot for local/selfhost, out-of-band script for cloud); runtime code | ||
| 5 | // decodes ONLY the canonical shape (`decodeGraphqlIntegrationConfig*`). | ||
| 6 | // | ||
| 7 | // Stored legacy families (all observed in real databases): | ||
| 8 | // 1. the v1→v2 migration's singular `auth: {kind:"none"|"oauth2"}` (the | ||
| 9 | // graphql config had no `authenticationTemplate` unless v1 carried | ||
| 10 | // credential placements), or a config with no auth fields at all | ||
| 11 | // 2. `authenticationTemplate` entries of the retired native shape: | ||
| 12 | // `{kind:"apiKey", slug, in:"header"|"query", name, prefix?}` | ||
| 13 | // 3. openapi-shaped entries the v1→v2 migration wrote: | ||
| 14 | // `{slug, type:"apiKey", headers/queryParams: <template values>}` and | ||
| 15 | // `{slug, type:"oauth", …}` | ||
| 16 | // | ||
| 17 | // Invariants: slugs are preserved verbatim (`connection.template` binds by | ||
| 18 | // them) and so are variable names (they key the connections' stored | ||
| 19 | // `item_ids`). | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | import { Option, Schema } from "effect"; | ||
| 23 | import { | ||
| 24 | apiKeyMethodFromLegacyTemplate, | ||
| 25 | decodeLegacyApiKeyTemplate, | ||
| 26 | } from "@executor-js/sdk/http-auth"; | ||
| 27 | |||
| 28 | import { decodeGraphqlIntegrationConfigOption, type GraphqlAuthMethod } from "./types"; | ||
| 29 | |||
| 30 | const LegacySingleAuth = Schema.Union([ | ||
| 31 | Schema.Struct({ kind: Schema.Literal("none") }), | ||
| 32 | Schema.Struct({ kind: Schema.Literal("oauth2") }), | ||
| 33 | ]); | ||
| 34 | const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth); | ||
| 35 | |||
| 36 | const LegacyNativeApiKey = Schema.Struct({ | ||
| 37 | slug: Schema.String, | ||
| 38 | kind: Schema.Literal("apiKey"), | ||
| 39 | in: Schema.Literals(["header", "query"]), | ||
| 40 | name: Schema.String, | ||
| 41 | prefix: Schema.optional(Schema.String), | ||
| 42 | }); | ||
| 43 | const LegacyOAuthTemplate = Schema.Struct({ | ||
| 44 | slug: Schema.String, | ||
| 45 | type: Schema.Literal("oauth"), | ||
| 46 | }); | ||
| 47 | const decodeLegacyNativeApiKey = Schema.decodeUnknownOption(LegacyNativeApiKey); | ||
| 48 | const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate); | ||
| 49 | |||
| 50 | const CanonicalMethod = Schema.decodeUnknownOption( | ||
| 51 | Schema.Union([ | ||
| 52 | Schema.Struct({ slug: Schema.String, kind: Schema.Literals(["none", "oauth2"]) }), | ||
| 53 | Schema.Struct({ | ||
| 54 | slug: Schema.String, | ||
| 55 | kind: Schema.Literal("apikey"), | ||
| 56 | label: Schema.optional(Schema.String), | ||
| 57 | placements: Schema.Array(Schema.Unknown), | ||
| 58 | }), | ||
| 59 | ]), | ||
| 60 | ); | ||
| 61 | |||
| 62 | /** Rewrite one stored method entry to canonical, or null when undecodable. */ | ||
| 63 | const migrateMethodEntry = (entry: unknown): GraphqlAuthMethod | null => { | ||
| 64 | const canonical = Option.getOrNull(CanonicalMethod(entry)); | ||
| 65 | if (canonical !== null) return entry as GraphqlAuthMethod; | ||
| 66 | |||
| 67 | const native = Option.getOrNull(decodeLegacyNativeApiKey(entry)); | ||
| 68 | if (native !== null) { | ||
| 69 | return { | ||
| 70 | slug: native.slug, | ||
| 71 | kind: "apikey", | ||
| 72 | placements: [ | ||
| 73 | { | ||
| 74 | carrier: native.in, | ||
| 75 | name: native.name, | ||
| 76 | ...(native.prefix !== undefined ? { prefix: native.prefix } : {}), | ||
| 77 | }, | ||
| 78 | ], | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | const legacyApiKey = Option.getOrNull(decodeLegacyApiKeyTemplate(entry)); | ||
| 83 | if (legacyApiKey !== null) return apiKeyMethodFromLegacyTemplate(legacyApiKey); | ||
| 84 | |||
| 85 | const legacyOAuth = Option.getOrNull(decodeLegacyOAuthTemplate(entry)); | ||
| 86 | if (legacyOAuth !== null) return { slug: legacyOAuth.slug, kind: "oauth2" }; | ||
| 87 | |||
| 88 | return null; | ||
| 89 | }; | ||
| 90 | |||
| 91 | const parseConfig = (config: unknown): unknown | null => | ||
| 92 | Option.isSome(decodeGraphqlIntegrationConfigOption(config)) ? config : null; | ||
| 93 | |||
| 94 | /** Rewrite a stored GraphQL integration config blob into the canonical shape. | ||
| 95 | * Returns the rewritten config, or `null` when no rewrite is needed (already | ||
| 96 | * canonical, or not this plugin's shape at all). Throws nothing: an | ||
| 97 | * unmigratable blob also returns `null` and stays untouched. Idempotent — | ||
| 98 | * feeding the output back returns `null`. */ | ||
| 99 | export const migrateGraphqlAuthConfig = (config: unknown): unknown | null => { | ||
| 100 | if (typeof config !== "object" || config === null) return null; | ||
| 101 | // A GraphQL config is keyed by its endpoint; `transport` marks MCP blobs. | ||
| 102 | if (!("endpoint" in config) || typeof config.endpoint !== "string") return null; | ||
| 103 | if ("transport" in config) return null; | ||
| 104 | |||
| 105 | const legacyAuth = | ||
| 106 | "auth" in config | ||
| 107 | ? Option.getOrNull(decodeLegacySingleAuth((config as { auth: unknown }).auth)) | ||
| 108 | : null; | ||
| 109 | |||
| 110 | if (!("authenticationTemplate" in config)) { | ||
| 111 | // Family 1: singular `auth`, or an open endpoint with no auth field. | ||
| 112 | if ("auth" in config && legacyAuth === null) return null; | ||
| 113 | const kind = legacyAuth?.kind ?? "none"; | ||
| 114 | const { auth: _auth, ...rest } = config as { auth?: unknown }; | ||
| 115 | const migrated = { ...rest, authenticationTemplate: [{ slug: kind, kind }] }; | ||
| 116 | return parseConfig(migrated); | ||
| 117 | } | ||
| 118 | |||
| 119 | const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate; | ||
| 120 | if (!Array.isArray(entries)) return null; | ||
| 121 | |||
| 122 | const migrated = entries.map(migrateMethodEntry); | ||
| 123 | if (migrated.some((entry) => entry === null)) return null; | ||
| 124 | const methods = migrated as GraphqlAuthMethod[]; | ||
| 125 | |||
| 126 | // The v1→v2 migration wrote BOTH `auth: {kind:"oauth2"}` and an apiKey | ||
| 127 | // `authenticationTemplate` when a source had credential placements and | ||
| 128 | // oauth; fold the singular auth into the declared set (slug = kind, the | ||
| 129 | // slug the connection migration bound oauth connections to). | ||
| 130 | if ( | ||
| 131 | legacyAuth?.kind === "oauth2" && | ||
| 132 | !methods.some((method: GraphqlAuthMethod) => method.kind === "oauth2") | ||
| 133 | ) { | ||
| 134 | methods.push({ slug: "oauth2", kind: "oauth2" }); | ||
| 135 | } | ||
| 136 | |||
| 137 | const entriesChanged = | ||
| 138 | methods.length !== entries.length || | ||
| 139 | methods.some((entry: GraphqlAuthMethod, index: number) => entry !== entries[index]); | ||
| 140 | if (!entriesChanged && legacyAuth === null) return null; | ||
| 141 | |||
| 142 | const { auth: _auth, ...withoutAuth } = config as { auth?: unknown }; | ||
| 143 | const next = { | ||
| 144 | ...(legacyAuth !== null ? withoutAuth : config), | ||
| 145 | authenticationTemplate: methods, | ||
| 146 | }; | ||
| 147 | return parseConfig(next); | ||
| 148 | }; | ||
| @@ -0,0 +1,206 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // GraphQL multi-placement auth (wire-level) | ||
| 3 | // | ||
| 4 | // The extreme case the unified model exists for: ONE auth method that mixes a | ||
| 5 | // bearer HEADER and a team-id QUERY PARAM, each rendered from its own | ||
| 6 | // credential input — plus the single-placement query case, and the explicit | ||
| 7 | // failure when an input is missing. Assertions are made against what the | ||
| 8 | // server actually RECEIVED (connect-time introspection and tool invocation). | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | import { describe, expect, it } from "@effect/vitest"; | ||
| 12 | import { Effect } from "effect"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | AuthTemplateSlug, | ||
| 16 | ConnectionName, | ||
| 17 | IntegrationSlug, | ||
| 18 | ToolAddress, | ||
| 19 | createExecutor, | ||
| 20 | } from "@executor-js/sdk"; | ||
| 21 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 22 | |||
| 23 | import { graphqlPlugin } from "./plugin"; | ||
| 24 | import { variable } from "@executor-js/sdk/http-auth"; | ||
| 25 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; | ||
| 26 | |||
| 27 | const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); | ||
| 28 | |||
| 29 | const makeExecutor = () => | ||
| 30 | createExecutor( | ||
| 31 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }), | ||
| 32 | ); | ||
| 33 | |||
| 34 | const toolAddr = (integration: string, connection: string, tool: string): ToolAddress => | ||
| 35 | ToolAddress.make(`tools.${integration}.org.${connection}.${tool}`); | ||
| 36 | |||
| 37 | describe("GraphQL multi-placement auth", () => { | ||
| 38 | it.effect("the request-shaped authoring dialect lands identically on the wire", () => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const server = yield* serveGreetingServer; | ||
| 41 | const executor = yield* makeExecutor(); | ||
| 42 | |||
| 43 | yield* executor.graphql.addIntegration({ | ||
| 44 | endpoint: server.endpoint, | ||
| 45 | slug: "authored_gql", | ||
| 46 | name: "Authored GraphQL", | ||
| 47 | authenticationTemplate: [ | ||
| 48 | { | ||
| 49 | slug: "token_and_team", | ||
| 50 | type: "apiKey", | ||
| 51 | headers: { Authorization: ["Bearer ", variable("api_token")] }, | ||
| 52 | queryParams: { team_id: [variable("team_id")] }, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | }); | ||
| 56 | |||
| 57 | yield* executor.connections.create({ | ||
| 58 | owner: "org", | ||
| 59 | name: ConnectionName.make("main"), | ||
| 60 | integration: IntegrationSlug.make("authored_gql"), | ||
| 61 | template: AuthTemplateSlug.make("token_and_team"), | ||
| 62 | values: { api_token: "tok_A", team_id: "team_B" }, | ||
| 63 | }); | ||
| 64 | |||
| 65 | const requests = yield* server.requests; | ||
| 66 | expect(requests.length).toBeGreaterThan(0); | ||
| 67 | const last = requests[requests.length - 1]!; | ||
| 68 | expect(last.headers["authorization"]).toBe("Bearer tok_A"); | ||
| 69 | expect(last.url.includes("team_id=team_B")).toBe(true); | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | |||
| 73 | it.effect("one method renders a bearer header AND a team-id query param", () => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const server = yield* serveGreetingServer; | ||
| 76 | const executor = yield* makeExecutor(); | ||
| 77 | |||
| 78 | yield* executor.graphql.addIntegration({ | ||
| 79 | endpoint: server.endpoint, | ||
| 80 | slug: "mixed_gql", | ||
| 81 | name: "Mixed GraphQL", | ||
| 82 | authenticationTemplate: [ | ||
| 83 | { | ||
| 84 | slug: "token_and_team", | ||
| 85 | kind: "apikey", | ||
| 86 | placements: [ | ||
| 87 | { | ||
| 88 | carrier: "header", | ||
| 89 | name: "Authorization", | ||
| 90 | prefix: "Bearer ", | ||
| 91 | variable: "api_token", | ||
| 92 | }, | ||
| 93 | { carrier: "query", name: "team_id", variable: "team_id" }, | ||
| 94 | ], | ||
| 95 | }, | ||
| 96 | ], | ||
| 97 | }); | ||
| 98 | |||
| 99 | yield* executor.connections.create({ | ||
| 100 | owner: "org", | ||
| 101 | name: ConnectionName.make("main"), | ||
| 102 | integration: IntegrationSlug.make("mixed_gql"), | ||
| 103 | template: AuthTemplateSlug.make("token_and_team"), | ||
| 104 | values: { api_token: "tok_A", team_id: "team_B" }, | ||
| 105 | }); | ||
| 106 | |||
| 107 | // Connect-time introspection runs WITH the connection's rendered auth — | ||
| 108 | // both carriers, each from its own credential input. | ||
| 109 | const afterConnect = yield* server.requests; | ||
| 110 | const introspections = afterConnect.filter((request) => | ||
| 111 | request.payload.query?.includes("__schema"), | ||
| 112 | ); | ||
| 113 | expect(introspections.length).toBeGreaterThan(0); | ||
| 114 | expect(introspections.every((r) => r.headers.authorization === "Bearer tok_A")).toBe(true); | ||
| 115 | expect(introspections.every((r) => r.url.includes("team_id=team_B"))).toBe(true); | ||
| 116 | |||
| 117 | yield* server.clearRequests; | ||
| 118 | |||
| 119 | const result = yield* executor.execute(toolAddr("mixed_gql", "main", "query.hello"), { | ||
| 120 | name: "Ada", | ||
| 121 | }); | ||
| 122 | expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } }); | ||
| 123 | |||
| 124 | const requests = yield* server.requests; | ||
| 125 | expect(requests.length).toBeGreaterThan(0); | ||
| 126 | expect(requests.every((r) => r.headers.authorization === "Bearer tok_A")).toBe(true); | ||
| 127 | expect(requests.every((r) => r.url.includes("team_id=team_B"))).toBe(true); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | |||
| 131 | it.effect("a query-only method renders ?token= and no Authorization header", () => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const server = yield* serveGreetingServer; | ||
| 134 | const executor = yield* makeExecutor(); | ||
| 135 | |||
| 136 | yield* executor.graphql.addIntegration({ | ||
| 137 | endpoint: server.endpoint, | ||
| 138 | slug: "query_gql", | ||
| 139 | name: "Query GraphQL", | ||
| 140 | authenticationTemplate: [ | ||
| 141 | { kind: "apikey", placements: [{ carrier: "query", name: "token" }] }, | ||
| 142 | ], | ||
| 143 | }); | ||
| 144 | |||
| 145 | // Slug-less single-query-placement methods get the `query` slug. | ||
| 146 | yield* executor.connections.create({ | ||
| 147 | owner: "org", | ||
| 148 | name: ConnectionName.make("main"), | ||
| 149 | integration: IntegrationSlug.make("query_gql"), | ||
| 150 | template: AuthTemplateSlug.make("query"), | ||
| 151 | value: "tok_secret_123", | ||
| 152 | }); | ||
| 153 | |||
| 154 | yield* server.clearRequests; | ||
| 155 | |||
| 156 | const result = yield* executor.execute(toolAddr("query_gql", "main", "query.hello"), { | ||
| 157 | name: "Ada", | ||
| 158 | }); | ||
| 159 | expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } }); | ||
| 160 | |||
| 161 | const requests = yield* server.requests; | ||
| 162 | expect(requests.length).toBeGreaterThan(0); | ||
| 163 | expect(requests.every((r) => r.url.includes("token=tok_secret_123"))).toBe(true); | ||
| 164 | expect(requests.every((r) => r.headers.authorization === undefined)).toBe(true); | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | it.effect("invoking with a missing credential input fails explicitly, not silently", () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const server = yield* serveGreetingServer; | ||
| 171 | const executor = yield* makeExecutor(); | ||
| 172 | |||
| 173 | yield* executor.graphql.addIntegration({ | ||
| 174 | endpoint: server.endpoint, | ||
| 175 | slug: "strict_gql", | ||
| 176 | name: "Strict GraphQL", | ||
| 177 | authenticationTemplate: [ | ||
| 178 | { | ||
| 179 | slug: "two_inputs", | ||
| 180 | kind: "apikey", | ||
| 181 | placements: [ | ||
| 182 | { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "a" }, | ||
| 183 | { carrier: "query", name: "team_id", variable: "b" }, | ||
| 184 | ], | ||
| 185 | }, | ||
| 186 | ], | ||
| 187 | }); | ||
| 188 | |||
| 189 | // Only one of the two inputs is supplied. | ||
| 190 | yield* executor.connections.create({ | ||
| 191 | owner: "org", | ||
| 192 | name: ConnectionName.make("half"), | ||
| 193 | integration: IntegrationSlug.make("strict_gql"), | ||
| 194 | template: AuthTemplateSlug.make("two_inputs"), | ||
| 195 | values: { a: "tok_A" }, | ||
| 196 | }); | ||
| 197 | |||
| 198 | const result = (yield* executor.execute(toolAddr("strict_gql", "half", "query.hello"), { | ||
| 199 | name: "Ada", | ||
| 200 | })) as { ok: boolean; error?: { code?: string; message?: string } }; | ||
| 201 | expect(result.ok).toBe(false); | ||
| 202 | expect(result.error).toMatchObject({ code: "connection_value_missing" }); | ||
| 203 | expect(String(result.error?.message ?? "")).toContain("b"); | ||
| 204 | }), | ||
| 205 | ); | ||
| 206 | }); | ||
| @@ -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 | }); |
| @@ -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 | ||
| 24 | import { | ||
| 25 | TOKEN_VARIABLE, | ||
| 26 | describeApiKeyAuthMethod, | ||
| 27 | describeNoneAuthMethod, | ||
| 28 | oauthBearerPlacement, | ||
| 29 | renderAuthPlacements, | ||
| 30 | requiredPlacementVariables, | ||
| 31 | type RenderedAuthPlacements, | ||
| 32 | } from "@executor-js/sdk/http-auth"; | ||
| 33 | |||
| 25 | import { | 34 | import { |
| 26 | introspect, | 35 | introspect, |
| 27 | parseIntrospectionJson, | 36 | parseIntrospectionJson, |
| @@ -40,12 +49,15 @@ import { invokeWithLayer } from "./invoke"; · 12 unmodified lines | |||
| 40 | import { graphqlPresets } from "./presets"; | 49 | import { graphqlPresets } from "./presets"; |
| 41 | import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store"; | 50 | import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store"; |
| 42 | import { | 51 | import { |
| 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 shapes | 84 | // Extension input shapes |
| 73 | // --------------------------------------------------------------------------- | 85 | // --------------------------------------------------------------------------- |
| 74 | 86 | ||
| 75 | const 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 | }); |
| 92 | export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type; | 102 | export 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 | }); |
| 101 | export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type; | 111 | export 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 the | 114 | * :slug/config`). Unlike `configure` (which REPLACES the whole config for the |
| 105 | * generic repair path), `configureAuth` MERGE-APPENDS these templates onto the | 115 | * generic repair path), `configureAuth` MERGE-APPENDS these methods onto the |
| 106 | * integration's existing `authenticationTemplate`, mirroring OpenAPI's | 116 | * integration's existing `authenticationTemplate`, mirroring OpenAPI's |
| 107 | * `configure`. */ | 117 | * `configure`. */ |
| 108 | const GraphqlConfigureAuthInputSchema = Schema.Struct({ | 118 | const 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 | }); |
| 112 | export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type; | 122 | export 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 through | 350 | // 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 | ||
| 344 | interface RenderedAuth { | 356 | const 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); | |
| 349 | const 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. */ |
| 388 | const introspectHeadersForConnection = ( | 393 | const 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 slug | 401 | // (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 !== null | 404 | ? 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. */ |
| 414 | const introspectForConnection = ( | 419 | const 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 slug | 451 | // 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. Exported | 494 | // 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 (the | 497 | // none → a no-auth method carrying no credential inputs |
| 493 | // `name` from the template, with the template's `prefix` defaulted to | 498 | // apikey → carried placements (headers / query params) verbatim |
| 494 | // `""`). | ||
| 495 | // oauth2 → one oauth method (no resolved endpoints; graphql renders the | 499 | // 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 | ||
| 499 | const graphqlApiKeyLabel = (placement: AuthPlacementDescriptor): string => | ||
| 500 | `API key (${placement.name || (placement.carrier === "header" ? "header" : "query")})`; | ||
| 501 | |||
| 502 | export const describeGraphqlAuthMethods = ( | 503 | export 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 a | 553 | /** 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) !== undefined | 655 | ...((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.authenticationTemplate | 702 | ? 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's | 865 | // 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 === undefined | 868 | 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 | } |
| @@ -1,4 +1,12 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { | ||
| 3 | ApiKeyAuthTemplate, | ||
| 4 | apiKeyMethodFromAuthTemplate, | ||
| 5 | isApiKeyAuthTemplate, | ||
| 6 | ApiKeyAuthMethod, | ||
| 7 | NoneAuthMethod, | ||
| 8 | normalizeAuthMethodSlugs, | ||
| 9 | } from "@executor-js/sdk/http-auth"; | ||
| 2 | 10 | ||
| 3 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| 4 | // GraphQL operation kind | 12 | // GraphQL operation kind |
| @@ -55,35 +63,27 @@ export const OperationBinding = Schema.Struct({ · 50 unmodified lines | |||
| 55 | export type OperationBinding = typeof OperationBinding.Type; | 63 | export 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 a | 74 | // none — no credential (open endpoint) |
| 61 | // connection's resolved value is applied: an apiKey header / query param (with | 75 | // 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 no | 76 | // placements (one credential input per distinct placement |
| 63 | // secret slots and no credential bindings — a connection IS the credential, and | 77 | // `variable`; a method may mix carriers — e.g. a bearer header |
| 64 | // the plugin renders `credential.value` onto the request through the template | 78 | // plus a team-id query param) |
| 65 | // matched by `credential.template` (D11: "auth state derived into the | 79 | // 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 apiKey | 80 | // (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. */ |
| 72 | export const ApiKeyAuthTemplate = Schema.Struct({ | 86 | export 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 | }); | ||
| 82 | export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type; | ||
| 83 | |||
| 84 | /** An OAuth bearer template: write `Authorization: Bearer <access-token>`. The | ||
| 85 | * resolved (and refreshed) access token is `credential.value`. */ | ||
| 86 | export const OAuthAuthTemplate = Schema.Struct({ | ||
| 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 | }); |
| 94 | export type OAuthAuthTemplate = typeof OAuthAuthTemplate.Type; | 94 | export type GraphqlOAuthMethod = typeof GraphqlOAuthMethod.Type; |
| 95 | 95 | ||
| 96 | export const AuthTemplate = Schema.Union([ApiKeyAuthTemplate, OAuthAuthTemplate]); | 96 | export const GraphqlAuthMethod = Schema.Union([ |
| 97 | export type AuthTemplate = typeof AuthTemplate.Type; | 97 | NoneAuthMethod, |
| 98 | ApiKeyAuthMethod, | ||
| 99 | GraphqlOAuthMethod, | ||
| 100 | ]); | ||
| 101 | export type GraphqlAuthMethod = typeof GraphqlAuthMethod.Type; | ||
| 102 | |||
| 103 | /** Input variant of `GraphqlAuthMethod` — callers (UI, agents) may omit the | ||
| 104 | * slug; `normalizeGraphqlAuthMethods` backfills it. */ | ||
| 105 | export const GraphqlAuthMethodInput = Schema.Union([ | ||
| 106 | Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), | ||
| 107 | Schema.Struct({ | ||
| 108 | slug: Schema.optional(Schema.String), | ||
| 109 | kind: Schema.Literal("oauth2"), | ||
| 110 | header: Schema.optional(Schema.String), | ||
| 111 | prefix: Schema.optional(Schema.String), | ||
| 112 | }), | ||
| 113 | // Credential methods are authored request-shaped — the ONE apikey input | ||
| 114 | // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", | ||
| 115 | // variable("token")] }, queryParams: { … } }`. Stored configs and the | ||
| 116 | // catalog read as canonical placements; `apiKeyAuthTemplateFromMethod` | ||
| 117 | // serializes them back for read-modify-write flows. | ||
| 118 | ApiKeyAuthTemplate, | ||
| 119 | ]); | ||
| 120 | export type GraphqlAuthMethodInput = typeof GraphqlAuthMethodInput.Type; | ||
| 121 | |||
| 122 | /** The expansion target: input arms with the dialect resolved to canonical | ||
| 123 | * placements (slug still optional — backfill is a separate pass). */ | ||
| 124 | export type GraphqlCanonicalAuthMethodInput = | ||
| 125 | | Exclude<GraphqlAuthMethodInput, ApiKeyAuthTemplate> | ||
| 126 | | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string }); | ||
| 127 | |||
| 128 | /** The default slug for a slug-less input method. Carrier-derived for the | ||
| 129 | * single-placement apikey cases (`header` / `query`) so the UI, agent, and | ||
| 130 | * migration paths all converge on the same names. */ | ||
| 131 | const defaultGraphqlAuthSlug = (method: GraphqlCanonicalAuthMethodInput): string => { | ||
| 132 | if (method.kind !== "apikey") return method.kind; | ||
| 133 | if (method.placements.length === 1) { | ||
| 134 | return method.placements[0]!.carrier === "header" ? "header" : "query"; | ||
| 135 | } | ||
| 136 | return "apikey"; | ||
| 137 | }; | ||
| 138 | |||
| 139 | /** Expand request-shaped dialect entries into canonical placements; canonical | ||
| 140 | * entries pass through. Slug backfill is the caller's concern | ||
| 141 | * (`normalizeGraphqlAuthMethods` for declare flows, `mergeAuthTemplates` for | ||
| 142 | * the custom-method merge). */ | ||
| 143 | export const expandGraphqlAuthMethodInputs = ( | ||
| 144 | methods: readonly GraphqlAuthMethodInput[], | ||
| 145 | ): readonly GraphqlCanonicalAuthMethodInput[] => | ||
| 146 | methods.map( | ||
| 147 | (method): GraphqlCanonicalAuthMethodInput => | ||
| 148 | isApiKeyAuthTemplate(method) | ||
| 149 | ? (apiKeyMethodFromAuthTemplate(method) as GraphqlCanonicalAuthMethodInput) | ||
| 150 | : (method as GraphqlCanonicalAuthMethodInput), | ||
| 151 | ); | ||
| 152 | |||
| 153 | /** Assign each method a stable slug: a caller-provided one wins, otherwise a | ||
| 154 | * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The | ||
| 155 | * request-shaped dialect is expanded to canonical placements first. */ | ||
| 156 | export const normalizeGraphqlAuthMethods = ( | ||
| 157 | methods: readonly GraphqlAuthMethodInput[], | ||
| 158 | ): readonly GraphqlAuthMethod[] => | ||
| 159 | normalizeAuthMethodSlugs( | ||
| 160 | expandGraphqlAuthMethodInputs(methods), | ||
| 161 | defaultGraphqlAuthSlug, | ||
| 162 | ) as readonly GraphqlAuthMethod[]; | ||
| 98 | 163 | ||
| 99 | // --------------------------------------------------------------------------- | 164 | // --------------------------------------------------------------------------- |
| 100 | // Integration config — the opaque-to-core blob the graphql plugin stores on the | 165 | // 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 | }); |
| 120 | export type GraphqlIntegrationConfig = typeof GraphqlIntegrationConfig.Type; | 186 | export 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. | ||
| 122 | export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig); | 191 | export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig); |
| 123 | export const decodeGraphqlIntegrationConfigOption = | 192 | export const decodeGraphqlIntegrationConfigOption = |
| 124 | Schema.decodeUnknownOption(GraphqlIntegrationConfig); | 193 | Schema.decodeUnknownOption(GraphqlIntegrationConfig); |
| @@ -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 | ||
| @@ -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 | } | ||
| @@ -1 +0,0 @@ | |||
| 1 | export * from "./sdk/index"; | ||
| @@ -1,25 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // @executor-js/plugin-http-source/react — v2 client surface. | ||
| 3 | // | ||
| 4 | // The v1 client (http-credentials editor) was built entirely on deleted v1 | ||
| 5 | // concepts: SecretBackedValue, ScopeId, ConfiguredCredentialBinding, secret | ||
| 6 | // pickers, and `@executor-js/react`'s credential-binding components. In v2 a | ||
| 7 | // connection IS the credential and the value origin is a single paste / OAuth | ||
| 8 | // flow / provider reference — there is no per-header secret-binding editor. | ||
| 9 | // | ||
| 10 | // The full connection-create UI is owned by the shared `@executor-js/react` | ||
| 11 | // package (not yet migrated to v2). Until that lands, this module re-exports the | ||
| 12 | // browser-safe v2 auth-template model so client code can render an http | ||
| 13 | // integration's auth methods. | ||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | |||
| 16 | export { | ||
| 17 | CREDENTIAL_VARIABLE, | ||
| 18 | variable, | ||
| 19 | type APIKeyAuthentication, | ||
| 20 | type Authentication, | ||
| 21 | type AuthenticationTemplateValue, | ||
| 22 | type AuthenticationVariable, | ||
| 23 | type HttpSourceConfig, | ||
| 24 | type HttpSourceIntegration, | ||
| 25 | } from "../sdk/types"; | ||
| @@ -1,223 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // http-source plugin — `configure` (add custom auth method) coverage. Mirrors | ||
| 3 | // the openapi plugin's configure tests against the http-source extension. | ||
| 4 | // | ||
| 5 | // - round-trip: register an integration → `configure` adds a custom apiKey | ||
| 6 | // method → `getConfig` shows it with the correct header placement + | ||
| 7 | // `variable("token")` slot, | ||
| 8 | // - slug generation for a method submitted without a slug, | ||
| 9 | // - slug uniqueness/dedupe (matching slug replaces in place; two slugless get | ||
| 10 | // distinct generated slugs), | ||
| 11 | // - the merged template renders against a connection value at invoke time. | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | import { describe, expect, it } from "@effect/vitest"; | ||
| 15 | import { Effect, Layer } from "effect"; | ||
| 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | createExecutor, | ||
| 20 | AuthTemplateSlug, | ||
| 21 | ConnectionName, | ||
| 22 | IntegrationSlug, | ||
| 23 | ToolAddress, | ||
| 24 | } from "@executor-js/sdk"; | ||
| 25 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 26 | |||
| 27 | import { httpSourcePlugin } from "./plugin"; | ||
| 28 | import { variable, type APIKeyAuthentication, type Authentication } from "./types"; | ||
| 29 | |||
| 30 | const HTTP_SLUG = "http-source" as const; | ||
| 31 | |||
| 32 | const capturingLayer = ( | ||
| 33 | capture: { request?: HttpClientRequest.HttpClientRequest }, | ||
| 34 | response: Response, | ||
| 35 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 36 | Layer.succeed( | ||
| 37 | HttpClient.HttpClient, | ||
| 38 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 39 | capture.request = request; | ||
| 40 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | ||
| 41 | }), | ||
| 42 | ); | ||
| 43 | |||
| 44 | const testConfig = (httpClientLayer?: Layer.Layer<HttpClient.HttpClient>) => { | ||
| 45 | const base = makeTestConfig({ | ||
| 46 | plugins: [httpSourcePlugin(), memoryCredentialsPlugin()] as const, | ||
| 47 | }); | ||
| 48 | // `httpClientLayer` lives on the executor config (not a `makeTestConfig` | ||
| 49 | // option) — the http-source plugin reads `ctx.httpClientLayer` at invoke time. | ||
| 50 | return httpClientLayer ? { ...base, httpClientLayer } : base; | ||
| 51 | }; | ||
| 52 | |||
| 53 | // A custom apiKey method placing the connection value into `x-api-key`. | ||
| 54 | const customApiKey: APIKeyAuthentication = { | ||
| 55 | slug: AuthTemplateSlug.make("my_custom"), | ||
| 56 | type: "apiKey", | ||
| 57 | headers: { "x-api-key": [variable("token")] }, | ||
| 58 | }; | ||
| 59 | |||
| 60 | // Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that | ||
| 61 | // omits it, which `configure` should backfill with a generated `custom_<id>`. | ||
| 62 | // The cast is confined to this one boundary helper. | ||
| 63 | const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication => | ||
| 64 | // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it | ||
| 65 | ({ type: "apiKey", ...placement }) as unknown as Authentication; | ||
| 66 | |||
| 67 | // Register an http-source integration directly through the plugin extension. | ||
| 68 | interface HttpRegistrar { | ||
| 69 | readonly register: (input: { | ||
| 70 | readonly slug: IntegrationSlug; | ||
| 71 | readonly description: string; | ||
| 72 | readonly config: { | ||
| 73 | readonly baseUrl: string; | ||
| 74 | readonly authenticationTemplate: readonly Authentication[]; | ||
| 75 | }; | ||
| 76 | }) => Effect.Effect<void, unknown>; | ||
| 77 | } | ||
| 78 | |||
| 79 | const registerHttp = ( | ||
| 80 | ext: HttpRegistrar, | ||
| 81 | slug: string, | ||
| 82 | authenticationTemplate: readonly Authentication[] = [], | ||
| 83 | ) => | ||
| 84 | ext.register({ | ||
| 85 | slug: IntegrationSlug.make(slug), | ||
| 86 | description: slug, | ||
| 87 | config: { baseUrl: "https://api.example.com", authenticationTemplate }, | ||
| 88 | }); | ||
| 89 | |||
| 90 | describe("httpSourcePlugin — configure (custom auth method)", () => { | ||
| 91 | it.effect("adds a custom apiKey method and getConfig reflects it", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const executor = yield* createExecutor(testConfig()); | ||
| 94 | |||
| 95 | yield* registerHttp(executor[HTTP_SLUG], "cfg_api"); | ||
| 96 | |||
| 97 | const merged = yield* executor[HTTP_SLUG].configure("cfg_api", { | ||
| 98 | authenticationTemplate: [customApiKey], | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(merged).toHaveLength(1); | ||
| 102 | expect(String(merged[0]!.slug)).toBe("my_custom"); | ||
| 103 | |||
| 104 | const config = yield* executor[HTTP_SLUG].getConfig("cfg_api"); | ||
| 105 | const template = config?.authenticationTemplate ?? []; | ||
| 106 | expect(template).toHaveLength(1); | ||
| 107 | const entry = template[0] as APIKeyAuthentication; | ||
| 108 | expect(entry.type).toBe("apiKey"); | ||
| 109 | expect(String(entry.slug)).toBe("my_custom"); | ||
| 110 | expect(entry.headers).toEqual({ "x-api-key": [variable("token")] }); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | |||
| 114 | it.effect("appends without dropping existing template entries", () => | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const executor = yield* createExecutor(testConfig()); | ||
| 117 | |||
| 118 | const seed: Authentication = { | ||
| 119 | slug: AuthTemplateSlug.make("seed"), | ||
| 120 | type: "apiKey", | ||
| 121 | headers: { authorization: ["Bearer ", variable("token")] }, | ||
| 122 | }; | ||
| 123 | |||
| 124 | yield* registerHttp(executor[HTTP_SLUG], "cfg_append", [seed]); | ||
| 125 | |||
| 126 | const merged = yield* executor[HTTP_SLUG].configure("cfg_append", { | ||
| 127 | authenticationTemplate: [customApiKey], | ||
| 128 | }); | ||
| 129 | |||
| 130 | expect(merged.map((m: Authentication) => String(m.slug))).toEqual(["seed", "my_custom"]); | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | |||
| 134 | it.effect("generates a custom_<id> slug for a method submitted without one", () => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const executor = yield* createExecutor(testConfig()); | ||
| 137 | |||
| 138 | yield* registerHttp(executor[HTTP_SLUG], "cfg_genslug"); | ||
| 139 | |||
| 140 | const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } }); | ||
| 141 | |||
| 142 | const merged = yield* executor[HTTP_SLUG].configure("cfg_genslug", { | ||
| 143 | authenticationTemplate: [slugless], | ||
| 144 | }); | ||
| 145 | |||
| 146 | expect(merged).toHaveLength(1); | ||
| 147 | expect(String(merged[0]!.slug)).toMatch(/^custom_[a-z0-9]+$/); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | |||
| 151 | it.effect("dedupes: a matching slug replaces in place; two slugless get distinct slugs", () => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const executor = yield* createExecutor(testConfig()); | ||
| 154 | |||
| 155 | yield* registerHttp(executor[HTTP_SLUG], "cfg_dedupe", [customApiKey]); | ||
| 156 | |||
| 157 | const replacement: APIKeyAuthentication = { | ||
| 158 | slug: AuthTemplateSlug.make("my_custom"), | ||
| 159 | type: "apiKey", | ||
| 160 | headers: { "x-other": [variable("token")] }, | ||
| 161 | }; | ||
| 162 | const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } }); | ||
| 163 | const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } }); | ||
| 164 | |||
| 165 | const merged = yield* executor[HTTP_SLUG].configure("cfg_dedupe", { | ||
| 166 | authenticationTemplate: [replacement, slugless, slugless2], | ||
| 167 | }); | ||
| 168 | |||
| 169 | const slugs = merged.map((m: Authentication) => String(m.slug)); | ||
| 170 | expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1); | ||
| 171 | expect(merged).toHaveLength(3); | ||
| 172 | const generated = slugs.filter((s: string) => s.startsWith("custom_")); | ||
| 173 | expect(generated).toHaveLength(2); | ||
| 174 | expect(new Set(generated).size).toBe(2); | ||
| 175 | const replaced = merged.find( | ||
| 176 | (m: Authentication) => String(m.slug) === "my_custom", | ||
| 177 | ) as APIKeyAuthentication; | ||
| 178 | expect(replaced.headers).toEqual({ "x-other": [variable("token")] }); | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | |||
| 182 | it.effect("a configured custom method renders against a connection value at invoke time", () => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 185 | const layer = capturingLayer( | ||
| 186 | capture, | ||
| 187 | new Response('{"ok":true}', { | ||
| 188 | status: 200, | ||
| 189 | headers: { "content-type": "application/json" }, | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | const executor = yield* createExecutor(testConfig(layer)); | ||
| 193 | |||
| 194 | yield* registerHttp(executor[HTTP_SLUG], "cfg_invoke"); | ||
| 195 | |||
| 196 | yield* executor[HTTP_SLUG].configure("cfg_invoke", { | ||
| 197 | authenticationTemplate: [customApiKey], | ||
| 198 | }); | ||
| 199 | |||
| 200 | yield* executor.connections.create({ | ||
| 201 | owner: "org", | ||
| 202 | name: ConnectionName.make("main"), | ||
| 203 | integration: IntegrationSlug.make("cfg_invoke"), | ||
| 204 | template: AuthTemplateSlug.make("my_custom"), | ||
| 205 | value: "configured-secret", | ||
| 206 | }); | ||
| 207 | |||
| 208 | yield* executor.execute(ToolAddress.make("tools.cfg_invoke.org.main.request"), { | ||
| 209 | method: "GET", | ||
| 210 | path: "/ping", | ||
| 211 | }); | ||
| 212 | |||
| 213 | expect(capture.request!.headers["x-api-key"]).toBe("configured-secret"); | ||
| 214 | }), | ||
| 215 | ); | ||
| 216 | |||
| 217 | it.effect("getConfig returns null for an unknown integration", () => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const executor = yield* createExecutor(testConfig()); | ||
| 220 | expect(yield* executor[HTTP_SLUG].getConfig("nope")).toBeNull(); | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | }); | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | /** A raw HTTP request issued by the http-source `request` tool failed at the | ||
| 4 | * transport layer (connection error, body read failure). Upstream non-2xx | ||
| 5 | * responses are NOT errors — they're returned as a shaped `HttpResponse`. */ | ||
| 6 | export class HttpRequestError extends Schema.TaggedErrorClass<HttpRequestError>()( | ||
| 7 | "HttpRequestError", | ||
| 8 | { | ||
| 9 | message: Schema.String, | ||
| 10 | cause: Schema.optional(Schema.Unknown), | ||
| 11 | }, | ||
| 12 | ) {} | ||
| 13 | |||
| 14 | /** The integration config stored on the row wasn't the expected http-source | ||
| 15 | * shape (missing `baseUrl` / `authenticationTemplate`). */ | ||
| 16 | export class HttpConfigError extends Schema.TaggedErrorClass<HttpConfigError>()("HttpConfigError", { | ||
| 17 | message: Schema.String, | ||
| 18 | }) {} | ||
| @@ -1,33 +0,0 @@ | |||
| 1 | export { | ||
| 2 | httpSourcePlugin, | ||
| 3 | HTTP_SOURCE_PLUGIN_ID, | ||
| 4 | REQUEST_TOOL_NAME, | ||
| 5 | type HttpSourceExtension, | ||
| 6 | type HttpSourceConfigureInput, | ||
| 7 | type RegisterHttpIntegrationInput, | ||
| 8 | } from "./plugin"; | ||
| 9 | |||
| 10 | export { | ||
| 11 | CREDENTIAL_VARIABLE, | ||
| 12 | HttpMethod, | ||
| 13 | HttpRequestArgs, | ||
| 14 | HttpResponse, | ||
| 15 | variable, | ||
| 16 | type APIKeyAuthentication, | ||
| 17 | type Authentication, | ||
| 18 | type AuthenticationTemplateValue, | ||
| 19 | type AuthenticationVariable, | ||
| 20 | type HttpSourceConfig, | ||
| 21 | type HttpSourceIntegration, | ||
| 22 | } from "./types"; | ||
| 23 | |||
| 24 | export { | ||
| 25 | applyAuthTemplate, | ||
| 26 | findAuthTemplate, | ||
| 27 | renderAuthTemplate, | ||
| 28 | type RenderedAuth, | ||
| 29 | } from "./template"; | ||
| 30 | |||
| 31 | export { issueRequest, type IssueRequestInput } from "./request"; | ||
| 32 | |||
| 33 | export { HttpConfigError, HttpRequestError } from "./errors"; | ||
| @@ -1,210 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | AuthTemplateSlug, | ||
| 7 | ConnectionName, | ||
| 8 | IntegrationSlug, | ||
| 9 | isToolResult, | ||
| 10 | type PluginCtx, | ||
| 11 | type ResolveToolsInput, | ||
| 12 | type ToolInvocationCredential, | ||
| 13 | type ToolResult, | ||
| 14 | type ToolRow, | ||
| 15 | } from "@executor-js/sdk"; | ||
| 16 | |||
| 17 | import { httpSourcePlugin, REQUEST_TOOL_NAME } from "./plugin"; | ||
| 18 | import { variable, type HttpSourceConfig } from "./types"; | ||
| 19 | |||
| 20 | const plugin = httpSourcePlugin(); | ||
| 21 | |||
| 22 | // The store + invoke-input types the plugin's hooks expect (store inferred as | ||
| 23 | // `{}` from `storage: () => ({})`). Derive them so the test inputs line up. | ||
| 24 | type Store = ReturnType<typeof plugin.storage>; | ||
| 25 | type InvokeInput = Parameters<NonNullable<typeof plugin.invokeTool>>[0]; | ||
| 26 | |||
| 27 | const config: HttpSourceConfig = { | ||
| 28 | baseUrl: "https://api.example.com", | ||
| 29 | authenticationTemplate: [ | ||
| 30 | { | ||
| 31 | slug: AuthTemplateSlug.make("apiKey"), | ||
| 32 | type: "apiKey", | ||
| 33 | headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | }; | ||
| 37 | |||
| 38 | const capturingLayer = ( | ||
| 39 | capture: { request?: HttpClientRequest.HttpClientRequest }, | ||
| 40 | response: Response, | ||
| 41 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 42 | Layer.succeed( | ||
| 43 | HttpClient.HttpClient, | ||
| 44 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 45 | capture.request = request; | ||
| 46 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | const emptyHttpLayer: Layer.Layer<HttpClient.HttpClient> = capturingLayer( | ||
| 51 | {}, | ||
| 52 | new Response("{}", { | ||
| 53 | status: 200, | ||
| 54 | headers: { "content-type": "application/json" }, | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | // Test fixture: the hooks only read `ctx.httpClientLayer` (and `detect` ignores | ||
| 59 | // ctx entirely), so a stub carrying just that is sufficient. | ||
| 60 | const ctxWith = (httpClientLayer: Layer.Layer<HttpClient.HttpClient>): PluginCtx<Store> => | ||
| 61 | // lint-allow-double-cast: test stub — hooks under test only touch httpClientLayer | ||
| 62 | ({ httpClientLayer }) as unknown as PluginCtx<Store>; | ||
| 63 | |||
| 64 | const credential = (value: string | null): ToolInvocationCredential => ({ | ||
| 65 | owner: "user", | ||
| 66 | integration: IntegrationSlug.make("example"), | ||
| 67 | connection: ConnectionName.make("default"), | ||
| 68 | template: AuthTemplateSlug.make("apiKey"), | ||
| 69 | value, | ||
| 70 | values: value === null ? {} : { token: value }, | ||
| 71 | config, | ||
| 72 | }); | ||
| 73 | |||
| 74 | const toolRow = | ||
| 75 | // lint-allow-double-cast: test fixture — invokeTool doesn't read toolRow fields | ||
| 76 | { | ||
| 77 | name: String(REQUEST_TOOL_NAME), | ||
| 78 | integration: "example", | ||
| 79 | connection: "default", | ||
| 80 | } as unknown as ToolRow; | ||
| 81 | |||
| 82 | const noElicit = (() => Effect.die("no elicit")) as InvokeInput["elicit"]; | ||
| 83 | |||
| 84 | // Assert an invokeTool result is a failed ToolResult and return its error code. | ||
| 85 | const failureCode = (out: unknown): string => { | ||
| 86 | expect(isToolResult(out)).toBe(true); | ||
| 87 | const result = out as ToolResult<unknown>; | ||
| 88 | expect(result.ok).toBe(false); | ||
| 89 | return result.ok ? "" : result.error.code; | ||
| 90 | }; | ||
| 91 | |||
| 92 | describe("httpSourcePlugin.resolveTools", () => { | ||
| 93 | it.effect("produces a single `request` tool referencing the base URL", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const input: ResolveToolsInput = { | ||
| 96 | integration: { | ||
| 97 | slug: IntegrationSlug.make("example"), | ||
| 98 | description: "Example", | ||
| 99 | kind: "http-source", | ||
| 100 | canRemove: true, | ||
| 101 | canRefresh: true, | ||
| 102 | authMethods: [], | ||
| 103 | }, | ||
| 104 | config, | ||
| 105 | connection: { | ||
| 106 | owner: "user", | ||
| 107 | integration: IntegrationSlug.make("example"), | ||
| 108 | name: ConnectionName.make("default"), | ||
| 109 | }, | ||
| 110 | template: null, | ||
| 111 | getValue: () => Effect.succeed(null), | ||
| 112 | }; | ||
| 113 | const result = yield* plugin.resolveTools!(input); | ||
| 114 | expect(result.tools).toHaveLength(1); | ||
| 115 | expect(String(result.tools[0]!.name)).toBe("request"); | ||
| 116 | expect(result.tools[0]!.description).toContain("https://api.example.com"); | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | describe("httpSourcePlugin.invokeTool", () => { | ||
| 122 | it.effect("renders the auth template onto the request and returns ToolResult.ok", () => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 125 | const layer = capturingLayer( | ||
| 126 | capture, | ||
| 127 | new Response('{"ok":true}', { | ||
| 128 | status: 200, | ||
| 129 | headers: { "content-type": "application/json" }, | ||
| 130 | }), | ||
| 131 | ); | ||
| 132 | |||
| 133 | const input: InvokeInput = { | ||
| 134 | ctx: ctxWith(layer), | ||
| 135 | toolRow, | ||
| 136 | credential: credential("sk-secret"), | ||
| 137 | args: { method: "GET", path: "/ping" }, | ||
| 138 | elicit: noElicit, | ||
| 139 | }; | ||
| 140 | |||
| 141 | const out = yield* plugin.invokeTool!(input); | ||
| 142 | expect(isToolResult(out)).toBe(true); | ||
| 143 | expect(out).toMatchObject({ ok: true, data: { status: 200 } }); | ||
| 144 | expect(capture.request!.headers["authorization"]).toBe("Bearer sk-secret"); | ||
| 145 | expect(capture.request!.url).toContain("https://api.example.com/ping"); | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("returns an auth failure ToolResult when the credential value is missing", () => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 152 | const layer = capturingLayer(capture, new Response("{}", { status: 200 })); | ||
| 153 | |||
| 154 | const input: InvokeInput = { | ||
| 155 | ctx: ctxWith(layer), | ||
| 156 | toolRow, | ||
| 157 | credential: credential(null), | ||
| 158 | args: { path: "/ping" }, | ||
| 159 | elicit: noElicit, | ||
| 160 | }; | ||
| 161 | |||
| 162 | const out = yield* plugin.invokeTool!(input); | ||
| 163 | expect(failureCode(out)).toBe("connection_value_missing"); | ||
| 164 | // No request should have been issued. | ||
| 165 | expect(capture.request).toBeUndefined(); | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | |||
| 169 | it.effect("rejects args without a `path`", () => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const input: InvokeInput = { | ||
| 172 | ctx: ctxWith(emptyHttpLayer), | ||
| 173 | toolRow, | ||
| 174 | credential: credential("sk"), | ||
| 175 | args: { method: "GET" }, | ||
| 176 | elicit: noElicit, | ||
| 177 | }; | ||
| 178 | const out = yield* plugin.invokeTool!(input); | ||
| 179 | expect(failureCode(out)).toBe("invalid_arguments"); | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | }); | ||
| 183 | |||
| 184 | describe("httpSourcePlugin.detect", () => { | ||
| 185 | it.effect("claims any http(s) URL at low confidence", () => | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const result = yield* plugin.detect!({ | ||
| 188 | ctx: ctxWith(emptyHttpLayer), | ||
| 189 | url: "https://api.acme.dev/v1", | ||
| 190 | }); | ||
| 191 | expect(result).toEqual({ | ||
| 192 | kind: "http-source", | ||
| 193 | confidence: "low", | ||
| 194 | endpoint: "https://api.acme.dev/v1", | ||
| 195 | name: "api.acme.dev", | ||
| 196 | slug: "api.acme.dev", | ||
| 197 | }); | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("ignores non-http URLs", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const result = yield* plugin.detect!({ | ||
| 204 | ctx: ctxWith(emptyHttpLayer), | ||
| 205 | url: "ftp://example.com", | ||
| 206 | }); | ||
| 207 | expect(result).toBeNull(); | ||
| 208 | }), | ||
| 209 | ); | ||
| 210 | }); | ||
| @@ -1,404 +0,0 @@ | |||
| 1 | import { Effect, type Layer } from "effect"; | ||
| 2 | import type { HttpClient } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | AuthTemplateSlug, | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationSlug, | ||
| 8 | ToolName, | ||
| 9 | ToolResult, | ||
| 10 | authToolFailure, | ||
| 11 | definePlugin, | ||
| 12 | type IntegrationConfig, | ||
| 13 | type IntegrationDetectionResult, | ||
| 14 | type InvokeToolInput, | ||
| 15 | type PluginCtx, | ||
| 16 | type ResolveToolsInput, | ||
| 17 | type ResolveToolsResult, | ||
| 18 | type StorageFailure, | ||
| 19 | type ToolDef, | ||
| 20 | } from "@executor-js/sdk"; | ||
| 21 | |||
| 22 | import { HttpRequestError } from "./errors"; | ||
| 23 | import { applyAuthTemplate, findAuthTemplate, requiredVariables } from "./template"; | ||
| 24 | import { issueRequest } from "./request"; | ||
| 25 | import { | ||
| 26 | type Authentication, | ||
| 27 | type HttpMethod, | ||
| 28 | type HttpRequestArgs, | ||
| 29 | type HttpSourceConfig, | ||
| 30 | } from "./types"; | ||
| 31 | |||
| 32 | // --------------------------------------------------------------------------- | ||
| 33 | // Plugin identity | ||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | |||
| 36 | export const HTTP_SOURCE_PLUGIN_ID = "http-source"; | ||
| 37 | export const REQUEST_TOOL_NAME = ToolName.make("request"); | ||
| 38 | |||
| 39 | // --------------------------------------------------------------------------- | ||
| 40 | // Config narrowing — core stores `IntegrationConfig` as an opaque blob; read it | ||
| 41 | // back into the http-source shape. | ||
| 42 | // --------------------------------------------------------------------------- | ||
| 43 | |||
| 44 | const asHttpConfig = (config: IntegrationConfig): HttpSourceConfig | null => { | ||
| 45 | if (typeof config !== "object" || config === null) return null; | ||
| 46 | const candidate = config as Partial<HttpSourceConfig>; | ||
| 47 | if (typeof candidate.baseUrl !== "string") return null; | ||
| 48 | if (!Array.isArray(candidate.authenticationTemplate)) return null; | ||
| 49 | return { | ||
| 50 | baseUrl: candidate.baseUrl, | ||
| 51 | authenticationTemplate: candidate.authenticationTemplate as readonly Authentication[], | ||
| 52 | defaultHeaders: candidate.defaultHeaders, | ||
| 53 | }; | ||
| 54 | }; | ||
| 55 | |||
| 56 | // --------------------------------------------------------------------------- | ||
| 57 | // Auth-template merge — `configure` appends custom auth methods to the | ||
| 58 | // integration's existing `authenticationTemplate`. Mirrors the openapi plugin: | ||
| 59 | // a method whose slug matches an existing entry replaces it in place; a method | ||
| 60 | // with no usable slug is assigned a generated `custom_<id>` slug, collision- | ||
| 61 | // checked against the merged set. | ||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | |||
| 64 | const shortId = (): string => Math.random().toString(36).slice(2, 8); | ||
| 65 | |||
| 66 | const freshCustomSlug = (taken: ReadonlySet<string>): AuthTemplateSlug => { | ||
| 67 | let candidate = `custom_${shortId()}`; | ||
| 68 | while (taken.has(candidate)) candidate = `custom_${shortId()}`; | ||
| 69 | return AuthTemplateSlug.make(candidate); | ||
| 70 | }; | ||
| 71 | |||
| 72 | const mergeAuthenticationTemplate = ( | ||
| 73 | existing: readonly Authentication[], | ||
| 74 | incoming: readonly Authentication[], | ||
| 75 | ): readonly Authentication[] => { | ||
| 76 | const result: Authentication[] = existing.map((entry: Authentication) => entry); | ||
| 77 | const taken = new Set<string>(result.map((entry: Authentication) => String(entry.slug))); | ||
| 78 | for (const entry of incoming) { | ||
| 79 | // `slug` is branded as required, but JSON callers may omit it; read defensively. | ||
| 80 | const rawSlug = (entry as { readonly slug?: unknown }).slug; | ||
| 81 | const requested = typeof rawSlug === "string" ? rawSlug.trim() : ""; | ||
| 82 | const existingIndex = result.findIndex( | ||
| 83 | (current: Authentication) => String(current.slug) === requested, | ||
| 84 | ); | ||
| 85 | if (requested.length > 0 && existingIndex >= 0) { | ||
| 86 | result[existingIndex] = entry; | ||
| 87 | continue; | ||
| 88 | } | ||
| 89 | const slug = | ||
| 90 | requested.length > 0 && !taken.has(requested) | ||
| 91 | ? AuthTemplateSlug.make(requested) | ||
| 92 | : freshCustomSlug(taken); | ||
| 93 | taken.add(String(slug)); | ||
| 94 | result.push({ ...entry, slug } as Authentication); | ||
| 95 | } | ||
| 96 | return result; | ||
| 97 | }; | ||
| 98 | |||
| 99 | // --------------------------------------------------------------------------- | ||
| 100 | // Argument coercion — the executor passes through `args: unknown`. Decode into | ||
| 101 | // the request envelope, tolerating loose shapes. | ||
| 102 | // --------------------------------------------------------------------------- | ||
| 103 | |||
| 104 | const decodeRequestArgs = (args: unknown): HttpRequestArgs | null => { | ||
| 105 | if (typeof args !== "object" || args === null) return null; | ||
| 106 | const record = args as Record<string, unknown>; | ||
| 107 | if (typeof record.path !== "string") return null; | ||
| 108 | const method = | ||
| 109 | typeof record.method === "string" ? (record.method.toUpperCase() as HttpMethod) : undefined; | ||
| 110 | return { | ||
| 111 | method, | ||
| 112 | path: record.path, | ||
| 113 | query: | ||
| 114 | typeof record.query === "object" && record.query !== null | ||
| 115 | ? (record.query as Record<string, unknown>) | ||
| 116 | : undefined, | ||
| 117 | headers: | ||
| 118 | typeof record.headers === "object" && record.headers !== null | ||
| 119 | ? (record.headers as Record<string, string>) | ||
| 120 | : undefined, | ||
| 121 | body: record.body, | ||
| 122 | }; | ||
| 123 | }; | ||
| 124 | |||
| 125 | // --------------------------------------------------------------------------- | ||
| 126 | // The generic `request` tool — identical per connection (raw HTTP has no spec), | ||
| 127 | // so `resolveTools` never needs `getValue`. The JSON schema is a literal so the | ||
| 128 | // plugin doesn't depend on a Schema→JSON-Schema conversion at runtime. | ||
| 129 | // --------------------------------------------------------------------------- | ||
| 130 | |||
| 131 | const REQUEST_INPUT_SCHEMA = { | ||
| 132 | type: "object", | ||
| 133 | properties: { | ||
| 134 | method: { | ||
| 135 | type: "string", | ||
| 136 | enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], | ||
| 137 | description: "HTTP method. Defaults to GET.", | ||
| 138 | }, | ||
| 139 | path: { | ||
| 140 | type: "string", | ||
| 141 | description: "Path appended to the integration's base URL, or an absolute http(s) URL.", | ||
| 142 | }, | ||
| 143 | query: { | ||
| 144 | type: "object", | ||
| 145 | additionalProperties: true, | ||
| 146 | description: "Query string parameters.", | ||
| 147 | }, | ||
| 148 | headers: { | ||
| 149 | type: "object", | ||
| 150 | additionalProperties: { type: "string" }, | ||
| 151 | description: "Additional request headers.", | ||
| 152 | }, | ||
| 153 | body: { | ||
| 154 | description: "Request body. Objects are JSON-encoded; strings pass through.", | ||
| 155 | }, | ||
| 156 | }, | ||
| 157 | required: ["path"], | ||
| 158 | additionalProperties: false, | ||
| 159 | } as const; | ||
| 160 | |||
| 161 | const REQUEST_OUTPUT_SCHEMA = { | ||
| 162 | type: "object", | ||
| 163 | properties: { | ||
| 164 | status: { type: "number" }, | ||
| 165 | headers: { type: "object", additionalProperties: { type: "string" } }, | ||
| 166 | body: {}, | ||
| 167 | }, | ||
| 168 | required: ["status", "headers", "body"], | ||
| 169 | } as const; | ||
| 170 | |||
| 171 | const requestToolDef = (baseUrl: string): ToolDef => ({ | ||
| 172 | name: REQUEST_TOOL_NAME, | ||
| 173 | description: `Issue a raw HTTP request against ${baseUrl}.`, | ||
| 174 | inputSchema: REQUEST_INPUT_SCHEMA, | ||
| 175 | outputSchema: REQUEST_OUTPUT_SCHEMA, | ||
| 176 | annotations: { | ||
| 177 | // Raw HTTP can mutate; the executor's tool-policy layer can require approval | ||
| 178 | // per address. We flag it conservatively. | ||
| 179 | requiresApproval: true, | ||
| 180 | approvalDescription: `Raw HTTP request to ${baseUrl}`, | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | |||
| 184 | // --------------------------------------------------------------------------- | ||
| 185 | // Extension — the plugin's public API. `register` writes an http-source | ||
| 186 | // integration into the catalog with its opaque config. | ||
| 187 | // --------------------------------------------------------------------------- | ||
| 188 | |||
| 189 | export interface RegisterHttpIntegrationInput { | ||
| 190 | readonly slug: IntegrationSlug; | ||
| 191 | readonly description: string; | ||
| 192 | readonly config: HttpSourceConfig; | ||
| 193 | readonly canRemove?: boolean; | ||
| 194 | } | ||
| 195 | |||
| 196 | /** Add / merge custom auth methods onto an existing http-source integration's | ||
| 197 | * `authenticationTemplate`. Mirrors the openapi plugin's `configure`. */ | ||
| 198 | export interface HttpSourceConfigureInput { | ||
| 199 | readonly authenticationTemplate: readonly Authentication[]; | ||
| 200 | } | ||
| 201 | |||
| 202 | // --------------------------------------------------------------------------- | ||
| 203 | // Plugin | ||
| 204 | // --------------------------------------------------------------------------- | ||
| 205 | |||
| 206 | export const httpSourcePlugin = definePlugin(() => ({ | ||
| 207 | id: HTTP_SOURCE_PLUGIN_ID, | ||
| 208 | packageName: "@executor-js/plugin-http-source", | ||
| 209 | |||
| 210 | storage: () => ({}), | ||
| 211 | |||
| 212 | // The extension's shape is inferred from this factory's return value; | ||
| 213 | // `HttpSourceExtension` below is derived from it (no duplicated shape). | ||
| 214 | extension: (ctx: PluginCtx) => ({ | ||
| 215 | register: ( | ||
| 216 | input: RegisterHttpIntegrationInput, | ||
| 217 | ): Effect.Effect<void, IntegrationAlreadyExistsError | StorageFailure> => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | // Block re-adding an existing slug. The core `integrations.register` | ||
| 220 | // primitive upserts (so boot re-registration is idempotent), but this | ||
| 221 | // user-facing add must NOT silently clobber an existing integration's | ||
| 222 | // tools, connections, and policies. To add more auth, update the | ||
| 223 | // existing integration instead. | ||
| 224 | const existing = yield* ctx.core.integrations.get(input.slug); | ||
| 225 | if (existing) { | ||
| 226 | return yield* new IntegrationAlreadyExistsError({ slug: input.slug }); | ||
| 227 | } | ||
| 228 | yield* ctx.core.integrations.register({ | ||
| 229 | slug: input.slug, | ||
| 230 | description: input.description, | ||
| 231 | config: input.config, | ||
| 232 | canRemove: input.canRemove ?? true, | ||
| 233 | // Raw-HTTP tools are static per connection, but refresh is harmless | ||
| 234 | // (re-stamps the same `request` tool), so allow it. | ||
| 235 | canRefresh: true, | ||
| 236 | }); | ||
| 237 | }), | ||
| 238 | |||
| 239 | /** Read the integration's full opaque config (including its | ||
| 240 | * `authenticationTemplate`). Returns null when the integration is absent | ||
| 241 | * or its config isn't a valid http-source config. */ | ||
| 242 | getConfig: (slug: string): Effect.Effect<HttpSourceConfig | null, StorageFailure> => | ||
| 243 | ctx.core.integrations | ||
| 244 | .get(IntegrationSlug.make(slug)) | ||
| 245 | .pipe(Effect.map((record) => (record ? asHttpConfig(record.config) : null))), | ||
| 246 | |||
| 247 | /** Add / merge custom auth methods onto the integration's | ||
| 248 | * `authenticationTemplate`. Returns the resulting template array. */ | ||
| 249 | configure: ( | ||
| 250 | slug: string, | ||
| 251 | input: HttpSourceConfigureInput, | ||
| 252 | ): Effect.Effect<readonly Authentication[], StorageFailure> => | ||
| 253 | ctx.transaction( | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); | ||
| 256 | if (!record) return [] as readonly Authentication[]; | ||
| 257 | const current = asHttpConfig(record.config); | ||
| 258 | if (!current) return [] as readonly Authentication[]; | ||
| 259 | |||
| 260 | const merged = mergeAuthenticationTemplate( | ||
| 261 | current.authenticationTemplate, | ||
| 262 | input.authenticationTemplate, | ||
| 263 | ); | ||
| 264 | |||
| 265 | const next: HttpSourceConfig = { | ||
| 266 | ...current, | ||
| 267 | authenticationTemplate: merged, | ||
| 268 | }; | ||
| 269 | |||
| 270 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { | ||
| 271 | config: next satisfies HttpSourceConfig as IntegrationConfig, | ||
| 272 | }); | ||
| 273 | |||
| 274 | return merged; | ||
| 275 | }), | ||
| 276 | ), | ||
| 277 | }), | ||
| 278 | |||
| 279 | // Per-connection tool production. Raw HTTP yields one generic `request` tool; | ||
| 280 | // identical for every connection, so `getValue` is never called. | ||
| 281 | resolveTools: (input: ResolveToolsInput): Effect.Effect<ResolveToolsResult, StorageFailure> => | ||
| 282 | Effect.sync(() => { | ||
| 283 | const config = asHttpConfig(input.config); | ||
| 284 | const baseUrl = config?.baseUrl ?? "the configured endpoint"; | ||
| 285 | return { tools: [requestToolDef(baseUrl)] }; | ||
| 286 | }), | ||
| 287 | |||
| 288 | // Apply the resolved credential to the outbound request via the integration's | ||
| 289 | // auth template (D11) and issue it. | ||
| 290 | invokeTool: (input: InvokeToolInput): Effect.Effect<unknown, unknown> => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const config = asHttpConfig(input.credential.config); | ||
| 293 | if (config === null) { | ||
| 294 | return ToolResult.fail({ | ||
| 295 | code: "http_config_invalid", | ||
| 296 | message: | ||
| 297 | "The http-source integration config is missing baseUrl / authenticationTemplate.", | ||
| 298 | retryable: false, | ||
| 299 | }); | ||
| 300 | } | ||
| 301 | |||
| 302 | const args = decodeRequestArgs(input.args); | ||
| 303 | if (args === null) { | ||
| 304 | return ToolResult.fail({ | ||
| 305 | code: "invalid_arguments", | ||
| 306 | message: "Expected a request with at least a `path` string.", | ||
| 307 | retryable: false, | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | // A selected auth template with any unresolved input means the credential | ||
| 312 | // couldn't be resolved — surface a reauth-style failure keyed to the | ||
| 313 | // connection (owner/integration/name), not a v1 slot. Checks every input the | ||
| 314 | // template requires (e.g. both of Datadog's two keys), not just the primary. | ||
| 315 | const selectedTemplate = findAuthTemplate( | ||
| 316 | config.authenticationTemplate, | ||
| 317 | input.credential.template, | ||
| 318 | ); | ||
| 319 | if (selectedTemplate) { | ||
| 320 | const missing = requiredVariables(selectedTemplate).filter((name) => { | ||
| 321 | const value = input.credential.values[name]; | ||
| 322 | return value == null || value === ""; | ||
| 323 | }); | ||
| 324 | if (missing.length > 0) { | ||
| 325 | return authToolFailure({ | ||
| 326 | code: "connection_value_missing", | ||
| 327 | message: `No credential value resolved for connection "${input.credential.connection}" on integration "${input.credential.integration}".`, | ||
| 328 | credential: { | ||
| 329 | kind: "secret", | ||
| 330 | label: String(input.credential.connection), | ||
| 331 | }, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | } | ||
| 335 | |||
| 336 | const auth = applyAuthTemplate( | ||
| 337 | config.authenticationTemplate, | ||
| 338 | input.credential.template, | ||
| 339 | input.credential.values, | ||
| 340 | ); | ||
| 341 | |||
| 342 | const httpClientLayer: Layer.Layer<HttpClient.HttpClient> = input.ctx.httpClientLayer; | ||
| 343 | |||
| 344 | const response = yield* issueRequest( | ||
| 345 | { | ||
| 346 | baseUrl: config.baseUrl, | ||
| 347 | method: args.method ?? "GET", | ||
| 348 | path: args.path, | ||
| 349 | query: args.query, | ||
| 350 | headers: args.headers, | ||
| 351 | body: args.body, | ||
| 352 | auth, | ||
| 353 | defaultHeaders: config.defaultHeaders, | ||
| 354 | }, | ||
| 355 | httpClientLayer, | ||
| 356 | ).pipe( | ||
| 357 | // `HttpRequestError` is a typed tagged error; reshape it into a | ||
| 358 | // value-based `ToolResult` so transport failures surface as data. | ||
| 359 | Effect.catchTag("HttpRequestError", (failure: HttpRequestError) => | ||
| 360 | Effect.succeed( | ||
| 361 | ToolResult.fail({ | ||
| 362 | code: "http_request_failed", | ||
| 363 | message: failure.message, | ||
| 364 | retryable: true, | ||
| 365 | }), | ||
| 366 | ), | ||
| 367 | ), | ||
| 368 | ); | ||
| 369 | |||
| 370 | // `issueRequest` resolves to `HttpResponse`; on transport failure the | ||
| 371 | // catch above replaced it with a `ToolResult`. Discriminate. | ||
| 372 | return "ok" in response ? response : ToolResult.ok(response); | ||
| 373 | }), | ||
| 374 | |||
| 375 | // Raw HTTP claims any http(s) URL as a low-confidence fallback so onboarding | ||
| 376 | // can offer it when no spec-derived plugin matches. | ||
| 377 | detect: (input: { | ||
| 378 | readonly ctx: PluginCtx; | ||
| 379 | readonly url: string; | ||
| 380 | }): Effect.Effect<IntegrationDetectionResult | null, never> => | ||
| 381 | Effect.sync(() => { | ||
| 382 | if (!/^https?:\/\//i.test(input.url)) return null; | ||
| 383 | let host: string; | ||
| 384 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing of arbitrary onboarding input; fall back to the raw string | ||
| 385 | try { | ||
| 386 | host = new URL(input.url).host; | ||
| 387 | } catch { | ||
| 388 | host = input.url; | ||
| 389 | } | ||
| 390 | return { | ||
| 391 | kind: HTTP_SOURCE_PLUGIN_ID, | ||
| 392 | confidence: "low", | ||
| 393 | endpoint: input.url, | ||
| 394 | name: host, | ||
| 395 | slug: host, | ||
| 396 | }; | ||
| 397 | }), | ||
| 398 | })); | ||
| 399 | |||
| 400 | /** The plugin's extension API (`executor["http-source"]`) — derived from the | ||
| 401 | * inferred `extension` factory return so the shape isn't duplicated. */ | ||
| 402 | export type HttpSourceExtension = ReturnType< | ||
| 403 | NonNullable<ReturnType<typeof httpSourcePlugin>["extension"]> | ||
| 404 | >; | ||
| @@ -1,142 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { issueRequest } from "./request"; | ||
| 6 | import type { RenderedAuth } from "./template"; | ||
| 7 | |||
| 8 | /* A capturing in-memory HttpClient: records the request it was handed and | ||
| 9 | * returns a canned web Response. No network. */ | ||
| 10 | const capturingClient = ( | ||
| 11 | capture: { request?: HttpClientRequest.HttpClientRequest }, | ||
| 12 | response: Response, | ||
| 13 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 14 | Layer.succeed( | ||
| 15 | HttpClient.HttpClient, | ||
| 16 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 17 | capture.request = request; | ||
| 18 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | ||
| 19 | }), | ||
| 20 | ); | ||
| 21 | |||
| 22 | const noAuth: RenderedAuth = { headers: {}, queryParams: {} }; | ||
| 23 | |||
| 24 | describe("issueRequest", () => { | ||
| 25 | it.effect("issues a GET, merges auth headers + query, parses JSON body", () => | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 28 | const layer = capturingClient( | ||
| 29 | capture, | ||
| 30 | new Response('{"hello":"world"}', { | ||
| 31 | status: 200, | ||
| 32 | headers: { "content-type": "application/json" }, | ||
| 33 | }), | ||
| 34 | ); | ||
| 35 | |||
| 36 | const result = yield* issueRequest( | ||
| 37 | { | ||
| 38 | baseUrl: "https://api.example.com", | ||
| 39 | method: "GET", | ||
| 40 | path: "/things", | ||
| 41 | query: { limit: 5 }, | ||
| 42 | headers: { "x-trace": "abc" }, | ||
| 43 | auth: { | ||
| 44 | headers: { Authorization: "Bearer tok" }, | ||
| 45 | queryParams: { api_key: "k" }, | ||
| 46 | }, | ||
| 47 | defaultHeaders: { "x-default": "d" }, | ||
| 48 | }, | ||
| 49 | layer, | ||
| 50 | ); | ||
| 51 | |||
| 52 | expect(result.status).toBe(200); | ||
| 53 | expect(result.body).toEqual({ hello: "world" }); | ||
| 54 | |||
| 55 | const sent = capture.request!; | ||
| 56 | expect(sent.method).toBe("GET"); | ||
| 57 | expect(sent.url).toContain("https://api.example.com/things"); | ||
| 58 | const params = UrlParams.toString(sent.urlParams); | ||
| 59 | expect(params).toContain("limit=5"); | ||
| 60 | expect(params).toContain("api_key=k"); | ||
| 61 | expect(sent.headers["authorization"]).toBe("Bearer tok"); | ||
| 62 | expect(sent.headers["x-trace"]).toBe("abc"); | ||
| 63 | expect(sent.headers["x-default"]).toBe("d"); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("returns non-2xx upstream responses as data, not an error", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 70 | const layer = capturingClient( | ||
| 71 | capture, | ||
| 72 | new Response("nope", { | ||
| 73 | status: 404, | ||
| 74 | headers: { "content-type": "text/plain" }, | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | |||
| 78 | const result = yield* issueRequest( | ||
| 79 | { | ||
| 80 | baseUrl: "https://api.example.com", | ||
| 81 | method: "GET", | ||
| 82 | path: "/missing", | ||
| 83 | auth: noAuth, | ||
| 84 | }, | ||
| 85 | layer, | ||
| 86 | ); | ||
| 87 | |||
| 88 | expect(result.status).toBe(404); | ||
| 89 | expect(result.body).toBe("nope"); | ||
| 90 | }), | ||
| 91 | ); | ||
| 92 | |||
| 93 | it.effect("JSON-encodes object bodies on POST", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 96 | const layer = capturingClient(capture, new Response(null, { status: 204 })); | ||
| 97 | |||
| 98 | const result = yield* issueRequest( | ||
| 99 | { | ||
| 100 | baseUrl: "https://api.example.com", | ||
| 101 | method: "POST", | ||
| 102 | path: "things", | ||
| 103 | body: { name: "n" }, | ||
| 104 | auth: noAuth, | ||
| 105 | }, | ||
| 106 | layer, | ||
| 107 | ); | ||
| 108 | |||
| 109 | expect(result.status).toBe(204); | ||
| 110 | expect(result.body).toBe(null); | ||
| 111 | const sent = capture.request!; | ||
| 112 | expect(sent.method).toBe("POST"); | ||
| 113 | // path without leading slash is still joined under the base | ||
| 114 | expect(sent.url).toContain("https://api.example.com/things"); | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | |||
| 118 | it.effect("treats an absolute path as the full URL (ignores baseUrl)", () => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const capture: { request?: HttpClientRequest.HttpClientRequest } = {}; | ||
| 121 | const layer = capturingClient( | ||
| 122 | capture, | ||
| 123 | new Response("{}", { | ||
| 124 | status: 200, | ||
| 125 | headers: { "content-type": "application/json" }, | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | yield* issueRequest( | ||
| 130 | { | ||
| 131 | baseUrl: "https://api.example.com", | ||
| 132 | method: "GET", | ||
| 133 | path: "https://other.example.org/x", | ||
| 134 | auth: noAuth, | ||
| 135 | }, | ||
| 136 | layer, | ||
| 137 | ); | ||
| 138 | |||
| 139 | expect(capture.request!.url).toContain("https://other.example.org/x"); | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | }); | ||
| @@ -1,134 +0,0 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | import { HttpRequestError } from "./errors"; | ||
| 5 | import type { RenderedAuth } from "./template"; | ||
| 6 | import type { HttpMethod, HttpResponse } from "./types"; | ||
| 7 | |||
| 8 | /* Build + issue a raw HTTP request for the http-source `request` tool. The auth | ||
| 9 | * template's rendered headers/query (from the connection's resolved value) are | ||
| 10 | * merged with the per-call args; the response is shaped into `HttpResponse`. */ | ||
| 11 | |||
| 12 | const normalizeContentType = (ct: string | null | undefined): string => | ||
| 13 | ct?.split(";")[0]?.trim().toLowerCase() ?? ""; | ||
| 14 | |||
| 15 | const isJsonContentType = (ct: string | null | undefined): boolean => { | ||
| 16 | const normalized = normalizeContentType(ct); | ||
| 17 | if (!normalized) return false; | ||
| 18 | return normalized === "application/json" || normalized.includes("+json"); | ||
| 19 | }; | ||
| 20 | |||
| 21 | const joinUrl = (baseUrl: string, path: string): string => { | ||
| 22 | if (/^https?:\/\//i.test(path)) return path; | ||
| 23 | const base = baseUrl.replace(/\/+$/, ""); | ||
| 24 | const suffix = path.replace(/^\/+/, ""); | ||
| 25 | return suffix.length > 0 ? `${base}/${suffix}` : base; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const applyBody = ( | ||
| 29 | request: HttpClientRequest.HttpClientRequest, | ||
| 30 | body: unknown, | ||
| 31 | ): HttpClientRequest.HttpClientRequest => { | ||
| 32 | if (body === undefined || body === null) return request; | ||
| 33 | if (typeof body === "string") { | ||
| 34 | return HttpClientRequest.bodyText(request, body, "text/plain"); | ||
| 35 | } | ||
| 36 | return HttpClientRequest.bodyJsonUnsafe(request, body); | ||
| 37 | }; | ||
| 38 | |||
| 39 | export interface IssueRequestInput { | ||
| 40 | readonly baseUrl: string; | ||
| 41 | readonly method: HttpMethod; | ||
| 42 | readonly path: string; | ||
| 43 | readonly query?: Record<string, unknown>; | ||
| 44 | readonly headers?: Record<string, string>; | ||
| 45 | readonly body?: unknown; | ||
| 46 | /** Headers/query the connection's auth template contributes. */ | ||
| 47 | readonly auth: RenderedAuth; | ||
| 48 | /** Non-secret default headers from the integration config. */ | ||
| 49 | readonly defaultHeaders?: Record<string, string>; | ||
| 50 | } | ||
| 51 | |||
| 52 | const buildRequest = (input: IssueRequestInput): HttpClientRequest.HttpClientRequest => { | ||
| 53 | const url = joinUrl(input.baseUrl, input.path); | ||
| 54 | let request = HttpClientRequest.make(input.method)(url); | ||
| 55 | |||
| 56 | for (const [name, value] of Object.entries(input.defaultHeaders ?? {})) { | ||
| 57 | request = HttpClientRequest.setHeader(request, name, value); | ||
| 58 | } | ||
| 59 | for (const [name, value] of Object.entries(input.headers ?? {})) { | ||
| 60 | request = HttpClientRequest.setHeader(request, name, value); | ||
| 61 | } | ||
| 62 | for (const [name, value] of Object.entries(input.auth.headers)) { | ||
| 63 | request = HttpClientRequest.setHeader(request, name, value); | ||
| 64 | } | ||
| 65 | |||
| 66 | for (const [name, value] of Object.entries(input.query ?? {})) { | ||
| 67 | if (value === undefined || value === null) continue; | ||
| 68 | request = HttpClientRequest.setUrlParam(request, name, String(value)); | ||
| 69 | } | ||
| 70 | for (const [name, value] of Object.entries(input.auth.queryParams)) { | ||
| 71 | request = HttpClientRequest.setUrlParam(request, name, value); | ||
| 72 | } | ||
| 73 | |||
| 74 | return applyBody(request, input.body); | ||
| 75 | }; | ||
| 76 | |||
| 77 | /** Issue the request through a provided HttpClient layer, returning a shaped | ||
| 78 | * `HttpResponse` (status / headers / parsed-or-text body). */ | ||
| 79 | export const issueRequest = ( | ||
| 80 | input: IssueRequestInput, | ||
| 81 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | ||
| 82 | ): Effect.Effect<HttpResponse, HttpRequestError> => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const client = yield* HttpClient.HttpClient; | ||
| 85 | const request = buildRequest(input); | ||
| 86 | |||
| 87 | const response = yield* client.execute(request).pipe( | ||
| 88 | Effect.mapError( | ||
| 89 | (cause: unknown) => | ||
| 90 | new HttpRequestError({ | ||
| 91 | message: `HTTP request to ${joinUrl(input.baseUrl, input.path)} failed`, | ||
| 92 | cause, | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | ); | ||
| 96 | |||
| 97 | const status = response.status; | ||
| 98 | const headers: Record<string, string> = { ...response.headers }; | ||
| 99 | const contentType = response.headers["content-type"] ?? null; | ||
| 100 | |||
| 101 | const body: unknown = | ||
| 102 | status === 204 | ||
| 103 | ? null | ||
| 104 | : isJsonContentType(contentType) | ||
| 105 | ? yield* response.json.pipe( | ||
| 106 | Effect.catch(() => response.text), | ||
| 107 | Effect.mapError( | ||
| 108 | (cause: unknown) => | ||
| 109 | new HttpRequestError({ | ||
| 110 | message: "Failed to read response body", | ||
| 111 | cause, | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ) | ||
| 115 | : yield* response.text.pipe( | ||
| 116 | Effect.mapError( | ||
| 117 | (cause: unknown) => | ||
| 118 | new HttpRequestError({ | ||
| 119 | message: "Failed to read response body", | ||
| 120 | cause, | ||
| 121 | }), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | |||
| 125 | return { status, headers, body }; | ||
| 126 | }).pipe( | ||
| 127 | Effect.provide(httpClientLayer), | ||
| 128 | Effect.withSpan("plugin.http-source.request", { | ||
| 129 | attributes: { | ||
| 130 | "http.method": input.method, | ||
| 131 | "http.url": joinUrl(input.baseUrl, input.path), | ||
| 132 | }, | ||
| 133 | }), | ||
| 134 | ); | ||
| @@ -1,113 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | applyAuthTemplate, | ||
| 6 | findAuthTemplate, | ||
| 7 | renderAuthTemplate, | ||
| 8 | requiredVariables, | ||
| 9 | } from "./template"; | ||
| 10 | import { variable, type Authentication } from "./types"; | ||
| 11 | |||
| 12 | // removed: the v1 `httpCredentialsFromConfiguredCredentialBindings` test | ||
| 13 | // (react/http-credentials.test.ts) — it exercised the v1 secret/scope/binding | ||
| 14 | // model (ScopeId + SecretId + ConfiguredCredentialBinding + per-header slots), | ||
| 15 | // all deleted in v2. In v2 a connection IS the credential and its value renders | ||
| 16 | // through the integration's auth template, which the cases below cover. | ||
| 17 | |||
| 18 | const apiKeyHeader: Authentication = { | ||
| 19 | slug: AuthTemplateSlug.make("apiKey-header"), | ||
| 20 | type: "apiKey", | ||
| 21 | headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 22 | }; | ||
| 23 | |||
| 24 | const apiKeyQuery: Authentication = { | ||
| 25 | slug: AuthTemplateSlug.make("apiKey-query"), | ||
| 26 | type: "apiKey", | ||
| 27 | queryParams: { api_key: [variable("token")] }, | ||
| 28 | }; | ||
| 29 | |||
| 30 | const oauth: Authentication = { | ||
| 31 | slug: AuthTemplateSlug.make("oauth"), | ||
| 32 | type: "oauth", | ||
| 33 | authorizationUrl: "https://auth.example.com/authorize", | ||
| 34 | tokenUrl: "https://auth.example.com/token", | ||
| 35 | scopes: ["read"], | ||
| 36 | }; | ||
| 37 | |||
| 38 | // Two distinct credential inputs on one method (e.g. Datadog) — each header | ||
| 39 | // renders from its own variable. | ||
| 40 | const datadog: Authentication = { | ||
| 41 | slug: AuthTemplateSlug.make("datadog"), | ||
| 42 | type: "apiKey", | ||
| 43 | headers: { | ||
| 44 | "DD-API-KEY": [variable("dd_api_key")], | ||
| 45 | "DD-APPLICATION-KEY": [variable("dd_application_key")], | ||
| 46 | }, | ||
| 47 | }; | ||
| 48 | |||
| 49 | describe("renderAuthTemplate", () => { | ||
| 50 | it("renders an apiKey header template, substituting the credential variable", () => { | ||
| 51 | const rendered = renderAuthTemplate(apiKeyHeader, { token: "sk-123" }); | ||
| 52 | expect(rendered.headers).toEqual({ Authorization: "Bearer sk-123" }); | ||
| 53 | expect(rendered.queryParams).toEqual({}); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("renders an apiKey query template", () => { | ||
| 57 | const rendered = renderAuthTemplate(apiKeyQuery, { token: "qtoken" }); | ||
| 58 | expect(rendered.queryParams).toEqual({ api_key: "qtoken" }); | ||
| 59 | expect(rendered.headers).toEqual({}); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("renders two distinct inputs into their own headers, no cross-bleed", () => { | ||
| 63 | const rendered = renderAuthTemplate(datadog, { dd_api_key: "a", dd_application_key: "b" }); | ||
| 64 | expect(rendered.headers).toEqual({ "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("renders an oauth access token exactly like a bearer apiKey (D11)", () => { | ||
| 68 | const rendered = renderAuthTemplate(oauth, { token: "access-tok" }); | ||
| 69 | expect(rendered.headers).toEqual({ Authorization: "Bearer access-tok" }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("renders nothing for oauth when the token input is unresolved", () => { | ||
| 73 | expect(renderAuthTemplate(oauth, {})).toEqual({ headers: {}, queryParams: {} }); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("renders nothing when the template is absent", () => { | ||
| 77 | expect(renderAuthTemplate(undefined, { token: "v" })).toEqual({ | ||
| 78 | headers: {}, | ||
| 79 | queryParams: {}, | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | }); | ||
| 83 | |||
| 84 | describe("requiredVariables", () => { | ||
| 85 | it("lists the single token for oauth", () => { | ||
| 86 | expect(requiredVariables(oauth)).toEqual(["token"]); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("lists every distinct variable across an apiKey method's placements", () => { | ||
| 90 | expect([...requiredVariables(datadog)].sort()).toEqual(["dd_api_key", "dd_application_key"]); | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | describe("findAuthTemplate / applyAuthTemplate", () => { | ||
| 95 | const templates: readonly Authentication[] = [apiKeyHeader, apiKeyQuery, oauth]; | ||
| 96 | |||
| 97 | it("finds a template by its slug", () => { | ||
| 98 | expect(findAuthTemplate(templates, AuthTemplateSlug.make("oauth"))).toBe(oauth); | ||
| 99 | expect(findAuthTemplate(templates, AuthTemplateSlug.make("missing"))).toBeUndefined(); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("selects the connection's template by slug and renders it", () => { | ||
| 103 | const rendered = applyAuthTemplate(templates, AuthTemplateSlug.make("apiKey-query"), { | ||
| 104 | token: "abc", | ||
| 105 | }); | ||
| 106 | expect(rendered.queryParams).toEqual({ api_key: "abc" }); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("renders nothing when the connection's template slug doesn't match", () => { | ||
| 110 | const rendered = applyAuthTemplate(templates, AuthTemplateSlug.make("nope"), { token: "abc" }); | ||
| 111 | expect(rendered).toEqual({ headers: {}, queryParams: {} }); | ||
| 112 | }); | ||
| 113 | }); | ||
| @@ -1,106 +0,0 @@ | |||
| 1 | import type { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CREDENTIAL_VARIABLE, | ||
| 5 | type APIKeyAuthentication, | ||
| 6 | type Authentication, | ||
| 7 | type AuthenticationTemplateValue, | ||
| 8 | } from "./types"; | ||
| 9 | |||
| 10 | /* Render an integration's auth template onto an outbound request (D11: "auth | ||
| 11 | * state derived into the auth-template format"). A connection resolves a MAP of | ||
| 12 | * named inputs (`variable → value`); a single-secret connection has just | ||
| 13 | * `{ token }`, an apiKey method with two distinct inputs (e.g. Datadog) one entry | ||
| 14 | * per template variable. Each `variable("<name>")` renders from the matching map | ||
| 15 | * entry — a refreshed OAuth access token is simply the `token` entry. | ||
| 16 | * | ||
| 17 | * Returns the headers + query params the template contributes. The plugin's | ||
| 18 | * `invokeTool` merges these into the request it builds from the tool args. */ | ||
| 19 | |||
| 20 | export interface RenderedAuth { | ||
| 21 | readonly headers: Record<string, string>; | ||
| 22 | readonly queryParams: Record<string, string>; | ||
| 23 | } | ||
| 24 | |||
| 25 | const EMPTY_AUTH: RenderedAuth = { headers: {}, queryParams: {} }; | ||
| 26 | |||
| 27 | const renderTemplateValue = ( | ||
| 28 | parts: AuthenticationTemplateValue, | ||
| 29 | values: Record<string, string | null>, | ||
| 30 | ): string => { | ||
| 31 | if (typeof parts === "string") return parts; | ||
| 32 | let out = ""; | ||
| 33 | for (const part of parts) { | ||
| 34 | if (typeof part === "string") { | ||
| 35 | out += part; | ||
| 36 | } else if (part.type === "variable") { | ||
| 37 | // Each variable renders from its own input; an absent input renders empty. | ||
| 38 | out += values[part.name] ?? ""; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | return out; | ||
| 42 | }; | ||
| 43 | |||
| 44 | /** Find the integration auth template a connection's `template` slug selects. */ | ||
| 45 | export const findAuthTemplate = ( | ||
| 46 | templates: readonly Authentication[], | ||
| 47 | slug: AuthTemplateSlug, | ||
| 48 | ): Authentication | undefined => | ||
| 49 | templates.find((template: Authentication) => template.slug === slug); | ||
| 50 | |||
| 51 | /** The distinct input variables a template references — the credential inputs a | ||
| 52 | * connection must supply. An oauth template needs the single `token`; an apiKey | ||
| 53 | * template needs every `variable("<name>")` across its header/query placements. */ | ||
| 54 | export const requiredVariables = (template: Authentication): readonly string[] => { | ||
| 55 | if (template.type === "oauth") return [CREDENTIAL_VARIABLE]; | ||
| 56 | const names = new Set<string>(); | ||
| 57 | const collect = (parts: AuthenticationTemplateValue): void => { | ||
| 58 | if (typeof parts === "string") return; | ||
| 59 | for (const part of parts) { | ||
| 60 | if (typeof part !== "string" && part.type === "variable") names.add(part.name); | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | for (const parts of Object.values(template.headers ?? {})) collect(parts); | ||
| 64 | for (const parts of Object.values(template.queryParams ?? {})) collect(parts); | ||
| 65 | return [...names]; | ||
| 66 | }; | ||
| 67 | |||
| 68 | /** Render an `apiKey`/`oauth` template into request headers + query params using | ||
| 69 | * the connection's resolved input `values`. An oauth template carries no | ||
| 70 | * placement fields, so its access token (the `token` input) is applied as a | ||
| 71 | * conventional `Authorization: Bearer <token>` header — the "derived into | ||
| 72 | * template format" rendering. */ | ||
| 73 | export const renderAuthTemplate = ( | ||
| 74 | template: Authentication | undefined, | ||
| 75 | values: Record<string, string | null>, | ||
| 76 | ): RenderedAuth => { | ||
| 77 | if (template === undefined) return EMPTY_AUTH; | ||
| 78 | |||
| 79 | if (template.type === "oauth") { | ||
| 80 | const token = values[CREDENTIAL_VARIABLE]; | ||
| 81 | if (token == null) return EMPTY_AUTH; | ||
| 82 | return { | ||
| 83 | headers: { Authorization: `Bearer ${token}` }, | ||
| 84 | queryParams: {}, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | const apiKey: APIKeyAuthentication = template; | ||
| 89 | const headers: Record<string, string> = {}; | ||
| 90 | const queryParams: Record<string, string> = {}; | ||
| 91 | for (const [name, parts] of Object.entries(apiKey.headers ?? {})) { | ||
| 92 | headers[name] = renderTemplateValue(parts, values); | ||
| 93 | } | ||
| 94 | for (const [name, parts] of Object.entries(apiKey.queryParams ?? {})) { | ||
| 95 | queryParams[name] = renderTemplateValue(parts, values); | ||
| 96 | } | ||
| 97 | return { headers, queryParams }; | ||
| 98 | }; | ||
| 99 | |||
| 100 | /** Resolve the auth a connection contributes: pick its template by slug, render | ||
| 101 | * it with the resolved input values. */ | ||
| 102 | export const applyAuthTemplate = ( | ||
| 103 | templates: readonly Authentication[], | ||
| 104 | slug: AuthTemplateSlug, | ||
| 105 | values: Record<string, string | null>, | ||
| 106 | ): RenderedAuth => renderAuthTemplate(findAuthTemplate(templates, slug), values); | ||
| @@ -1,97 +0,0 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import type { AuthTemplateSlug, Integration, OAuthAuthentication } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 5 | /* ─────────────────────── template variables ─────────────────────── | ||
| 6 | * Brought over from the v2 openapi scaffold (packages/sdk-v2/src/openapi/ | ||
| 7 | * types.ts). The apiKey template declares WHERE a connection's resolved value | ||
| 8 | * is placed on the outbound request (header / query) using `variable()` refs. | ||
| 9 | * That placement is HTTP-transport-specific, which is why it lives with the | ||
| 10 | * http plugin and not core. The oauth template is mechanism-intrinsic and comes | ||
| 11 | * from core (`OAuthAuthentication`). An integration's `Authentication` union | ||
| 12 | * composes the two — and an OAuth access token renders through the (oauth) | ||
| 13 | * template exactly like an apiKey bearer (D11). */ | ||
| 14 | |||
| 15 | export interface AuthenticationVariable { | ||
| 16 | readonly type: "variable"; | ||
| 17 | readonly name: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | /** A literal string, or a parts-array mixing literals and variable refs. */ | ||
| 21 | export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[]; | ||
| 22 | |||
| 23 | export const variable = (name: string): AuthenticationVariable => ({ | ||
| 24 | type: "variable", | ||
| 25 | name, | ||
| 26 | }); | ||
| 27 | |||
| 28 | /** The conventional variable name an http auth template renders the connection's | ||
| 29 | * resolved credential value into. `applyAuthTemplate` substitutes this. */ | ||
| 30 | export const CREDENTIAL_VARIABLE = "token"; | ||
| 31 | |||
| 32 | export interface APIKeyAuthentication { | ||
| 33 | readonly slug: AuthTemplateSlug; | ||
| 34 | readonly type: "apiKey"; | ||
| 35 | readonly headers?: Record<string, AuthenticationTemplateValue>; | ||
| 36 | readonly queryParams?: Record<string, AuthenticationTemplateValue>; | ||
| 37 | } | ||
| 38 | |||
| 39 | export type Authentication = OAuthAuthentication | APIKeyAuthentication; | ||
| 40 | |||
| 41 | /* ─────────────────────── http integration config ─────────────────────── | ||
| 42 | * The opaque plugin config core stores on an http-source integration row. Core | ||
| 43 | * never parses it; the plugin reads it back in `resolveTools` (to produce the | ||
| 44 | * connection's request tool) and `invokeTool` (to render the auth template). */ | ||
| 45 | |||
| 46 | export interface HttpSourceConfig { | ||
| 47 | /** Base URL the connection's requests are issued against (e.g. | ||
| 48 | * `https://api.example.com`). A tool call's `path` is appended to it. */ | ||
| 49 | readonly baseUrl: string; | ||
| 50 | /** Auth methods this integration declares. A connection picks one by `template` | ||
| 51 | * slug; `invokeTool` renders it with the connection's resolved value. */ | ||
| 52 | readonly authenticationTemplate: readonly Authentication[]; | ||
| 53 | /** Optional default headers applied to every request (non-secret). */ | ||
| 54 | readonly defaultHeaders?: Record<string, string>; | ||
| 55 | } | ||
| 56 | |||
| 57 | /* ─────────────────────── request tool wire schemas ─────────────────────── | ||
| 58 | * Raw-HTTP tools take an explicit request envelope. Spec-derived plugins | ||
| 59 | * (openapi) shape one tool per operation; the http kind exposes a single | ||
| 60 | * generic `request` tool whose schema is identical per connection. */ | ||
| 61 | |||
| 62 | export const HttpMethod = Schema.Literals([ | ||
| 63 | "GET", | ||
| 64 | "POST", | ||
| 65 | "PUT", | ||
| 66 | "PATCH", | ||
| 67 | "DELETE", | ||
| 68 | "HEAD", | ||
| 69 | "OPTIONS", | ||
| 70 | ]); | ||
| 71 | export type HttpMethod = typeof HttpMethod.Type; | ||
| 72 | |||
| 73 | export const HttpRequestArgs = Schema.Struct({ | ||
| 74 | method: HttpMethod.pipe(Schema.optional), | ||
| 75 | /** Path appended to the integration's `baseUrl`. May be absolute. */ | ||
| 76 | path: Schema.String, | ||
| 77 | query: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), | ||
| 78 | headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), | ||
| 79 | /** Request body. Objects are JSON-encoded; strings pass through. */ | ||
| 80 | body: Schema.Unknown.pipe(Schema.optional), | ||
| 81 | }).annotate({ identifier: "HttpRequestArgs" }); | ||
| 82 | export type HttpRequestArgs = typeof HttpRequestArgs.Type; | ||
| 83 | |||
| 84 | export const HttpResponse = Schema.Struct({ | ||
| 85 | status: Schema.Number, | ||
| 86 | headers: Schema.Record(Schema.String, Schema.String), | ||
| 87 | body: Schema.Unknown, | ||
| 88 | }).annotate({ identifier: "HttpResponse" }); | ||
| 89 | export type HttpResponse = typeof HttpResponse.Type; | ||
| 90 | |||
| 91 | /* ─────────────────────── the integration projection ─────────────────────── | ||
| 92 | * Extends the core `Integration` identity with the auth templates a provider | ||
| 93 | * declares — the http-kind type-specific shape. Core never references it. */ | ||
| 94 | |||
| 95 | export type HttpSourceIntegration = Integration & { | ||
| 96 | readonly authenticationTemplate: readonly Authentication[]; | ||
| 97 | }; | ||
| @@ -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 | } | ||
| @@ -1,14 +0,0 @@ | |||
| 1 | import { defineConfig } from "tsup"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | entry: { | ||
| 5 | index: "src/index.ts", | ||
| 6 | sdk: "src/sdk/index.ts", | ||
| 7 | react: "src/react/index.ts", | ||
| 8 | }, | ||
| 9 | format: ["esm"], | ||
| 10 | dts: false, | ||
| 11 | sourcemap: true, | ||
| 12 | clean: true, | ||
| 13 | external: [/^@executor-js\//, /^effect/, /^@effect\//, /^react/], | ||
| 14 | }); | ||
| @@ -1,7 +0,0 @@ | |||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts", "src/**/*.test.tsx"], | ||
| 6 | }, | ||
| 7 | }); | ||
| @@ -10,7 +10,7 @@ import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; · 9 unmodified lines | |||
| 10 | import { | 10 | import { |
| 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 when | 41 | /** 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 | ||
| 46 | const AddStdioServerPayload = Schema.Struct({ | 46 | const AddStdioServerPayload = Schema.Struct({ |
| @@ -37,7 +37,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 36 unmodified lines | |||
| 37 | import type { McpAuthMethodInput } from "../sdk/types"; | 37 | import type { McpAuthMethodInput } from "../sdk/types"; |
| 38 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; | 38 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 39 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; | 39 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; |
| 40 | import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config"; | 40 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 42 | 42 | ||
| 43 | // The remote add flow REGISTERS the server's declared auth methods through the | 43 | // 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 registers | 322 | // 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 }], |
| @@ -4,6 +4,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 3 unmodified lines | |||
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | ||
| 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; | ||
| 7 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 8 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 8 | import { | 9 | import { |
| 9 | AuthMethodListEditor, | 10 | AuthMethodListEditor, |
| @@ -24,10 +25,15 @@ import { Badge } from "@executor-js/react/components/badge"; · 14 unmodified lines | |||
| 24 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | 25 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 25 | 26 | ||
| 26 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; | 27 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 27 | import type { McpAuthMethod, McpAuthMethodInput, McpIntegrationConfig } from "../sdk/types"; | 28 | import type { |
| 29 | McpAuthMethod, | ||
| 30 | McpCanonicalAuthMethodInput, | ||
| 31 | McpIntegrationConfig, | ||
| 32 | } from "../sdk/types"; | ||
| 28 | import { | 33 | import { |
| 29 | editorValueFromMcpAuthMethod, | 34 | editorValueFromMcpAuthMethod, |
| 30 | mcpAuthMethodInputFromEditorValue, | 35 | mcpAuthMethodInputFromEditorValue, |
| 36 | mcpWireAuthInput, | ||
| 31 | } from "./auth-method-config"; | 37 | } from "./auth-method-config"; |
| 32 | 38 | ||
| 33 | type McpServer = { | 39 | type McpServer = { |
| @@ -43,10 +49,30 @@ type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; · 9 unmodified lines | |||
| 43 | 49 | ||
| 44 | const methodSeedLabel = (method: McpAuthMethod): string => { | 50 | const 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 | ||
| 56 | const samePlacements = ( | ||
| 57 | a: readonly AuthPlacement[] | undefined, | ||
| 58 | b: readonly AuthPlacement[] | undefined, | ||
| 59 | ): boolean => { | ||
| 60 | const left = a ?? []; | ||
| 61 | const right = b ?? []; | ||
| 62 | if (left.length !== right.length) return false; | ||
| 63 | return left.every((placement: AuthPlacement, index: number) => { | ||
| 64 | const other = right[index]; | ||
| 65 | return ( | ||
| 66 | other !== undefined && | ||
| 67 | placement.carrier === other.carrier && | ||
| 68 | placement.name === other.name && | ||
| 69 | (placement.prefix ?? "") === (other.prefix ?? "") && | ||
| 70 | (placement.variable ?? "") === (other.variable ?? "") && | ||
| 71 | (placement.literal ?? null) === (other.literal ?? null) | ||
| 72 | ); | ||
| 73 | }); | ||
| 74 | }; | ||
| 75 | |||
| 50 | // --------------------------------------------------------------------------- | 76 | // --------------------------------------------------------------------------- |
| 51 | // Remote edit — v2: the integration's endpoint is part of its identity | 77 | // Remote edit — v2: the integration's endpoint is part of its identity |
| 52 | // (opaque-to-core config); the editable surface is the declared auth-method | 78 | // (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 existing | 106 | // The edited methods, slugs preserved for seeded rows so existing |
| 81 | // connections (bound by template slug) stay attached. New rows omit the | 107 | // 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, |
| @@ -15,7 +15,11 @@ import { · 14 unmodified lines | |||
| 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 16 | 16 | ||
| 17 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; | 17 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 18 | import { authMethodsFromConfig, mcpAuthMethodInputsFromPlacements } from "./auth-method-config"; | 18 | import { |
| 19 | authMethodsFromConfig, | ||
| 20 | mcpAuthMethodInputsFromPlacements, | ||
| 21 | mcpWireAuthInput, | ||
| 22 | } from "./auth-method-config"; | ||
| 19 | import type { McpAuthMethod } from "../sdk/types"; | 23 | import 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, |
| @@ -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 | ||
| 72 | describe("editorValueFromMcpAuthMethod", () => { | 75 | describe("editorValueFromMcpAuthMethod", () => { |
| 73 | it("round-trips a header method into an apikey editor value", () => { | 76 | it("round-trips an apikey method, making the shared token variable explicit", () => { |
| 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 | ||
| 124 | describe("mcpAuthMethodInputsFromPlacements", () => { | 172 | describe("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 | }); |
| @@ -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 the | 4 | // paths (multi-placement, multi-variable) live in the shared codec; MCP only |
| 5 | // plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP | 5 | // 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 | ||
| 12 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 9 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 13 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 10 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 14 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 11 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 12 | import { | ||
| 13 | authMethodFromSharedTemplate, | ||
| 14 | editorValueFromSharedMethod, | ||
| 15 | sharedMethodInputFromEditorValue, | ||
| 16 | wirePlacementsFromEditor, | ||
| 17 | } from "@executor-js/react/lib/shared-auth-method-codec"; | ||
| 15 | 18 | ||
| 16 | import type { McpAuthMethod, McpAuthMethodInput } from "../sdk/types"; | 19 | import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec"; |
| 20 | import type { McpAuthMethod, McpAuthMethodInput, McpCanonicalAuthMethodInput } from "../sdk/types"; | ||
| 21 | |||
| 22 | /** Serialize a canonical method into the wire input union (apikey → the | ||
| 23 | * request-shaped dialect; none/oauth2 pass through). */ | ||
| 24 | export const mcpWireAuthInput = ( | ||
| 25 | method: McpAuthMethod | McpCanonicalAuthMethodInput, | ||
| 26 | ): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput; | ||
| 27 | |||
| 28 | const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ | ||
| 29 | id: slug, | ||
| 30 | label: "OAuth", | ||
| 31 | kind: "oauth", | ||
| 32 | source: slug.startsWith("custom_") ? "custom" : "spec", | ||
| 33 | template: AuthTemplateSlug.make(slug), | ||
| 34 | placements: [], | ||
| 35 | oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, | ||
| 36 | }); | ||
| 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; an | 40 | * 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`. */ |
| 22 | export function mcpAuthMethodInputFromEditorValue( | 42 | export 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 placement | 74 | * ONE method carrying every named placement (header + query mix in a single |
| 99 | * is used (query placements have no MCP credential carrier). Empty when no | 75 | * method; each placement renders from its own input variable, or shares one). |
| 100 | * usable header placement exists. */ | 76 | * Empty when no placement is usable. */ |
| 101 | export function mcpAuthMethodInputsFromPlacements( | 77 | export 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 | } |
| @@ -7,8 +7,9 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 6 unmodified lines | |||
| 7 | // `describeMcpAuthMethods` projects the stored MCP config into the catalog's | 7 | // `describeMcpAuthMethods` projects the stored MCP config into the catalog's |
| 8 | // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is | 8 | // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is |
| 9 | // pure/sync and must tolerate a malformed or foreign config blob by returning | 9 | // pure/sync and must tolerate a malformed or foreign config blob by returning |
| 10 | // `[]`. Legacy configs (singular `auth`, or no auth field at all) are lifted | 10 | // `[]`. 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 | ||
| 14 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ | 15 | const 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", () => { |
| @@ -12,7 +12,9 @@ export { · 11 unmodified lines | |||
| 12 | } from "./plugin"; | 12 | } from "./plugin"; |
| 13 | 13 | ||
| 14 | export { | 14 | export { |
| 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 | ||
| 28 | export { migrateMcpAuthConfig } from "./migrate-config"; | ||
| 29 | |||
| 30 | // Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`. | ||
| 31 | export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 32 | |||
| 26 | export { | 33 | export { |
| 27 | McpConnectionError, | 34 | McpConnectionError, |
| 28 | McpToolDiscoveryError, | 35 | McpToolDiscoveryError, |
| @@ -0,0 +1,165 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { migrateMcpAuthConfig } from "./migrate-config"; | ||
| 4 | import { parseMcpIntegrationConfig } from "./types"; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // The one-off config migration rewrites every pre-canonical MCP blob family | ||
| 8 | // into the canonical placements model. Two invariants are load-bearing: | ||
| 9 | // slugs are preserved verbatim (connections bind by `connection.template`) | ||
| 10 | // and variable names are preserved verbatim (they key stored `item_ids`). | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | const REMOTE = { transport: "remote", endpoint: "https://x.example/mcp" } as const; | ||
| 14 | |||
| 15 | describe("migrateMcpAuthConfig", () => { | ||
| 16 | it("lifts a singular `auth: {kind:'header'}` into a one-method template, slug = kind", () => { | ||
| 17 | const migrated = migrateMcpAuthConfig({ | ||
| 18 | ...REMOTE, | ||
| 19 | auth: { kind: "header", headerName: "Authorization", prefix: "Bearer " }, | ||
| 20 | }); | ||
| 21 | expect(migrated).toEqual({ | ||
| 22 | ...REMOTE, | ||
| 23 | authenticationTemplate: [ | ||
| 24 | { | ||
| 25 | slug: "header", | ||
| 26 | kind: "apikey", | ||
| 27 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 28 | }, | ||
| 29 | ], | ||
| 30 | }); | ||
| 31 | expect(parseMcpIntegrationConfig(migrated)).not.toBeNull(); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("lifts a singular `auth: {kind:'oauth2'}` (the v1→v2 migration's output)", () => { | ||
| 35 | expect(migrateMcpAuthConfig({ ...REMOTE, auth: { kind: "oauth2" } })).toEqual({ | ||
| 36 | ...REMOTE, | ||
| 37 | authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("treats a remote config with no auth field as an open server", () => { | ||
| 42 | expect(migrateMcpAuthConfig({ ...REMOTE })).toEqual({ | ||
| 43 | ...REMOTE, | ||
| 44 | authenticationTemplate: [{ slug: "none", kind: "none" }], | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("rewrites retired single-placement `header` / `query` methods, slugs preserved", () => { | ||
| 49 | const migrated = migrateMcpAuthConfig({ | ||
| 50 | ...REMOTE, | ||
| 51 | authenticationTemplate: [ | ||
| 52 | { slug: "header", kind: "header", headerName: "X-Api-Key", prefix: "Bearer " }, | ||
| 53 | { slug: "custom_q1", kind: "query", paramName: "token" }, | ||
| 54 | { slug: "none", kind: "none" }, | ||
| 55 | ], | ||
| 56 | }); | ||
| 57 | expect(migrated).toEqual({ | ||
| 58 | ...REMOTE, | ||
| 59 | authenticationTemplate: [ | ||
| 60 | { | ||
| 61 | slug: "header", | ||
| 62 | kind: "apikey", | ||
| 63 | placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | slug: "custom_q1", | ||
| 67 | kind: "apikey", | ||
| 68 | placements: [{ carrier: "query", name: "token" }], | ||
| 69 | }, | ||
| 70 | { slug: "none", kind: "none" }, | ||
| 71 | ], | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("rewrites openapi-shaped entries the v1→v2 migration wrote, variables preserved", () => { | ||
| 76 | // Exactly what `migrateOpenApiAuthTemplate` emits for a two-input source. | ||
| 77 | const migrated = migrateMcpAuthConfig({ | ||
| 78 | ...REMOTE, | ||
| 79 | authenticationTemplate: [ | ||
| 80 | { | ||
| 81 | slug: "apiKey", | ||
| 82 | type: "apiKey", | ||
| 83 | headers: { | ||
| 84 | "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }], | ||
| 85 | Authorization: ["Bearer ", { type: "variable", name: "dd_application_key" }], | ||
| 86 | }, | ||
| 87 | queryParams: { team: [{ type: "variable", name: "team" }] }, | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | }); | ||
| 91 | expect(migrated).toEqual({ | ||
| 92 | ...REMOTE, | ||
| 93 | authenticationTemplate: [ | ||
| 94 | { | ||
| 95 | slug: "apiKey", | ||
| 96 | kind: "apikey", | ||
| 97 | placements: [ | ||
| 98 | { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | ||
| 99 | { | ||
| 100 | carrier: "header", | ||
| 101 | name: "Authorization", | ||
| 102 | prefix: "Bearer ", | ||
| 103 | variable: "dd_application_key", | ||
| 104 | }, | ||
| 105 | { carrier: "query", name: "team", variable: "team" }, | ||
| 106 | ], | ||
| 107 | }, | ||
| 108 | ], | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("stores the canonical `token` variable as absent and literal-only values as literals", () => { | ||
| 113 | const migrated = migrateMcpAuthConfig({ | ||
| 114 | ...REMOTE, | ||
| 115 | authenticationTemplate: [ | ||
| 116 | { | ||
| 117 | slug: "apiKey", | ||
| 118 | type: "apiKey", | ||
| 119 | headers: { | ||
| 120 | Authorization: ["Bearer ", { type: "variable", name: "token" }], | ||
| 121 | "X-Api-Version": "2023-01-01", | ||
| 122 | }, | ||
| 123 | }, | ||
| 124 | ], | ||
| 125 | }) as { authenticationTemplate: readonly unknown[] }; | ||
| 126 | expect(migrated.authenticationTemplate[0]).toEqual({ | ||
| 127 | slug: "apiKey", | ||
| 128 | kind: "apikey", | ||
| 129 | placements: [ | ||
| 130 | { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 131 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 132 | ], | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("is idempotent — a canonical config returns null (no rewrite)", () => { | ||
| 137 | const canonical = { | ||
| 138 | ...REMOTE, | ||
| 139 | authenticationTemplate: [ | ||
| 140 | { slug: "oauth2", kind: "oauth2" }, | ||
| 141 | { | ||
| 142 | slug: "header", | ||
| 143 | kind: "apikey", | ||
| 144 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 145 | }, | ||
| 146 | ], | ||
| 147 | }; | ||
| 148 | expect(migrateMcpAuthConfig(canonical)).toBeNull(); | ||
| 149 | // And the lifted output of every family is itself canonical. | ||
| 150 | const lifted = migrateMcpAuthConfig({ ...REMOTE, auth: { kind: "oauth2" } }); | ||
| 151 | expect(migrateMcpAuthConfig(lifted)).toBeNull(); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("leaves stdio, foreign, and unmigratable blobs untouched", () => { | ||
| 155 | expect(migrateMcpAuthConfig({ transport: "stdio", command: "run" })).toBeNull(); | ||
| 156 | expect(migrateMcpAuthConfig({ not: "mcp" })).toBeNull(); | ||
| 157 | expect(migrateMcpAuthConfig(null)).toBeNull(); | ||
| 158 | expect( | ||
| 159 | migrateMcpAuthConfig({ | ||
| 160 | ...REMOTE, | ||
| 161 | authenticationTemplate: [{ slug: "x", kind: "mystery" }], | ||
| 162 | }), | ||
| 163 | ).toBeNull(); | ||
| 164 | }); | ||
| 165 | }); | ||
| @@ -0,0 +1,185 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // One-off config migration — rewrite pre-canonical MCP integration `config` | ||
| 3 | // blobs into the canonical placements model. Runs once per database (boot for | ||
| 4 | // local/selfhost, out-of-band script for cloud); runtime code decodes ONLY | ||
| 5 | // the canonical shape (`parseMcpIntegrationConfig`). | ||
| 6 | // | ||
| 7 | // Stored legacy families (all observed in real databases): | ||
| 8 | // 1. singular `auth: {kind:"none"|"header"|"oauth2"}` (pre-array configs), | ||
| 9 | // or a remote config with no auth field at all (open server) | ||
| 10 | // 2. `authenticationTemplate` entries of the retired single-placement | ||
| 11 | // shapes: `{slug, kind:"header", headerName, prefix?}` / | ||
| 12 | // `{slug, kind:"query", paramName, prefix?}` | ||
| 13 | // 3. openapi-shaped entries the v1→v2 migration wrote: | ||
| 14 | // `{slug, type:"apiKey", headers/queryParams: <template values>}` and | ||
| 15 | // `{slug, type:"oauth", …}` | ||
| 16 | // | ||
| 17 | // Invariants: slugs are preserved verbatim (`connection.template` binds by | ||
| 18 | // them) and so are variable names (they key the connections' stored | ||
| 19 | // `item_ids`). | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | import { Option, Schema } from "effect"; | ||
| 23 | import { | ||
| 24 | apiKeyMethodFromLegacyTemplate, | ||
| 25 | decodeLegacyApiKeyTemplate, | ||
| 26 | } from "@executor-js/sdk/http-auth"; | ||
| 27 | |||
| 28 | import { parseMcpIntegrationConfig, type McpAuthMethod } from "./types"; | ||
| 29 | |||
| 30 | const LegacySingleAuth = Schema.Union([ | ||
| 31 | Schema.Struct({ kind: Schema.Literal("none") }), | ||
| 32 | Schema.Struct({ | ||
| 33 | kind: Schema.Literal("header"), | ||
| 34 | headerName: Schema.String, | ||
| 35 | prefix: Schema.optional(Schema.String), | ||
| 36 | }), | ||
| 37 | Schema.Struct({ | ||
| 38 | kind: Schema.Literal("query"), | ||
| 39 | paramName: Schema.String, | ||
| 40 | prefix: Schema.optional(Schema.String), | ||
| 41 | }), | ||
| 42 | Schema.Struct({ kind: Schema.Literal("oauth2") }), | ||
| 43 | ]); | ||
| 44 | const decodeLegacySingleAuth = Schema.decodeUnknownOption(LegacySingleAuth); | ||
| 45 | |||
| 46 | const LegacyHeaderMethod = Schema.Struct({ | ||
| 47 | slug: Schema.String, | ||
| 48 | kind: Schema.Literal("header"), | ||
| 49 | headerName: Schema.String, | ||
| 50 | prefix: Schema.optional(Schema.String), | ||
| 51 | }); | ||
| 52 | const LegacyQueryMethod = Schema.Struct({ | ||
| 53 | slug: Schema.String, | ||
| 54 | kind: Schema.Literal("query"), | ||
| 55 | paramName: Schema.String, | ||
| 56 | prefix: Schema.optional(Schema.String), | ||
| 57 | }); | ||
| 58 | const LegacyOAuthTemplate = Schema.Struct({ | ||
| 59 | slug: Schema.String, | ||
| 60 | type: Schema.Literal("oauth"), | ||
| 61 | }); | ||
| 62 | const decodeLegacyHeaderMethod = Schema.decodeUnknownOption(LegacyHeaderMethod); | ||
| 63 | const decodeLegacyQueryMethod = Schema.decodeUnknownOption(LegacyQueryMethod); | ||
| 64 | const decodeLegacyOAuthTemplate = Schema.decodeUnknownOption(LegacyOAuthTemplate); | ||
| 65 | |||
| 66 | const CanonicalMethod = Schema.decodeUnknownOption( | ||
| 67 | Schema.Union([ | ||
| 68 | Schema.Struct({ slug: Schema.String, kind: Schema.Literals(["none", "oauth2"]) }), | ||
| 69 | Schema.Struct({ | ||
| 70 | slug: Schema.String, | ||
| 71 | kind: Schema.Literal("apikey"), | ||
| 72 | label: Schema.optional(Schema.String), | ||
| 73 | placements: Schema.Array(Schema.Unknown), | ||
| 74 | }), | ||
| 75 | ]), | ||
| 76 | ); | ||
| 77 | |||
| 78 | /** Rewrite one stored method entry to canonical, or null when undecodable. */ | ||
| 79 | const migrateMethodEntry = (entry: unknown): McpAuthMethod | null => { | ||
| 80 | const canonical = Option.getOrNull(CanonicalMethod(entry)); | ||
| 81 | if (canonical !== null) return entry as McpAuthMethod; | ||
| 82 | |||
| 83 | const header = Option.getOrNull(decodeLegacyHeaderMethod(entry)); | ||
| 84 | if (header !== null) { | ||
| 85 | return { | ||
| 86 | slug: header.slug, | ||
| 87 | kind: "apikey", | ||
| 88 | placements: [ | ||
| 89 | { | ||
| 90 | carrier: "header", | ||
| 91 | name: header.headerName, | ||
| 92 | ...(header.prefix !== undefined ? { prefix: header.prefix } : {}), | ||
| 93 | }, | ||
| 94 | ], | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | const query = Option.getOrNull(decodeLegacyQueryMethod(entry)); | ||
| 99 | if (query !== null) { | ||
| 100 | return { | ||
| 101 | slug: query.slug, | ||
| 102 | kind: "apikey", | ||
| 103 | placements: [ | ||
| 104 | { | ||
| 105 | carrier: "query", | ||
| 106 | name: query.paramName, | ||
| 107 | ...(query.prefix !== undefined ? { prefix: query.prefix } : {}), | ||
| 108 | }, | ||
| 109 | ], | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | const legacyApiKey = Option.getOrNull(decodeLegacyApiKeyTemplate(entry)); | ||
| 114 | if (legacyApiKey !== null) return apiKeyMethodFromLegacyTemplate(legacyApiKey); | ||
| 115 | |||
| 116 | const legacyOAuth = Option.getOrNull(decodeLegacyOAuthTemplate(entry)); | ||
| 117 | if (legacyOAuth !== null) return { slug: legacyOAuth.slug, kind: "oauth2" }; | ||
| 118 | |||
| 119 | return null; | ||
| 120 | }; | ||
| 121 | |||
| 122 | const slugFromSingleAuth = (auth: typeof LegacySingleAuth.Type): string => auth.kind; | ||
| 123 | |||
| 124 | const methodFromSingleAuth = (auth: typeof LegacySingleAuth.Type): McpAuthMethod => { | ||
| 125 | if (auth.kind === "header") { | ||
| 126 | return { | ||
| 127 | slug: slugFromSingleAuth(auth), | ||
| 128 | kind: "apikey", | ||
| 129 | placements: [ | ||
| 130 | { | ||
| 131 | carrier: "header", | ||
| 132 | name: auth.headerName, | ||
| 133 | ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}), | ||
| 134 | }, | ||
| 135 | ], | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | if (auth.kind === "query") { | ||
| 139 | return { | ||
| 140 | slug: slugFromSingleAuth(auth), | ||
| 141 | kind: "apikey", | ||
| 142 | placements: [ | ||
| 143 | { | ||
| 144 | carrier: "query", | ||
| 145 | name: auth.paramName, | ||
| 146 | ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}), | ||
| 147 | }, | ||
| 148 | ], | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | return { slug: auth.kind, kind: auth.kind }; | ||
| 152 | }; | ||
| 153 | |||
| 154 | /** Rewrite a stored MCP integration config blob into the canonical shape. | ||
| 155 | * Returns the rewritten config, or `null` when no rewrite is needed (already | ||
| 156 | * canonical, a stdio config, or not this plugin's shape at all). Throws | ||
| 157 | * nothing: an unmigratable blob also returns `null` and stays untouched. | ||
| 158 | * Idempotent — feeding the output back returns `null`. */ | ||
| 159 | export const migrateMcpAuthConfig = (config: unknown): unknown | null => { | ||
| 160 | if (typeof config !== "object" || config === null) return null; | ||
| 161 | if (!("transport" in config) || config.transport !== "remote") return null; | ||
| 162 | |||
| 163 | if (!("authenticationTemplate" in config)) { | ||
| 164 | // Family 1: singular `auth`, or an open server with no auth field. | ||
| 165 | const auth = | ||
| 166 | "auth" in config | ||
| 167 | ? Option.getOrNull(decodeLegacySingleAuth(config.auth)) | ||
| 168 | : { kind: "none" as const }; | ||
| 169 | if (auth === null) return null; | ||
| 170 | const { auth: _auth, ...rest } = config as { auth?: unknown }; | ||
| 171 | const migrated = { ...rest, authenticationTemplate: [methodFromSingleAuth(auth)] }; | ||
| 172 | return parseMcpIntegrationConfig(migrated) !== null ? migrated : null; | ||
| 173 | } | ||
| 174 | |||
| 175 | const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate; | ||
| 176 | if (!Array.isArray(entries)) return null; | ||
| 177 | |||
| 178 | const migrated = entries.map(migrateMethodEntry); | ||
| 179 | if (migrated.some((entry) => entry === null)) return null; | ||
| 180 | const changed = migrated.some((entry, index) => entry !== entries[index]); | ||
| 181 | if (!changed) return null; | ||
| 182 | |||
| 183 | const next = { ...config, authenticationTemplate: migrated }; | ||
| 184 | return parseMcpIntegrationConfig(next) !== null ? next : null; | ||
| 185 | }; | ||
| @@ -0,0 +1,228 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // MCP multi-placement auth (wire-level) | ||
| 3 | // | ||
| 4 | // The extreme case the unified model exists for: ONE auth method that mixes a | ||
| 5 | // bearer HEADER and a team-id QUERY PARAM, each rendered from its own | ||
| 6 | // credential input — plus the single-placement query case (servers like ui.sh | ||
| 7 | // authenticate via `?token=`), and the explicit failure when an input is | ||
| 8 | // missing. Methods are authored request-shaped (the one input dialect); assertions are | ||
| 9 | // made against what the server actually RECEIVED. | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | import { describe, expect, it } from "@effect/vitest"; | ||
| 13 | import { Effect } from "effect"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | AuthTemplateSlug, | ||
| 17 | ConnectionName, | ||
| 18 | IntegrationSlug, | ||
| 19 | ToolAddress, | ||
| 20 | createExecutor, | ||
| 21 | } from "@executor-js/sdk"; | ||
| 22 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 23 | |||
| 24 | import { mcpPlugin } from "./plugin"; | ||
| 25 | import { variable } from "@executor-js/sdk/http-auth"; | ||
| 26 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; | ||
| 27 | |||
| 28 | const serveRecordingServer = serveMcpServer(() => | ||
| 29 | makeEchoMcpServer({ | ||
| 30 | name: "multi-auth-test", | ||
| 31 | toolName: "whoami", | ||
| 32 | toolDescription: "Echoes a marker so the test can prove the invoke reached the server", | ||
| 33 | inputName: "marker", | ||
| 34 | text: (marker) => `ok:${marker}`, | ||
| 35 | }), | ||
| 36 | ); | ||
| 37 | |||
| 38 | describe("MCP multi-placement auth", () => { | ||
| 39 | it.effect("one method renders a bearer header AND a team-id query param", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const server = yield* serveRecordingServer; | ||
| 42 | const executor = yield* createExecutor( | ||
| 43 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | ||
| 44 | ); | ||
| 45 | |||
| 46 | yield* executor.mcp.addServer({ | ||
| 47 | name: "Mixed MCP", | ||
| 48 | endpoint: server.url, | ||
| 49 | slug: "mixed_mcp", | ||
| 50 | authenticationTemplate: [ | ||
| 51 | { | ||
| 52 | slug: "token_and_team", | ||
| 53 | type: "apiKey", | ||
| 54 | headers: { Authorization: ["Bearer ", variable("api_token")] }, | ||
| 55 | queryParams: { team_id: [variable("team_id")] }, | ||
| 56 | }, | ||
| 57 | ], | ||
| 58 | }); | ||
| 59 | |||
| 60 | yield* executor.connections.create({ | ||
| 61 | owner: "org", | ||
| 62 | name: ConnectionName.make("main"), | ||
| 63 | integration: IntegrationSlug.make("mixed_mcp"), | ||
| 64 | template: AuthTemplateSlug.make("token_and_team"), | ||
| 65 | values: { api_token: "tok_A", team_id: "team_B" }, | ||
| 66 | }); | ||
| 67 | |||
| 68 | const before = (yield* server.requests).length; | ||
| 69 | const result = yield* executor.execute( | ||
| 70 | ToolAddress.make("tools.mixed_mcp.org.main.whoami"), | ||
| 71 | { marker: "mixed" }, | ||
| 72 | { onElicitation: "accept-all" }, | ||
| 73 | ); | ||
| 74 | expect(result).toMatchObject({ | ||
| 75 | ok: true, | ||
| 76 | data: { content: [{ type: "text", text: "ok:mixed" }] }, | ||
| 77 | }); | ||
| 78 | |||
| 79 | const requests = (yield* server.requests).slice(before); | ||
| 80 | expect(requests.length).toBeGreaterThan(0); | ||
| 81 | // BOTH carriers rendered, each from its own credential input. | ||
| 82 | expect(requests.every((request) => request.authorization === "Bearer tok_A")).toBe(true); | ||
| 83 | expect(requests.every((request) => request.url.includes("team_id=team_B"))).toBe(true); | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | |||
| 87 | it.effect("a query-only method renders ?token= and no Authorization header (ui.sh)", () => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const server = yield* serveRecordingServer; | ||
| 90 | const executor = yield* createExecutor( | ||
| 91 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | yield* executor.mcp.addServer({ | ||
| 95 | name: "Query MCP", | ||
| 96 | endpoint: server.url, | ||
| 97 | slug: "query_mcp", | ||
| 98 | authenticationTemplate: [{ type: "apiKey", queryParams: { token: [variable("token")] } }], | ||
| 99 | }); | ||
| 100 | |||
| 101 | // Slug-less single-query-placement methods get the `query` slug. | ||
| 102 | yield* executor.connections.create({ | ||
| 103 | owner: "org", | ||
| 104 | name: ConnectionName.make("main"), | ||
| 105 | integration: IntegrationSlug.make("query_mcp"), | ||
| 106 | template: AuthTemplateSlug.make("query"), | ||
| 107 | value: "tok_secret_123", | ||
| 108 | }); | ||
| 109 | |||
| 110 | const before = (yield* server.requests).length; | ||
| 111 | const result = yield* executor.execute( | ||
| 112 | ToolAddress.make("tools.query_mcp.org.main.whoami"), | ||
| 113 | { marker: "qconn" }, | ||
| 114 | { onElicitation: "accept-all" }, | ||
| 115 | ); | ||
| 116 | expect(result).toMatchObject({ ok: true }); | ||
| 117 | |||
| 118 | const requests = (yield* server.requests).slice(before); | ||
| 119 | expect(requests.length).toBeGreaterThan(0); | ||
| 120 | expect(requests.every((request) => request.url.includes("token=tok_secret_123"))).toBe(true); | ||
| 121 | expect(requests.every((request) => request.authorization === undefined)).toBe(true); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("a connection binding one method does not leak into another method's shape", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const server = yield* serveRecordingServer; | ||
| 128 | const executor = yield* createExecutor( | ||
| 129 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | ||
| 130 | ); | ||
| 131 | |||
| 132 | // Two declared methods; each connection picks one by template slug. | ||
| 133 | yield* executor.mcp.addServer({ | ||
| 134 | name: "Multi MCP", | ||
| 135 | endpoint: server.url, | ||
| 136 | slug: "multi_mcp", | ||
| 137 | authenticationTemplate: [ | ||
| 138 | { | ||
| 139 | slug: "bearer", | ||
| 140 | type: "apiKey", | ||
| 141 | headers: { Authorization: ["Bearer ", variable("token")] }, | ||
| 142 | }, | ||
| 143 | { | ||
| 144 | slug: "query_token", | ||
| 145 | type: "apiKey", | ||
| 146 | queryParams: { auth_token: [variable("token")] }, | ||
| 147 | }, | ||
| 148 | ], | ||
| 149 | }); | ||
| 150 | |||
| 151 | yield* executor.connections.create({ | ||
| 152 | owner: "org", | ||
| 153 | name: ConnectionName.make("hconn"), | ||
| 154 | integration: IntegrationSlug.make("multi_mcp"), | ||
| 155 | template: AuthTemplateSlug.make("bearer"), | ||
| 156 | value: "header-secret", | ||
| 157 | }); | ||
| 158 | yield* executor.connections.create({ | ||
| 159 | owner: "org", | ||
| 160 | name: ConnectionName.make("qconn"), | ||
| 161 | integration: IntegrationSlug.make("multi_mcp"), | ||
| 162 | template: AuthTemplateSlug.make("query_token"), | ||
| 163 | value: "query-secret", | ||
| 164 | }); | ||
| 165 | |||
| 166 | const beforeHeader = (yield* server.requests).length; | ||
| 167 | yield* executor.execute( | ||
| 168 | ToolAddress.make("tools.multi_mcp.org.hconn.whoami"), | ||
| 169 | { marker: "h" }, | ||
| 170 | { onElicitation: "accept-all" }, | ||
| 171 | ); | ||
| 172 | const headerRequests = (yield* server.requests).slice(beforeHeader); | ||
| 173 | expect(headerRequests.every((r) => r.authorization === "Bearer header-secret")).toBe(true); | ||
| 174 | expect(headerRequests.every((r) => !r.url.includes("auth_token="))).toBe(true); | ||
| 175 | |||
| 176 | const beforeQuery = (yield* server.requests).length; | ||
| 177 | yield* executor.execute( | ||
| 178 | ToolAddress.make("tools.multi_mcp.org.qconn.whoami"), | ||
| 179 | { marker: "q" }, | ||
| 180 | { onElicitation: "accept-all" }, | ||
| 181 | ); | ||
| 182 | const queryRequests = (yield* server.requests).slice(beforeQuery); | ||
| 183 | expect(queryRequests.every((r) => r.url.includes("auth_token=query-secret"))).toBe(true); | ||
| 184 | expect(queryRequests.every((r) => r.authorization === undefined)).toBe(true); | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.effect("invoking with a missing credential input fails explicitly, not silently", () => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const server = yield* serveRecordingServer; | ||
| 191 | const executor = yield* createExecutor( | ||
| 192 | makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), | ||
| 193 | ); | ||
| 194 | |||
| 195 | yield* executor.mcp.addServer({ | ||
| 196 | name: "Strict MCP", | ||
| 197 | endpoint: server.url, | ||
| 198 | slug: "strict_mcp", | ||
| 199 | authenticationTemplate: [ | ||
| 200 | { | ||
| 201 | slug: "two_inputs", | ||
| 202 | type: "apiKey", | ||
| 203 | headers: { Authorization: ["Bearer ", variable("a")] }, | ||
| 204 | queryParams: { team_id: [variable("b")] }, | ||
| 205 | }, | ||
| 206 | ], | ||
| 207 | }); | ||
| 208 | |||
| 209 | // Only one of the two inputs is supplied. | ||
| 210 | yield* executor.connections.create({ | ||
| 211 | owner: "org", | ||
| 212 | name: ConnectionName.make("half"), | ||
| 213 | integration: IntegrationSlug.make("strict_mcp"), | ||
| 214 | template: AuthTemplateSlug.make("two_inputs"), | ||
| 215 | values: { a: "tok_A" }, | ||
| 216 | }); | ||
| 217 | |||
| 218 | const result = (yield* executor.execute( | ||
| 219 | ToolAddress.make("tools.strict_mcp.org.half.whoami"), | ||
| 220 | { marker: "x" }, | ||
| 221 | { onElicitation: "accept-all" }, | ||
| 222 | )) as { ok: boolean; error?: { code?: string; message?: string } }; | ||
| 223 | expect(result.ok).toBe(false); | ||
| 224 | expect(result.error).toMatchObject({ code: "connection_value_missing" }); | ||
| 225 | expect(String(result.error?.message ?? "")).toContain("b"); | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | }); | ||
| @@ -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 | }); |
| @@ -28,6 +28,14 @@ import { · 27 unmodified lines | |||
| 28 | ToolName, | 28 | ToolName, |
| 29 | } from "@executor-js/sdk"; | 29 | } from "@executor-js/sdk"; |
| 30 | 30 | ||
| 31 | import { | ||
| 32 | TOKEN_VARIABLE, | ||
| 33 | describeApiKeyAuthMethod, | ||
| 34 | describeNoneAuthMethod, | ||
| 35 | renderAuthPlacements, | ||
| 36 | requiredPlacementVariables, | ||
| 37 | } from "@executor-js/sdk/http-auth"; | ||
| 38 | |||
| 31 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; | 39 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; |
| 32 | import { discoverTools } from "./discover"; | 40 | import { discoverTools } from "./discover"; |
| 33 | import { McpConnectionError, McpToolDiscoveryError } from "./errors"; | 41 | import { McpConnectionError, McpToolDiscoveryError } from "./errors"; |
| @@ -37,11 +45,12 @@ import { mcpPresets } from "./presets"; · 3 unmodified lines | |||
| 37 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; | 45 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 38 | import { | 46 | import { |
| 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 when | 155 | /** 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 | ||
| 151 | const McpStdioServerInputSchema = Schema.Struct({ | 160 | const 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.authenticationTemplate | 276 | 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 | ||
| 430 | const buildConnectorInput = ( | 439 | const 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 inputs | 496 | // 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 placement | 498 | // apikey → carried placements (headers / query params) verbatim |
| 487 | // oauth2 → an oauth method carrying the MCP endpoint to probe | 499 | // oauth2 → an oauth method carrying the MCP endpoint to probe |
| 488 | // (`discoveryUrl`); endpoints are discovered live at | 500 | // (`discoveryUrl`); endpoints are discovered live at |
| 489 | // connect time, so they are NOT pre-resolved here. We | 501 | // 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 set | 870 | // 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))); |
| @@ -1,16 +1,23 @@ | |||
| 1 | import { Effect, Option, Schema } from "effect"; | 1 | import { Effect, Option, Schema } from "effect"; |
| 2 | import { | ||
| 3 | ApiKeyAuthMethod, | ||
| 4 | ApiKeyAuthTemplate, | ||
| 5 | NoneAuthMethod, | ||
| 6 | apiKeyMethodFromAuthTemplate, | ||
| 7 | isApiKeyAuthTemplate, | ||
| 8 | normalizeAuthMethodSlugs, | ||
| 9 | } from "@executor-js/sdk/http-auth"; | ||
| 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, stored | 14 | // An MCP integration is one server. Its `config` blob (opaque to core, stored |
| 7 | // on the integration row) carries everything needed to dial the server plus an | 15 | // on the integration row) carries everything needed to dial the server plus |
| 8 | // `auth` *template* describing how a connection's resolved value is applied to | 16 | // the declared auth methods describing how a connection's resolved credential |
| 9 | // the request. A connection IS the credential: at execute time core resolves | 17 | // values are applied to the request. A connection IS the credential: at |
| 10 | // the connection's value through its provider (refreshing OAuth tokens), and | 18 | // 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 path | 19 | // (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 value | 20 | // 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 | |||
| 25 | export type McpTransport = typeof McpTransport.Type; | 32 | export type McpTransport = typeof McpTransport.Type; |
| 26 | 33 | ||
| 27 | // --------------------------------------------------------------------------- | 34 | // --------------------------------------------------------------------------- |
| 28 | // Auth methods — the ways a connection's resolved value can be applied to the | 35 | // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) |
| 29 | // request. An integration declares zero or more methods, each with a stable | 36 | // plus MCP's own oauth variant. An integration declares zero or more methods, |
| 30 | // `slug` a connection binds against (`connection.template`), mirroring the | 37 | // 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 value | 42 | // placements (one credential input per distinct placement |
| 43 | // `variable`; servers like ui.sh authenticate via a `?token=` | ||
| 44 | // query placement, and a method may mix carriers — e.g. a bearer | ||
| 45 | // header plus a team-id query param) | ||
| 36 | // oauth2 — the value is an OAuth access token, applied as a Bearer header | 46 | // oauth2 — the value is an OAuth access token, applied as a Bearer header |
| 37 | // via the MCP SDK's OAuthClientProvider | 47 | // via the MCP SDK's OAuthClientProvider. MCP oauth carries no |
| 48 | // stored endpoints: metadata is discovered live at connect time. | ||
| 38 | // --------------------------------------------------------------------------- | 49 | // --------------------------------------------------------------------------- |
| 39 | 50 | ||
| 40 | export const McpAuthMethod = Schema.Union([ | 51 | export 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"), | 55 | export type McpOAuthMethod = typeof McpOAuthMethod.Type; |
| 45 | headerName: Schema.String, | 56 | |
| 46 | prefix: Schema.optional(Schema.String), | 57 | export const McpAuthMethod = Schema.Union([NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod]); |
| 47 | }), | ||
| 48 | Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }), | ||
| 49 | ]); | ||
| 50 | export type McpAuthMethod = typeof McpAuthMethod.Type; | 58 | export type McpAuthMethod = typeof McpAuthMethod.Type; |
| 51 | 59 | ||
| 52 | /** Legacy single-method template (pre-array configs and the `auth` input | 60 | /** Single-method `auth` shorthand on `addServer` — agent convenience for the |
| 53 | * shorthand on `addServer`): the method without a slug. Lifted into a | 61 | * common cases. Normalized into `authenticationTemplate` at the boundary; |
| 54 | * one-element `authenticationTemplate` whose slug is the method kind, which | 62 | * never stored. */ |
| 55 | * matches the template slugs existing connections were created against. */ | 63 | export const McpAuthShorthand = Schema.Union([ |
| 56 | export 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 | ]); |
| 65 | export type McpAuthTemplate = typeof McpAuthTemplate.Type; | 72 | export type McpAuthShorthand = typeof McpAuthShorthand.Type; |
| 66 | 73 | ||
| 67 | export 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 | }); | 77 | export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMethod => { |
| 78 | if (auth.kind === "header") { | ||
| 79 | return { | ||
| 80 | slug: "header", | ||
| 81 | kind: "apikey", | ||
| 82 | placements: [ | ||
| 83 | { | ||
| 84 | carrier: "header", | ||
| 85 | name: auth.headerName, | ||
| 86 | ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}), | ||
| 87 | }, | ||
| 88 | ], | ||
| 89 | }; | ||
| 90 | } | ||
| 91 | return { slug: auth.kind, kind: auth.kind }; | ||
| 92 | }; | ||
| 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. */ |
| 74 | export const McpAuthMethodInput = Schema.Union([ | 96 | export 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 | ]); |
| 84 | export type McpAuthMethodInput = typeof McpAuthMethodInput.Type; | 106 | export type McpAuthMethodInput = typeof McpAuthMethodInput.Type; |
| 85 | 107 | ||
| 86 | /** Assign each method a stable slug: a caller-provided one wins, otherwise the | 108 | /** 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). */ |
| 110 | export type McpCanonicalAuthMethodInput = | ||
| 111 | | Exclude<McpAuthMethodInput, ApiKeyAuthTemplate> | ||
| 112 | | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string }); | ||
| 113 | |||
| 114 | /** The default slug for a slug-less input method. Carrier-derived for the | ||
| 115 | * single-placement apikey cases (`header` / `query`) — the slugs those | ||
| 116 | * methods have always had — so the shorthand, UI, and migration paths all | ||
| 117 | * converge on the same names. */ | ||
| 118 | const defaultMcpAuthSlug = (method: McpCanonicalAuthMethodInput): string => { | ||
| 119 | if (method.kind !== "apikey") return method.kind; | ||
| 120 | if (method.placements.length === 1) { | ||
| 121 | return method.placements[0]!.carrier === "header" ? "header" : "query"; | ||
| 122 | } | ||
| 123 | return "apikey"; | ||
| 124 | }; | ||
| 125 | |||
| 126 | /** Expand request-shaped dialect entries into canonical placements; canonical | ||
| 127 | * entries pass through. Slug backfill is the caller's concern | ||
| 128 | * (`normalizeMcpAuthMethods` for declare flows, `mergeAuthTemplates` for the | ||
| 129 | * custom-method merge). */ | ||
| 130 | export const expandMcpAuthMethodInputs = ( | ||
| 131 | methods: readonly McpAuthMethodInput[], | ||
| 132 | ): readonly McpCanonicalAuthMethodInput[] => | ||
| 133 | methods.map( | ||
| 134 | (method): McpCanonicalAuthMethodInput => | ||
| 135 | isApiKeyAuthTemplate(method) | ||
| 136 | ? (apiKeyMethodFromAuthTemplate(method) as McpCanonicalAuthMethodInput) | ||
| 137 | : (method as McpCanonicalAuthMethodInput), | ||
| 138 | ); | ||
| 139 | |||
| 140 | /** Assign each method a stable slug: a caller-provided one wins, otherwise a | ||
| 141 | * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The | ||
| 142 | * request-shaped dialect is expanded to canonical placements first. */ | ||
| 88 | export const normalizeMcpAuthMethods = ( | 143 | export 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. A | 152 | // 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 onto | 171 | /** 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 | |||
| 144 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; | 194 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; |
| 145 | 195 | ||
| 146 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); | 196 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); |
| 147 | const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate); | ||
| 148 | 197 | ||
| 149 | /** Parse an opaque integration `config` blob into a typed MCP config, or null | 198 | /** 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 remote | 201 | * decoded here — runtime code knows only the canonical model. */ |
| 153 | * config with neither field is an open server (`[{ slug: "none", … }]`). */ | 202 | export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => |
| 154 | export 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 (destructiveHint | 206 | // Tool annotations — upstream MCP ToolAnnotations we honour (destructiveHint |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 3 | import { | 4 | import { |
| 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 | ||
| 49 | const AuthenticationVariablePayload = Schema.Struct({ | 50 | const 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 | }); |
| 53 | const 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])), | 59 | const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); |
| 56 | ]); | 60 | |
| 57 | const AuthenticationPayload = Schema.Union([ | 61 | /** Auth in RESPONSES: the canonical stored shapes (placements). */ |
| 58 | Schema.Struct({ | 62 | const 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 | ||
| 73 | const AddSpecPayload = Schema.Struct({ | 64 | const 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 new | 116 | // The configure result — the merged `authenticationTemplate` after the new |
| 126 | // custom methods were appended/replaced. | 117 | // custom methods were appended/replaced. |
| 127 | const ConfigureResponse = Schema.Struct({ | 118 | const ConfigureResponse = Schema.Struct({ |
| 128 | authenticationTemplate: Schema.Array(AuthenticationPayload), | 119 | authenticationTemplate: Schema.Array(AuthenticationResponse), |
| 129 | }); | 120 | }); |
| 130 | 121 | ||
| 131 | // --------------------------------------------------------------------------- | 122 | // --------------------------------------------------------------------------- |
| @@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line | |||
| 2 | import { Context, Effect } from "effect"; | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup, capture } from "@executor-js/api"; | 4 | import { addGroup, capture } from "@executor-js/api"; |
| 5 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 6 | import type { Authentication } from "../sdk"; | ||
| 7 | import type { OpenApiPluginExtension } from "../sdk/plugin"; | 5 | import type { OpenApiPluginExtension } from "../sdk/plugin"; |
| 8 | import { OpenApiGroup } from "./group"; | 6 | import { 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] }; |
| @@ -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 a | 10 | // 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 ", |
| 12 | export { variable, TOKEN_VARIABLE } from "./sdk/types"; | 12 | // variable("token")] }` — both normalize to the same stored model. |
| 13 | export type { | 13 | export { TOKEN_VARIABLE } from "./sdk/types"; |
| 14 | Authentication, | 14 | export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types"; |
| 15 | APIKeyAuthentication, | 15 | export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; |
| 16 | AuthenticationVariable, | ||
| 17 | AuthenticationTemplateValue, | ||
| 18 | } from "./sdk/types"; | ||
| @@ -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 | ||
| 37 | import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config"; | 37 | import { |
| 38 | authenticationFromEditorValue, | ||
| 39 | editorValueFromAuthentication, | ||
| 40 | openApiWireAuthInput, | ||
| 41 | } from "./auth-method-config"; | ||
| 38 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | 42 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 39 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | 43 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 40 | import { GoogleProductPicker } from "./GoogleProductPicker"; | 44 | import { 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"; |
| 47 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 51 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 48 | import { | 52 | import { 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"; | ||
| 55 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; | 53 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 56 | 54 | ||
| 57 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | 55 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; |
| @@ -172,14 +170,16 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi · 114 unmodified lines | |||
| 172 | const apiKeyTemplateFromHeaderPreset = ( | 170 | const 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 | ||
| 184 | const oauthTemplateFromPreset = ( | 184 | const 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 > 0 | 455 | ...(!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 | }, |
| @@ -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 | ||
| 20 | import { openApiConfigAtom, openapiConfigure } from "./atoms"; | 20 | import { openApiConfigAtom, openapiConfigure } from "./atoms"; |
| 21 | import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config"; | 21 | import { |
| 22 | authMethodsFromConfig, | ||
| 23 | templateFromPlacements, | ||
| 24 | openApiWireAuthInput, | ||
| 25 | } from "./auth-method-config"; | ||
| 22 | import { googleAudienceWarningsForUrls } from "../sdk/google-presets"; | 26 | import { googleAudienceWarningsForUrls } from "../sdk/google-presets"; |
| 23 | import type { Authentication } from "../sdk/types"; | 27 | import 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, |
| @@ -1,111 +1,118 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 2 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 3 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | ||
| 4 | 3 | ||
| 5 | import { | 4 | import { |
| 5 | authMethodsFromConfig, | ||
| 6 | authenticationFromEditorValue, | 6 | authenticationFromEditorValue, |
| 7 | editorValueFromAuthentication, | 7 | editorValueFromAuthentication, |
| 8 | templateFromPlacements, | 8 | templateFromPlacements, |
| 9 | } from "./auth-method-config"; | 9 | } from "./auth-method-config"; |
| 10 | import { TOKEN_VARIABLE, variable, type Authentication } from "../sdk/types"; | 10 | import type { Authentication } from "../sdk/types"; |
| 11 | 11 | ||
| 12 | describe("editorValueFromAuthentication", () => { | 12 | describe("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 | ||
| 56 | describe("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", () => { | 54 | describe("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 | ||
| 88 | describe("round-trip (apiKey + oauth)", () => { | 91 | describe("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 | }); |
| @@ -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 OpenAPI | 5 | // 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 | ||
| 13 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 9 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 14 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 10 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 15 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 16 | |||
| 17 | import { | 12 | import { |
| 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 | // --------------------------------------------------------------------------- | 20 | import 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 | ||
| 33 | const 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). */ |
| 24 | export 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 the | 27 | export 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). */ | ||
| 39 | const parseTemplateValue = ( | ||
| 40 | value: AuthenticationTemplateValue, | ||
| 41 | ): { readonly prefix: string; readonly variable: string } => { | ||
| 42 | if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE }; | ||
| 43 | const parts: string[] = []; | ||
| 44 | for (const part of value) { | ||
| 45 | if (isVariablePart(part)) { | ||
| 46 | return { prefix: parts.join(""), variable: (part as { readonly name: string }).name }; | ||
| 47 | } | ||
| 48 | if (typeof part === "string") parts.push(part); | ||
| 49 | } | ||
| 50 | return { prefix: parts.join(""), variable: TOKEN_VARIABLE }; | ||
| 51 | }; | ||
| 52 | 29 | ||
| 53 | export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => { | 30 | const 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 | }; | |
| 70 | const labelForApiKey = (slug: string, placements: readonly Placement[]): string => { | ||
| 71 | const first = placements[0]; | ||
| 72 | if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`; | ||
| 73 | return `API key (${slug})`; | ||
| 74 | }; | 47 | }; |
| 75 | 48 | ||
| 76 | /** Map each stored auth template to a generic `AuthMethod`. */ | 49 | /** Map each stored auth template to a generic `AuthMethod`. */ |
| 77 | export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] { | 50 | export 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`. */ | ||
| 120 | const slugifyVariable = (name: string): string => | ||
| 121 | name | ||
| 122 | .toLowerCase() | ||
| 123 | .replace(/[^a-z0-9]+/g, "_") | ||
| 124 | .replace(/^_+|_+$/g, ""); | ||
| 125 | |||
| 126 | /** Assign an input variable to each (named) placement. A lone input is the | ||
| 127 | * canonical `token`; multiple inputs each get their own distinct variable so a | ||
| 128 | * connection can carry a different value per location. An explicit | ||
| 129 | * `placement.variable` (from a round-trip) is honored. */ | ||
| 130 | const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => { | ||
| 131 | const named = placements.filter((p) => p.name); | ||
| 132 | const out = new Map<Placement, string>(); | ||
| 133 | if (named.length <= 1) { | ||
| 134 | for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE); | ||
| 135 | return out; | ||
| 136 | } | ||
| 137 | const taken = new Set<string>(); | ||
| 138 | for (const p of named) { | ||
| 139 | const base = slugifyVariable(p.name) || "input"; | ||
| 140 | let candidate = p.variable ?? base; | ||
| 141 | let n = 2; | ||
| 142 | while (taken.has(candidate)) candidate = `${base}_${n++}`; | ||
| 143 | taken.add(candidate); | ||
| 144 | out.set(p, candidate); | ||
| 145 | } | ||
| 146 | return out; | ||
| 147 | }; | ||
| 148 | |||
| 149 | const valueFromPlacement = (placement: Placement, varName: string): AuthenticationTemplateValue => | ||
| 150 | placement.prefix ? [placement.prefix, variable(varName)] : [variable(varName)]; | ||
| 151 | |||
| 152 | /** Build an `APIKeyAuthentication` template from generic placements. When | ||
| 153 | * `slug` is omitted the backend assigns a `custom_<id>` slug. */ | ||
| 154 | export function templateFromPlacements( | 59 | export 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. */ |
| 188 | export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue { | 75 | export 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], |
| @@ -1,7 +1,12 @@ | |||
| 1 | import { Option, Schema } from "effect"; | 1 | import { Option, Schema } from "effect"; |
| 2 | import { | ||
| 3 | ApiKeyAuthMethod, | ||
| 4 | TOKEN_VARIABLE, | ||
| 5 | renderAuthPlacements, | ||
| 6 | requiredPlacementVariables, | ||
| 7 | } from "@executor-js/sdk/http-auth"; | ||
| 2 | 8 | ||
| 3 | import type { Authentication, AuthenticationTemplateValue, AuthenticationVariable } from "./types"; | 9 | import type { Authentication } from "./types"; |
| 4 | import { TOKEN_VARIABLE } from "./types"; | ||
| 5 | 10 | ||
| 6 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| 7 | // OpenAPI integration config — the opaque blob stored on the catalog | 12 | // 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 | ||
| 18 | const AuthenticationVariableSchema = Schema.Struct({ | ||
| 19 | type: Schema.Literal("variable"), | ||
| 20 | name: Schema.String, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const AuthenticationTemplateValueSchema = Schema.Union([ | ||
| 24 | Schema.String, | ||
| 25 | Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])), | ||
| 26 | ]); | ||
| 27 | |||
| 28 | const APIKeyAuthenticationSchema = Schema.Struct({ | ||
| 29 | slug: Schema.String, | ||
| 30 | type: Schema.Literal("apiKey"), | ||
| 31 | headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)), | ||
| 32 | queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)), | ||
| 33 | }); | ||
| 34 | |||
| 35 | const OAuthAuthenticationSchema = Schema.Struct({ | 23 | const 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 | ||
| 43 | export const AuthenticationSchema = Schema.Union([ | 31 | export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]); |
| 44 | OAuthAuthenticationSchema, | ||
| 45 | APIKeyAuthenticationSchema, | ||
| 46 | ]); | ||
| 47 | 32 | ||
| 48 | export const OpenApiIntegrationConfigSchema = Schema.Struct({ | 33 | export 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's | 68 | // (D11). An apiKey method renders through the shared placements renderer; an |
| 84 | // `variable("token")` slots, identically for apiKey and oauth (the oauth value | 69 | // 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 | ||
| 88 | const isVariable = (part: string | AuthenticationVariable): part is AuthenticationVariable => | ||
| 89 | typeof part !== "string"; | ||
| 90 | |||
| 91 | const renderTemplateValue = ( | ||
| 92 | template: AuthenticationTemplateValue, | ||
| 93 | values: Record<string, string | null>, | ||
| 94 | ): string => { | ||
| 95 | if (typeof template === "string") return template; | ||
| 96 | return template.map((part) => (isVariable(part) ? (values[part.name] ?? "") : part)).join(""); | ||
| 97 | }; | ||
| 98 | |||
| 99 | export interface RenderedAuth { | 73 | export 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>")` is | 79 | * (`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 (no | 81 | * different value. */ |
| 108 | * explicit placement), render a bearer Authorization header from `token`. */ | ||
| 109 | export const renderAuthTemplate = ( | 82 | export 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 connection | 95 | /** The distinct input variables a template references — the inputs a connection |
| 131 | * must supply. An oauth template needs `token`; an apiKey template needs every | 96 | * must supply. An oauth template needs `token`; an apiKey method needs every |
| 132 | * `variable("<name>")` across its placements. */ | 97 | * variable across its placements. */ |
| 133 | export const requiredTemplateVariables = (template: Authentication): readonly string[] => { | 98 | export 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 | }; |
| @@ -5,7 +5,7 @@ · 4 unmodified lines | |||
| 5 | // integration's opaque config. These tests exercise the extension method | 5 | // 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 the | 7 | // - 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 entry | 10 | // - slug uniqueness/dedupe: a method whose slug matches an existing entry |
| 11 | // replaces it rather than duplicating, and two slug-less methods in one call | 11 | // replaces it rather than duplicating, and two slug-less methods in one call |
| @@ -28,7 +28,8 @@ import { · 16 unmodified lines | |||
| 28 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 28 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 29 | 29 | ||
| 30 | import { openApiPlugin } from "./plugin"; | 30 | import { openApiPlugin } from "./plugin"; |
| 31 | import { variable, type APIKeyAuthentication, type Authentication } from "./types"; | 31 | import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types"; |
| 32 | import { variable } from "@executor-js/sdk/http-auth"; | ||
| 32 | import { | 33 | import { |
| 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`, |
| 75 | const customApiKey: APIKeyAuthentication = { | 76 | // authored request-shaped (the one apikey input dialect). |
| 76 | slug: AuthTemplateSlug.make("my_custom"), | 77 | const 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 that | 83 | // 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. |
| 84 | const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication => | 86 | const sluglessApiKey = ( |
| 85 | // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it | 87 | template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">, |
| 86 | ({ type: "apiKey", ...placement }) as unknown as Authentication; | 88 | ): AuthenticationInput => ({ type: "apiKey", ...template }); |
| 87 | 89 | ||
| 88 | describe("OpenAPI Plugin — configure (custom auth method)", () => { | 90 | describe("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* () { |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | } from "@executor-js/sdk/core"; | 7 | } from "@executor-js/sdk/core"; |
| 8 | 8 | ||
| 9 | import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin"; | 9 | import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin"; |
| 10 | import { variable, type Authentication } from "./types"; | 10 | import { 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"], |
| @@ -205,8 +205,8 @@ describe("Google bundle add flow", () => { · 204 unmodified lines | |||
| 205 | // collapses under `https://mail.google.com/`, so the requested consent | 205 | // 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", |
| @@ -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 v1 | 244 | // 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 set | 580 | // 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 | ||
| @@ -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), |
| @@ -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")] }`. | ||
| 77 | export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 78 | |||
| 79 | export { migrateOpenApiAuthConfig } from "./migrate-config"; | ||
| @@ -0,0 +1,225 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { renderAuthTemplate } from "./config"; | ||
| 4 | import { migrateOpenApiAuthConfig } from "./migrate-config"; | ||
| 5 | import type { Authentication } from "./types"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // The one-off config migration rewrites the retired `variable()`-templated | ||
| 9 | // apiKey shape into canonical placements. Slugs and variable names are | ||
| 10 | // preserved verbatim (`connection.template` / `item_ids` contracts), and the | ||
| 11 | // EQUIVALENCE tests pin that a migrated template renders byte-identically to | ||
| 12 | // what the legacy renderer produced for the same values. | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | const BASE = { spec: "{}", baseUrl: "https://api.example.com" } as const; | ||
| 16 | |||
| 17 | describe("migrateOpenApiAuthConfig", () => { | ||
| 18 | it("rewrites a single-input bearer template (prefix + token)", () => { | ||
| 19 | const migrated = migrateOpenApiAuthConfig({ | ||
| 20 | ...BASE, | ||
| 21 | authenticationTemplate: [ | ||
| 22 | { | ||
| 23 | slug: "bearer", | ||
| 24 | type: "apiKey", | ||
| 25 | headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, | ||
| 26 | }, | ||
| 27 | ], | ||
| 28 | }); | ||
| 29 | expect(migrated).toEqual({ | ||
| 30 | ...BASE, | ||
| 31 | authenticationTemplate: [ | ||
| 32 | { | ||
| 33 | slug: "bearer", | ||
| 34 | kind: "apikey", | ||
| 35 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 36 | }, | ||
| 37 | ], | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("rewrites a multi-input template (Datadog), variables preserved verbatim", () => { | ||
| 42 | const migrated = migrateOpenApiAuthConfig({ | ||
| 43 | ...BASE, | ||
| 44 | authenticationTemplate: [ | ||
| 45 | { | ||
| 46 | slug: "datadog", | ||
| 47 | type: "apiKey", | ||
| 48 | headers: { | ||
| 49 | "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }], | ||
| 50 | "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }], | ||
| 51 | }, | ||
| 52 | queryParams: { site: [{ type: "variable", name: "site" }] }, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | }); | ||
| 56 | expect(migrated).toEqual({ | ||
| 57 | ...BASE, | ||
| 58 | authenticationTemplate: [ | ||
| 59 | { | ||
| 60 | slug: "datadog", | ||
| 61 | kind: "apikey", | ||
| 62 | placements: [ | ||
| 63 | { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | ||
| 64 | { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" }, | ||
| 65 | { carrier: "query", name: "site", variable: "site" }, | ||
| 66 | ], | ||
| 67 | }, | ||
| 68 | ], | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("rewrites literal-only template values into literal placements", () => { | ||
| 73 | const migrated = migrateOpenApiAuthConfig({ | ||
| 74 | ...BASE, | ||
| 75 | authenticationTemplate: [ | ||
| 76 | { | ||
| 77 | slug: "versioned", | ||
| 78 | type: "apiKey", | ||
| 79 | headers: { | ||
| 80 | "X-Api-Version": "2023-01-01", | ||
| 81 | Authorization: ["Bearer ", { type: "variable", name: "token" }], | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | ], | ||
| 85 | }) as { authenticationTemplate: readonly unknown[] }; | ||
| 86 | expect(migrated.authenticationTemplate[0]).toEqual({ | ||
| 87 | slug: "versioned", | ||
| 88 | kind: "apikey", | ||
| 89 | placements: [ | ||
| 90 | { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, | ||
| 91 | { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 92 | ], | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it('re-keys the retired `type: "oauth"` spelling to `kind: "oauth2"`', () => { | ||
| 97 | const migrated = migrateOpenApiAuthConfig({ | ||
| 98 | ...BASE, | ||
| 99 | authenticationTemplate: [ | ||
| 100 | { | ||
| 101 | slug: "oauth", | ||
| 102 | type: "oauth", | ||
| 103 | authorizationUrl: "https://x.example/auth", | ||
| 104 | tokenUrl: "https://x.example/token", | ||
| 105 | scopes: ["read"], | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | slug: "key", | ||
| 109 | type: "apiKey", | ||
| 110 | queryParams: { key: [{ type: "variable", name: "token" }] }, | ||
| 111 | }, | ||
| 112 | ], | ||
| 113 | }) as { authenticationTemplate: readonly unknown[] }; | ||
| 114 | expect(migrated.authenticationTemplate[0]).toEqual({ | ||
| 115 | slug: "oauth", | ||
| 116 | kind: "oauth2", | ||
| 117 | authorizationUrl: "https://x.example/auth", | ||
| 118 | tokenUrl: "https://x.example/token", | ||
| 119 | scopes: ["read"], | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("is idempotent — canonical configs return null", () => { | ||
| 124 | const canonical = { | ||
| 125 | ...BASE, | ||
| 126 | authenticationTemplate: [ | ||
| 127 | { | ||
| 128 | slug: "bearer", | ||
| 129 | kind: "apikey", | ||
| 130 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 131 | }, | ||
| 132 | ], | ||
| 133 | }; | ||
| 134 | expect(migrateOpenApiAuthConfig(canonical)).toBeNull(); | ||
| 135 | const lifted = migrateOpenApiAuthConfig({ | ||
| 136 | ...BASE, | ||
| 137 | authenticationTemplate: [ | ||
| 138 | { slug: "b", type: "apiKey", headers: { A: [{ type: "variable", name: "token" }] } }, | ||
| 139 | ], | ||
| 140 | }); | ||
| 141 | expect(migrateOpenApiAuthConfig(lifted)).toBeNull(); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("leaves template-less, foreign, and unmigratable blobs untouched", () => { | ||
| 145 | expect(migrateOpenApiAuthConfig({ ...BASE })).toBeNull(); | ||
| 146 | expect(migrateOpenApiAuthConfig({ transport: "remote", endpoint: "x" })).toBeNull(); | ||
| 147 | expect(migrateOpenApiAuthConfig(null)).toBeNull(); | ||
| 148 | expect( | ||
| 149 | migrateOpenApiAuthConfig({ ...BASE, authenticationTemplate: [{ garbage: true }] }), | ||
| 150 | ).toBeNull(); | ||
| 151 | }); | ||
| 152 | }); | ||
| 153 | |||
| 154 | // --------------------------------------------------------------------------- | ||
| 155 | // Equivalence — the migrated template renders EXACTLY what the legacy | ||
| 156 | // renderer produced. Expected outputs below are the legacy renderer's | ||
| 157 | // behavior, written out by hand so the old code can stay deleted. | ||
| 158 | // --------------------------------------------------------------------------- | ||
| 159 | |||
| 160 | describe("legacy render equivalence", () => { | ||
| 161 | const migrateTemplate = (legacy: unknown): Authentication => { | ||
| 162 | const migrated = migrateOpenApiAuthConfig({ | ||
| 163 | ...BASE, | ||
| 164 | authenticationTemplate: [legacy], | ||
| 165 | }) as { authenticationTemplate: readonly Authentication[] }; | ||
| 166 | return migrated.authenticationTemplate[0]!; | ||
| 167 | }; | ||
| 168 | |||
| 169 | it("bearer header (prefix + token)", () => { | ||
| 170 | const template = migrateTemplate({ | ||
| 171 | slug: "bearer", | ||
| 172 | type: "apiKey", | ||
| 173 | headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, | ||
| 174 | }); | ||
| 175 | expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({ | ||
| 176 | headers: { Authorization: "Bearer tok_1" }, | ||
| 177 | queryParams: {}, | ||
| 178 | }); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("two distinct inputs across headers and query (Datadog-style)", () => { | ||
| 182 | const template = migrateTemplate({ | ||
| 183 | slug: "datadog", | ||
| 184 | type: "apiKey", | ||
| 185 | headers: { | ||
| 186 | "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }], | ||
| 187 | "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }], | ||
| 188 | }, | ||
| 189 | queryParams: { team: [{ type: "variable", name: "team" }] }, | ||
| 190 | }); | ||
| 191 | expect( | ||
| 192 | renderAuthTemplate(template, { dd_api_key: "a", dd_application_key: "b", team: "t" }), | ||
| 193 | ).toEqual({ | ||
| 194 | headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" }, | ||
| 195 | queryParams: { team: "t" }, | ||
| 196 | }); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it("literal-only value renders verbatim with no credential", () => { | ||
| 200 | const template = migrateTemplate({ | ||
| 201 | slug: "versioned", | ||
| 202 | type: "apiKey", | ||
| 203 | headers: { | ||
| 204 | "X-Api-Version": "2023-01-01", | ||
| 205 | Authorization: ["Bearer ", { type: "variable", name: "token" }], | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({ | ||
| 209 | headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" }, | ||
| 210 | queryParams: {}, | ||
| 211 | }); | ||
| 212 | }); | ||
| 213 | |||
| 214 | it("bare query token (no prefix)", () => { | ||
| 215 | const template = migrateTemplate({ | ||
| 216 | slug: "key", | ||
| 217 | type: "apiKey", | ||
| 218 | queryParams: { api_key: [{ type: "variable", name: "token" }] }, | ||
| 219 | }); | ||
| 220 | expect(renderAuthTemplate(template, { token: "k1" })).toEqual({ | ||
| 221 | headers: {}, | ||
| 222 | queryParams: { api_key: "k1" }, | ||
| 223 | }); | ||
| 224 | }); | ||
| 225 | }); | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // One-off config migration — rewrite pre-canonical OpenAPI integration | ||
| 3 | // `config` blobs into the canonical placements model. Runs once per database | ||
| 4 | // (boot for local/selfhost, out-of-band script for cloud); runtime code | ||
| 5 | // decodes ONLY the canonical shape (`decodeOpenApiIntegrationConfig`). | ||
| 6 | // | ||
| 7 | // The retired shape is the `variable()`-templated apiKey: | ||
| 8 | // { slug, type: "apiKey", headers/queryParams: Record<name, | ||
| 9 | // string | (string | {type:"variable", name})[]> } | ||
| 10 | // Each entry becomes one placement: prefix = literals before the first | ||
| 11 | // variable, the variable name preserved VERBATIM (it keys the connections' | ||
| 12 | // stored `item_ids`; the canonical `token` is stored as absent), a | ||
| 13 | // literal-only value becomes a `literal` placement. OAuth templates | ||
| 14 | // re-key from the retired `type: "oauth"` spelling to `kind: "oauth2"`. | ||
| 15 | // --------------------------------------------------------------------------- | ||
| 16 | |||
| 17 | import { Option } from "effect"; | ||
| 18 | import { | ||
| 19 | apiKeyMethodFromLegacyTemplate, | ||
| 20 | decodeLegacyApiKeyTemplate, | ||
| 21 | } from "@executor-js/sdk/http-auth"; | ||
| 22 | |||
| 23 | import { decodeOpenApiIntegrationConfig } from "./config"; | ||
| 24 | |||
| 25 | const isCanonicalEntry = (entry: unknown): boolean => | ||
| 26 | typeof entry === "object" && | ||
| 27 | entry !== null && | ||
| 28 | "kind" in entry && | ||
| 29 | ((entry as { kind: unknown }).kind === "apikey" || | ||
| 30 | (entry as { kind: unknown }).kind === "oauth2"); | ||
| 31 | |||
| 32 | /** The retired oauth spelling: `type: "oauth"` instead of `kind: "oauth2"`. */ | ||
| 33 | const isLegacyOAuthEntry = ( | ||
| 34 | entry: unknown, | ||
| 35 | ): entry is { readonly type: "oauth" } & Record<string, unknown> => | ||
| 36 | typeof entry === "object" && | ||
| 37 | entry !== null && | ||
| 38 | "type" in entry && | ||
| 39 | (entry as { type: unknown }).type === "oauth"; | ||
| 40 | |||
| 41 | const migrateEntry = (entry: unknown): unknown | null => { | ||
| 42 | if (isCanonicalEntry(entry)) return entry; | ||
| 43 | if (isLegacyOAuthEntry(entry)) { | ||
| 44 | const { type: _type, ...rest } = entry; | ||
| 45 | return { ...rest, kind: "oauth2" }; | ||
| 46 | } | ||
| 47 | const legacy = Option.getOrNull(decodeLegacyApiKeyTemplate(entry)); | ||
| 48 | if (legacy !== null) return apiKeyMethodFromLegacyTemplate(legacy); | ||
| 49 | return null; | ||
| 50 | }; | ||
| 51 | |||
| 52 | /** Rewrite a stored OpenAPI integration config blob into the canonical shape. | ||
| 53 | * Returns the rewritten config, or `null` when no rewrite is needed (already | ||
| 54 | * canonical, no auth templates, or not this plugin's shape). Idempotent. */ | ||
| 55 | export const migrateOpenApiAuthConfig = (config: unknown): unknown | null => { | ||
| 56 | if (typeof config !== "object" || config === null) return null; | ||
| 57 | if (!("spec" in config)) return null; | ||
| 58 | if (!("authenticationTemplate" in config)) return null; | ||
| 59 | |||
| 60 | const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate; | ||
| 61 | if (!Array.isArray(entries)) return null; | ||
| 62 | |||
| 63 | const migrated = entries.map(migrateEntry); | ||
| 64 | if (migrated.some((entry) => entry === null)) return null; | ||
| 65 | const changed = migrated.some((entry, index) => entry !== entries[index]); | ||
| 66 | if (!changed) return null; | ||
| 67 | |||
| 68 | const next = { ...config, authenticationTemplate: migrated }; | ||
| 69 | return decodeOpenApiIntegrationConfig(next) !== null ? next : null; | ||
| 70 | }; | ||
| @@ -36,7 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | } from "@executor-js/plugin-openapi/testing"; | 36 | } from "@executor-js/plugin-openapi/testing"; |
| 37 | 37 | ||
| 38 | import { openApiPlugin } from "./plugin"; | 38 | import { openApiPlugin } from "./plugin"; |
| 39 | import { variable, type Authentication } from "./types"; | 39 | import { type AuthenticationInput } from "./types"; |
| 40 | 40 | ||
| 41 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 42 | // Test API — a single endpoint that echoes the Authorization header so the | 42 | // 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 …`. |
| 71 | const bearerTemplate: Authentication = { | 71 | const 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 | ||
| 77 | const testPlugins = () => | 77 | const testPlugins = () => |
| @@ -32,7 +32,7 @@ import { · 31 unmodified lines | |||
| 32 | } from "@executor-js/sdk/testing"; | 32 | } from "@executor-js/sdk/testing"; |
| 33 | 33 | ||
| 34 | import { openApiPlugin } from "./plugin"; | 34 | import { openApiPlugin } from "./plugin"; |
| 35 | import { variable, type Authentication } from "./types"; | 35 | import { type AuthenticationInput } from "./types"; |
| 36 | import { | 36 | import { |
| 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`. |
| 157 | const apiKeyTemplate: Authentication = { | 157 | const 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. |
| 164 | const oauthTemplate: Authentication = { | 164 | const 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"], |
| @@ -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 | |||
| 44 | import { previewSpec, type SpecPreview } from "./preview"; | 43 | import { previewSpec, type SpecPreview } from "./preview"; |
| 45 | import { openApiPresets } from "./presets"; | 44 | import { openApiPresets } from "./presets"; |
| 46 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; | 45 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; |
| 47 | import type { Authentication, AuthenticationTemplateValue } from "./types"; | 46 | import type { Authentication } from "./types"; |
| 48 | import { OperationBinding, TOKEN_VARIABLE } from "./types"; | 47 | import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; |
| 48 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | ||
| 49 | 49 | ||
| 50 | // --------------------------------------------------------------------------- | 50 | // --------------------------------------------------------------------------- |
| 51 | // Plugin config | 51 | // 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 | ||
| 161 | export interface OpenApiExtensionFailure { | 162 | export interface OpenApiExtensionFailure { |
| @@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines | |||
| 169 | * already exists, replaces) the integration's existing template array. A | 170 | * 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 | ||
| 298 | const AuthenticationVariableSchema = Schema.Struct({ | ||
| 299 | type: Schema.Literal("variable"), | ||
| 300 | name: Schema.String, | ||
| 301 | }); | ||
| 302 | const AuthenticationTemplateValueSchema = Schema.Union([ | ||
| 303 | Schema.String, | ||
| 304 | Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])), | ||
| 305 | ]); | ||
| 306 | const AuthenticationSchema = Schema.Union([ | 299 | const 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 | ||
| 322 | const AddSourceInputSchema = Schema.Struct({ | 313 | const 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 the | 516 | // Declared auth methods — project the stored `authenticationTemplate` into the |
| 526 | // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's | 517 | // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's |
| 527 | // `authMethodsFromConfig` (in the React auth-method-config module) on the | 518 | // `authMethodsFromConfig` (in the React auth-method-config module) on the |
| 528 | // server so the catalog field is consistent. OpenAPI also renders its own | 519 | // 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). */ | ||
| 540 | const parseTemplateValue = ( | ||
| 541 | value: AuthenticationTemplateValue, | ||
| 542 | ): { readonly prefix: string; readonly variable: string } => { | ||
| 543 | if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE }; | ||
| 544 | const parts: string[] = []; | ||
| 545 | for (const part of value) { | ||
| 546 | if (typeof part !== "string" && part.type === "variable") { | ||
| 547 | return { prefix: parts.join(""), variable: part.name }; | ||
| 548 | } | ||
| 549 | if (typeof part === "string") parts.push(part); | ||
| 550 | } | ||
| 551 | return { prefix: parts.join(""), variable: TOKEN_VARIABLE }; | ||
| 552 | }; | ||
| 553 | |||
| 554 | const placementsFromAuthentication = ( | ||
| 555 | template: Extract<Authentication, { readonly type: "apiKey" }>, | ||
| 556 | ): readonly AuthPlacementDescriptor[] => { | ||
| 557 | const placements: AuthPlacementDescriptor[] = []; | ||
| 558 | for (const [name, value] of Object.entries(template.headers ?? {})) { | ||
| 559 | const { prefix, variable } = parseTemplateValue(value); | ||
| 560 | placements.push({ carrier: "header", name, prefix, variable }); | ||
| 561 | } | ||
| 562 | for (const [name, value] of Object.entries(template.queryParams ?? {})) { | ||
| 563 | const { prefix, variable } = parseTemplateValue(value); | ||
| 564 | placements.push({ carrier: "query", name, prefix, variable }); | ||
| 565 | } | ||
| 566 | return placements; | ||
| 567 | }; | ||
| 568 | |||
| 569 | const apiKeyLabel = (slug: string, placements: readonly AuthPlacementDescriptor[]): string => { | ||
| 570 | const first = placements[0]; | ||
| 571 | if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`; | ||
| 572 | return `API key (${slug})`; | ||
| 573 | }; | ||
| 574 | |||
| 575 | export const describeOpenApiAuthMethods = ( | 523 | export 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 add | 738 | // 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.authenticationTemplate | 740 | ...(config.authenticationTemplate |
| 801 | ? { authenticationTemplate: config.authenticationTemplate } | 741 | ? { |
| 742 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), | ||
| 743 | } | ||
| 802 | : resolved.authenticationTemplate | 744 | : 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.authenticationTemplate | 839 | ? 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 as | 901 | 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); |
| @@ -1,43 +1,58 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import type { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 2 | import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared"; |
| 3 | import type { OAuthAuthentication } from "@executor-js/sdk/shared"; | 3 | import { |
| 4 | apiKeyMethodFromAuthTemplate, | ||
| 5 | isApiKeyAuthTemplate, | ||
| 6 | type ApiKeyAuthMethod, | ||
| 7 | type ApiKeyAuthTemplate, | ||
| 8 | } from "@executor-js/sdk/http-auth"; | ||
| 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's | 13 | // The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`, |
| 9 | // credential goes on the outbound request (headers / query params) via the | 14 | // the same shape the graphql/mcp plugins store): N header/query placements, |
| 10 | // `variable()` templating below. That placement is why it lives with the | 15 | // each rendered from its own credential input. The oauth template is |
| 11 | // openapi plugin rather than in core. The oauth template is mechanism-intrinsic | 16 | // 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 core | 18 | // `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 | ||
| 17 | export type AuthenticationVariable = { | 26 | export { 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. */ | ||
| 23 | export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[]; | ||
| 24 | 27 | ||
| 25 | export const variable = (name: string): AuthenticationVariable => ({ | 28 | export 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"`. */ |
| 31 | export const TOKEN_VARIABLE = "token" as const; | 31 | export type Authentication = OAuthAuthentication | APIKeyAuthentication; |
| 32 | 32 | ||
| 33 | export 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. */ |
| 38 | export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & { | ||
| 39 | readonly slug: string; | ||
| 38 | }; | 40 | }; |
| 39 | 41 | export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate; | |
| 40 | export 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>`. */ | ||
| 46 | export const normalizeOpenApiAuthInputs = ( | ||
| 47 | inputs: readonly AuthenticationInput[], | ||
| 48 | ): readonly Authentication[] => | ||
| 49 | inputs.map((input): Authentication => { | ||
| 50 | if (!isApiKeyAuthTemplate(input)) { | ||
| 51 | return { ...input, slug: AuthTemplateSlug.make(input.slug) }; | ||
| 52 | } | ||
| 53 | const method = apiKeyMethodFromAuthTemplate(input); | ||
| 54 | return { ...method, slug: method.slug ?? "" }; | ||
| 55 | }); | ||
| 41 | 56 | ||
| 42 | // --------------------------------------------------------------------------- | 57 | // --------------------------------------------------------------------------- |
| 43 | // Branded IDs | 58 | // Branded IDs |
| @@ -31,6 +31,10 @@ export interface Placement { · 30 unmodified lines | |||
| 31 | * each get a distinct variable. Two placements sharing a variable share one | 31 | * 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 from | 46 | * `source` distinguishes integration-declared ("spec") methods from |
| 43 | * user-defined ("custom") ones. `template` is the auth-template slug the | 47 | * user-defined ("custom") ones. `template` is the auth-template slug the |
| 44 | * method applies a connection through. */ | 48 | * method applies a connection through. */ |
| 45 | export type AuthMethodKind = "oauth" | "apikey" | "custom" | "none"; | 49 | export type AuthMethodKind = "oauth" | "apikey" | "none"; |
| 46 | 50 | ||
| 47 | /** Provider OAuth endpoints/scopes an `oauth` method declares, used to pre-fill | 51 | /** 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; |
| @@ -0,0 +1,224 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Shared auth-method codec — the one bridge between the canonical | ||
| 3 | // placements-based wire model (`@executor-js/sdk/http-auth`: apikey/none methods | ||
| 4 | // every protocol plugin stores) and the client's presentational shapes | ||
| 5 | // (`AuthMethod`, `Placement`, `AuthTemplateEditorValue`). | ||
| 6 | // | ||
| 7 | // OAuth methods are the only per-plugin wire variant (openapi stores | ||
| 8 | // endpoints+scopes, graphql a header override, mcp discovers at connect), so | ||
| 9 | // each plugin contributes a small OAUTH ADAPTER and composes everything else | ||
| 10 | // from here. This replaces the three per-plugin codec modules that each | ||
| 11 | // reimplemented (and disagreed on) the apikey path — MCP silently dropped all | ||
| 12 | // but the first placement, GraphQL split one method into N single-placement | ||
| 13 | // templates. | ||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | |||
| 16 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 17 | import { | ||
| 18 | TOKEN_VARIABLE, | ||
| 19 | apiKeyAuthTemplateFromMethod, | ||
| 20 | apiKeyMethodLabel, | ||
| 21 | type ApiKeyAuthMethod, | ||
| 22 | type ApiKeyAuthTemplate, | ||
| 23 | type AuthPlacement, | ||
| 24 | type NoneAuthMethod, | ||
| 25 | } from "@executor-js/sdk/http-auth"; | ||
| 26 | |||
| 27 | import type { AuthTemplateEditorValue } from "../components/auth-template-editor"; | ||
| 28 | import type { AuthMethod, Placement } from "./auth-placements"; | ||
| 29 | import type { AuthMethodsCodec } from "./custom-auth-methods"; | ||
| 30 | |||
| 31 | export type SharedAuthMethod = NoneAuthMethod | ApiKeyAuthMethod; | ||
| 32 | |||
| 33 | /** A method input the backend slugs itself (custom-method create flow). */ | ||
| 34 | export type SharedAuthMethodInput = | ||
| 35 | | { readonly slug?: string; readonly kind: "none" } | ||
| 36 | | { | ||
| 37 | readonly slug?: string; | ||
| 38 | readonly kind: "apikey"; | ||
| 39 | readonly label?: string; | ||
| 40 | readonly placements: readonly AuthPlacement[]; | ||
| 41 | }; | ||
| 42 | |||
| 43 | export const isSharedAuthMethod = (template: { | ||
| 44 | readonly kind: string; | ||
| 45 | }): template is SharedAuthMethod => template.kind === "apikey" || template.kind === "none"; | ||
| 46 | |||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | // Wire placements ⇆ editor placements. | ||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | |||
| 51 | /** Wire → editor. The variable is made explicit (`token` when absent) so a | ||
| 52 | * round-trip preserves which placements share a credential input. */ | ||
| 53 | export const editorPlacementsFromWire = ( | ||
| 54 | placements: readonly AuthPlacement[], | ||
| 55 | ): readonly Placement[] => | ||
| 56 | placements.map( | ||
| 57 | (placement: AuthPlacement): Placement => ({ | ||
| 58 | carrier: placement.carrier, | ||
| 59 | name: placement.name, | ||
| 60 | prefix: placement.prefix ?? "", | ||
| 61 | ...(placement.literal !== undefined | ||
| 62 | ? { literal: placement.literal } | ||
| 63 | : { variable: placement.variable ?? TOKEN_VARIABLE }), | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | /** Slugify a placement name into a variable identifier: `DD-API-KEY` → | ||
| 68 | * `dd_api_key`. */ | ||
| 69 | const slugifyVariable = (name: string): string => | ||
| 70 | name | ||
| 71 | .toLowerCase() | ||
| 72 | .replace(/[^a-z0-9]+/g, "_") | ||
| 73 | .replace(/^_+|_+$/g, ""); | ||
| 74 | |||
| 75 | /** Assign an input variable to each named credential placement. A lone input | ||
| 76 | * is the canonical `token`; multiple inputs each get their own distinct | ||
| 77 | * variable so a connection carries a different value per location. An | ||
| 78 | * explicit `placement.variable` (from a round-trip) is honored — that is how | ||
| 79 | * two placements keep SHARING one input across an edit. */ | ||
| 80 | const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => { | ||
| 81 | const named = placements.filter((p: Placement) => p.name.trim() && p.literal === undefined); | ||
| 82 | const out = new Map<Placement, string>(); | ||
| 83 | if (named.length <= 1) { | ||
| 84 | for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE); | ||
| 85 | return out; | ||
| 86 | } | ||
| 87 | // Explicit variables are kept VERBATIM — and may repeat: that is how two | ||
| 88 | // placements share one credential input. Only derived names dedupe. | ||
| 89 | const taken = new Set<string>(); | ||
| 90 | for (const p of named) { | ||
| 91 | if (p.variable) { | ||
| 92 | out.set(p, p.variable); | ||
| 93 | taken.add(p.variable); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | for (const p of named) { | ||
| 97 | if (p.variable) continue; | ||
| 98 | const base = slugifyVariable(p.name) || "input"; | ||
| 99 | let candidate = base; | ||
| 100 | let n = 2; | ||
| 101 | while (taken.has(candidate)) candidate = `${base}_${n++}`; | ||
| 102 | taken.add(candidate); | ||
| 103 | out.set(p, candidate); | ||
| 104 | } | ||
| 105 | return out; | ||
| 106 | }; | ||
| 107 | |||
| 108 | /** Editor → wire. Unnamed placements are dropped; the canonical `token` | ||
| 109 | * variable is stored as absent (the wire convention single-input connections | ||
| 110 | * depend on — their stored values are keyed `token`). */ | ||
| 111 | export const wirePlacementsFromEditor = ( | ||
| 112 | placements: readonly Placement[], | ||
| 113 | ): readonly AuthPlacement[] => { | ||
| 114 | const variables = assignVariables(placements); | ||
| 115 | return placements | ||
| 116 | .filter((p: Placement) => p.name.trim().length > 0 || p.literal !== undefined) | ||
| 117 | .map((p: Placement): AuthPlacement => { | ||
| 118 | const variable = variables.get(p); | ||
| 119 | return { | ||
| 120 | carrier: p.carrier, | ||
| 121 | name: p.name.trim(), | ||
| 122 | ...(p.prefix ? { prefix: p.prefix } : {}), | ||
| 123 | ...(p.literal !== undefined | ||
| 124 | ? { literal: p.literal } | ||
| 125 | : variable && variable !== TOKEN_VARIABLE | ||
| 126 | ? { variable } | ||
| 127 | : {}), | ||
| 128 | }; | ||
| 129 | }); | ||
| 130 | }; | ||
| 131 | |||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Method ⇆ editor value / presentational method. | ||
| 134 | // --------------------------------------------------------------------------- | ||
| 135 | |||
| 136 | /** Convert one stored apikey/none method into the generic editor value. */ | ||
| 137 | export const editorValueFromSharedMethod = (method: SharedAuthMethod): AuthTemplateEditorValue => | ||
| 138 | method.kind === "apikey" | ||
| 139 | ? { kind: "apikey", placements: editorPlacementsFromWire(method.placements) } | ||
| 140 | : { kind: "none" }; | ||
| 141 | |||
| 142 | /** Convert an apikey/none editor value back into a method input (no slug — | ||
| 143 | * the backend assigns one). Returns null for oauth values: those convert | ||
| 144 | * through the plugin's oauth adapter. An apikey value with no usable | ||
| 145 | * placements degrades to `none`. */ | ||
| 146 | export const sharedMethodInputFromEditorValue = ( | ||
| 147 | value: AuthTemplateEditorValue, | ||
| 148 | ): SharedAuthMethodInput | null => { | ||
| 149 | if (value.kind === "oauth") return null; | ||
| 150 | if (value.kind === "none") return { kind: "none" }; | ||
| 151 | const placements = wirePlacementsFromEditor(value.placements); | ||
| 152 | return placements.length > 0 ? { kind: "apikey", placements } : { kind: "none" }; | ||
| 153 | }; | ||
| 154 | |||
| 155 | /** Serialize an apikey method (stored or slug-optional) into the wire input | ||
| 156 | * dialect — auth INPUTS accept only the request-shaped template; stored | ||
| 157 | * configs and the catalog read as placements. Non-apikey arms pass through | ||
| 158 | * untouched. */ | ||
| 159 | export const wireAuthInputFromShared = <T extends { readonly kind: string }>( | ||
| 160 | method: | ||
| 161 | | T | ||
| 162 | | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string; readonly kind: "apikey" }), | ||
| 163 | ): T | ApiKeyAuthTemplate => | ||
| 164 | method.kind === "apikey" | ||
| 165 | ? apiKeyAuthTemplateFromMethod( | ||
| 166 | method as Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string }, | ||
| 167 | ) | ||
| 168 | : (method as T); | ||
| 169 | |||
| 170 | const sourceOf = (slug: string): "spec" | "custom" => | ||
| 171 | slug.startsWith("custom_") ? "custom" : "spec"; | ||
| 172 | |||
| 173 | /** Project one stored apikey/none method into the presentational `AuthMethod` | ||
| 174 | * the hub renders (mirrors the server's catalog projection). */ | ||
| 175 | export const authMethodFromSharedTemplate = (method: SharedAuthMethod): AuthMethod => { | ||
| 176 | if (method.kind === "apikey") { | ||
| 177 | return { | ||
| 178 | id: method.slug, | ||
| 179 | label: apiKeyMethodLabel(method), | ||
| 180 | kind: "apikey", | ||
| 181 | source: sourceOf(method.slug), | ||
| 182 | template: AuthTemplateSlug.make(method.slug), | ||
| 183 | placements: editorPlacementsFromWire(method.placements), | ||
| 184 | }; | ||
| 185 | } | ||
| 186 | return { | ||
| 187 | id: method.slug, | ||
| 188 | label: "No authentication", | ||
| 189 | kind: "none", | ||
| 190 | source: sourceOf(method.slug), | ||
| 191 | template: AuthTemplateSlug.make(method.slug), | ||
| 192 | placements: [], | ||
| 193 | }; | ||
| 194 | }; | ||
| 195 | |||
| 196 | // --------------------------------------------------------------------------- | ||
| 197 | // The codec factory for `useCustomMethodActions` — apikey/none handled here, | ||
| 198 | // oauth delegated to the plugin's adapter. | ||
| 199 | // --------------------------------------------------------------------------- | ||
| 200 | |||
| 201 | export interface SharedCodecOAuthAdapter<TOAuth extends { readonly slug: string }> { | ||
| 202 | readonly isOAuth: (template: SharedAuthMethod | TOAuth) => template is TOAuth; | ||
| 203 | readonly toAuthMethod: (template: TOAuth) => AuthMethod; | ||
| 204 | } | ||
| 205 | |||
| 206 | export function makeSharedAuthMethodCodec<TOAuth extends { readonly slug: string }>( | ||
| 207 | adapter: SharedCodecOAuthAdapter<TOAuth>, | ||
| 208 | ): AuthMethodsCodec<SharedAuthMethod | TOAuth> { | ||
| 209 | return { | ||
| 210 | toAuthMethods: (templates) => | ||
| 211 | templates.map((template) => | ||
| 212 | adapter.isOAuth(template) | ||
| 213 | ? adapter.toAuthMethod(template) | ||
| 214 | : authMethodFromSharedTemplate(template), | ||
| 215 | ), | ||
| 216 | templatesFromPlacements: (placements) => { | ||
| 217 | const wire = wirePlacementsFromEditor(placements); | ||
| 218 | if (wire.length === 0) return []; | ||
| 219 | // Slug-less: the backend's merge assigns `custom_<id>`. | ||
| 220 | return [{ slug: "", kind: "apikey", placements: wire } as ApiKeyAuthMethod]; | ||
| 221 | }, | ||
| 222 | slugOf: (template) => template.slug, | ||
| 223 | }; | ||
| 224 | } | ||