* 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 | 47 | ## Package Roles |
| 48 | 48 | |
| 49 | 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 | 54 | - `packages/core/storage-*`: storage adapters and storage test support. |
| 52 | 55 | - `packages/plugins/*`: protocol and provider plugins. Plugin-specific |
| 53 | 56 | runtime, React, API, and testing helpers should live with the owning plugin. |
| @@ -13,6 +13,8 @@ · 12 unmodified lines | ||
| 13 | 13 | "db:studio": "drizzle-kit studio", |
| 14 | 14 | "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio", |
| 15 | 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 | 18 | "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate", |
| 17 | 19 | "build": "node scripts/build.mjs", |
| 18 | 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 | 5 | import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; |
| 6 | 6 | |
| 7 | +import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; | |
| 8 | + | |
| 7 | 9 | import { resolveAuthProviders } from "./auth"; |
| 10 | +import { authConfigTransforms } from "./db/auth-config-migration"; | |
| 8 | 11 | import { makeSelfHostAdminApiLayer } from "./admin/handlers"; |
| 9 | 12 | import { makeSelfHostSystemApiLayer } from "./system/handlers"; |
| 10 | 13 | import { selfHostAccountMiddleware } from "./account"; |
| @@ -54,6 +57,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 43 unmodified lines | ||
| 54 | 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 | 65 | // ---- auth providers --------------------------------------------------- |
| 58 | 66 | // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account |
| 59 | 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 | 5 | import { createHash } from "node:crypto"; |
| 6 | 6 | |
| 7 | 7 | import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk"; |
| 8 | +import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; | |
| 8 | 9 | import { collectTables } from "@executor-js/api/server"; |
| 9 | 10 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 10 | 11 | |
| 11 | 12 | import executorConfig from "../executor.config"; |
| 13 | +import { authConfigTransforms } from "./db/auth-config-migration"; | |
| 12 | 14 | import { createSqliteFumaDb } from "./db/sqlite-fumadb"; |
| 13 | 15 | import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; |
| 14 | 16 | |
| @@ -167,6 +169,15 @@ const createLocalExecutorLayer = () => { · 152 unmodified lines | ||
| 167 | 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 | 181 | // webBaseUrl is where the executor's web UI listens — same port as the |
| 171 | 182 | // daemon API since the daemon serves both. Mirrors serve.ts's port |
| 172 | 183 | // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL |
| @@ -846,57 +846,6 @@ · 845 unmodified lines | ||
| 846 | 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 | 849 | "packages/plugins/keychain": { |
| 901 | 850 | "name": "@executor-js/plugin-keychain", |
| 902 | 851 | "version": "1.5.3", |
| @@ -1598,10 +1547,6 @@ · 695 unmodified lines | ||
| 1598 | 1547 | |
| 1599 | 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 | 1550 | "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], |
| 1606 | 1551 | |
| 1607 | 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 | 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 | 8 | // connection's credential is placed on each request — here, an `X-API-Key` |
| 9 | 9 | // header. `variable("token")` is the slot the resolved credential renders into. |
| 10 | 10 | await executor.openapi.addSpec({ |
| @@ -1,26 +1,33 @@ | ||
| 1 | 1 | // Cross-target: multi-method authentication — an integration declares SEVERAL |
| 2 | −// auth methods and a connection picks one by template slug. Covers the model | |
| 3 | −// rework: MCP's slugged `authenticationTemplate` array, the merge-append | |
| 4 | −// configureAuth flow (a custom API key must never displace a detected OAuth | |
| 5 | −// method), declaring a method on a server that advertises none, and GraphQL's | |
| 6 | −// multi-method add. Entirely through the typed clients — no MCP server is | |
| 7 | −// dialed (registration and method configuration are catalog statements). | |
| 2 | +// auth methods and a connection picks one by template slug. Covers the unified | |
| 3 | +// placements model: one apikey method can mix header and query placements, | |
| 4 | +// each rendered from its own credential input; the merge-append configureAuth | |
| 5 | +// flow (a custom API key must never displace a detected OAuth method); | |
| 6 | +// declaring a method on a server that advertises none; GraphQL's multi-method | |
| 7 | +// add — and the EXTREME case end-to-end: a source declaring | |
| 8 | +// [oauth] [bearer header + team-id query] [bearer header] [query token], | |
| 9 | +// one connection per credential method, every invocation asserted against | |
| 10 | +// what a real recording MCP server received. | |
| 8 | 11 | import { randomBytes } from "node:crypto"; |
| 9 | 12 | |
| 10 | 13 | import { expect } from "@effect/vitest"; |
| 11 | 14 | import { Effect } from "effect"; |
| 12 | 15 | import { composePluginApi } from "@executor-js/api/server"; |
| 13 | 16 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 17 | +import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 14 | 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 | 23 | import { scenario } from "../src/scenario"; |
| 18 | 24 | |
| 19 | 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 | 31 | const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp"; |
| 25 | 32 | |
| 26 | 33 | scenario( |
| @@ -30,7 +37,7 @@ scenario( · 3 unmodified lines | ||
| 30 | 37 | Effect.gen(function* () { |
| 31 | 38 | const identity = yield* ctx.target.newIdentity(); |
| 32 | 39 | const client = yield* ctx.api.client(api, identity); |
| 33 | − const slug = freshSlug("mcp-multiauth"); | |
| 40 | + const slug = freshSlug("mcp_multiauth"); | |
| 34 | 41 | |
| 35 | 42 | yield* client.mcp.addServer({ |
| 36 | 43 | payload: { |
| @@ -40,7 +47,7 @@ scenario( · 3 unmodified lines | ||
| 40 | 47 | slug, |
| 41 | 48 | authenticationTemplate: [ |
| 42 | 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 | 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 | 62 | // connect flow can offer either and a connection binds one by slug. |
| 55 | 63 | expect( |
| 56 | 64 | integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })), |
| @@ -81,7 +89,7 @@ scenario( · 24 unmodified lines | ||
| 81 | 89 | Effect.gen(function* () { |
| 82 | 90 | const identity = yield* ctx.target.newIdentity(); |
| 83 | 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 | 94 | // The add flow registered what the probe detected: OAuth only. |
| 87 | 95 | yield* client.mcp.addServer({ |
| @@ -99,13 +107,15 @@ scenario( · 11 unmodified lines | ||
| 99 | 107 | const configured = yield* client.mcp.configureAuth({ |
| 100 | 108 | params: { slug: IntegrationSlug.make(slug) }, |
| 101 | 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 | 115 | expect( |
| 106 | 116 | configured.authenticationTemplate.map((m) => m.kind), |
| 107 | 117 | "the declared set now holds both methods", |
| 108 | − ).toEqual(["oauth2", "header"]); | |
| 118 | + ).toEqual(["oauth2", "apikey"]); | |
| 109 | 119 | expect( |
| 110 | 120 | configured.authenticationTemplate[1]?.slug, |
| 111 | 121 | "the custom method gets its own custom_ slug", |
| @@ -135,7 +145,7 @@ scenario( · 23 unmodified lines | ||
| 135 | 145 | Effect.gen(function* () { |
| 136 | 146 | const identity = yield* ctx.target.newIdentity(); |
| 137 | 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 | 150 | // No declared auth — the server advertises nothing. |
| 141 | 151 | yield* client.mcp.addServer({ |
| @@ -155,7 +165,7 @@ scenario( · 13 unmodified lines | ||
| 155 | 165 | params: { slug: IntegrationSlug.make(slug) }, |
| 156 | 166 | payload: { |
| 157 | 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 | 194 | Effect.gen(function* () { |
| 185 | 195 | const identity = yield* ctx.target.newIdentity(); |
| 186 | 196 | const client = yield* ctx.api.client(api, identity); |
| 187 | − const slug = freshSlug("graphql-multiauth"); | |
| 197 | + const slug = freshSlug("graphql_multiauth"); | |
| 188 | 198 | |
| 189 | 199 | yield* client.graphql.addIntegration({ |
| 190 | 200 | payload: { |
| @@ -192,8 +202,8 @@ scenario( · 1 unmodified line | ||
| 192 | 202 | slug, |
| 193 | 203 | name: "Multi-auth GraphQL", |
| 194 | 204 | authenticationTemplate: [ |
| 195 | − { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" }, | |
| 196 | − { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" }, | |
| 205 | + { slug: "apiKey", type: "apiKey", headers: { "X-Api-Key": [variable("token")] } }, | |
| 206 | + { slug: "apikey-2", type: "apiKey", queryParams: { api_key: [variable("token")] } }, | |
| 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 | 46 | slug, |
| 47 | 47 | authenticationTemplate: [ |
| 48 | 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 | 63 | authenticationTemplate: [ |
| 64 | 64 | { |
| 65 | 65 | slug: "oauth", |
| 66 | − type: "oauth", | |
| 66 | + kind: "oauth2", | |
| 67 | 67 | authorizationUrl: "https://auth.example/authorize", |
| 68 | 68 | tokenUrl: "https://auth.example/token", |
| 69 | 69 | scopes: [], |
| @@ -235,7 +235,7 @@ const program = Effect.gen(function* () { · 234 unmodified lines | ||
| 235 | 235 | baseUrl: "https://example.com/api", |
| 236 | 236 | authenticationTemplate: [ |
| 237 | 237 | { |
| 238 | − slug: AuthTemplateSlug.make("apiKey"), | |
| 238 | + slug: "apiKey", | |
| 239 | 239 | type: "apiKey", |
| 240 | 240 | headers: { "X-API-Key": [variable("token")] }, |
| 241 | 241 | }, |
| @@ -99,7 +99,7 @@ const executor = await createExecutor({ · 98 unmodified lines | ||
| 99 | 99 | // docs:end create-executor |
| 100 | 100 | |
| 101 | 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 | 103 | // connection's credential is placed on each request — here, an `X-API-Key` |
| 104 | 104 | // header. `variable("token")` is the slot the resolved credential renders into. |
| 105 | 105 | await executor.openapi.addSpec({ |
| @@ -130,7 +130,7 @@ await executor.connections.create({ · 129 unmodified lines | ||
| 130 | 130 | // template: "bearer", |
| 131 | 131 | // value: "sk_live_...", |
| 132 | 132 | // }); |
| 133 | −void variable; | |
| 133 | +void variable; // referenced by the commented authoring example above | |
| 134 | 134 | |
| 135 | 135 | // --------------------------------------------------------------------------- |
| 136 | 136 | // 4. GraphQL — introspect an endpoint as an integration, then connect. |
| @@ -34,6 +34,12 @@ const PlacementDescriptor = Schema.Struct({ · 33 unmodified lines | ||
| 34 | 34 | carrier: Schema.Literals(["header", "query"]), |
| 35 | 35 | name: Schema.String, |
| 36 | 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 | 45 | /** OAuth specifics — mirrors the SDK's `AuthMethodOAuthDescriptor`. */ |
| @@ -23,7 +23,8 @@ · 22 unmodified lines | ||
| 23 | 23 | "./promise": "./src/promise.ts", |
| 24 | 24 | "./client": "./src/client.ts", |
| 25 | 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 | 29 | "publishConfig": { |
| 29 | 30 | "access": "public", |
| @@ -69,6 +70,12 @@ · 39 unmodified lines | ||
| 69 | 70 | "types": "./dist/migration-spec.d.ts", |
| 70 | 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 | 1547 | Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null), |
| 1548 | 1548 | ); |
| 1549 | 1549 | |
| 1550 | − const resolveConnectionValueByRef = ( | |
| 1551 | − ref: ConnectionRef, | |
| 1552 | − ): Effect.Effect<string | null, StorageFailure> => | |
| 1553 | − Effect.gen(function* () { | |
| 1554 | − const row = yield* findConnectionRow(ref); | |
| 1555 | − if (!row) return null; | |
| 1556 | − return yield* resolveConnectionValue(row); | |
| 1557 | − }).pipe( | |
| 1558 | − // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`) | |
| 1559 | − // is `StorageFailure`-typed; fold a reauth-required resolution failure | |
| 1560 | − // into a StorageError so the public surface stays stable. | |
| 1550 | + // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`, | |
| 1551 | + // `getValues`) is `StorageFailure`-typed; fold a reauth-required resolution | |
| 1552 | + // failure into a StorageError so the public surface stays stable. | |
| 1553 | + const foldResolutionFailure = <A>( | |
| 1554 | + effect: Effect.Effect<A, StorageFailure | CredentialResolutionError>, | |
| 1555 | + ): Effect.Effect<A, StorageFailure> => | |
| 1556 | + effect.pipe( | |
| 1561 | 1557 | Effect.catchTag("CredentialResolutionError", (err) => |
| 1562 | 1558 | Effect.fail( |
| 1563 | 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 | 1591 | // Integrations |
| 1574 | 1592 | // ------------------------------------------------------------------ |
| @@ -1810,6 +1828,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 235 unmodified lines | ||
| 1810 | 1828 | connection: ref, |
| 1811 | 1829 | template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null, |
| 1812 | 1830 | getValue: () => resolveConnectionValueByRef(ref), |
| 1831 | + getValues: () => resolveConnectionValuesByRef(ref), | |
| 1813 | 1832 | }) |
| 1814 | 1833 | .pipe( |
| 1815 | 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 | 35 | * methods; a distinct name per input for multi-input ones (e.g. Datadog). |
| 36 | 36 | * Absent → treated as `token`. */ |
| 37 | 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 | 44 | /** OAuth specifics for an `oauth` auth method. For probe-at-connect providers |
| @@ -25,10 +25,11 @@ import { · 24 unmodified lines | ||
| 25 | 25 | export type OAuthGrant = "authorization_code" | "client_credentials"; |
| 26 | 26 | |
| 27 | 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 | 30 | export interface OAuthAuthentication { |
| 30 | 31 | readonly slug: AuthTemplateSlug; |
| 31 | − readonly type: "oauth"; | |
| 32 | + readonly kind: "oauth2"; | |
| 32 | 33 | readonly authorizationUrl: string; |
| 33 | 34 | readonly tokenUrl: string; |
| 34 | 35 | readonly scopes: readonly string[]; |
| @@ -201,6 +201,10 @@ export interface ResolveToolsInput { · 200 unmodified lines | ||
| 201 | 201 | /** Lazily resolve the connection's credential value via its provider — only |
| 202 | 202 | * the kinds that actually call out (mcp) pay for it. */ |
| 203 | 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 | 210 | export interface ResolveToolsResult { |
| @@ -8,6 +8,7 @@ export default defineConfig({ · 7 unmodified lines | ||
| 8 | 8 | "host-internal": "src/host-internal.ts", |
| 9 | 9 | client: "src/client.ts", |
| 10 | 10 | "migration-spec": "src/migration-spec.ts", |
| 11 | + "http-auth": "src/http-auth/index.ts", | |
| 11 | 12 | testing: "src/testing.ts", |
| 12 | 13 | }, |
| 13 | 14 | format: ["esm"], |
| @@ -43,7 +43,7 @@ import { · 42 unmodified lines | ||
| 43 | 43 | type FumaTables, |
| 44 | 44 | } from "@executor-js/sdk"; |
| 45 | 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 | 48 | import { makeDynamicWorkerExecutor } from "./executor"; |
| 49 | 49 | |
| @@ -85,8 +85,8 @@ const memoryProvider = (): CredentialProvider => { · 35 unmodified lines | ||
| 85 | 85 | // Minimal apiKey template: the resolved connection value renders into an |
| 86 | 86 | // `x-api-key` header. The body round-trip tests don't assert on auth, but a |
| 87 | 87 | // template + connection are required for tools to exist per-connection. |
| 88 | −const apiKeyTemplate: Authentication = { | |
| 89 | − slug: AuthTemplateSlug.make("apiKey"), | |
| 88 | +const apiKeyTemplate: AuthenticationInput = { | |
| 89 | + slug: "apiKey", | |
| 90 | 90 | type: "apiKey", |
| 91 | 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 | 3 | import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; |
| 4 | 4 | |
| 5 | 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 | 9 | // Params |
| @@ -24,14 +24,15 @@ const AddIntegrationPayload = Schema.Struct({ · 14 unmodified lines | ||
| 24 | 24 | introspectionJson: Schema.optional(Schema.String), |
| 25 | 25 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 26 | 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 | 30 | // The `configure` payload — the custom auth methods to merge-append onto the |
| 31 | −// integration's `authenticationTemplate`. Reuses the same `AuthTemplate` schema | |
| 32 | −// as `addIntegration` so a custom apiKey method round-trips identically. | |
| 31 | +// integration's `authenticationTemplate`. Reuses the same input schema as | |
| 32 | +// `addIntegration` (slug optional — the backend backfills it) so a custom | |
| 33 | +// apikey method round-trips identically. | |
| 33 | 34 | const ConfigurePayload = Schema.Struct({ |
| 34 | − authenticationTemplate: Schema.Array(AuthTemplate), | |
| 35 | + authenticationTemplate: Schema.Array(GraphqlAuthMethodInput), | |
| 35 | 36 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), |
| 36 | 37 | }); |
| 37 | 38 | |
| @@ -52,13 +53,13 @@ const GraphqlConfigView = Schema.Struct({ · 14 unmodified lines | ||
| 52 | 53 | introspectionJson: Schema.optional(Schema.String), |
| 53 | 54 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 54 | 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 | 59 | // The configure result — the merged `authenticationTemplate` after the new |
| 59 | 60 | // custom methods were appended/replaced. |
| 60 | 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 | 17 | import { addGroup, observabilityMiddleware } from "@executor-js/api"; |
| 18 | 18 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; |
| 19 | 19 | |
| 20 | +import { expandGraphqlAuthMethodInputs } from "../sdk/types"; | |
| 20 | 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 | 27 | import { GraphqlExtensionService, GraphqlHandlers } from "./handlers"; |
| 23 | 28 | import { GraphqlGroup } from "./group"; |
| 24 | 29 | |
| @@ -31,22 +36,22 @@ const makeStubExtension = ( · 6 unmodified lines | ||
| 31 | 36 | ): GraphqlPluginExtension => { |
| 32 | 37 | let counter = 0; |
| 33 | 38 | const merge = ( |
| 34 | − existing: readonly AuthTemplate[], | |
| 35 | − incoming: readonly AuthTemplate[], | |
| 36 | − ): readonly AuthTemplate[] => { | |
| 37 | − const result: AuthTemplate[] = existing.map((entry: AuthTemplate) => entry); | |
| 38 | − const taken = new Set<string>(result.map((entry: AuthTemplate) => entry.slug)); | |
| 39 | + existing: readonly GraphqlAuthMethod[], | |
| 40 | + incoming: readonly GraphqlAuthMethodInput[], | |
| 41 | + ): readonly GraphqlAuthMethod[] => { | |
| 42 | + const result: GraphqlAuthMethod[] = existing.map((entry: GraphqlAuthMethod) => entry); | |
| 43 | + const taken = new Set<string>(result.map((entry: GraphqlAuthMethod) => entry.slug)); | |
| 39 | 44 | for (const entry of incoming) { |
| 40 | − const requested = entry.slug.trim(); | |
| 41 | − const index = result.findIndex((current: AuthTemplate) => current.slug === requested); | |
| 45 | + const requested = entry.slug?.trim() ?? ""; | |
| 46 | + const index = result.findIndex((current: GraphqlAuthMethod) => current.slug === requested); | |
| 42 | 47 | if (requested.length > 0 && index >= 0) { |
| 43 | − result[index] = entry; | |
| 48 | + result[index] = entry as GraphqlAuthMethod; | |
| 44 | 49 | continue; |
| 45 | 50 | } |
| 46 | 51 | const slug = |
| 47 | 52 | requested.length > 0 && !taken.has(requested) ? requested : `custom_${counter++}`; |
| 48 | 53 | taken.add(slug); |
| 49 | − result.push({ ...entry, slug } as AuthTemplate); | |
| 54 | + result.push({ ...entry, slug } as GraphqlAuthMethod); | |
| 50 | 55 | } |
| 51 | 56 | return result; |
| 52 | 57 | }; |
| @@ -59,12 +64,17 @@ const makeStubExtension = ( · 6 unmodified lines | ||
| 59 | 64 | getConfig: (slug: string) => Effect.sync(() => store.get(slug) ?? null), |
| 60 | 65 | configureAuth: ( |
| 61 | 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 | 70 | const current = store.get(slug); |
| 66 | 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 | 78 | store.set(slug, { ...current, authenticationTemplate: merged }); |
| 69 | 79 | return merged; |
| 70 | 80 | }), |
| @@ -102,7 +112,9 @@ const seededStore = (): Map<string, GraphqlIntegrationConfig> => { · 31 unmodified lines | ||
| 102 | 112 | store.set("gql", { |
| 103 | 113 | endpoint: "https://x.example/graphql", |
| 104 | 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 | 119 | return store; |
| 108 | 120 | }; |
| @@ -134,7 +146,13 @@ describe("GraphqlHandlers — config surface", () => { · 25 unmodified lines | ||
| 134 | 146 | }; |
| 135 | 147 | |
| 136 | 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 | 157 | expect(configureRes.status).toBe(200); |
| 140 | 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 | 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 | 188 | expect(res.status).toBe(200); |
| 165 | 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 | 195 | expect(body.authenticationTemplate).toHaveLength(1); |
| 169 | 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 | 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 | 217 | expect(res.status).toBe(200); |
| 185 | 218 | const body = (yield* Effect.promise(() => res.json())) as { |
| @@ -26,9 +26,8 @@ import { · 25 unmodified lines | ||
| 26 | 26 | |
| 27 | 27 | import { addGraphqlIntegrationOptimistic } from "./atoms"; |
| 28 | 28 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 29 | −import { graphqlTemplatesFromPlacements } from "./auth-method-config"; | |
| 30 | −import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | |
| 31 | −import type { AuthTemplate } from "../sdk/types"; | |
| 29 | +import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | |
| 30 | +import type { GraphqlAuthMethodInput } from "../sdk/types"; | |
| 32 | 31 | |
| 33 | 32 | // v2 GraphQL add flow: register the integration with its declared auth-method |
| 34 | 33 | // LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey; |
| @@ -57,25 +56,19 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines | ||
| 57 | 56 | mode: "promiseExit", |
| 58 | 57 | }); |
| 59 | 58 | |
| 60 | − // The templates to register: each apikey row emits one template per named | |
| 61 | − // placement (GraphQL templates carry one header/query slot each). The first | |
| 62 | − // keeps the primary `apikey` slug (matching the prior single-method flow); | |
| 63 | − // the rest get deterministic `apikey-<n>` slugs. `none` rows register | |
| 64 | − // nothing. | |
| 65 | − const authenticationTemplate = useMemo<readonly AuthTemplate[]>(() => { | |
| 66 | − const templates: AuthTemplate[] = []; | |
| 67 | − for (const row of authMethodList.rows) { | |
| 68 | − if (row.value.kind !== "apikey") continue; | |
| 69 | − for (const template of graphqlTemplatesFromPlacements(row.value.placements, "")) { | |
| 70 | − const index = templates.length; | |
| 71 | − templates.push({ | |
| 72 | − ...template, | |
| 73 | − slug: index === 0 ? GRAPHQL_APIKEY_TEMPLATE : `apikey-${index + 1}`, | |
| 74 | − }); | |
| 75 | − } | |
| 76 | − } | |
| 77 | − return templates; | |
| 78 | − }, [authMethodList.rows]); | |
| 59 | + // The methods to register: each apikey row declares ONE method carrying | |
| 60 | + // every named placement (header + query mix in a single method). Inputs | |
| 61 | + // omit slugs — the backend assigns carrier-derived ones. `none` rows | |
| 62 | + // register nothing. | |
| 63 | + const authenticationTemplate = useMemo<readonly GraphqlAuthMethodInput[]>( | |
| 64 | + () => | |
| 65 | + authMethodList.rows.flatMap((row: AuthMethodRow) => | |
| 66 | + row.value.kind === "apikey" | |
| 67 | + ? graphqlAuthMethodInputsFromPlacements(row.value.placements) | |
| 68 | + : [], | |
| 69 | + ), | |
| 70 | + [authMethodList.rows], | |
| 71 | + ); | |
| 79 | 72 | |
| 80 | 73 | // Every apikey row needs at least one named placement; `none` rows are |
| 81 | 74 | // always valid. |
| @@ -15,8 +15,12 @@ import { · 14 unmodified lines | ||
| 15 | 15 | } from "@executor-js/react/lib/custom-auth-methods"; |
| 16 | 16 | |
| 17 | 17 | import { graphqlConfigAtom, graphqlConfigure } from "./atoms"; |
| 18 | −import { authMethodsFromConfig, graphqlTemplatesFromPlacements } from "./auth-method-config"; | |
| 19 | −import type { AuthTemplate } from "../sdk/types"; | |
| 18 | +import { | |
| 19 | + authMethodsFromConfig, | |
| 20 | + graphqlAuthMethodInputsFromPlacements, | |
| 21 | + graphqlWireAuthInput, | |
| 22 | +} from "./auth-method-config"; | |
| 23 | +import type { GraphqlAuthMethod } from "../sdk/types"; | |
| 20 | 24 | |
| 21 | 25 | // --------------------------------------------------------------------------- |
| 22 | 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 | 28 | // Reads the integration's real `authenticationTemplate` (via `getConfig`), |
| 25 | 29 | // converts it to generic `AuthMethod[]`, and composes the generic |
| 26 | 30 | // `AccountsSection` — whose Add-account offers those methods plus a "+ Custom |
| 27 | −// method" row (apiKey-only). The custom-method create is INJECTED here | |
| 28 | −// (`createCustomMethod`): generic placements → graphql `apiKey` templates | |
| 29 | −// (`graphqlTemplatesFromPlacements`, slug omitted → backend `custom_<id>`) | |
| 30 | −// merge-appended via `configure`. Stays plugin-side because it touches the | |
| 31 | −// graphql sdk `AuthTemplate` types. | |
| 31 | +// method" row (apikey-only). The custom-method create is INJECTED here | |
| 32 | +// (`createCustomMethod`): generic placements → ONE apikey method | |
| 33 | +// (`graphqlAuthMethodInputsFromPlacements`, slug omitted → backend | |
| 34 | +// `custom_<id>`) merge-appended via `configure`. Stays plugin-side because it | |
| 35 | +// touches the graphql sdk method types. | |
| 32 | 36 | // --------------------------------------------------------------------------- |
| 33 | 37 | |
| 34 | 38 | export default function GraphqlAccountsPanel(props: { |
| @@ -41,7 +45,7 @@ export default function GraphqlAccountsPanel(props: { · 6 unmodified lines | ||
| 41 | 45 | const configResult = useAtomValue(graphqlConfigAtom(slug)); |
| 42 | 46 | const doConfigure = useAtomSet(graphqlConfigure, { mode: "promiseExit" }); |
| 43 | 47 | |
| 44 | − const existingTemplate = useMemo<readonly AuthTemplate[]>(() => { | |
| 48 | + const existingTemplate = useMemo<readonly GraphqlAuthMethod[]>(() => { | |
| 45 | 49 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; |
| 46 | 50 | return configResult.value.authenticationTemplate ?? []; |
| 47 | 51 | }, [configResult]); |
| @@ -53,13 +57,13 @@ export default function GraphqlAccountsPanel(props: { · 5 unmodified lines | ||
| 53 | 57 | |
| 54 | 58 | // Custom-method create/remove: the shared skeleton (merge-append → diff out |
| 55 | 59 | // the created method; filter → replace) parameterized by the GraphQL codec. |
| 56 | − // Stays plugin-side only where it touches the graphql `AuthTemplate` types. | |
| 57 | − const configure = useCallback<ConfigureAuthMethods<AuthTemplate>>( | |
| 60 | + // Stays plugin-side only where it touches the graphql method types. | |
| 61 | + const configure = useCallback<ConfigureAuthMethods<GraphqlAuthMethod>>( | |
| 58 | 62 | async (input) => { |
| 59 | 63 | const exit = await doConfigure({ |
| 60 | 64 | params: { slug: String(slug) }, |
| 61 | 65 | payload: { |
| 62 | − authenticationTemplate: input.authenticationTemplate, | |
| 66 | + authenticationTemplate: input.authenticationTemplate.map(graphqlWireAuthInput), | |
| 63 | 67 | ...(input.mode ? { mode: input.mode } : {}), |
| 64 | 68 | }, |
| 65 | 69 | reactivityKeys: integrationWriteKeys, |
| @@ -69,13 +73,13 @@ export default function GraphqlAccountsPanel(props: { · 3 unmodified lines | ||
| 69 | 73 | [doConfigure, slug], |
| 70 | 74 | ); |
| 71 | 75 | |
| 72 | − const codec = useMemo<AuthMethodsCodec<AuthTemplate>>( | |
| 76 | + const codec = useMemo<AuthMethodsCodec<GraphqlAuthMethod>>( | |
| 73 | 77 | () => ({ |
| 74 | 78 | toAuthMethods: authMethodsFromConfig, |
| 75 | − // Slug blank → backend backfills `custom_<id>` per emitted template. | |
| 79 | + // Inputs omit slugs — the backend merge backfills `custom_<id>`. | |
| 76 | 80 | templatesFromPlacements: (placements: readonly Placement[]) => |
| 77 | − graphqlTemplatesFromPlacements(placements, ""), | |
| 78 | − slugOf: (template: AuthTemplate) => template.slug, | |
| 81 | + graphqlAuthMethodInputsFromPlacements(placements) as readonly GraphqlAuthMethod[], | |
| 82 | + slugOf: (template: GraphqlAuthMethod) => template.slug, | |
| 79 | 83 | }), |
| 80 | 84 | [], |
| 81 | 85 | ); |
| @@ -1,98 +1,191 @@ | ||
| 1 | 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"; | |
| 5 | −import { GRAPHQL_APIKEY_TEMPLATE } from "./defaults"; | |
| 6 | −import type { AuthTemplate } from "../sdk/types"; | |
| 4 | +import { | |
| 5 | + authMethodsFromConfig, | |
| 6 | + editorValueFromGraphqlAuthMethod, | |
| 7 | + graphqlAuthMethodInputFromEditorValue, | |
| 8 | + graphqlAuthMethodInputsFromPlacements, | |
| 9 | +} from "./auth-method-config"; | |
| 7 | 10 | |
| 8 | −describe("graphqlTemplatesFromPlacements", () => { | |
| 9 | − it("maps a single named header placement → one apiKey template (primary slug)", () => { | |
| 10 | − const templates = graphqlTemplatesFromPlacements([ | |
| 11 | − { carrier: "header", name: "Authorization", prefix: "Bearer " }, | |
| 12 | − ]); | |
| 13 | − expect(templates).toEqual([ | |
| 14 | − { | |
| 15 | − kind: "apiKey", | |
| 16 | − slug: GRAPHQL_APIKEY_TEMPLATE, | |
| 17 | − in: "header", | |
| 18 | − name: "Authorization", | |
| 19 | − prefix: "Bearer ", | |
| 20 | − }, | |
| 21 | − ]); | |
| 11 | +describe("graphqlAuthMethodInputFromEditorValue", () => { | |
| 12 | + it("maps 'none' → { kind: 'none' }", () => { | |
| 13 | + expect(graphqlAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" }); | |
| 22 | 14 | }); |
| 23 | 15 | |
| 24 | − it("emits one template per named placement; only the first keeps the slug", () => { | |
| 25 | − const templates = graphqlTemplatesFromPlacements( | |
| 26 | − [ | |
| 27 | − { carrier: "header", name: "Authorization", prefix: "" }, | |
| 28 | − { carrier: "query", name: "api_key", prefix: "" }, | |
| 16 | + it("maps 'oauth' → { kind: 'oauth2' } (graphql oauth stores no endpoints)", () => { | |
| 17 | + const value: AuthTemplateEditorValue = { | |
| 18 | + kind: "oauth", | |
| 19 | + authorizationUrl: "https://a.example.com/auth", | |
| 20 | + tokenUrl: "https://a.example.com/token", | |
| 21 | + scopes: ["read"], | |
| 22 | + }; | |
| 23 | + expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({ kind: "oauth2" }); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("maps a header placement to an apikey method (prefix preserved)", () => { | |
| 27 | + const value: AuthTemplateEditorValue = { | |
| 28 | + kind: "apikey", | |
| 29 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 30 | + }; | |
| 31 | + expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({ | |
| 32 | + kind: "apikey", | |
| 33 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 34 | + }); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it("keeps EVERY named placement — header + query mix in one method", () => { | |
| 38 | + const value: AuthTemplateEditorValue = { | |
| 39 | + kind: "apikey", | |
| 40 | + placements: [ | |
| 41 | + { carrier: "header", name: "Authorization", prefix: "Bearer " }, | |
| 42 | + { carrier: "query", name: "team_id", prefix: "" }, | |
| 29 | 43 | ], |
| 30 | − "", | |
| 31 | − ); | |
| 32 | − expect(templates).toEqual([ | |
| 33 | − { kind: "apiKey", slug: "", in: "header", name: "Authorization" }, | |
| 34 | − { kind: "apiKey", slug: "", in: "query", name: "api_key" }, | |
| 35 | − ]); | |
| 44 | + }; | |
| 45 | + expect(graphqlAuthMethodInputFromEditorValue(value)).toEqual({ | |
| 46 | + kind: "apikey", | |
| 47 | + placements: [ | |
| 48 | + { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" }, | |
| 49 | + { carrier: "query", name: "team_id", variable: "team_id" }, | |
| 50 | + ], | |
| 51 | + }); | |
| 36 | 52 | }); |
| 37 | 53 | |
| 38 | − it("drops unnamed placements", () => { | |
| 39 | − const templates = graphqlTemplatesFromPlacements([ | |
| 40 | − { carrier: "header", name: "", prefix: "" }, | |
| 41 | − { carrier: "header", name: "X-Token", prefix: "" }, | |
| 42 | − ]); | |
| 43 | − expect(templates).toEqual([ | |
| 44 | − { kind: "apiKey", slug: GRAPHQL_APIKEY_TEMPLATE, in: "header", name: "X-Token" }, | |
| 45 | − ]); | |
| 54 | + it("drops unnamed placements and degrades to none when nothing is usable", () => { | |
| 55 | + expect( | |
| 56 | + graphqlAuthMethodInputFromEditorValue({ | |
| 57 | + kind: "apikey", | |
| 58 | + placements: [{ carrier: "header", name: " ", prefix: "" }], | |
| 59 | + }), | |
| 60 | + ).toEqual({ kind: "none" }); | |
| 61 | + }); | |
| 62 | +}); | |
| 63 | + | |
| 64 | +describe("editorValueFromGraphqlAuthMethod", () => { | |
| 65 | + it("round-trips an apikey method, making the shared token variable explicit", () => { | |
| 66 | + expect( | |
| 67 | + editorValueFromGraphqlAuthMethod({ | |
| 68 | + slug: "header", | |
| 69 | + kind: "apikey", | |
| 70 | + placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | |
| 71 | + }), | |
| 72 | + ).toEqual({ | |
| 73 | + kind: "apikey", | |
| 74 | + placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer ", variable: "token" }], | |
| 75 | + }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("round-trip edit preserves placement variables (sharing survives)", () => { | |
| 79 | + const stored = { | |
| 80 | + slug: "custom_two_spots", | |
| 81 | + kind: "apikey", | |
| 82 | + placements: [ | |
| 83 | + { carrier: "header", name: "X-Token" }, | |
| 84 | + { carrier: "query", name: "token" }, | |
| 85 | + ], | |
| 86 | + } as const; | |
| 87 | + const editor = editorValueFromGraphqlAuthMethod(stored); | |
| 88 | + const back = graphqlAuthMethodInputFromEditorValue(editor); | |
| 89 | + // Both placements still share the canonical `token` input (stored as | |
| 90 | + // absent on the wire) — a round-trip must not split one credential in two. | |
| 91 | + expect(back).toEqual({ | |
| 92 | + kind: "apikey", | |
| 93 | + placements: [ | |
| 94 | + { carrier: "header", name: "X-Token" }, | |
| 95 | + { carrier: "query", name: "token" }, | |
| 96 | + ], | |
| 97 | + }); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it("maps oauth2 to an oauth editor value with no endpoints", () => { | |
| 101 | + expect(editorValueFromGraphqlAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({ | |
| 102 | + kind: "oauth", | |
| 103 | + authorizationUrl: "", | |
| 104 | + tokenUrl: "", | |
| 105 | + scopes: [], | |
| 106 | + }); | |
| 46 | 107 | }); |
| 47 | 108 | }); |
| 48 | 109 | |
| 49 | 110 | describe("authMethodsFromConfig", () => { |
| 50 | − it("maps a header apiKey template → a generic apikey method with a placement", () => { | |
| 51 | − const config: AuthTemplate[] = [ | |
| 52 | − { kind: "apiKey", slug: "apiKey", in: "header", name: "Authorization", prefix: "Bearer " }, | |
| 53 | − ]; | |
| 54 | − expect(authMethodsFromConfig(config)).toEqual([ | |
| 111 | + it("projects every declared method and marks custom_ slugs as custom", () => { | |
| 112 | + const methods = authMethodsFromConfig([ | |
| 113 | + { slug: "oauth2", kind: "oauth2" }, | |
| 55 | 114 | { |
| 56 | − id: "apiKey", | |
| 57 | − label: "API key (Authorization)", | |
| 115 | + slug: "custom_abc123", | |
| 58 | 116 | kind: "apikey", |
| 59 | − source: "spec", | |
| 60 | − template: AuthTemplateSlug.make("apiKey"), | |
| 61 | − placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 117 | + placements: [{ carrier: "header", name: "X-Api-Key" }], | |
| 62 | 118 | }, |
| 119 | + { slug: "none", kind: "none" }, | |
| 63 | 120 | ]); |
| 64 | − }); | |
| 65 | 121 | |
| 66 | − it("marks a custom_ slug as a custom method", () => { | |
| 67 | − const config: AuthTemplate[] = [ | |
| 68 | − { kind: "apiKey", slug: "custom_abc", in: "query", name: "token" }, | |
| 69 | − ]; | |
| 70 | − const [method] = authMethodsFromConfig(config); | |
| 71 | − expect(method?.source).toBe("custom"); | |
| 72 | − expect(method?.placements).toEqual([{ carrier: "query", name: "token", prefix: "" }]); | |
| 122 | + expect( | |
| 123 | + methods.map((method) => ({ | |
| 124 | + id: method.id, | |
| 125 | + kind: method.kind, | |
| 126 | + source: method.source, | |
| 127 | + template: String(method.template), | |
| 128 | + })), | |
| 129 | + ).toEqual([ | |
| 130 | + { id: "oauth2", kind: "oauth", source: "spec", template: "oauth2" }, | |
| 131 | + { id: "custom_abc123", kind: "apikey", source: "custom", template: "custom_abc123" }, | |
| 132 | + { id: "none", kind: "none", source: "spec", template: "none" }, | |
| 133 | + ]); | |
| 134 | + expect(methods[0]?.oauth).toEqual({}); | |
| 73 | 135 | }); |
| 74 | 136 | |
| 75 | − it("maps an oauth2 template → an oauth method", () => { | |
| 76 | − const config: AuthTemplate[] = [{ kind: "oauth2", slug: "oauth2" }]; | |
| 77 | − expect(authMethodsFromConfig(config)).toEqual([ | |
| 137 | + it("carries multi-placement methods through to the hub", () => { | |
| 138 | + const methods = authMethodsFromConfig([ | |
| 78 | 139 | { |
| 79 | − id: "oauth2", | |
| 80 | − label: "OAuth2", | |
| 81 | − kind: "oauth", | |
| 82 | − source: "spec", | |
| 83 | − template: AuthTemplateSlug.make("oauth2"), | |
| 84 | − placements: [], | |
| 85 | − oauth: {}, | |
| 140 | + slug: "custom_mix", | |
| 141 | + kind: "apikey", | |
| 142 | + placements: [ | |
| 143 | + { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, | |
| 144 | + { carrier: "query", name: "team_id", variable: "team_id" }, | |
| 145 | + ], | |
| 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)", () => { | |
| 92 | − it("a header placement survives the round-trip into a generic method", () => { | |
| 93 | − const placements = [{ carrier: "header" as const, name: "X-Api-Key", prefix: "" }]; | |
| 94 | − const templates = graphqlTemplatesFromPlacements(placements, ""); | |
| 95 | − const [method] = authMethodsFromConfig(templates); | |
| 96 | − expect(method?.placements).toEqual(placements); | |
| 155 | +describe("graphqlAuthMethodInputsFromPlacements", () => { | |
| 156 | + it("builds ONE method carrying every named placement", () => { | |
| 157 | + expect( | |
| 158 | + graphqlAuthMethodInputsFromPlacements([ | |
| 159 | + { carrier: "header", name: "X-Token", prefix: "Bearer " }, | |
| 160 | + { carrier: "query", name: "team_id", prefix: "" }, | |
| 161 | + ]), | |
| 162 | + ).toEqual([ | |
| 163 | + { | |
| 164 | + type: "apiKey", | |
| 165 | + headers: { "X-Token": ["Bearer ", { type: "variable", name: "x_token" }] }, | |
| 166 | + queryParams: { team_id: [{ type: "variable", name: "team_id" }] }, | |
| 167 | + }, | |
| 168 | + ]); | |
| 169 | + }); | |
| 170 | + | |
| 171 | + it("builds a query method from a query placement", () => { | |
| 172 | + expect( | |
| 173 | + graphqlAuthMethodInputsFromPlacements([{ carrier: "query", name: "token", prefix: "" }]), | |
| 174 | + ).toEqual([{ type: "apiKey", queryParams: { token: [{ type: "variable", name: "token" }] } }]); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + it("skips unnamed placements", () => { | |
| 178 | + expect( | |
| 179 | + graphqlAuthMethodInputsFromPlacements([ | |
| 180 | + { carrier: "query", name: "", prefix: "" }, | |
| 181 | + { carrier: "query", name: "token", prefix: "" }, | |
| 182 | + ]), | |
| 183 | + ).toEqual([{ type: "apiKey", queryParams: { token: [{ type: "variable", name: "token" }] } }]); | |
| 184 | + }); | |
| 185 | + | |
| 186 | + it("is empty when no placement has a usable name", () => { | |
| 187 | + expect( | |
| 188 | + graphqlAuthMethodInputsFromPlacements([{ carrier: "query", name: " ", prefix: "" }]), | |
| 189 | + ).toEqual([]); | |
| 97 | 190 | }); |
| 98 | 191 | }); |
| @@ -1,84 +1,85 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// GraphQL ↔ generic auth-method converters. | |
| 3 | −// | |
| 4 | −// The generic Accounts hub + add-time auth editor speak in plugin-agnostic | |
| 5 | −// `AuthMethod` / `Placement` values (`@executor-js/react/lib/auth-placements`). | |
| 6 | −// The GraphQL plugin stores auth as its own `AuthTemplate` wire shape | |
| 7 | −// (`{ kind:"apiKey", slug, in, name, prefix? }` / `{ kind:"oauth2", slug, … }`). | |
| 8 | −// These converters bridge that wire shape to/from the generic placement model, | |
| 9 | −// so they live with the GraphQL plugin — they touch the graphql sdk | |
| 10 | −// `AuthTemplate` types and would pull transport specifics into core. | |
| 2 | +// GraphQL ↔ generic auth-method converters — a thin oauth adapter over the | |
| 3 | +// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The | |
| 4 | +// apikey/none paths (multi-placement, multi-variable) live in the shared | |
| 5 | +// codec; GraphQL only contributes its oauth flavor: endpoint-less methods that | |
| 6 | +// render the connection's access token as a bearer header at invoke time | |
| 7 | +// (optionally overriding the header name / prefix). | |
| 11 | 8 | // --------------------------------------------------------------------------- |
| 12 | 9 | |
| 13 | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | +import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | |
| 14 | 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"; | |
| 17 | −import type { AuthTemplate } from "../sdk/types"; | |
| 21 | +import type { | |
| 22 | + GraphqlAuthMethod, | |
| 23 | + GraphqlAuthMethodInput, | |
| 24 | + GraphqlCanonicalAuthMethodInput, | |
| 25 | +} from "../sdk/types"; | |
| 18 | 26 | |
| 19 | −// --------------------------------------------------------------------------- | |
| 20 | −// Templates → generic methods. | |
| 21 | −// --------------------------------------------------------------------------- | |
| 27 | +const oauthAuthMethod = (slug: string): AuthMethod => ({ | |
| 28 | + id: slug, | |
| 29 | + label: "OAuth", | |
| 30 | + kind: "oauth", | |
| 31 | + source: slug.startsWith("custom_") ? "custom" : "spec", | |
| 32 | + template: AuthTemplateSlug.make(slug), | |
| 33 | + placements: [], | |
| 34 | + oauth: {}, | |
| 35 | +}); | |
| 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_` | |
| 26 | − * slug marks a user-defined method; everything else is spec-declared. */ | |
| 27 | −export function authMethodsFromConfig(templates: readonly AuthTemplate[]): AuthMethod[] { | |
| 28 | − return templates.map((template: AuthTemplate): AuthMethod => { | |
| 29 | − const slug = String(template.slug); | |
| 30 | − const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec"; | |
| 31 | − if (template.kind === "oauth2") { | |
| 32 | − return { | |
| 33 | − id: slug, | |
| 34 | − label: "OAuth2", | |
| 35 | − kind: "oauth", | |
| 36 | − source, | |
| 37 | − template: AuthTemplateSlug.make(slug), | |
| 38 | − placements: [], | |
| 39 | − oauth: {}, | |
| 40 | − }; | |
| 41 | − } | |
| 42 | − const placement: Placement = { | |
| 43 | − carrier: template.in, | |
| 44 | − name: template.name, | |
| 45 | − prefix: template.prefix ?? "", | |
| 46 | − }; | |
| 47 | − return { | |
| 48 | − id: slug, | |
| 49 | − label: labelForApiKey(slug, template.name), | |
| 50 | − kind: "apikey", | |
| 51 | − source, | |
| 52 | − template: AuthTemplateSlug.make(slug), | |
| 53 | − placements: [placement], | |
| 54 | − }; | |
| 55 | − }); | |
| 48 | +/** Convert one stored GraphQL method into the generic editor value. */ | |
| 49 | +export function editorValueFromGraphqlAuthMethod( | |
| 50 | + method: GraphqlAuthMethod, | |
| 51 | +): AuthTemplateEditorValue { | |
| 52 | + if (method.kind === "oauth2") { | |
| 53 | + // GraphQL oauth methods store no endpoints — only the bearer rendering. | |
| 54 | + return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; | |
| 55 | + } | |
| 56 | + return editorValueFromSharedMethod(method); | |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | −// --------------------------------------------------------------------------- | |
| 59 | −// Generic placements → graphql apiKey templates (inverse). | |
| 60 | −// | |
| 61 | −// GraphQL's `AuthTemplate` carries ONE header/query slot per template, so a | |
| 62 | −// multi-placement method emits one template per named placement. When `slug` is | |
| 63 | −// omitted the backend backfills `custom_<id>`; the first template keeps the | |
| 64 | −// integration's primary `apiKey` slug so the add flow stays stable. | |
| 65 | −// --------------------------------------------------------------------------- | |
| 59 | +/** Project the stored methods into the generic `AuthMethod[]` the hub renders. | |
| 60 | + * Mirrors the server's `describeGraphqlAuthMethods`; `custom_` slugs mark | |
| 61 | + * user-created methods (removable from the hub). */ | |
| 62 | +export function authMethodsFromConfig(methods: readonly GraphqlAuthMethod[]): AuthMethod[] { | |
| 63 | + return methods.map((method: GraphqlAuthMethod): AuthMethod => { | |
| 64 | + if (method.kind === "oauth2") return oauthAuthMethod(method.slug); | |
| 65 | + return authMethodFromSharedTemplate(method); | |
| 66 | + }); | |
| 67 | +} | |
| 66 | 68 | |
| 67 | −/** Build GraphQL `apiKey` templates from generic placements. The optional | |
| 68 | − * `slug` names the FIRST emitted template (subsequent placements get an empty | |
| 69 | − * slug so the backend assigns a `custom_<id>` each). */ | |
| 70 | −export function graphqlTemplatesFromPlacements( | |
| 69 | +/** Build the GraphQL method input for a custom method from generic placements | |
| 70 | + * — ONE method carrying every named placement (header + query mix in a single | |
| 71 | + * method; each placement renders from its own input variable, or shares one). | |
| 72 | + * Empty when no placement is usable. */ | |
| 73 | +export function graphqlAuthMethodInputsFromPlacements( | |
| 71 | 74 | placements: readonly Placement[], |
| 72 | − slug?: string, | |
| 73 | −): AuthTemplate[] { | |
| 74 | − const named = placements.filter((placement: Placement) => placement.name.trim().length > 0); | |
| 75 | − return named.map( | |
| 76 | − (placement: Placement, index: number): AuthTemplate => ({ | |
| 77 | − kind: "apiKey", | |
| 78 | − slug: index === 0 ? (slug ?? GRAPHQL_APIKEY_TEMPLATE) : "", | |
| 79 | − in: placement.carrier, | |
| 80 | − name: placement.name, | |
| 81 | − ...(placement.prefix ? { prefix: placement.prefix } : {}), | |
| 82 | − }), | |
| 83 | − ); | |
| 75 | +): GraphqlAuthMethodInput[] { | |
| 76 | + const wire = wirePlacementsFromEditor(placements); | |
| 77 | + if (wire.length === 0) return []; | |
| 78 | + return [graphqlWireAuthInput({ kind: "apikey", placements: wire })]; | |
| 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − GRAPHQL_APIKEY_TEMPLATE, | |
| 5 | − graphqlApiKeyAuthTemplate, | |
| 6 | − graphqlConnectionName, | |
| 7 | −} from "./defaults"; | |
| 8 | − | |
| 9 | −describe("graphqlApiKeyAuthTemplate", () => { | |
| 10 | − it("builds an apiKey header template using the template slug", () => { | |
| 11 | − const template = graphqlApiKeyAuthTemplate("X-Api-Key"); | |
| 12 | − | |
| 13 | − expect(template).toEqual({ | |
| 14 | − kind: "apiKey", | |
| 15 | − slug: GRAPHQL_APIKEY_TEMPLATE, | |
| 16 | − in: "header", | |
| 17 | − name: "X-Api-Key", | |
| 18 | − }); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("falls back to Authorization when no header name is given", () => { | |
| 22 | − expect(graphqlApiKeyAuthTemplate("").name).toBe("Authorization"); | |
| 23 | − }); | |
| 24 | −}); | |
| 3 | +import { graphqlConnectionName } from "./defaults"; | |
| 25 | 4 | |
| 26 | 5 | describe("graphqlConnectionName", () => { |
| 27 | 6 | it("is deterministic per integration + owner", () => { |
| @@ -7,26 +7,6 @@ import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; · 6 unmodified lines | ||
| 7 | 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 | 10 | /** Deterministic connection name for a GraphQL integration + owner. Keeps a |
| 31 | 11 | * single owner-scoped credential per integration so re-adding overwrites |
| 32 | 12 | * rather than accumulating duplicates. */ |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | 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 | 5 | // integration's opaque `authenticationTemplate`. These tests exercise the |
| 6 | 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 | 8 | // - append: a configured method does not drop the integration's existing |
| 9 | 9 | // declared methods, |
| 10 | 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 | 20 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 21 | 21 | |
| 22 | 22 | import { graphqlPlugin } from "./plugin"; |
| 23 | −import type { AuthTemplate } from "./types"; | |
| 23 | +import type { GraphqlAuthMethod } from "./types"; | |
| 24 | 24 | |
| 25 | 25 | const makeExecutor = () => |
| 26 | 26 | createExecutor( |
| @@ -29,11 +29,10 @@ const makeExecutor = () => · 2 unmodified lines | ||
| 29 | 29 | }), |
| 30 | 30 | ); |
| 31 | 31 | |
| 32 | −const customApiKey: AuthTemplate = { | |
| 33 | − kind: "apiKey", | |
| 32 | +const customApiKey: GraphqlAuthMethod = { | |
| 34 | 33 | slug: "my_custom", |
| 35 | − in: "header", | |
| 36 | − name: "X-Api-Key", | |
| 34 | + kind: "apikey", | |
| 35 | + placements: [{ carrier: "header", name: "X-Api-Key" }], | |
| 37 | 36 | }; |
| 38 | 37 | |
| 39 | 38 | describe("GraphQL Plugin — configureAuth (custom auth method)", () => { |
| @@ -56,7 +55,11 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 16 unmodified lines | ||
| 56 | 55 | const config = yield* executor.graphql.getConfig("cfg_api"); |
| 57 | 56 | const template = config?.authenticationTemplate ?? []; |
| 58 | 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 | 71 | yield* executor.graphql.addIntegration({ |
| 69 | 72 | endpoint: "https://x.example/graphql", |
| 70 | 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 | 79 | const merged = yield* executor.graphql.configureAuth("cfg_append", { |
| 75 | 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 | 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 | 102 | expect(merged).toHaveLength(1); |
| @@ -111,29 +118,27 @@ describe("GraphQL Plugin — configureAuth (custom auth method)", () => { · 15 unmodified lines | ||
| 111 | 118 | authenticationTemplate: [ |
| 112 | 119 | // Re-submit the same slug with a different placement → replace in place. |
| 113 | 120 | { |
| 114 | − kind: "apiKey", | |
| 115 | 121 | slug: "my_custom", |
| 116 | − in: "header", | |
| 117 | − name: "X-Other", | |
| 122 | + kind: "apikey", | |
| 123 | + placements: [{ carrier: "header", name: "X-Other" }], | |
| 118 | 124 | }, |
| 119 | − { kind: "apiKey", slug: "", in: "query", name: "api_key" }, | |
| 120 | − { kind: "apiKey", slug: "", in: "query", name: "token" }, | |
| 125 | + { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "api_key" }] }, | |
| 126 | + { slug: "", kind: "apikey", placements: [{ carrier: "query", name: "token" }] }, | |
| 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 | 131 | expect(slugs.filter((s: string) => s === "my_custom")).toHaveLength(1); |
| 126 | 132 | expect(merged).toHaveLength(3); |
| 127 | 133 | const generated = slugs.filter((s: string) => s.startsWith("custom_")); |
| 128 | 134 | expect(generated).toHaveLength(2); |
| 129 | 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 | 138 | expect(replaced).toEqual({ |
| 133 | − kind: "apiKey", | |
| 134 | 139 | slug: "my_custom", |
| 135 | − in: "header", | |
| 136 | − name: "X-Other", | |
| 140 | + kind: "apikey", | |
| 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 | 151 | endpoint: "https://x.example/graphql", |
| 147 | 152 | slug: "cfg_replace", |
| 148 | 153 | authenticationTemplate: [ |
| 149 | − { kind: "apiKey", slug: "seed", in: "header", name: "X-Seed" }, | |
| 154 | + { slug: "seed", kind: "apikey", placements: [{ carrier: "header", name: "X-Seed" }] }, | |
| 150 | 155 | customApiKey, |
| 151 | 156 | ], |
| 152 | 157 | }); |
| 153 | 158 | |
| 154 | 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 | 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 | 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 | 26 | const methods = describeGraphqlAuthMethods( |
| 27 | 27 | recordWith({ |
| 28 | 28 | endpoint: "https://x.example/graphql", |
| 29 | 29 | name: "x", |
| 30 | 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 | 52 | const methods = describeGraphqlAuthMethods( |
| 49 | 53 | recordWith({ |
| 50 | 54 | endpoint: "https://x.example/graphql", |
| 51 | 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 | 102 | it("defaults the placement prefix to an empty string when unset", () => { |
| 68 | 103 | const methods = describeGraphqlAuthMethods( |
| 69 | 104 | recordWith({ |
| 70 | 105 | endpoint: "https://x.example/graphql", |
| 71 | 106 | name: "x", |
| 72 | 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 | 119 | const methods = describeGraphqlAuthMethods( |
| 85 | 120 | recordWith({ |
| 86 | 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 | 152 | it("projects every declared method (multi-method specs)", () => { |
| 104 | 153 | const methods = describeGraphqlAuthMethods( |
| 105 | 154 | recordWith({ |
| 106 | 155 | endpoint: "https://x.example/graphql", |
| 107 | 156 | name: "x", |
| 108 | 157 | authenticationTemplate: [ |
| 109 | − { kind: "apiKey", slug: "a", in: "header", name: "X-Api-Key" }, | |
| 110 | − { kind: "apiKey", slug: "b", in: "query", name: "token" }, | |
| 158 | + { slug: "a", kind: "apikey", placements: [{ carrier: "header", name: "X-Api-Key" }] }, | |
| 159 | + { slug: "b", kind: "apikey", placements: [{ carrier: "query", name: "token" }] }, | |
| 111 | 160 | ], |
| 112 | 161 | }), |
| 113 | 162 | ); |
| @@ -20,17 +20,22 @@ export { · 19 unmodified lines | ||
| 20 | 20 | } from "./errors"; |
| 21 | 21 | |
| 22 | 22 | export { |
| 23 | − ApiKeyAuthTemplate, | |
| 24 | − AuthTemplate, | |
| 25 | 23 | decodeGraphqlIntegrationConfig, |
| 26 | 24 | decodeGraphqlIntegrationConfigOption, |
| 27 | 25 | ExtractedField, |
| 28 | 26 | ExtractionResult, |
| 29 | 27 | GraphqlArgument, |
| 28 | + GraphqlAuthMethod, | |
| 29 | + GraphqlAuthMethodInput, | |
| 30 | 30 | GraphqlIntegrationConfig, |
| 31 | + GraphqlOAuthMethod, | |
| 31 | 32 | GraphqlOperationKind, |
| 32 | 33 | InvocationResult, |
| 33 | − OAuthAuthTemplate, | |
| 34 | + normalizeGraphqlAuthMethods, | |
| 34 | 35 | OperationBinding, |
| 35 | − type ApiKeyAuthTemplate as ApiKeyAuthTemplateType, | |
| 36 | 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 | 328 | slug: "live_invoke", |
| 329 | 329 | queryParams: { trace: "on" }, |
| 330 | 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 | 334 | yield* createOrgConnection(executor, { |
| @@ -768,11 +768,9 @@ describe("graphqlPlugin", () => { · 433 unmodified lines | ||
| 768 | 768 | introspectionJson, |
| 769 | 769 | authenticationTemplate: [ |
| 770 | 770 | { |
| 771 | − kind: "apiKey", | |
| 772 | 771 | slug: "header", |
| 773 | − in: "header", | |
| 774 | − name: "Authorization", | |
| 775 | − prefix: "Bearer ", | |
| 772 | + kind: "apikey", | |
| 773 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 776 | 774 | }, |
| 777 | 775 | ], |
| 778 | 776 | }); |
| @@ -13,7 +13,6 @@ import { · 12 unmodified lines | ||
| 13 | 13 | ToolName, |
| 14 | 14 | ToolResult, |
| 15 | 15 | type AuthMethodDescriptor, |
| 16 | − type AuthPlacementDescriptor, | |
| 17 | 16 | type IntegrationConfig, |
| 18 | 17 | type IntegrationRecord, |
| 19 | 18 | type PluginCtx, |
| @@ -22,6 +21,16 @@ import { · 2 unmodified lines | ||
| 22 | 21 | type ToolDef, |
| 23 | 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 | 34 | import { |
| 26 | 35 | introspect, |
| 27 | 36 | parseIntrospectionJson, |
| @@ -40,12 +49,15 @@ import { invokeWithLayer } from "./invoke"; · 12 unmodified lines | ||
| 40 | 49 | import { graphqlPresets } from "./presets"; |
| 41 | 50 | import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store"; |
| 42 | 51 | import { |
| 43 | − AuthTemplate, | |
| 52 | + GraphqlAuthMethodInput, | |
| 44 | 53 | decodeGraphqlIntegrationConfig, |
| 45 | 54 | decodeGraphqlIntegrationConfigOption, |
| 46 | 55 | ExtractedField, |
| 47 | 56 | GraphqlIntegrationConfig, |
| 57 | + expandGraphqlAuthMethodInputs, | |
| 58 | + normalizeGraphqlAuthMethods, | |
| 48 | 59 | OperationBinding, |
| 60 | + type GraphqlAuthMethod, | |
| 49 | 61 | type GraphqlOperationKind, |
| 50 | 62 | } from "./types"; |
| 51 | 63 | |
| @@ -72,8 +84,6 @@ const GRAPHQL_PLUGIN_ID = "graphql"; · 20 unmodified lines | ||
| 72 | 84 | // Extension input shapes |
| 73 | 85 | // --------------------------------------------------------------------------- |
| 74 | 86 | |
| 75 | −const AuthTemplateSchema = AuthTemplate; | |
| 76 | − | |
| 77 | 87 | /** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL; |
| 78 | 88 | * `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson` |
| 79 | 89 | * supplies the schema when the endpoint disables live introspection; `headers` |
| @@ -87,7 +97,7 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 7 unmodified lines | ||
| 87 | 97 | introspectionJson: Schema.optional(Schema.String), |
| 88 | 98 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 89 | 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 | 102 | export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type; |
| 93 | 103 | |
| @@ -96,17 +106,17 @@ const GraphqlConfigureInputSchema = Schema.Struct({ · 2 unmodified lines | ||
| 96 | 106 | endpoint: Schema.optional(Schema.String), |
| 97 | 107 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 98 | 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 | 111 | export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type; |
| 102 | 112 | |
| 103 | 113 | /** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/ |
| 104 | 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 | 116 | * integration's existing `authenticationTemplate`, mirroring OpenAPI's |
| 107 | 117 | * `configure`. */ |
| 108 | 118 | const GraphqlConfigureAuthInputSchema = Schema.Struct({ |
| 109 | − authenticationTemplate: Schema.Array(AuthTemplateSchema), | |
| 119 | + authenticationTemplate: Schema.Array(GraphqlAuthMethodInput), | |
| 110 | 120 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), |
| 111 | 121 | }); |
| 112 | 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 | |
| 341 | −// the template the connection references, exactly like an apiKey bearer. | |
| 350 | +// Auth method rendering (D11) — apply the connection's resolved values through | |
| 351 | +// the method the connection references. An oauth2 method is the conventional | |
| 352 | +// bearer placement (with the method's optional header/prefix override) over | |
| 353 | +// the resolved access token; an apikey method renders its declared placements. | |
| 342 | 354 | // --------------------------------------------------------------------------- |
| 343 | 355 | |
| 344 | −interface RenderedAuth { | |
| 345 | − readonly headers: Record<string, string>; | |
| 346 | − readonly queryParams: Record<string, string>; | |
| 347 | −} | |
| 348 | − | |
| 349 | −const renderAuthTemplate = (template: AuthTemplate, value: string): RenderedAuth => { | |
| 350 | − if (template.kind === "oauth2") { | |
| 351 | − const header = template.header ?? "Authorization"; | |
| 352 | − const prefix = template.prefix ?? "Bearer "; | |
| 353 | − return { headers: { [header]: `${prefix}${value}` }, queryParams: {} }; | |
| 354 | − } | |
| 355 | − const rendered = template.prefix ? `${template.prefix}${value}` : value; | |
| 356 | − if (template.in === "query") { | |
| 357 | − return { headers: {}, queryParams: { [template.name]: rendered } }; | |
| 356 | +const renderGraphqlAuthMethod = ( | |
| 357 | + method: GraphqlAuthMethod, | |
| 358 | + values: Record<string, string | null>, | |
| 359 | +): RenderedAuthPlacements => { | |
| 360 | + if (method.kind === "apikey") return renderAuthPlacements(method.placements, values); | |
| 361 | + if (method.kind === "oauth2") { | |
| 362 | + return renderAuthPlacements([oauthBearerPlacement(method.header, method.prefix)], values); | |
| 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 | 392 | * auth-required endpoint introspects successfully here rather than at add-time. */ |
| 388 | 393 | const introspectHeadersForConnection = ( |
| 389 | 394 | config: GraphqlIntegrationConfig, |
| 390 | − credentialValue: string | null, | |
| 395 | + values: Record<string, string | null>, | |
| 391 | 396 | templateSlug: AuthTemplateSlug | null, |
| 392 | −): RenderedAuth => { | |
| 397 | +): RenderedAuthPlacements => { | |
| 393 | 398 | const headers: Record<string, string> = { ...(config.headers ?? {}) }; |
| 394 | 399 | const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) }; |
| 395 | − if (credentialValue !== null) { | |
| 396 | − // Render the exact template the connection references; with no slug | |
| 397 | − // (connection row not yet persisted) fall back to the first declared. | |
| 398 | − const template = | |
| 399 | − (templateSlug !== null | |
| 400 | − ? config.authenticationTemplate.find((t: AuthTemplate) => t.slug === String(templateSlug)) | |
| 401 | − : undefined) ?? config.authenticationTemplate[0]; | |
| 402 | − if (template) { | |
| 403 | − const rendered = renderAuthTemplate(template, credentialValue); | |
| 404 | − Object.assign(headers, rendered.headers); | |
| 405 | − Object.assign(queryParams, rendered.queryParams); | |
| 406 | − } | |
| 400 | + // Render the exact method the connection references; with no slug | |
| 401 | + // (connection row not yet persisted) fall back to the first declared. | |
| 402 | + const method = | |
| 403 | + (templateSlug !== null | |
| 404 | + ? config.authenticationTemplate.find( | |
| 405 | + (m: GraphqlAuthMethod) => m.slug === String(templateSlug), | |
| 406 | + ) | |
| 407 | + : undefined) ?? config.authenticationTemplate[0]; | |
| 408 | + if (method) { | |
| 409 | + const rendered = renderGraphqlAuthMethod(method, values); | |
| 410 | + Object.assign(headers, rendered.headers); | |
| 411 | + Object.assign(queryParams, rendered.queryParams); | |
| 407 | 412 | } |
| 408 | 413 | return { headers, queryParams }; |
| 409 | 414 | }; |
| @@ -413,14 +418,14 @@ const introspectHeadersForConnection = ( · 3 unmodified lines | ||
| 413 | 418 | * present; otherwise this introspects the endpoint with the rendered credential. */ |
| 414 | 419 | const introspectForConnection = ( |
| 415 | 420 | config: GraphqlIntegrationConfig, |
| 416 | − credentialValue: string | null, | |
| 421 | + values: Record<string, string | null>, | |
| 417 | 422 | templateSlug: AuthTemplateSlug | null, |
| 418 | 423 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 419 | 424 | ): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => { |
| 420 | 425 | if (config.introspectionJson) { |
| 421 | 426 | return parseIntrospectionJson(config.introspectionJson); |
| 422 | 427 | } |
| 423 | − const auth = introspectHeadersForConnection(config, credentialValue, templateSlug); | |
| 428 | + const auth = introspectHeadersForConnection(config, values, templateSlug); | |
| 424 | 429 | return introspect( |
| 425 | 430 | config.endpoint, |
| 426 | 431 | Object.keys(auth.headers).length > 0 ? auth.headers : undefined, |
| @@ -438,22 +443,22 @@ const materializeOperations = ( · 11 unmodified lines | ||
| 438 | 443 | config: GraphqlIntegrationConfig, |
| 439 | 444 | credential: { |
| 440 | 445 | readonly template: AuthTemplateSlug; |
| 441 | − readonly value: string | null; | |
| 446 | + readonly values: Record<string, string | null>; | |
| 442 | 447 | }, |
| 443 | 448 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 444 | 449 | ): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> => |
| 445 | 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 | 452 | // here, unlike `resolveTools`) so an auth-required endpoint introspects. |
| 448 | − const template = config.authenticationTemplate.find( | |
| 449 | − (t: AuthTemplate) => t.slug === String(credential.template), | |
| 453 | + const method = config.authenticationTemplate.find( | |
| 454 | + (m: GraphqlAuthMethod) => m.slug === String(credential.template), | |
| 450 | 455 | ); |
| 451 | 456 | const headers: Record<string, string> = { ...(config.headers ?? {}) }; |
| 452 | 457 | const queryParams: Record<string, string> = { |
| 453 | 458 | ...(config.queryParams ?? {}), |
| 454 | 459 | }; |
| 455 | − if (template && credential.value !== null) { | |
| 456 | − const rendered = renderAuthTemplate(template, credential.value); | |
| 460 | + if (method) { | |
| 461 | + const rendered = renderGraphqlAuthMethod(method, credential.values); | |
| 457 | 462 | Object.assign(headers, rendered.headers); |
| 458 | 463 | Object.assign(queryParams, rendered.queryParams); |
| 459 | 464 | } |
| @@ -489,44 +494,29 @@ const materializeOperations = ( · 29 unmodified lines | ||
| 489 | 494 | // through the catalog's `authMethods` to the hub / Add-account flows. Exported |
| 490 | 495 | // for tests. |
| 491 | 496 | // |
| 492 | −// apiKey → one apikey method carrying a single header/query placement (the | |
| 493 | −// `name` from the template, with the template's `prefix` defaulted to | |
| 494 | −// `""`). | |
| 497 | +// none → a no-auth method carrying no credential inputs | |
| 498 | +// apikey → carried placements (headers / query params) verbatim | |
| 495 | 499 | // oauth2 → one oauth method (no resolved endpoints; graphql renders the |
| 496 | 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 | 503 | export const describeGraphqlAuthMethods = ( |
| 503 | 504 | record: IntegrationRecord, |
| 504 | 505 | ): readonly AuthMethodDescriptor[] => { |
| 505 | 506 | const config = Option.getOrUndefined(decodeGraphqlIntegrationConfigOption(record.config)); |
| 506 | 507 | if (!config) return []; |
| 507 | − return config.authenticationTemplate.map((template: AuthTemplate): AuthMethodDescriptor => { | |
| 508 | − const slug = template.slug; | |
| 509 | − if (template.kind === "oauth2") { | |
| 508 | + return config.authenticationTemplate.map((method: GraphqlAuthMethod): AuthMethodDescriptor => { | |
| 509 | + if (method.kind === "apikey") return describeApiKeyAuthMethod(method); | |
| 510 | + if (method.kind === "oauth2") { | |
| 510 | 511 | return { |
| 511 | − id: slug, | |
| 512 | + id: method.slug, | |
| 512 | 513 | label: "OAuth", |
| 513 | 514 | kind: "oauth", |
| 514 | − template: slug, | |
| 515 | + template: method.slug, | |
| 515 | 516 | oauth: {}, |
| 516 | 517 | }; |
| 517 | 518 | } |
| 518 | − const placement: AuthPlacementDescriptor = { | |
| 519 | − carrier: template.in, | |
| 520 | − name: template.name, | |
| 521 | − prefix: template.prefix ?? "", | |
| 522 | − }; | |
| 523 | − return { | |
| 524 | − id: slug, | |
| 525 | − label: graphqlApiKeyLabel(placement), | |
| 526 | − kind: "apikey", | |
| 527 | − template: slug, | |
| 528 | − placements: [placement], | |
| 529 | − }; | |
| 519 | + return describeNoneAuthMethod(method.slug); | |
| 530 | 520 | }); |
| 531 | 521 | }; |
| 532 | 522 | |
| @@ -555,7 +545,9 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 22 unmodified lines | ||
| 555 | 545 | : {}), |
| 556 | 546 | ...(input.headers !== undefined ? { headers: input.headers } : {}), |
| 557 | 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 | 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 | 655 | ...((input.queryParams ?? current.queryParams) !== undefined |
| 664 | 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 | 663 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { |
| @@ -691,18 +685,27 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines | ||
| 691 | 685 | const configureAuthMethods = ( |
| 692 | 686 | slug: string, |
| 693 | 687 | input: GraphqlConfigureAuthInput, |
| 694 | − ): Effect.Effect<readonly AuthTemplate[], StorageFailure> => | |
| 688 | + ): Effect.Effect<readonly GraphqlAuthMethod[], StorageFailure> => | |
| 695 | 689 | ctx.transaction( |
| 696 | 690 | Effect.gen(function* () { |
| 697 | 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 | 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 | 700 | const merged = |
| 703 | 701 | input.mode === "replace" |
| 704 | − ? input.authenticationTemplate | |
| 705 | − : mergeAuthTemplates(current.authenticationTemplate, input.authenticationTemplate); | |
| 702 | + ? normalizeGraphqlAuthMethods(input.authenticationTemplate) | |
| 703 | + : mergeAuthTemplates( | |
| 704 | + current.authenticationTemplate, | |
| 705 | + expandGraphqlAuthMethodInputs( | |
| 706 | + input.authenticationTemplate, | |
| 707 | + ) as readonly GraphqlAuthMethod[], | |
| 708 | + ); | |
| 706 | 709 | |
| 707 | 710 | const next = GraphqlIntegrationConfig.make({ |
| 708 | 711 | ...current, |
| @@ -849,25 +852,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 140 unmodified lines | ||
| 849 | 852 | resolveTools: ({ |
| 850 | 853 | config, |
| 851 | 854 | template, |
| 852 | − getValue, | |
| 855 | + getValues, | |
| 853 | 856 | }: { |
| 854 | 857 | readonly config: IntegrationConfig; |
| 855 | 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 | 861 | Effect.gen(function* () { |
| 859 | 862 | const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option); |
| 860 | 863 | if (Option.isNone(decoded)) return { tools: [] }; |
| 861 | 864 | const graphqlConfig = decoded.value; |
| 862 | 865 | // Live introspection (no stored snapshot) needs the connection's |
| 863 | − // credential for auth-required endpoints; resolve it lazily. | |
| 864 | − const credentialValue = | |
| 866 | + // credential inputs for auth-required endpoints; resolve them lazily. | |
| 867 | + const values = | |
| 865 | 868 | graphqlConfig.introspectionJson === undefined |
| 866 | − ? yield* getValue().pipe(Effect.catch(() => Effect.succeed(null))) | |
| 867 | − : null; | |
| 869 | + ? yield* getValues().pipe( | |
| 870 | + Effect.catch(() => Effect.succeed({} as Record<string, string | null>)), | |
| 871 | + ) | |
| 872 | + : ({} as Record<string, string | null>); | |
| 868 | 873 | const introspection = yield* introspectForConnection( |
| 869 | 874 | graphqlConfig, |
| 870 | − credentialValue, | |
| 875 | + values, | |
| 871 | 876 | template, |
| 872 | 877 | options?.httpClientLayer ?? httpClientLayerFallback, |
| 873 | 878 | ).pipe(Effect.option); |
| @@ -929,29 +934,35 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 55 unmodified lines | ||
| 929 | 934 | ...(config.queryParams ?? {}), |
| 930 | 935 | }; |
| 931 | 936 | |
| 932 | − const template = config.authenticationTemplate.find( | |
| 933 | − (t: AuthTemplate) => t.slug === String(credential.template), | |
| 937 | + const method = config.authenticationTemplate.find( | |
| 938 | + (m: GraphqlAuthMethod) => m.slug === String(credential.template), | |
| 934 | 939 | ); |
| 935 | − if (template) { | |
| 936 | − if (credential.value === null) { | |
| 940 | + if (method && method.kind !== "none") { | |
| 941 | + // A method with unresolved inputs fails the invocation explicitly | |
| 942 | + // instead of dialing unauthenticated. oauth2 requires the resolved | |
| 943 | + // access token (`token`); apikey requires every placement variable. | |
| 944 | + const missing = ( | |
| 945 | + method.kind === "oauth2" | |
| 946 | + ? [TOKEN_VARIABLE] | |
| 947 | + : requiredPlacementVariables(method.placements) | |
| 948 | + ).filter((variable) => credential.values[variable] == null); | |
| 949 | + if (missing.length > 0) { | |
| 937 | 950 | return yield* new GraphqlAuthRequiredError({ |
| 938 | 951 | code: |
| 939 | − template.kind === "oauth2" | |
| 940 | − ? "oauth_connection_missing" | |
| 941 | − : "connection_value_missing", | |
| 952 | + method.kind === "oauth2" ? "oauth_connection_missing" : "connection_value_missing", | |
| 942 | 953 | message: |
| 943 | − template.kind === "oauth2" | |
| 954 | + method.kind === "oauth2" | |
| 944 | 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 | 957 | owner: credential.owner, |
| 947 | 958 | integration, |
| 948 | 959 | connection: String(credential.connection), |
| 949 | − credentialKind: template.kind === "oauth2" ? "oauth" : "secret", | |
| 950 | − credentialLabel: template.kind === "oauth2" ? "OAuth sign-in" : "API key", | |
| 960 | + credentialKind: method.kind === "oauth2" ? "oauth" : "secret", | |
| 961 | + credentialLabel: method.kind === "oauth2" ? "OAuth sign-in" : "API key", | |
| 951 | 962 | template: String(credential.template), |
| 952 | 963 | }); |
| 953 | 964 | } |
| 954 | − const rendered = renderAuthTemplate(template, credential.value); | |
| 965 | + const rendered = renderGraphqlAuthMethod(method, credential.values); | |
| 955 | 966 | Object.assign(headers, rendered.headers); |
| 956 | 967 | Object.assign(queryParams, rendered.queryParams); |
| 957 | 968 | } |
| @@ -1,4 +1,12 @@ | ||
| 1 | 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 | 12 | // GraphQL operation kind |
| @@ -55,35 +63,27 @@ export const OperationBinding = Schema.Struct({ · 50 unmodified lines | ||
| 55 | 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 | |
| 61 | −// connection's resolved value is applied: an apiKey header / query param (with | |
| 62 | −// an optional prefix like `Bearer `) or an OAuth bearer header. There are no | |
| 63 | −// secret slots and no credential bindings — a connection IS the credential, and | |
| 64 | −// the plugin renders `credential.value` onto the request through the template | |
| 65 | −// matched by `credential.template` (D11: "auth state derived into the | |
| 66 | −// auth-template format" — an OAuth access token renders exactly like an apiKey | |
| 67 | −// bearer). | |
| 74 | +// none — no credential (open endpoint) | |
| 75 | +// apikey — render the connection's values through the method's header/query | |
| 76 | +// placements (one credential input per distinct placement | |
| 77 | +// `variable`; a method may mix carriers — e.g. a bearer header | |
| 78 | +// plus a team-id query param) | |
| 79 | +// oauth2 — the value is an OAuth access token, applied as a bearer header | |
| 80 | +// (optionally overriding the header name / prefix). GraphQL oauth | |
| 81 | +// stores no endpoints — only how the token is rendered. | |
| 68 | 82 | // --------------------------------------------------------------------------- |
| 69 | 83 | |
| 70 | −/** An apiKey-style template: place the value in a header or query parameter, | |
| 71 | − * optionally prefixed (e.g. `Bearer `). */ | |
| 72 | −export const ApiKeyAuthTemplate = Schema.Struct({ | |
| 73 | − kind: Schema.Literal("apiKey"), | |
| 74 | − /** The template slug a connection references via `connection.template`. */ | |
| 75 | − slug: Schema.String, | |
| 76 | − in: Schema.Literals(["header", "query"]), | |
| 77 | − /** The header / query-parameter name the value is written to. */ | |
| 78 | − name: Schema.String, | |
| 79 | − /** Optional prefix prepended to the value (e.g. `Bearer `). */ | |
| 80 | − prefix: Schema.optional(Schema.String), | |
| 81 | −}); | |
| 82 | −export type ApiKeyAuthTemplate = typeof ApiKeyAuthTemplate.Type; | |
| 83 | − | |
| 84 | −/** An OAuth bearer template: write `Authorization: Bearer <access-token>`. The | |
| 85 | − * resolved (and refreshed) access token is `credential.value`. */ | |
| 86 | −export const OAuthAuthTemplate = Schema.Struct({ | |
| 84 | +/** An OAuth bearer method: write `<header>: <prefix><access-token>`. The | |
| 85 | + * resolved (and refreshed) access token is the connection's `token` value. */ | |
| 86 | +export const GraphqlOAuthMethod = Schema.Struct({ | |
| 87 | 87 | kind: Schema.Literal("oauth2"), |
| 88 | 88 | slug: Schema.String, |
| 89 | 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 | 91 | /** The token prefix. Defaults to `Bearer `. */ |
| 92 | 92 | prefix: Schema.optional(Schema.String), |
| 93 | 93 | }); |
| 94 | −export type OAuthAuthTemplate = typeof OAuthAuthTemplate.Type; | |
| 95 | − | |
| 96 | −export const AuthTemplate = Schema.Union([ApiKeyAuthTemplate, OAuthAuthTemplate]); | |
| 97 | −export type AuthTemplate = typeof AuthTemplate.Type; | |
| 94 | +export type GraphqlOAuthMethod = typeof GraphqlOAuthMethod.Type; | |
| 95 | + | |
| 96 | +export const GraphqlAuthMethod = Schema.Union([ | |
| 97 | + NoneAuthMethod, | |
| 98 | + ApiKeyAuthMethod, | |
| 99 | + GraphqlOAuthMethod, | |
| 100 | +]); | |
| 101 | +export type GraphqlAuthMethod = typeof GraphqlAuthMethod.Type; | |
| 102 | + | |
| 103 | +/** Input variant of `GraphqlAuthMethod` — callers (UI, agents) may omit the | |
| 104 | + * slug; `normalizeGraphqlAuthMethods` backfills it. */ | |
| 105 | +export const GraphqlAuthMethodInput = Schema.Union([ | |
| 106 | + Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), | |
| 107 | + Schema.Struct({ | |
| 108 | + slug: Schema.optional(Schema.String), | |
| 109 | + kind: Schema.Literal("oauth2"), | |
| 110 | + header: Schema.optional(Schema.String), | |
| 111 | + prefix: Schema.optional(Schema.String), | |
| 112 | + }), | |
| 113 | + // Credential methods are authored request-shaped — the ONE apikey input | |
| 114 | + // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", | |
| 115 | + // variable("token")] }, queryParams: { … } }`. Stored configs and the | |
| 116 | + // catalog read as canonical placements; `apiKeyAuthTemplateFromMethod` | |
| 117 | + // serializes them back for read-modify-write flows. | |
| 118 | + ApiKeyAuthTemplate, | |
| 119 | +]); | |
| 120 | +export type GraphqlAuthMethodInput = typeof GraphqlAuthMethodInput.Type; | |
| 121 | + | |
| 122 | +/** The expansion target: input arms with the dialect resolved to canonical | |
| 123 | + * placements (slug still optional — backfill is a separate pass). */ | |
| 124 | +export type GraphqlCanonicalAuthMethodInput = | |
| 125 | + | Exclude<GraphqlAuthMethodInput, ApiKeyAuthTemplate> | |
| 126 | + | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string }); | |
| 127 | + | |
| 128 | +/** The default slug for a slug-less input method. Carrier-derived for the | |
| 129 | + * single-placement apikey cases (`header` / `query`) so the UI, agent, and | |
| 130 | + * migration paths all converge on the same names. */ | |
| 131 | +const defaultGraphqlAuthSlug = (method: GraphqlCanonicalAuthMethodInput): string => { | |
| 132 | + if (method.kind !== "apikey") return method.kind; | |
| 133 | + if (method.placements.length === 1) { | |
| 134 | + return method.placements[0]!.carrier === "header" ? "header" : "query"; | |
| 135 | + } | |
| 136 | + return "apikey"; | |
| 137 | +}; | |
| 138 | + | |
| 139 | +/** Expand request-shaped dialect entries into canonical placements; canonical | |
| 140 | + * entries pass through. Slug backfill is the caller's concern | |
| 141 | + * (`normalizeGraphqlAuthMethods` for declare flows, `mergeAuthTemplates` for | |
| 142 | + * the custom-method merge). */ | |
| 143 | +export const expandGraphqlAuthMethodInputs = ( | |
| 144 | + methods: readonly GraphqlAuthMethodInput[], | |
| 145 | +): readonly GraphqlCanonicalAuthMethodInput[] => | |
| 146 | + methods.map( | |
| 147 | + (method): GraphqlCanonicalAuthMethodInput => | |
| 148 | + isApiKeyAuthTemplate(method) | |
| 149 | + ? (apiKeyMethodFromAuthTemplate(method) as GraphqlCanonicalAuthMethodInput) | |
| 150 | + : (method as GraphqlCanonicalAuthMethodInput), | |
| 151 | + ); | |
| 152 | + | |
| 153 | +/** Assign each method a stable slug: a caller-provided one wins, otherwise a | |
| 154 | + * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The | |
| 155 | + * request-shaped dialect is expanded to canonical placements first. */ | |
| 156 | +export const normalizeGraphqlAuthMethods = ( | |
| 157 | + methods: readonly GraphqlAuthMethodInput[], | |
| 158 | +): readonly GraphqlAuthMethod[] => | |
| 159 | + normalizeAuthMethodSlugs( | |
| 160 | + expandGraphqlAuthMethodInputs(methods), | |
| 161 | + defaultGraphqlAuthSlug, | |
| 162 | + ) as readonly GraphqlAuthMethod[]; | |
| 98 | 163 | |
| 99 | 164 | // --------------------------------------------------------------------------- |
| 100 | 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 | 179 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 115 | 180 | /** Static query parameters applied to every request. */ |
| 116 | 181 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 117 | − /** Auth methods a connection can be applied through. */ | |
| 118 | − authenticationTemplate: Schema.Array(AuthTemplate), | |
| 182 | + /** Declared auth methods — how a connection's values are rendered onto | |
| 183 | + * requests. A connection's `template` picks one by slug. */ | |
| 184 | + authenticationTemplate: Schema.Array(GraphqlAuthMethod), | |
| 119 | 185 | }); |
| 120 | 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 | 191 | export const decodeGraphqlIntegrationConfig = Schema.decodeUnknownEffect(GraphqlIntegrationConfig); |
| 123 | 192 | export const decodeGraphqlIntegrationConfigOption = |
| 124 | 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 | 10 | import { |
| 11 | 11 | McpAuthMethod, |
| 12 | 12 | McpAuthMethodInput, |
| 13 | − McpAuthTemplate, | |
| 13 | + McpAuthShorthand, | |
| 14 | 14 | McpIntegrationConfig, |
| 15 | 15 | } from "../sdk/types"; |
| 16 | 16 | |
| @@ -40,7 +40,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 23 unmodified lines | ||
| 40 | 40 | authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)), |
| 41 | 41 | /** Single-method shorthand (legacy callers); ignored when |
| 42 | 42 | * `authenticationTemplate` is present. */ |
| 43 | − auth: Schema.optional(McpAuthTemplate), | |
| 43 | + auth: Schema.optional(McpAuthShorthand), | |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | const AddStdioServerPayload = Schema.Struct({ |
| @@ -37,7 +37,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 36 unmodified lines | ||
| 37 | 37 | import type { McpAuthMethodInput } from "../sdk/types"; |
| 38 | 38 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 39 | 39 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; |
| 40 | −import { mcpAuthMethodInputFromEditorValue } from "./auth-method-config"; | |
| 40 | +import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | |
| 41 | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 42 | 42 | |
| 43 | 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 | 322 | // Every row registers as a declared method (a lone no-auth row registers |
| 323 | 323 | // the open-server method). Slugs are assigned server-side by kind. |
| 324 | 324 | const methods = authMethodList.rows.map((row: AuthMethodRow) => |
| 325 | − mcpAuthMethodInputFromEditorValue(row.value), | |
| 325 | + mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(row.value)), | |
| 326 | 326 | ); |
| 327 | 327 | const slug = await registerIntegration( |
| 328 | 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 | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | |
| 6 | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | +import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; | |
| 7 | 8 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 8 | 9 | import { |
| 9 | 10 | AuthMethodListEditor, |
| @@ -24,10 +25,15 @@ import { Badge } from "@executor-js/react/components/badge"; · 14 unmodified lines | ||
| 24 | 25 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 25 | 26 | |
| 26 | 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 | 33 | import { |
| 29 | 34 | editorValueFromMcpAuthMethod, |
| 30 | 35 | mcpAuthMethodInputFromEditorValue, |
| 36 | + mcpWireAuthInput, | |
| 31 | 37 | } from "./auth-method-config"; |
| 32 | 38 | |
| 33 | 39 | type McpServer = { |
| @@ -43,10 +49,30 @@ type McpRemoteConfig = Extract<McpIntegrationConfig, { transport: "remote" }>; · 9 unmodified lines | ||
| 43 | 49 | |
| 44 | 50 | const methodSeedLabel = (method: McpAuthMethod): string => { |
| 45 | 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 | 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 | 77 | // Remote edit — v2: the integration's endpoint is part of its identity |
| 52 | 78 | // (opaque-to-core config); the editable surface is the declared auth-method |
| @@ -80,9 +106,9 @@ function RemoteEdit(props: { · 27 unmodified lines | ||
| 80 | 106 | // The edited methods, slugs preserved for seeded rows so existing |
| 81 | 107 | // connections (bound by template slug) stay attached. New rows omit the |
| 82 | 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 | 112 | const input = mcpAuthMethodInputFromEditorValue(row.value); |
| 87 | 113 | return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input; |
| 88 | 114 | }), |
| @@ -92,16 +118,13 @@ function RemoteEdit(props: { · 3 unmodified lines | ||
| 92 | 118 | const methodsChanged = useMemo(() => { |
| 93 | 119 | const stored = server.config.authenticationTemplate; |
| 94 | 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 | 122 | const current = stored[index]; |
| 97 | 123 | if (!current) return true; |
| 98 | 124 | if ((method.slug ?? "") !== current.slug) return true; |
| 99 | 125 | if (method.kind !== current.kind) return true; |
| 100 | − if (method.kind === "header" && current.kind === "header") { | |
| 101 | − return ( | |
| 102 | − method.headerName !== current.headerName || | |
| 103 | − (method.prefix ?? "") !== (current.prefix ?? "") | |
| 104 | − ); | |
| 126 | + if (method.kind === "apikey" && current.kind === "apikey") { | |
| 127 | + return !samePlacements(method.placements, current.placements); | |
| 105 | 128 | } |
| 106 | 129 | return false; |
| 107 | 130 | }); |
| @@ -114,7 +137,9 @@ function RemoteEdit(props: { · 6 unmodified lines | ||
| 114 | 137 | params: { slug: server.slug }, |
| 115 | 138 | payload: { |
| 116 | 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 | 143 | mode: "replace", |
| 119 | 144 | }, |
| 120 | 145 | reactivityKeys: integrationWriteKeys, |
| @@ -15,7 +15,11 @@ import { · 14 unmodified lines | ||
| 15 | 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 16 | 16 | |
| 17 | 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 | 23 | import type { McpAuthMethod } from "../sdk/types"; |
| 20 | 24 | |
| 21 | 25 | // --------------------------------------------------------------------------- |
| @@ -60,7 +64,7 @@ export default function McpAccountsPanel(props: { · 38 unmodified lines | ||
| 60 | 64 | const exit = await doConfigureAuth({ |
| 61 | 65 | params: { slug }, |
| 62 | 66 | payload: { |
| 63 | − authenticationTemplate: input.authenticationTemplate, | |
| 67 | + authenticationTemplate: input.authenticationTemplate.map(mcpWireAuthInput), | |
| 64 | 68 | ...(input.mode ? { mode: input.mode } : {}), |
| 65 | 69 | }, |
| 66 | 70 | reactivityKeys: integrationWriteKeys, |
| @@ -23,64 +23,88 @@ describe("mcpAuthMethodInputFromEditorValue", () => { · 22 unmodified lines | ||
| 23 | 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 | 27 | const value: AuthTemplateEditorValue = { |
| 28 | 28 | kind: "apikey", |
| 29 | 29 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], |
| 30 | 30 | }; |
| 31 | 31 | expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ |
| 32 | − kind: "header", | |
| 33 | − headerName: "Authorization", | |
| 34 | − prefix: "Bearer ", | |
| 32 | + kind: "apikey", | |
| 33 | + placements: [{ carrier: "header", name: "Authorization", 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 | 38 | const value: AuthTemplateEditorValue = { |
| 40 | 39 | kind: "apikey", |
| 41 | − placements: [{ carrier: "header", name: "X-Token", prefix: "" }], | |
| 40 | + placements: [{ carrier: "query", name: "token", prefix: "" }], | |
| 42 | 41 | }; |
| 43 | 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 | 43 | kind: "apikey", |
| 52 | − placements: [{ carrier: "query", name: "api_key", prefix: "" }], | |
| 53 | − }; | |
| 54 | − expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ kind: "none" }); | |
| 44 | + placements: [{ carrier: "query", name: "token" }], | |
| 45 | + }); | |
| 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 | 49 | const value: AuthTemplateEditorValue = { |
| 59 | 50 | kind: "apikey", |
| 60 | 51 | placements: [ |
| 61 | − { carrier: "header", name: "", prefix: "" }, | |
| 62 | − { carrier: "header", name: "X-Token", prefix: "" }, | |
| 52 | + { carrier: "header", name: "Authorization", prefix: "Bearer " }, | |
| 53 | + { carrier: "query", name: "team_id", prefix: "" }, | |
| 63 | 54 | ], |
| 64 | 55 | }; |
| 65 | 56 | expect(mcpAuthMethodInputFromEditorValue(value)).toEqual({ |
| 66 | − kind: "header", | |
| 67 | − headerName: "X-Token", | |
| 57 | + kind: "apikey", | |
| 58 | + placements: [ | |
| 59 | + { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "authorization" }, | |
| 60 | + { carrier: "query", name: "team_id", variable: "team_id" }, | |
| 61 | + ], | |
| 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 | 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 | 77 | expect( |
| 75 | 78 | editorValueFromMcpAuthMethod({ |
| 76 | 79 | slug: "header", |
| 77 | − kind: "header", | |
| 78 | − headerName: "X-Api-Key", | |
| 79 | − prefix: "Bearer ", | |
| 80 | + kind: "apikey", | |
| 81 | + placements: [{ carrier: "header", name: "X-Api-Key", prefix: "Bearer " }], | |
| 80 | 82 | }), |
| 81 | 83 | ).toEqual({ |
| 82 | 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 | 123 | const methods = authMethodsFromConfig( |
| 100 | 124 | [ |
| 101 | 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 | 131 | { slug: "none", kind: "none" }, |
| 104 | 132 | ], |
| 105 | 133 | "https://mcp.example.com/mcp", |
| @@ -119,21 +147,64 @@ describe("authMethodsFromConfig", () => { · 13 unmodified lines | ||
| 119 | 147 | ]); |
| 120 | 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 | 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 | 174 | expect( |
| 127 | 175 | mcpAuthMethodInputsFromPlacements([ |
| 128 | − { carrier: "query", name: "api_key", prefix: "" }, | |
| 129 | 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 | 206 | expect( |
| 136 | − mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }]), | |
| 207 | + mcpAuthMethodInputsFromPlacements([{ carrier: "query", name: " ", prefix: "" }]), | |
| 137 | 208 | ).toEqual([]); |
| 138 | 209 | }); |
| 139 | 210 | }); |
| @@ -1,40 +1,51 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// MCP ↔ generic auth-template converter. | |
| 3 | −// | |
| 4 | −// The shared add-time `AuthTemplateEditor` / `AuthMethodListEditor` speak the | |
| 5 | −// plugin-agnostic `AuthTemplateEditorValue`; the hub speaks `AuthMethod`. MCP | |
| 6 | −// stores a slugged `authenticationTemplate` array (`none` / `header` / | |
| 7 | −// `oauth2`, one method per entry — a header method carries a single header, | |
| 8 | −// not an array). These converters live with the MCP plugin because they touch | |
| 9 | −// the transport-specific `McpAuthMethod` types. | |
| 2 | +// MCP ↔ generic auth-method converters — a thin oauth adapter over the shared | |
| 3 | +// codec (`@executor-js/react/lib/shared-auth-method-codec`). The apikey/none | |
| 4 | +// paths (multi-placement, multi-variable) live in the shared codec; MCP only | |
| 5 | +// contributes its oauth flavor: endpoint-less methods whose metadata is | |
| 6 | +// discovered at connect time (`discoveryUrl` = the MCP endpoint). | |
| 10 | 7 | // --------------------------------------------------------------------------- |
| 11 | 8 | |
| 12 | 9 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 13 | 10 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 14 | 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 | 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` | |
| 20 | − * method using its FIRST named header placement, preserving the prefix; an | |
| 21 | − * apiKey value with no usable header placement falls back to `none`. */ | |
| 39 | + * the backend assigns carrier-derived slugs). An apikey value keeps every | |
| 40 | + * named placement (headers and query params mix freely); one with no usable | |
| 41 | + * placement falls back to `none`. */ | |
| 22 | 42 | export function mcpAuthMethodInputFromEditorValue( |
| 23 | 43 | value: AuthTemplateEditorValue, |
| 24 | −): McpAuthMethodInput { | |
| 44 | +): McpCanonicalAuthMethodInput { | |
| 25 | 45 | if (value.kind === "oauth") return { kind: "oauth2" }; |
| 26 | − if (value.kind === "apikey") { | |
| 27 | − const header = value.placements.find( | |
| 28 | − (placement) => placement.carrier === "header" && placement.name.trim().length > 0, | |
| 29 | − ); | |
| 30 | − if (!header) return { kind: "none" }; | |
| 31 | − return { | |
| 32 | − kind: "header", | |
| 33 | − headerName: header.name.trim(), | |
| 34 | − ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 35 | − }; | |
| 36 | − } | |
| 37 | − return { kind: "none" }; | |
| 46 | + return (sharedMethodInputFromEditorValue(value) ?? { | |
| 47 | + kind: "none", | |
| 48 | + }) as McpCanonicalAuthMethodInput; | |
| 38 | 49 | } |
| 39 | 50 | |
| 40 | 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 | 53 | if (method.kind === "oauth2") { |
| 43 | 54 | return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; |
| 44 | 55 | } |
| 45 | − if (method.kind === "header") { | |
| 46 | − return { | |
| 47 | − kind: "apikey", | |
| 48 | − placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | |
| 49 | − }; | |
| 50 | − } | |
| 51 | − return { kind: "none" }; | |
| 56 | + return editorValueFromSharedMethod(method); | |
| 52 | 57 | } |
| 53 | 58 | |
| 54 | 59 | /** Project the stored methods into the generic `AuthMethod[]` the hub renders. |
| @@ -60,56 +65,19 @@ export function authMethodsFromConfig( · 5 unmodified lines | ||
| 60 | 65 | endpoint: string, |
| 61 | 66 | ): AuthMethod[] { |
| 62 | 67 | return methods.map((method: McpAuthMethod): AuthMethod => { |
| 63 | − const source: "spec" | "custom" = method.slug.startsWith("custom_") ? "custom" : "spec"; | |
| 64 | − const template = AuthTemplateSlug.make(method.slug); | |
| 65 | − if (method.kind === "oauth2") { | |
| 66 | − return { | |
| 67 | − id: method.slug, | |
| 68 | − label: "OAuth", | |
| 69 | − kind: "oauth", | |
| 70 | − source, | |
| 71 | − template, | |
| 72 | − placements: [], | |
| 73 | − oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, | |
| 74 | − }; | |
| 75 | − } | |
| 76 | − if (method.kind === "header") { | |
| 77 | − return { | |
| 78 | − id: method.slug, | |
| 79 | − label: `API key (${method.headerName})`, | |
| 80 | − kind: "apikey", | |
| 81 | − source, | |
| 82 | − template, | |
| 83 | − placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | |
| 84 | − }; | |
| 85 | − } | |
| 86 | − return { | |
| 87 | − id: method.slug, | |
| 88 | − label: "No authentication", | |
| 89 | − kind: "none", | |
| 90 | − source, | |
| 91 | − template, | |
| 92 | − placements: [], | |
| 93 | − }; | |
| 68 | + if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); | |
| 69 | + return authMethodFromSharedTemplate(method); | |
| 94 | 70 | }); |
| 95 | 71 | } |
| 96 | 72 | |
| 97 | −/** Build MCP header-method inputs for a custom method from generic placements. | |
| 98 | − * MCP carries one header per method, so only the FIRST named header placement | |
| 99 | − * is used (query placements have no MCP credential carrier). Empty when no | |
| 100 | − * usable header placement exists. */ | |
| 73 | +/** Build the MCP method input for a custom method from generic placements — | |
| 74 | + * ONE method carrying every named placement (header + query mix in a single | |
| 75 | + * method; each placement renders from its own input variable, or shares one). | |
| 76 | + * Empty when no placement is usable. */ | |
| 101 | 77 | export function mcpAuthMethodInputsFromPlacements( |
| 102 | 78 | placements: readonly Placement[], |
| 103 | −): McpAuthMethodInput[] { | |
| 104 | − const header = placements.find( | |
| 105 | − (placement: Placement) => placement.carrier === "header" && placement.name.trim().length > 0, | |
| 106 | − ); | |
| 107 | − if (!header) return []; | |
| 108 | − return [ | |
| 109 | − { | |
| 110 | − kind: "header", | |
| 111 | − headerName: header.name.trim(), | |
| 112 | − ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 113 | − }, | |
| 114 | − ]; | |
| 79 | +): McpCanonicalAuthMethodInput[] { | |
| 80 | + const wire = wirePlacementsFromEditor(placements); | |
| 81 | + if (wire.length === 0) return []; | |
| 82 | + return [{ kind: "apikey", placements: wire }]; | |
| 115 | 83 | } |
| @@ -7,8 +7,9 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 6 unmodified lines | ||
| 7 | 7 | // `describeMcpAuthMethods` projects the stored MCP config into the catalog's |
| 8 | 8 | // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. It is |
| 9 | 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 | |
| 11 | −// into a one-element `authenticationTemplate` whose slug is the method kind. | |
| 10 | +// `[]`. Pre-canonical blobs are rewritten by the one-off config migration | |
| 11 | +// (see migrate-config.test.ts), so this projection only ever sees the | |
| 12 | +// canonical placements model. | |
| 12 | 13 | // --------------------------------------------------------------------------- |
| 13 | 14 | |
| 14 | 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 | 50 | const methods = describeMcpAuthMethods( |
| 50 | 51 | recordWith({ |
| 51 | 52 | transport: "remote", |
| 52 | 53 | endpoint: "https://x.example/mcp", |
| 53 | 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 | 76 | const methods = describeMcpAuthMethods( |
| 72 | 77 | recordWith({ |
| 73 | 78 | transport: "remote", |
| 74 | − endpoint: "https://x.example/mcp", | |
| 79 | + endpoint: "https://ui.sh/mcp", | |
| 75 | 80 | authenticationTemplate: [ |
| 76 | − { slug: "oauth2", kind: "oauth2" }, | |
| 77 | − { slug: "custom_abc123", kind: "header", headerName: "X-Api-Key" }, | |
| 81 | + { slug: "query", kind: "apikey", placements: [{ carrier: "query", name: "token" }] }, | |
| 78 | 82 | ], |
| 79 | 83 | }), |
| 80 | 84 | ); |
| 81 | 85 | |
| 82 | − expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([ | |
| 83 | − { id: "oauth2", kind: "oauth", template: "oauth2" }, | |
| 84 | − { id: "custom_abc123", kind: "apikey", template: "custom_abc123" }, | |
| 86 | + expect(methods).toEqual([ | |
| 87 | + { | |
| 88 | + id: "query", | |
| 89 | + label: "API key (token)", | |
| 90 | + kind: "apikey", | |
| 91 | + template: "query", | |
| 92 | + placements: [{ carrier: "query", name: "token", prefix: "" }], | |
| 93 | + }, | |
| 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 | 98 | const methods = describeMcpAuthMethods( |
| 90 | 99 | recordWith({ |
| 91 | 100 | transport: "remote", |
| 92 | 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 | 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 | 127 | const methods = describeMcpAuthMethods( |
| 104 | 128 | recordWith({ |
| 105 | 129 | transport: "remote", |
| 106 | 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", () => { | |
| 121 | − const methods = describeMcpAuthMethods( | |
| 122 | − recordWith({ | |
| 123 | − transport: "remote", | |
| 124 | − endpoint: "https://x.example/oauth/mcp", | |
| 125 | − auth: { kind: "oauth2" }, | |
| 126 | − }), | |
| 127 | − ); | |
| 128 | − expect(methods).toEqual([ | |
| 129 | − { | |
| 130 | − id: "oauth2", | |
| 131 | − label: "OAuth", | |
| 132 | − kind: "oauth", | |
| 133 | − template: "oauth2", | |
| 134 | − oauth: { | |
| 135 | − discoveryUrl: "https://x.example/oauth/mcp", | |
| 136 | − supportsDynamicRegistration: true, | |
| 137 | − }, | |
| 138 | − }, | |
| 142 | + expect(methods.map((m) => ({ id: m.id, kind: m.kind, template: m.template }))).toEqual([ | |
| 143 | + { id: "oauth2", kind: "oauth", template: "oauth2" }, | |
| 144 | + { id: "custom_abc123", kind: "apikey", template: "custom_abc123" }, | |
| 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 | 149 | const methods = describeMcpAuthMethods( |
| 144 | 150 | recordWith({ |
| 145 | 151 | transport: "remote", |
| 146 | 152 | endpoint: "https://x.example/mcp", |
| 153 | + authenticationTemplate: [{ slug: "none", kind: "none" }], | |
| 147 | 154 | }), |
| 148 | 155 | ); |
| 149 | 156 | expect(methods).toEqual([ |
| @@ -163,10 +170,20 @@ describe("describeMcpAuthMethods", () => { · 13 unmodified lines | ||
| 163 | 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 | 174 | expect(describeMcpAuthMethods(recordWith({ not: "an mcp config" }))).toEqual([]); |
| 168 | 175 | expect(describeMcpAuthMethods(recordWith(null))).toEqual([]); |
| 169 | 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 | 189 | it("projects remote endpoint as display metadata", () => { |
| @@ -12,7 +12,9 @@ export { · 11 unmodified lines | ||
| 12 | 12 | } from "./plugin"; |
| 13 | 13 | |
| 14 | 14 | export { |
| 15 | − McpAuthTemplate, | |
| 15 | + McpAuthMethod, | |
| 16 | + McpAuthMethodInput, | |
| 17 | + McpAuthShorthand, | |
| 16 | 18 | McpIntegrationConfig, |
| 17 | 19 | McpRemoteIntegrationConfig, |
| 18 | 20 | McpStdioIntegrationConfig, |
| @@ -23,6 +25,11 @@ export { · 4 unmodified lines | ||
| 23 | 25 | parseMcpIntegrationConfig, |
| 24 | 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 | 33 | export { |
| 27 | 34 | McpConnectionError, |
| 28 | 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 | 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 | 324 | expect(merged[0]?.slug).toBe("oauth2"); |
| 323 | 325 | expect(merged[1]?.slug).toMatch(/^custom_/); |
| 324 | 326 | |
| @@ -341,7 +343,10 @@ describe("mcpPlugin", () => { · 16 unmodified lines | ||
| 341 | 343 | const merged = yield* executor.mcp.configureAuth("open_mcp", { |
| 342 | 344 | authenticationTemplate: [ |
| 343 | 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 | 351 | mode: "replace", |
| 347 | 352 | }); |
| @@ -28,6 +28,14 @@ import { · 27 unmodified lines | ||
| 28 | 28 | ToolName, |
| 29 | 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 | 39 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; |
| 32 | 40 | import { discoverTools } from "./discover"; |
| 33 | 41 | import { McpConnectionError, McpToolDiscoveryError } from "./errors"; |
| @@ -37,11 +45,12 @@ import { mcpPresets } from "./presets"; · 3 unmodified lines | ||
| 37 | 45 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 38 | 46 | import { |
| 39 | 47 | McpAuthMethodInput, |
| 40 | − McpAuthTemplate, | |
| 48 | + McpAuthShorthand, | |
| 41 | 49 | McpRemoteTransport, |
| 42 | 50 | type McpAuthMethod, |
| 43 | 51 | type McpToolAnnotations, |
| 44 | − mcpAuthMethodFromLegacy, | |
| 52 | + expandMcpAuthMethodInputs, | |
| 53 | + mcpAuthMethodFromShorthand, | |
| 45 | 54 | normalizeMcpAuthMethods, |
| 46 | 55 | parseMcpIntegrationConfig, |
| 47 | 56 | type McpIntegrationConfig as McpIntegrationConfigType, |
| @@ -145,7 +154,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 97 unmodified lines | ||
| 145 | 154 | authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)), |
| 146 | 155 | /** Single-method shorthand (legacy callers). Ignored when |
| 147 | 156 | * `authenticationTemplate` is present. Defaults to none. */ |
| 148 | − auth: Schema.optional(McpAuthTemplate), | |
| 157 | + auth: Schema.optional(McpAuthShorthand), | |
| 149 | 158 | }); |
| 150 | 159 | |
| 151 | 160 | const McpStdioServerInputSchema = Schema.Struct({ |
| @@ -266,7 +275,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 114 unmodified lines | ||
| 266 | 275 | headers: input.headers, |
| 267 | 276 | authenticationTemplate: input.authenticationTemplate |
| 268 | 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 | 439 | const buildConnectorInput = ( |
| 431 | 440 | config: McpIntegrationConfigType, |
| 432 | − value: string | null, | |
| 441 | + values: Record<string, string | null>, | |
| 433 | 442 | templateSlug: string | null, |
| 434 | 443 | allowStdio: boolean, |
| 435 | 444 | ): Effect.Effect<ConnectorInput, McpConnectionError> => { |
| @@ -452,24 +461,27 @@ const buildConnectorInput = ( · 16 unmodified lines | ||
| 452 | 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 | 466 | const headers: Record<string, string> = { ...(config.headers ?? {}) }; |
| 467 | + const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) }; | |
| 456 | 468 | let authProvider: OAuthClientProvider | undefined; |
| 457 | 469 | |
| 458 | 470 | const auth = selectAuthMethod(config, templateSlug); |
| 459 | − if (auth?.kind === "header" && value !== null) { | |
| 460 | − headers[auth.headerName] = auth.prefix ? `${auth.prefix}${value}` : value; | |
| 461 | − } else if (auth?.kind === "oauth2" && value !== null) { | |
| 462 | − authProvider = makeOAuthProvider(value); | |
| 471 | + if (auth?.kind === "apikey") { | |
| 472 | + const rendered = renderAuthPlacements(auth.placements, values); | |
| 473 | + Object.assign(headers, rendered.headers); | |
| 474 | + Object.assign(queryParams, rendered.queryParams); | |
| 475 | + } else if (auth?.kind === "oauth2") { | |
| 476 | + const token = values[TOKEN_VARIABLE]; | |
| 477 | + if (token != null) authProvider = makeOAuthProvider(token); | |
| 463 | 478 | } |
| 464 | 479 | |
| 465 | 480 | return Effect.succeed({ |
| 466 | 481 | transport: "remote" as const, |
| 467 | 482 | endpoint: config.endpoint, |
| 468 | 483 | remoteTransport: config.remoteTransport ?? "auto", |
| 469 | − queryParams: | |
| 470 | − config.queryParams && Object.keys(config.queryParams).length > 0 | |
| 471 | − ? config.queryParams | |
| 472 | − : undefined, | |
| 484 | + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, | |
| 473 | 485 | headers: Object.keys(headers).length > 0 ? headers : undefined, |
| 474 | 486 | authProvider, |
| 475 | 487 | }); |
| @@ -483,7 +495,7 @@ const buildConnectorInput = ( · 7 unmodified lines | ||
| 483 | 495 | // |
| 484 | 496 | // none → a no-auth method carrying no credential inputs |
| 485 | 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 | 499 | // oauth2 → an oauth method carrying the MCP endpoint to probe |
| 488 | 500 | // (`discoveryUrl`); endpoints are discovered live at |
| 489 | 501 | // connect time, so they are NOT pre-resolved here. We |
| @@ -499,15 +511,7 @@ export const describeMcpAuthMethods = ( · 9 unmodified lines | ||
| 499 | 511 | if (!config || config.transport === "stdio") return []; |
| 500 | 512 | |
| 501 | 513 | return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => { |
| 502 | − if (method.kind === "header") { | |
| 503 | − return { | |
| 504 | − id: method.slug, | |
| 505 | − label: `API key (${method.headerName})`, | |
| 506 | − kind: "apikey", | |
| 507 | − template: method.slug, | |
| 508 | − placements: [{ carrier: "header", name: method.headerName, prefix: method.prefix ?? "" }], | |
| 509 | − }; | |
| 510 | − } | |
| 514 | + if (method.kind === "apikey") return describeApiKeyAuthMethod(method); | |
| 511 | 515 | if (method.kind === "oauth2") { |
| 512 | 516 | return { |
| 513 | 517 | id: method.slug, |
| @@ -517,12 +521,7 @@ export const describeMcpAuthMethods = ( · 3 unmodified lines | ||
| 517 | 521 | oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true }, |
| 518 | 522 | }; |
| 519 | 523 | } |
| 520 | − return { | |
| 521 | − id: method.slug, | |
| 522 | − label: "No authentication", | |
| 523 | − kind: "none", | |
| 524 | − template: method.slug, | |
| 525 | − }; | |
| 524 | + return describeNoneAuthMethod(method.slug); | |
| 526 | 525 | }); |
| 527 | 526 | }; |
| 528 | 527 | |
| @@ -837,7 +836,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 308 unmodified lines | ||
| 837 | 836 | ? normalizeMcpAuthMethods(input.authenticationTemplate) |
| 838 | 837 | : mergeAuthTemplates( |
| 839 | 838 | current.authenticationTemplate, |
| 840 | − input.authenticationTemplate as readonly McpAuthMethod[], | |
| 839 | + expandMcpAuthMethodInputs( | |
| 840 | + input.authenticationTemplate, | |
| 841 | + ) as readonly McpAuthMethod[], | |
| 841 | 842 | ); |
| 842 | 843 | |
| 843 | 844 | yield* ctx.core.integrations.update(slugFrom(slug), { |
| @@ -869,16 +870,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 25 unmodified lines | ||
| 869 | 870 | // Discovery failures (auth not ready, server down) yield an empty tool set |
| 870 | 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 | 874 | Effect.gen(function* () { |
| 874 | 875 | const parsed = parseMcpIntegrationConfig(config); |
| 875 | 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 | 884 | const built = yield* buildConnectorInput( |
| 880 | 885 | parsed, |
| 881 | − value, | |
| 886 | + values, | |
| 882 | 887 | template === null ? null : String(template), |
| 883 | 888 | allowStdio, |
| 884 | 889 | ).pipe( |
| @@ -925,9 +930,28 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 40 unmodified lines | ||
| 925 | 930 | const transport: string = |
| 926 | 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 | 952 | const connector: McpConnector = yield* buildConnectorInput( |
| 929 | 953 | parsed, |
| 930 | − credential.value, | |
| 954 | + credential.values, | |
| 931 | 955 | String(credential.template), |
| 932 | 956 | allowStdio, |
| 933 | 957 | ).pipe(Effect.map((ci) => createMcpConnector(ci))); |
| @@ -1,16 +1,23 @@ | ||
| 1 | 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 | 12 | // MCP plugin v2 data model. |
| 5 | 13 | // |
| 6 | 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 | |
| 8 | −// `auth` *template* describing how a connection's resolved value is applied to | |
| 9 | −// the request. A connection IS the credential: at execute time core resolves | |
| 10 | −// the connection's value through its provider (refreshing OAuth tokens), and | |
| 11 | −// the plugin renders it onto the request per the template (D11). The same path | |
| 12 | −// covers an API key bearer and an OAuth access token — both resolve to a value | |
| 13 | −// and render through their template. | |
| 15 | +// on the integration row) carries everything needed to dial the server plus | |
| 16 | +// the declared auth methods describing how a connection's resolved credential | |
| 17 | +// values are applied to the request. A connection IS the credential: at | |
| 18 | +// execute time core resolves the connection's values through its provider | |
| 19 | +// (refreshing OAuth tokens), and the plugin renders them onto the request per | |
| 20 | +// the method the connection binds (D11). | |
| 14 | 21 | // --------------------------------------------------------------------------- |
| 15 | 22 | |
| 16 | 23 | // --------------------------------------------------------------------------- |
| @@ -25,35 +32,35 @@ export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", · 8 unmodified lines | ||
| 25 | 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 | |
| 29 | −// request. An integration declares zero or more methods, each with a stable | |
| 30 | −// `slug` a connection binds against (`connection.template`), mirroring the | |
| 31 | −// OpenAPI/GraphQL `authenticationTemplate` arrays. | |
| 35 | +// Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) | |
| 36 | +// plus MCP's own oauth variant. An integration declares zero or more methods, | |
| 37 | +// each with a stable `slug` a connection binds against (`connection.template`), | |
| 38 | +// mirroring the OpenAPI/GraphQL `authenticationTemplate` arrays. | |
| 32 | 39 | // |
| 33 | 40 | // none — no credential (open server) |
| 34 | −// header — render the value into a request header (e.g. `Authorization: | |
| 35 | −// Bearer <value>`); `prefix` is prepended to the value | |
| 41 | +// apikey — render the connection's values through the method's header/query | |
| 42 | +// placements (one credential input per distinct placement | |
| 43 | +// `variable`; servers like ui.sh authenticate via a `?token=` | |
| 44 | +// query placement, and a method may mix carriers — e.g. a bearer | |
| 45 | +// header plus a team-id query param) | |
| 36 | 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([ | |
| 41 | − Schema.Struct({ slug: Schema.String, kind: Schema.Literal("none") }), | |
| 42 | − Schema.Struct({ | |
| 43 | − slug: Schema.String, | |
| 44 | − kind: Schema.Literal("header"), | |
| 45 | − headerName: Schema.String, | |
| 46 | − prefix: Schema.optional(Schema.String), | |
| 47 | − }), | |
| 48 | − Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2") }), | |
| 49 | −]); | |
| 51 | +export const McpOAuthMethod = Schema.Struct({ | |
| 52 | + slug: Schema.String, | |
| 53 | + kind: Schema.Literal("oauth2"), | |
| 54 | +}); | |
| 55 | +export type McpOAuthMethod = typeof McpOAuthMethod.Type; | |
| 56 | + | |
| 57 | +export const McpAuthMethod = Schema.Union([NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod]); | |
| 50 | 58 | export type McpAuthMethod = typeof McpAuthMethod.Type; |
| 51 | 59 | |
| 52 | −/** Legacy single-method template (pre-array configs and the `auth` input | |
| 53 | − * shorthand on `addServer`): the method without a slug. Lifted into a | |
| 54 | − * one-element `authenticationTemplate` whose slug is the method kind, which | |
| 55 | − * matches the template slugs existing connections were created against. */ | |
| 56 | −export const McpAuthTemplate = Schema.Union([ | |
| 60 | +/** Single-method `auth` shorthand on `addServer` — agent convenience for the | |
| 61 | + * common cases. Normalized into `authenticationTemplate` at the boundary; | |
| 62 | + * never stored. */ | |
| 63 | +export const McpAuthShorthand = Schema.Union([ | |
| 57 | 64 | Schema.Struct({ kind: Schema.Literal("none") }), |
| 58 | 65 | Schema.Struct({ |
| 59 | 66 | kind: Schema.Literal("header"), |
| @@ -62,41 +69,84 @@ export const McpAuthTemplate = Schema.Union([ · 2 unmodified lines | ||
| 62 | 69 | }), |
| 63 | 70 | Schema.Struct({ kind: Schema.Literal("oauth2") }), |
| 64 | 71 | ]); |
| 65 | −export type McpAuthTemplate = typeof McpAuthTemplate.Type; | |
| 66 | − | |
| 67 | −export const mcpAuthMethodFromLegacy = (auth: McpAuthTemplate): McpAuthMethod => ({ | |
| 68 | − ...auth, | |
| 69 | − slug: auth.kind, | |
| 70 | −}); | |
| 72 | +export type McpAuthShorthand = typeof McpAuthShorthand.Type; | |
| 73 | + | |
| 74 | +/** Expand the `auth` shorthand into a declared method. Slugs match what the | |
| 75 | + * shorthand has always produced (`none` / `header` / `oauth2`) so existing | |
| 76 | + * connections bound to them keep matching. */ | |
| 77 | +export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMethod => { | |
| 78 | + if (auth.kind === "header") { | |
| 79 | + return { | |
| 80 | + slug: "header", | |
| 81 | + kind: "apikey", | |
| 82 | + placements: [ | |
| 83 | + { | |
| 84 | + carrier: "header", | |
| 85 | + name: auth.headerName, | |
| 86 | + ...(auth.prefix !== undefined ? { prefix: auth.prefix } : {}), | |
| 87 | + }, | |
| 88 | + ], | |
| 89 | + }; | |
| 90 | + } | |
| 91 | + return { slug: auth.kind, kind: auth.kind }; | |
| 92 | +}; | |
| 71 | 93 | |
| 72 | 94 | /** Input variant of `McpAuthMethod` — callers (UI, agents) may omit the slug; |
| 73 | − * `normalizeMcpAuthMethods` backfills it from the kind. */ | |
| 95 | + * `normalizeMcpAuthMethods` backfills it. */ | |
| 74 | 96 | export const McpAuthMethodInput = Schema.Union([ |
| 75 | 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 | 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 | 106 | export type McpAuthMethodInput = typeof McpAuthMethodInput.Type; |
| 85 | 107 | |
| 86 | −/** Assign each method a stable slug: a caller-provided one wins, otherwise the | |
| 87 | − * kind (`oauth2`, `header`, `none`), suffixed `_2`, `_3`, … on collision. */ | |
| 108 | +/** The expansion target: input arms with the dialect resolved to canonical | |
| 109 | + * placements (slug still optional — backfill is a separate pass). */ | |
| 110 | +export type McpCanonicalAuthMethodInput = | |
| 111 | + | Exclude<McpAuthMethodInput, ApiKeyAuthTemplate> | |
| 112 | + | (Omit<ApiKeyAuthMethod, "slug"> & { readonly slug?: string }); | |
| 113 | + | |
| 114 | +/** The default slug for a slug-less input method. Carrier-derived for the | |
| 115 | + * single-placement apikey cases (`header` / `query`) — the slugs those | |
| 116 | + * methods have always had — so the shorthand, UI, and migration paths all | |
| 117 | + * converge on the same names. */ | |
| 118 | +const defaultMcpAuthSlug = (method: McpCanonicalAuthMethodInput): string => { | |
| 119 | + if (method.kind !== "apikey") return method.kind; | |
| 120 | + if (method.placements.length === 1) { | |
| 121 | + return method.placements[0]!.carrier === "header" ? "header" : "query"; | |
| 122 | + } | |
| 123 | + return "apikey"; | |
| 124 | +}; | |
| 125 | + | |
| 126 | +/** Expand request-shaped dialect entries into canonical placements; canonical | |
| 127 | + * entries pass through. Slug backfill is the caller's concern | |
| 128 | + * (`normalizeMcpAuthMethods` for declare flows, `mergeAuthTemplates` for the | |
| 129 | + * custom-method merge). */ | |
| 130 | +export const expandMcpAuthMethodInputs = ( | |
| 131 | + methods: readonly McpAuthMethodInput[], | |
| 132 | +): readonly McpCanonicalAuthMethodInput[] => | |
| 133 | + methods.map( | |
| 134 | + (method): McpCanonicalAuthMethodInput => | |
| 135 | + isApiKeyAuthTemplate(method) | |
| 136 | + ? (apiKeyMethodFromAuthTemplate(method) as McpCanonicalAuthMethodInput) | |
| 137 | + : (method as McpCanonicalAuthMethodInput), | |
| 138 | + ); | |
| 139 | + | |
| 140 | +/** Assign each method a stable slug: a caller-provided one wins, otherwise a | |
| 141 | + * kind/carrier-derived default, suffixed `_2`, `_3`, … on collision. The | |
| 142 | + * request-shaped dialect is expanded to canonical placements first. */ | |
| 88 | 143 | export const normalizeMcpAuthMethods = ( |
| 89 | 144 | methods: readonly McpAuthMethodInput[], |
| 90 | −): readonly McpAuthMethod[] => { | |
| 91 | − const taken = new Set<string>(); | |
| 92 | − return methods.map((method: McpAuthMethodInput): McpAuthMethod => { | |
| 93 | − const requested = method.slug?.trim() || method.kind; | |
| 94 | − let slug = requested; | |
| 95 | − for (let n = 2; taken.has(slug); n += 1) slug = `${requested}_${n}`; | |
| 96 | − taken.add(slug); | |
| 97 | − return { ...method, slug }; | |
| 98 | − }); | |
| 99 | −}; | |
| 145 | +): readonly McpAuthMethod[] => | |
| 146 | + normalizeAuthMethodSlugs( | |
| 147 | + expandMcpAuthMethodInputs(methods), | |
| 148 | + defaultMcpAuthSlug, | |
| 149 | + ) as readonly McpAuthMethod[]; | |
| 100 | 150 | |
| 101 | 151 | // --------------------------------------------------------------------------- |
| 102 | 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 | 168 | queryParams: Schema.optional(StringMap), |
| 119 | 169 | /** Static headers sent on every request (non-credential) */ |
| 120 | 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 | 172 | * requests. A connection's `template` picks one by slug. */ |
| 123 | 173 | authenticationTemplate: Schema.Array(McpAuthMethod), |
| 124 | 174 | }); |
| @@ -144,36 +194,13 @@ export const McpIntegrationConfig = Schema.Union([ · 19 unmodified lines | ||
| 144 | 194 | export type McpIntegrationConfig = typeof McpIntegrationConfig.Type; |
| 145 | 195 | |
| 146 | 196 | const decodeIntegrationConfig = Schema.decodeUnknownOption(McpIntegrationConfig); |
| 147 | −const decodeLegacyAuth = Schema.decodeUnknownOption(McpAuthTemplate); | |
| 148 | 197 | |
| 149 | 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: | |
| 151 | − * a singular `auth` becomes a one-element `authenticationTemplate` (slug = | |
| 152 | − * kind, matching the template slugs existing connections carry), and a remote | |
| 153 | − * config with neither field is an open server (`[{ slug: "none", … }]`). */ | |
| 154 | −export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => { | |
| 155 | − if ( | |
| 156 | − typeof config === "object" && | |
| 157 | − config !== null && | |
| 158 | − "transport" in config && | |
| 159 | − config.transport === "remote" && | |
| 160 | − !("authenticationTemplate" in config) | |
| 161 | − ) { | |
| 162 | − const legacyAuth = | |
| 163 | − "auth" in config | |
| 164 | − ? Option.getOrNull(decodeLegacyAuth(config.auth)) | |
| 165 | − : { kind: "none" as const }; | |
| 166 | − if (legacyAuth === null) return null; | |
| 167 | − const { auth: _auth, ...rest } = config as { auth?: unknown }; | |
| 168 | − return Option.getOrNull( | |
| 169 | − decodeIntegrationConfig({ | |
| 170 | − ...rest, | |
| 171 | − authenticationTemplate: [mcpAuthMethodFromLegacy(legacyAuth)], | |
| 172 | − }), | |
| 173 | − ); | |
| 174 | − } | |
| 175 | − return Option.getOrNull(decodeIntegrationConfig(config)); | |
| 176 | −}; | |
| 199 | + * if it isn't this plugin's (canonical) shape. Pre-canonical stored shapes | |
| 200 | + * are rewritten by the one-off config migration (`migrate-config.ts`), not | |
| 201 | + * decoded here — runtime code knows only the canonical model. */ | |
| 202 | +export const parseMcpIntegrationConfig = (config: unknown): McpIntegrationConfig | null => | |
| 203 | + Option.getOrNull(decodeIntegrationConfig(config)); | |
| 177 | 204 | |
| 178 | 205 | // --------------------------------------------------------------------------- |
| 179 | 206 | // Tool annotations — upstream MCP ToolAnnotations we honour (destructiveHint |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | +import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | |
| 3 | 4 | import { |
| 4 | 5 | InternalError, |
| 5 | 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 | − type: Schema.Literal("variable"), | |
| 51 | − name: Schema.String, | |
| 50 | +const OAuthTemplatePayload = Schema.Struct({ | |
| 51 | + slug: Schema.String, | |
| 52 | + kind: Schema.Literal("oauth2"), | |
| 53 | + authorizationUrl: Schema.String, | |
| 54 | + tokenUrl: Schema.String, | |
| 55 | + scopes: Schema.Array(Schema.String), | |
| 52 | 56 | }); |
| 53 | −const AuthenticationTemplateValuePayload = Schema.Union([ | |
| 54 | − Schema.String, | |
| 55 | − Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])), | |
| 56 | −]); | |
| 57 | −const AuthenticationPayload = Schema.Union([ | |
| 58 | − Schema.Struct({ | |
| 59 | − slug: Schema.String, | |
| 60 | − type: Schema.Literal("apiKey"), | |
| 61 | − headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)), | |
| 62 | − queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)), | |
| 63 | − }), | |
| 64 | − Schema.Struct({ | |
| 65 | − slug: Schema.String, | |
| 66 | − type: Schema.Literal("oauth"), | |
| 67 | − authorizationUrl: Schema.String, | |
| 68 | − tokenUrl: Schema.String, | |
| 69 | − scopes: Schema.Array(Schema.String), | |
| 70 | − }), | |
| 71 | −]); | |
| 57 | + | |
| 58 | +/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */ | |
| 59 | +const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); | |
| 60 | + | |
| 61 | +/** Auth in RESPONSES: the canonical stored shapes (placements). */ | |
| 62 | +const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); | |
| 72 | 63 | |
| 73 | 64 | const AddSpecPayload = Schema.Struct({ |
| 74 | 65 | spec: OpenApiSpecInputPayload, |
| @@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines | ||
| 119 | 110 | baseUrl: Schema.optional(Schema.String), |
| 120 | 111 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 121 | 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 | 116 | // The configure result — the merged `authenticationTemplate` after the new |
| 126 | 117 | // custom methods were appended/replaced. |
| 127 | 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 | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { addGroup, capture } from "@executor-js/api"; |
| 5 | −import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | |
| 6 | −import type { Authentication } from "../sdk"; | |
| 7 | 5 | import type { OpenApiPluginExtension } from "../sdk/plugin"; |
| 8 | 6 | import { OpenApiGroup } from "./group"; |
| 9 | 7 | |
| @@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines | ||
| 60 | 58 | baseUrl: payload.baseUrl, |
| 61 | 59 | headers: payload.headers ? { ...payload.headers } : undefined, |
| 62 | 60 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 63 | − authenticationTemplate: payload.authenticationTemplate?.map( | |
| 64 | − (entry): Authentication => ({ | |
| 65 | − ...entry, | |
| 66 | − slug: AuthTemplateSlug.make(entry.slug), | |
| 67 | − }), | |
| 68 | − ), | |
| 61 | + authenticationTemplate: payload.authenticationTemplate, | |
| 69 | 62 | }); |
| 70 | 63 | }), |
| 71 | 64 | ), |
| @@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines | ||
| 115 | 108 | Effect.gen(function* () { |
| 116 | 109 | const ext = yield* OpenApiExtensionService; |
| 117 | 110 | const authenticationTemplate = yield* ext.configure(params.slug, { |
| 118 | − authenticationTemplate: payload.authenticationTemplate.map( | |
| 119 | − (entry): Authentication => ({ | |
| 120 | − ...entry, | |
| 121 | − slug: AuthTemplateSlug.make(entry.slug), | |
| 122 | − }), | |
| 123 | − ), | |
| 111 | + authenticationTemplate: payload.authenticationTemplate, | |
| 124 | 112 | mode: payload.mode ?? "merge", |
| 125 | 113 | }); |
| 126 | 114 | return { authenticationTemplate: [...authenticationTemplate] }; |
| @@ -7,12 +7,9 @@ export type { · 6 unmodified lines | ||
| 7 | 7 | OpenApiPreviewInput, |
| 8 | 8 | } from "./sdk/plugin"; |
| 9 | 9 | |
| 10 | −// Auth-template authoring helpers. `variable("token")` marks where a | |
| 11 | −// connection's resolved credential renders into an `apiKey` template. | |
| 12 | −export { variable, TOKEN_VARIABLE } from "./sdk/types"; | |
| 13 | −export type { | |
| 14 | − Authentication, | |
| 15 | − APIKeyAuthentication, | |
| 16 | − AuthenticationVariable, | |
| 17 | − AuthenticationTemplateValue, | |
| 18 | −} from "./sdk/types"; | |
| 10 | +// Auth-template authoring helpers. Author apikey methods as canonical | |
| 11 | +// placements, or request-shaped: `headers: { Authorization: ["Bearer ", | |
| 12 | +// variable("token")] }` — both normalize to the same stored model. | |
| 13 | +export { TOKEN_VARIABLE } from "./sdk/types"; | |
| 14 | +export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types"; | |
| 15 | +export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | |
| @@ -34,7 +34,11 @@ import { · 33 unmodified lines | ||
| 34 | 34 | useSlugAlreadyExists, |
| 35 | 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 | 42 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 39 | 43 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 40 | 44 | import { GoogleProductPicker } from "./GoogleProductPicker"; |
| @@ -45,13 +49,7 @@ import { · 4 unmodified lines | ||
| 45 | 49 | type GoogleOpenApiPreset, |
| 46 | 50 | } from "../sdk/google-presets"; |
| 47 | 51 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 48 | −import { | |
| 49 | − type APIKeyAuthentication, | |
| 50 | − type Authentication, | |
| 51 | − type ServerInfo, | |
| 52 | − TOKEN_VARIABLE, | |
| 53 | − variable, | |
| 54 | −} from "../sdk/types"; | |
| 52 | +import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types"; | |
| 55 | 53 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 56 | 54 | |
| 57 | 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 | 170 | const apiKeyTemplateFromHeaderPreset = ( |
| 173 | 171 | preset: HeaderPreset, |
| 174 | 172 | slug: AuthTemplateSlug, |
| 175 | −): APIKeyAuthentication => { | |
| 176 | − const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {}; | |
| 177 | − for (const headerName of preset.secretHeaders) { | |
| 173 | +): APIKeyAuthentication => ({ | |
| 174 | + slug, | |
| 175 | + kind: "apikey", | |
| 176 | + // Every secret header shares the one credential input (the canonical | |
| 177 | + // `token`, stored as an absent placement variable). | |
| 178 | + placements: preset.secretHeaders.map((headerName) => { | |
| 178 | 179 | const prefix = headerPrefix(preset, headerName); |
| 179 | − headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)]; | |
| 180 | − } | |
| 181 | − return { slug, type: "apiKey", headers }; | |
| 182 | −}; | |
| 180 | + return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; | |
| 181 | + }), | |
| 182 | +}); | |
| 183 | 183 | |
| 184 | 184 | const oauthTemplateFromPreset = ( |
| 185 | 185 | preset: OAuth2Preset, |
| @@ -188,7 +188,7 @@ const oauthTemplateFromPreset = ( · 2 unmodified lines | ||
| 188 | 188 | scopes: readonly string[], |
| 189 | 189 | ): OAuthAuthentication => ({ |
| 190 | 190 | slug, |
| 191 | − type: "oauth", | |
| 191 | + kind: "oauth2", | |
| 192 | 192 | authorizationUrl: resolveOAuthUrl( |
| 193 | 193 | Option.getOrElse(preset.authorizationUrl, () => ""), |
| 194 | 194 | baseUrl, |
| @@ -454,10 +454,8 @@ export default function AddOpenApiSource(props: { · 259 unmodified lines | ||
| 454 | 454 | baseUrl: resolvedBaseUrl, |
| 455 | 455 | ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0 |
| 456 | 456 | ? { |
| 457 | − authenticationTemplate: editedAuthenticationTemplate.map((entry) => ({ | |
| 458 | − ...entry, | |
| 459 | − slug: String(entry.slug), | |
| 460 | − })), | |
| 457 | + // Serialize to the wire input dialect (apikey → request-shaped). | |
| 458 | + authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput), | |
| 461 | 459 | } |
| 462 | 460 | : {}), |
| 463 | 461 | }, |
| @@ -18,7 +18,11 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "@executor-js/react/lib/custom-auth-methods"; |
| 19 | 19 | |
| 20 | 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 | 26 | import { googleAudienceWarningsForUrls } from "../sdk/google-presets"; |
| 23 | 27 | import type { Authentication } from "../sdk/types"; |
| 24 | 28 | |
| @@ -82,7 +86,7 @@ export default function OpenApiAccountsPanel(props: { · 57 unmodified lines | ||
| 82 | 86 | const exit = await doConfigure({ |
| 83 | 87 | params: { slug }, |
| 84 | 88 | payload: { |
| 85 | − authenticationTemplate: input.authenticationTemplate, | |
| 89 | + authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), | |
| 86 | 90 | ...(input.mode ? { mode: input.mode } : {}), |
| 87 | 91 | }, |
| 88 | 92 | reactivityKeys: integrationWriteKeys, |
| @@ -1,111 +1,118 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 3 | −import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | |
| 4 | 3 | |
| 5 | 4 | import { |
| 5 | + authMethodsFromConfig, | |
| 6 | 6 | authenticationFromEditorValue, |
| 7 | 7 | editorValueFromAuthentication, |
| 8 | 8 | templateFromPlacements, |
| 9 | 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", () => { | |
| 13 | − it("maps a bearer apiKey header template → an apikey editor value with prefix", () => { | |
| 14 | − const template: Authentication = { | |
| 15 | − slug: AuthTemplateSlug.make("apikey-0"), | |
| 16 | − type: "apiKey", | |
| 17 | − headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] }, | |
| 18 | − }; | |
| 19 | − expect(editorValueFromAuthentication(template)).toEqual({ | |
| 20 | − kind: "apikey", | |
| 21 | − placements: [ | |
| 22 | − { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" }, | |
| 23 | − ], | |
| 24 | − }); | |
| 25 | − }); | |
| 26 | − | |
| 27 | − it("maps a query apiKey template → an apikey editor value (empty prefix)", () => { | |
| 28 | − const template: Authentication = { | |
| 29 | − slug: AuthTemplateSlug.make("apikey-1"), | |
| 30 | − type: "apiKey", | |
| 31 | − queryParams: { api_key: [variable(TOKEN_VARIABLE)] }, | |
| 32 | − }; | |
| 33 | − expect(editorValueFromAuthentication(template)).toEqual({ | |
| 34 | − kind: "apikey", | |
| 35 | − placements: [{ carrier: "query", name: "api_key", prefix: "", variable: "token" }], | |
| 36 | − }); | |
| 37 | − }); | |
| 38 | − | |
| 39 | − it("maps an oauth template → an oauth editor value carrying endpoints + scopes", () => { | |
| 40 | − const template: Authentication = { | |
| 41 | − slug: AuthTemplateSlug.make("oauth-google"), | |
| 42 | − type: "oauth", | |
| 43 | − authorizationUrl: "https://accounts.example.com/o/oauth2/auth", | |
| 44 | − tokenUrl: "https://oauth2.example.com/token", | |
| 45 | − scopes: ["openid", "email"], | |
| 46 | − }; | |
| 47 | − expect(editorValueFromAuthentication(template)).toEqual({ | |
| 12 | +describe("authMethodsFromConfig", () => { | |
| 13 | + it("projects oauth templates with their stored endpoints + scopes", () => { | |
| 14 | + const methods = authMethodsFromConfig([ | |
| 15 | + { | |
| 16 | + slug: AuthTemplateSlug.make("oauth"), | |
| 17 | + kind: "oauth2", | |
| 18 | + authorizationUrl: "https://x.example/auth", | |
| 19 | + tokenUrl: "https://x.example/token", | |
| 20 | + scopes: ["read"], | |
| 21 | + }, | |
| 22 | + ]); | |
| 23 | + expect(methods[0]).toMatchObject({ | |
| 24 | + id: "oauth", | |
| 48 | 25 | kind: "oauth", |
| 49 | − authorizationUrl: "https://accounts.example.com/o/oauth2/auth", | |
| 50 | − tokenUrl: "https://oauth2.example.com/token", | |
| 51 | − scopes: ["openid", "email"], | |
| 26 | + source: "spec", | |
| 27 | + oauth: { | |
| 28 | + authorizationUrl: "https://x.example/auth", | |
| 29 | + tokenUrl: "https://x.example/token", | |
| 30 | + scopes: ["read"], | |
| 31 | + }, | |
| 52 | 32 | }); |
| 53 | 33 | }); |
| 54 | −}); | |
| 55 | 34 | |
| 56 | −describe("authenticationFromEditorValue", () => { | |
| 57 | − it("returns null for a 'none' value (nothing to register)", () => { | |
| 58 | − expect(authenticationFromEditorValue({ kind: "none" })).toBeNull(); | |
| 35 | + it("projects apikey methods, multi-placement and multi-variable intact", () => { | |
| 36 | + const methods = authMethodsFromConfig([ | |
| 37 | + { | |
| 38 | + slug: "custom_dd", | |
| 39 | + kind: "apikey", | |
| 40 | + placements: [ | |
| 41 | + { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | |
| 42 | + { carrier: "query", name: "team_id", variable: "team_id" }, | |
| 43 | + ], | |
| 44 | + }, | |
| 45 | + ]); | |
| 46 | + expect(methods[0]).toMatchObject({ id: "custom_dd", kind: "apikey", source: "custom" }); | |
| 47 | + expect(methods[0]?.placements).toEqual([ | |
| 48 | + { carrier: "header", name: "DD-API-KEY", prefix: "", variable: "dd_api_key" }, | |
| 49 | + { carrier: "query", name: "team_id", prefix: "", variable: "team_id" }, | |
| 50 | + ]); | |
| 59 | 51 | }); |
| 52 | +}); | |
| 60 | 53 | |
| 61 | − it("builds an apiKey template from placements, stamping the slug", () => { | |
| 62 | − const value: AuthTemplateEditorValue = { | |
| 54 | +describe("editor round-trip", () => { | |
| 55 | + it("apikey stored → editor → stored preserves placements and shared token input", () => { | |
| 56 | + const stored: Authentication = { | |
| 57 | + slug: "bearer", | |
| 63 | 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( | |
| 67 | − templateFromPlacements(value.placements, "custom_x"), | |
| 68 | − ); | |
| 64 | + const editor = editorValueFromAuthentication(stored); | |
| 65 | + const back = authenticationFromEditorValue(editor, "bearer"); | |
| 66 | + expect(back).toEqual(stored); | |
| 69 | 67 | }); |
| 70 | 68 | |
| 71 | − it("builds an oauth template from an oauth value", () => { | |
| 72 | − const value: AuthTemplateEditorValue = { | |
| 69 | + it("oauth stored → editor carries endpoints + scopes", () => { | |
| 70 | + expect( | |
| 71 | + editorValueFromAuthentication({ | |
| 72 | + slug: AuthTemplateSlug.make("oauth"), | |
| 73 | + kind: "oauth2", | |
| 74 | + authorizationUrl: "https://x.example/auth", | |
| 75 | + tokenUrl: "https://x.example/token", | |
| 76 | + scopes: ["a", "b"], | |
| 77 | + }), | |
| 78 | + ).toEqual({ | |
| 73 | 79 | kind: "oauth", |
| 74 | − authorizationUrl: "https://a.example.com/auth", | |
| 75 | − tokenUrl: "https://a.example.com/token", | |
| 76 | − scopes: ["read"], | |
| 77 | − }; | |
| 78 | − expect(authenticationFromEditorValue(value, "oauth-0")).toEqual({ | |
| 79 | − slug: AuthTemplateSlug.make("oauth-0"), | |
| 80 | − type: "oauth", | |
| 81 | − authorizationUrl: "https://a.example.com/auth", | |
| 82 | − tokenUrl: "https://a.example.com/token", | |
| 83 | − scopes: ["read"], | |
| 80 | + authorizationUrl: "https://x.example/auth", | |
| 81 | + tokenUrl: "https://x.example/token", | |
| 82 | + scopes: ["a", "b"], | |
| 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)", () => { | |
| 89 | − it("apiKey: editorValue ∘ authentication is identity for the placement shape", () => { | |
| 90 | − const template: Authentication = { | |
| 91 | − slug: AuthTemplateSlug.make("apikey-0"), | |
| 92 | − type: "apiKey", | |
| 93 | − headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] }, | |
| 94 | − }; | |
| 95 | − const value = editorValueFromAuthentication(template); | |
| 96 | − const back = authenticationFromEditorValue(value, "apikey-0"); | |
| 97 | − expect(back).toEqual(template); | |
| 91 | +describe("templateFromPlacements", () => { | |
| 92 | + it("multi-placement custom methods assign distinct name-derived variables", () => { | |
| 93 | + expect( | |
| 94 | + templateFromPlacements( | |
| 95 | + [ | |
| 96 | + { carrier: "header", name: "DD-API-KEY", prefix: "" }, | |
| 97 | + { carrier: "header", name: "DD-APPLICATION-KEY", prefix: "" }, | |
| 98 | + ], | |
| 99 | + "custom_dd", | |
| 100 | + ), | |
| 101 | + ).toEqual({ | |
| 102 | + slug: "custom_dd", | |
| 103 | + kind: "apikey", | |
| 104 | + placements: [ | |
| 105 | + { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, | |
| 106 | + { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" }, | |
| 107 | + ], | |
| 108 | + }); | |
| 98 | 109 | }); |
| 99 | 110 | |
| 100 | − it("oauth: editorValue ∘ authentication preserves endpoints + scopes", () => { | |
| 101 | − const template: Authentication = { | |
| 102 | − slug: AuthTemplateSlug.make("oauth-x"), | |
| 103 | − type: "oauth", | |
| 104 | − authorizationUrl: "https://a.example.com/auth", | |
| 105 | − tokenUrl: "https://a.example.com/token", | |
| 106 | − scopes: ["read", "write"], | |
| 107 | − }; | |
| 108 | − const back = authenticationFromEditorValue(editorValueFromAuthentication(template), "oauth-x"); | |
| 109 | − expect(back).toEqual(template); | |
| 111 | + it("a lone placement keeps the canonical token input (stored absent)", () => { | |
| 112 | + expect(templateFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }])).toEqual({ | |
| 113 | + slug: "", | |
| 114 | + kind: "apikey", | |
| 115 | + placements: [{ carrier: "query", name: "api_key" }], | |
| 116 | + }); | |
| 110 | 117 | }); |
| 111 | 118 | }); |
| @@ -1,192 +1,79 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// OpenAPI ↔ generic auth-method converters. | |
| 3 | −// | |
| 4 | −// The generic Accounts hub speaks in plugin-agnostic `AuthMethod` / | |
| 5 | −// `Placement` values (`@executor-js/react/lib/auth-placements`). The OpenAPI | |
| 6 | −// plugin stores auth as the HTTP-transport-specific `APIKeyAuthentication` | |
| 7 | −// template (header/query slots templated with `variable("token")`). These two | |
| 8 | −// converters bridge the wire template to/from the generic placement model, so | |
| 9 | −// they live with the OpenAPI plugin — they touch the openapi sdk | |
| 10 | −// `Authentication` types and would pull transport specifics into core. | |
| 2 | +// OpenAPI ↔ generic auth-method converters — a thin oauth adapter over the | |
| 3 | +// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The | |
| 4 | +// apikey/none paths (multi-placement, multi-variable) live in the shared | |
| 5 | +// codec; OpenAPI only contributes its oauth flavor: stored endpoints + scopes | |
| 6 | +// (`kind: "oauth2"`, the core `OAuthAuthentication` shape) that pre-fill the client-registration form. | |
| 11 | 7 | // --------------------------------------------------------------------------- |
| 12 | 8 | |
| 13 | 9 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 14 | 10 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 15 | 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 16 | − | |
| 17 | 12 | import { |
| 18 | − TOKEN_VARIABLE, | |
| 19 | − variable, | |
| 20 | − type APIKeyAuthentication, | |
| 21 | − type Authentication, | |
| 22 | − type AuthenticationTemplateValue, | |
| 23 | −} from "../sdk/types"; | |
| 13 | + authMethodFromSharedTemplate, | |
| 14 | + editorPlacementsFromWire, | |
| 15 | + editorValueFromSharedMethod, | |
| 16 | + wireAuthInputFromShared, | |
| 17 | + wirePlacementsFromEditor, | |
| 18 | +} from "@executor-js/react/lib/shared-auth-method-codec"; | |
| 24 | 19 | |
| 25 | −// --------------------------------------------------------------------------- | |
| 26 | −// Template value → placement prefix. | |
| 27 | −// | |
| 28 | −// A header/query slot serializes the credential as `name -> [prefix, token]`, | |
| 29 | −// where `prefix` is the leading literal string before the `variable("token")` | |
| 30 | −// part. A bare `[token]` (or string-only) value has an empty prefix. | |
| 31 | −// --------------------------------------------------------------------------- | |
| 20 | +import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types"; | |
| 32 | 21 | |
| 33 | −const isVariablePart = (part: string | { readonly type: "variable"; readonly name: string }) => | |
| 34 | − typeof part !== "string" && part.type === "variable"; | |
| 22 | +/** Serialize a canonical method into the wire input union (apikey → the | |
| 23 | + * request-shaped dialect; oauth passes through). */ | |
| 24 | +export const openApiWireAuthInput = (method: Authentication): AuthenticationInput => | |
| 25 | + method.kind === "oauth2" ? method : (wireAuthInputFromShared(method) as AuthenticationInput); | |
| 35 | 26 | |
| 36 | −/** Extract the literal prefix preceding the credential variable, plus the | |
| 37 | − * variable name that placement renders from (`token` for single-input methods, | |
| 38 | − * a distinct name per input for multi-input ones). */ | |
| 39 | −const parseTemplateValue = ( | |
| 40 | − value: AuthenticationTemplateValue, | |
| 41 | −): { readonly prefix: string; readonly variable: string } => { | |
| 42 | − if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE }; | |
| 43 | − const parts: string[] = []; | |
| 44 | − for (const part of value) { | |
| 45 | − if (isVariablePart(part)) { | |
| 46 | − return { prefix: parts.join(""), variable: (part as { readonly name: string }).name }; | |
| 47 | − } | |
| 48 | − if (typeof part === "string") parts.push(part); | |
| 49 | − } | |
| 50 | − return { prefix: parts.join(""), variable: TOKEN_VARIABLE }; | |
| 51 | −}; | |
| 27 | +export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] => | |
| 28 | + editorPlacementsFromWire(template.placements); | |
| 52 | 29 | |
| 53 | −export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => { | |
| 54 | − const placements: Placement[] = []; | |
| 55 | − for (const [name, value] of Object.entries(template.headers ?? {})) { | |
| 56 | − const { prefix, variable } = parseTemplateValue(value); | |
| 57 | − placements.push({ carrier: "header", name, prefix, variable }); | |
| 58 | − } | |
| 59 | − for (const [name, value] of Object.entries(template.queryParams ?? {})) { | |
| 60 | − const { prefix, variable } = parseTemplateValue(value); | |
| 61 | − placements.push({ carrier: "query", name, prefix, variable }); | |
| 62 | − } | |
| 63 | − return placements; | |
| 64 | −}; | |
| 65 | − | |
| 66 | −// --------------------------------------------------------------------------- | |
| 67 | −// Templates → generic methods. | |
| 68 | −// --------------------------------------------------------------------------- | |
| 69 | − | |
| 70 | −const labelForApiKey = (slug: string, placements: readonly Placement[]): string => { | |
| 71 | − const first = placements[0]; | |
| 72 | − if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`; | |
| 73 | − return `API key (${slug})`; | |
| 30 | +const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): AuthMethod => { | |
| 31 | + const slug = String(template.slug); | |
| 32 | + return { | |
| 33 | + id: slug, | |
| 34 | + label: "OAuth2", | |
| 35 | + kind: "oauth", | |
| 36 | + source: slug.startsWith("custom_") ? "custom" : "spec", | |
| 37 | + template: AuthTemplateSlug.make(slug), | |
| 38 | + placements: [], | |
| 39 | + // Carry the integration's declared endpoints/scopes so the | |
| 40 | + // client-registration form pre-fills them. | |
| 41 | + oauth: { | |
| 42 | + authorizationUrl: template.authorizationUrl, | |
| 43 | + tokenUrl: template.tokenUrl, | |
| 44 | + scopes: template.scopes, | |
| 45 | + }, | |
| 46 | + }; | |
| 74 | 47 | }; |
| 75 | 48 | |
| 76 | 49 | /** Map each stored auth template to a generic `AuthMethod`. */ |
| 77 | 50 | export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] { |
| 78 | 51 | return templates.map((template: Authentication): AuthMethod => { |
| 79 | − const slug = String(template.slug); | |
| 80 | − const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec"; | |
| 81 | − if (template.type === "oauth") { | |
| 82 | − return { | |
| 83 | − id: slug, | |
| 84 | − label: "OAuth2", | |
| 85 | − kind: "oauth", | |
| 86 | − source, | |
| 87 | − template: AuthTemplateSlug.make(slug), | |
| 88 | − placements: [], | |
| 89 | − // Carry the integration's declared endpoints/scopes so the | |
| 90 | − // client-registration form pre-fills them. | |
| 91 | − oauth: { | |
| 92 | − authorizationUrl: template.authorizationUrl, | |
| 93 | − tokenUrl: template.tokenUrl, | |
| 94 | − scopes: template.scopes, | |
| 95 | − }, | |
| 96 | − }; | |
| 97 | − } | |
| 98 | − const placements = placementsFromApiKey(template); | |
| 99 | − return { | |
| 100 | − id: slug, | |
| 101 | − label: labelForApiKey(slug, placements), | |
| 102 | − kind: "apikey", | |
| 103 | − source, | |
| 104 | − template: AuthTemplateSlug.make(slug), | |
| 105 | − placements, | |
| 106 | − }; | |
| 52 | + if (template.kind === "oauth2") return oauthAuthMethod(template); | |
| 53 | + return authMethodFromSharedTemplate(template); | |
| 107 | 54 | }); |
| 108 | 55 | } |
| 109 | 56 | |
| 110 | −// --------------------------------------------------------------------------- | |
| 111 | −// Generic placements → apiKey template (inverse). | |
| 112 | −// | |
| 113 | −// Each placement becomes a header/query slot whose value is | |
| 114 | −// `prefix ? [prefix, variable("token")] : [variable("token")]`. A custom method | |
| 115 | −// may omit `slug`; the backend backfills `custom_<id>`. | |
| 116 | −// --------------------------------------------------------------------------- | |
| 117 | − | |
| 118 | −/** Slugify a placement name into a variable identifier: `DD-API-KEY` → | |
| 119 | − * `dd_api_key`. */ | |
| 120 | −const slugifyVariable = (name: string): string => | |
| 121 | − name | |
| 122 | − .toLowerCase() | |
| 123 | − .replace(/[^a-z0-9]+/g, "_") | |
| 124 | − .replace(/^_+|_+$/g, ""); | |
| 125 | − | |
| 126 | −/** Assign an input variable to each (named) placement. A lone input is the | |
| 127 | − * canonical `token`; multiple inputs each get their own distinct variable so a | |
| 128 | − * connection can carry a different value per location. An explicit | |
| 129 | − * `placement.variable` (from a round-trip) is honored. */ | |
| 130 | −const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => { | |
| 131 | − const named = placements.filter((p) => p.name); | |
| 132 | − const out = new Map<Placement, string>(); | |
| 133 | − if (named.length <= 1) { | |
| 134 | − for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE); | |
| 135 | − return out; | |
| 136 | − } | |
| 137 | − const taken = new Set<string>(); | |
| 138 | − for (const p of named) { | |
| 139 | − const base = slugifyVariable(p.name) || "input"; | |
| 140 | − let candidate = p.variable ?? base; | |
| 141 | − let n = 2; | |
| 142 | − while (taken.has(candidate)) candidate = `${base}_${n++}`; | |
| 143 | − taken.add(candidate); | |
| 144 | − out.set(p, candidate); | |
| 145 | − } | |
| 146 | − return out; | |
| 147 | −}; | |
| 148 | − | |
| 149 | −const valueFromPlacement = (placement: Placement, varName: string): AuthenticationTemplateValue => | |
| 150 | − placement.prefix ? [placement.prefix, variable(varName)] : [variable(varName)]; | |
| 151 | − | |
| 152 | −/** Build an `APIKeyAuthentication` template from generic placements. When | |
| 153 | − * `slug` is omitted the backend assigns a `custom_<id>` slug. */ | |
| 57 | +/** Build an apikey method from generic placements. When `slug` is omitted the | |
| 58 | + * backend assigns a `custom_<id>` slug. */ | |
| 154 | 59 | export function templateFromPlacements( |
| 155 | 60 | placements: readonly Placement[], |
| 156 | 61 | slug?: string, |
| 157 | 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 | 63 | return { |
| 171 | − slug: AuthTemplateSlug.make(slug ?? ""), | |
| 172 | − type: "apiKey", | |
| 173 | − ...(Object.keys(headers).length > 0 ? { headers } : {}), | |
| 174 | − ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), | |
| 64 | + slug: slug ?? "", | |
| 65 | + kind: "apikey", | |
| 66 | + placements: wirePlacementsFromEditor(placements), | |
| 175 | 67 | }; |
| 176 | 68 | } |
| 177 | 69 | |
| 178 | 70 | // --------------------------------------------------------------------------- |
| 179 | 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 | 74 | /** Convert one stored `Authentication` template into a generic editor value. */ |
| 188 | 75 | export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue { |
| 189 | − if (template.type === "oauth") { | |
| 76 | + if (template.kind === "oauth2") { | |
| 190 | 77 | return { |
| 191 | 78 | kind: "oauth", |
| 192 | 79 | authorizationUrl: template.authorizationUrl ?? "", |
| @@ -194,7 +81,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 1 unmodified line | ||
| 194 | 81 | scopes: template.scopes ?? [], |
| 195 | 82 | }; |
| 196 | 83 | } |
| 197 | − return { kind: "apikey", placements: placementsFromApiKey(template) }; | |
| 84 | + return editorValueFromSharedMethod(template); | |
| 198 | 85 | } |
| 199 | 86 | |
| 200 | 87 | /** Build an `OAuthAuthentication` template from a generic oauth editor value. */ |
| @@ -203,7 +90,7 @@ const oauthTemplateFromEditorValue = ( · 2 unmodified lines | ||
| 203 | 90 | slug?: string, |
| 204 | 91 | ): Authentication => ({ |
| 205 | 92 | slug: AuthTemplateSlug.make(slug ?? ""), |
| 206 | − type: "oauth", | |
| 93 | + kind: "oauth2", | |
| 207 | 94 | authorizationUrl: value.authorizationUrl, |
| 208 | 95 | tokenUrl: value.tokenUrl, |
| 209 | 96 | scopes: [...value.scopes], |
| @@ -1,7 +1,12 @@ | ||
| 1 | 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"; | |
| 4 | −import { TOKEN_VARIABLE } from "./types"; | |
| 9 | +import type { Authentication } from "./types"; | |
| 5 | 10 | |
| 6 | 11 | // --------------------------------------------------------------------------- |
| 7 | 12 | // OpenAPI integration config — the opaque blob stored on the catalog |
| @@ -15,35 +20,15 @@ import { TOKEN_VARIABLE } from "./types"; · 7 unmodified lines | ||
| 15 | 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 | 23 | const OAuthAuthenticationSchema = Schema.Struct({ |
| 36 | 24 | slug: Schema.String, |
| 37 | − type: Schema.Literal("oauth"), | |
| 25 | + kind: Schema.Literal("oauth2"), | |
| 38 | 26 | authorizationUrl: Schema.String, |
| 39 | 27 | tokenUrl: Schema.String, |
| 40 | 28 | scopes: Schema.Array(Schema.String), |
| 41 | 29 | }); |
| 42 | 30 | |
| 43 | −export const AuthenticationSchema = Schema.Union([ | |
| 44 | − OAuthAuthenticationSchema, | |
| 45 | − APIKeyAuthenticationSchema, | |
| 46 | −]); | |
| 31 | +export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]); | |
| 47 | 32 | |
| 48 | 33 | export const OpenApiIntegrationConfigSchema = Schema.Struct({ |
| 49 | 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 | 67 | // Template rendering — "auth state derived into the auth-template format" |
| 83 | −// (D11). The resolved credential value renders into the template's | |
| 84 | −// `variable("token")` slots, identically for apiKey and oauth (the oauth value | |
| 85 | −// IS the access token). Returns the headers + query params to apply. | |
| 68 | +// (D11). An apiKey method renders through the shared placements renderer; an | |
| 69 | +// oauth template (no explicit placement) renders a bearer `authorization` | |
| 70 | +// header from the `token` input (the access token). | |
| 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 | 73 | export interface RenderedAuth { |
| 100 | 74 | readonly headers: Record<string, string>; |
| 101 | 75 | readonly queryParams: Record<string, string>; |
| 102 | 76 | } |
| 103 | 77 | |
| 104 | 78 | /** Render an auth template against a connection's resolved input `values` |
| 105 | − * (`variable → value`). For an apiKey template, each `variable("<name>")` is | |
| 106 | − * substituted from its own entry, so a method with two distinct inputs (e.g. | |
| 107 | − * Datadog) fills each header from a different value. For an oauth template (no | |
| 108 | − * explicit placement), render a bearer Authorization header from `token`. */ | |
| 79 | + * (`variable → value`). Each placement substitutes from its own entry, so a | |
| 80 | + * method with two distinct inputs (e.g. Datadog) fills each header from a | |
| 81 | + * different value. */ | |
| 109 | 82 | export const renderAuthTemplate = ( |
| 110 | 83 | template: Authentication, |
| 111 | 84 | values: Record<string, string | null>, |
| 112 | 85 | ): RenderedAuth => { |
| 113 | − if (template.type === "oauth") { | |
| 86 | + if (template.kind === "oauth2") { | |
| 114 | 87 | return { |
| 115 | 88 | headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` }, |
| 116 | 89 | queryParams: {}, |
| 117 | 90 | }; |
| 118 | 91 | } |
| 119 | − const headers: Record<string, string> = {}; | |
| 120 | − const queryParams: Record<string, string> = {}; | |
| 121 | − for (const [name, tmpl] of Object.entries(template.headers ?? {})) { | |
| 122 | − headers[name] = renderTemplateValue(tmpl, values); | |
| 123 | − } | |
| 124 | − for (const [name, tmpl] of Object.entries(template.queryParams ?? {})) { | |
| 125 | − queryParams[name] = renderTemplateValue(tmpl, values); | |
| 126 | − } | |
| 127 | − return { headers, queryParams }; | |
| 92 | + return renderAuthPlacements(template.placements, values); | |
| 128 | 93 | }; |
| 129 | 94 | |
| 130 | 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 | |
| 132 | − * `variable("<name>")` across its placements. */ | |
| 96 | + * must supply. An oauth template needs `token`; an apiKey method needs every | |
| 97 | + * variable across its placements. */ | |
| 133 | 98 | export const requiredTemplateVariables = (template: Authentication): readonly string[] => { |
| 134 | − if (template.type === "oauth") return [TOKEN_VARIABLE]; | |
| 135 | − const names = new Set<string>(); | |
| 136 | − const collect = (tmpl: AuthenticationTemplateValue): void => { | |
| 137 | − if (typeof tmpl === "string") return; | |
| 138 | − for (const part of tmpl) { | |
| 139 | − if (isVariable(part)) names.add(part.name); | |
| 140 | − } | |
| 141 | − }; | |
| 142 | − for (const tmpl of Object.values(template.headers ?? {})) collect(tmpl); | |
| 143 | − for (const tmpl of Object.values(template.queryParams ?? {})) collect(tmpl); | |
| 144 | − return [...names]; | |
| 99 | + if (template.kind === "oauth2") return [TOKEN_VARIABLE]; | |
| 100 | + return requiredPlacementVariables(template.placements); | |
| 145 | 101 | }; |
| @@ -5,7 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | // integration's opaque config. These tests exercise the extension method |
| 6 | 6 | // directly (the same path the HTTP `configure` handler calls): |
| 7 | 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 | 9 | // - slug generation: a method with no slug is assigned `custom_<id>`, |
| 10 | 10 | // - slug uniqueness/dedupe: a method whose slug matches an existing entry |
| 11 | 11 | // replaces it rather than duplicating, and two slug-less methods in one call |
| @@ -28,7 +28,8 @@ import { · 16 unmodified lines | ||
| 28 | 28 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 29 | 29 | |
| 30 | 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 | 33 | import { |
| 33 | 34 | makeOpenApiHttpApiTestSourceConfig, |
| 34 | 35 | serveOpenApiHttpApiTestServer, |
| @@ -71,9 +72,10 @@ const specText = () => { · 36 unmodified lines | ||
| 71 | 72 | return spec.url; |
| 72 | 73 | }; |
| 73 | 74 | |
| 74 | −// A custom apiKey method that places the connection value into `x-api-key`. | |
| 75 | −const customApiKey: APIKeyAuthentication = { | |
| 76 | − slug: AuthTemplateSlug.make("my_custom"), | |
| 75 | +// A custom apiKey method that places the connection value into `x-api-key`, | |
| 76 | +// authored request-shaped (the one apikey input dialect). | |
| 77 | +const customApiKey: AuthenticationInput = { | |
| 78 | + slug: "my_custom", | |
| 77 | 79 | type: "apiKey", |
| 78 | 80 | headers: { "x-api-key": [variable("token")] }, |
| 79 | 81 | }; |
| @@ -81,9 +83,9 @@ const customApiKey: APIKeyAuthentication = { · 1 unmodified line | ||
| 81 | 83 | // Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that |
| 82 | 84 | // omits it, which `configure` should backfill with a generated `custom_<id>`. |
| 83 | 85 | // The cast is confined to this one boundary helper. |
| 84 | −const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication => | |
| 85 | − // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it | |
| 86 | − ({ type: "apiKey", ...placement }) as unknown as Authentication; | |
| 86 | +const sluglessApiKey = ( | |
| 87 | + template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">, | |
| 88 | +): AuthenticationInput => ({ type: "apiKey", ...template }); | |
| 87 | 89 | |
| 88 | 90 | describe("OpenAPI Plugin — configure (custom auth method)", () => { |
| 89 | 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 | 109 | const template = (config?.authenticationTemplate ?? []) as readonly Authentication[]; |
| 108 | 110 | expect(template).toHaveLength(1); |
| 109 | 111 | const entry = template[0] as APIKeyAuthentication; |
| 110 | − expect(entry.type).toBe("apiKey"); | |
| 112 | + expect(entry.kind).toBe("apikey"); | |
| 111 | 113 | expect(String(entry.slug)).toBe("my_custom"); |
| 112 | − // The header placement preserves the `variable("token")` slot verbatim. | |
| 113 | − expect(entry.headers).toEqual({ "x-api-key": [variable("token")] }); | |
| 114 | + // The placement preserves the canonical `token` input (stored absent). | |
| 115 | + expect(entry.placements).toEqual([{ carrier: "header", name: "x-api-key" }]); | |
| 114 | 116 | }), |
| 115 | 117 | ), |
| 116 | 118 | ); |
| @@ -120,8 +122,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines | ||
| 120 | 122 | Effect.gen(function* () { |
| 121 | 123 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); |
| 122 | 124 | |
| 123 | − const seedTemplate: Authentication = { | |
| 124 | − slug: AuthTemplateSlug.make("seed"), | |
| 125 | + const seedTemplate: AuthenticationInput = { | |
| 126 | + slug: "seed", | |
| 125 | 127 | type: "apiKey", |
| 126 | 128 | headers: { authorization: ["Bearer ", variable("token")] }, |
| 127 | 129 | }; |
| @@ -151,9 +153,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 23 unmodified lines | ||
| 151 | 153 | slug: "cfg_genslug", |
| 152 | 154 | }); |
| 153 | 155 | |
| 154 | − const slugless = sluglessApiKey({ | |
| 155 | − headers: { "x-api-key": [variable("token")] }, | |
| 156 | − }); | |
| 156 | + const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } }); | |
| 157 | 157 | |
| 158 | 158 | const merged = yield* executor.openapi.configure("cfg_genslug", { |
| 159 | 159 | authenticationTemplate: [slugless], |
| @@ -177,17 +177,13 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines | ||
| 177 | 177 | }); |
| 178 | 178 | |
| 179 | 179 | // Re-submit the same slug with a different placement → replace in place. |
| 180 | − const replacement: APIKeyAuthentication = { | |
| 181 | − slug: AuthTemplateSlug.make("my_custom"), | |
| 180 | + const replacement: AuthenticationInput = { | |
| 181 | + slug: "my_custom", | |
| 182 | 182 | type: "apiKey", |
| 183 | 183 | headers: { "x-other": [variable("token")] }, |
| 184 | 184 | }; |
| 185 | − const slugless = sluglessApiKey({ | |
| 186 | − queryParams: { api_key: [variable("token")] }, | |
| 187 | − }); | |
| 188 | − const slugless2 = sluglessApiKey({ | |
| 189 | − queryParams: { token: [variable("token")] }, | |
| 190 | − }); | |
| 185 | + const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } }); | |
| 186 | + const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } }); | |
| 191 | 187 | |
| 192 | 188 | const merged = yield* executor.openapi.configure("cfg_dedupe", { |
| 193 | 189 | authenticationTemplate: [replacement, slugless, slugless2], |
| @@ -205,7 +201,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 11 unmodified lines | ||
| 205 | 201 | const replaced = merged.find( |
| 206 | 202 | (m: Authentication) => String(m.slug) === "my_custom", |
| 207 | 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 | 211 | Effect.gen(function* () { |
| 216 | 212 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); |
| 217 | 213 | |
| 218 | − const seedTemplate: Authentication = { | |
| 219 | − slug: AuthTemplateSlug.make("seed"), | |
| 214 | + const seedTemplate: AuthenticationInput = { | |
| 215 | + slug: "seed", | |
| 220 | 216 | type: "apiKey", |
| 221 | 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 | 321 | it.effect("getConfig returns null for an unknown integration", () => |
| 277 | 322 | Effect.scoped( |
| 278 | 323 | Effect.gen(function* () { |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@executor-js/sdk/core"; |
| 8 | 8 | |
| 9 | 9 | import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin"; |
| 10 | −import { variable, type Authentication } from "./types"; | |
| 10 | +import { type Authentication } from "./types"; | |
| 11 | 11 | |
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | // `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]` |
| @@ -33,8 +33,8 @@ describe("describeOpenApiAuthMethods", () => { · 19 unmodified lines | ||
| 33 | 33 | recordWith([ |
| 34 | 34 | { |
| 35 | 35 | slug: AuthTemplateSlug.make("bearer"), |
| 36 | − type: "apiKey", | |
| 37 | − headers: { Authorization: ["Bearer ", variable("token")] }, | |
| 36 | + kind: "apikey", | |
| 37 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 38 | 38 | }, |
| 39 | 39 | ]), |
| 40 | 40 | ); |
| @@ -45,9 +45,8 @@ describe("describeOpenApiAuthMethods", () => { · 4 unmodified lines | ||
| 45 | 45 | label: "API key (Authorization)", |
| 46 | 46 | kind: "apikey", |
| 47 | 47 | template: "bearer", |
| 48 | − placements: [ | |
| 49 | − { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" }, | |
| 50 | − ], | |
| 48 | + // The canonical `token` input is stored (and projected) as absent. | |
| 49 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 51 | 50 | }, |
| 52 | 51 | ]); |
| 53 | 52 | }); |
| @@ -57,7 +56,7 @@ describe("describeOpenApiAuthMethods", () => { · 3 unmodified lines | ||
| 57 | 56 | recordWith([ |
| 58 | 57 | { |
| 59 | 58 | slug: AuthTemplateSlug.make("oauth"), |
| 60 | − type: "oauth", | |
| 59 | + kind: "oauth2", | |
| 61 | 60 | authorizationUrl: "https://auth.example/authorize", |
| 62 | 61 | tokenUrl: "https://auth.example/token", |
| 63 | 62 | scopes: ["read", "write"], |
| @@ -205,8 +205,8 @@ describe("Google bundle add flow", () => { · 204 unmodified lines | ||
| 205 | 205 | // collapses under `https://mail.google.com/`, so the requested consent |
| 206 | 206 | // is clean rather than the raw per-method union. |
| 207 | 207 | const config = yield* executor.openapi.getConfig("google"); |
| 208 | − const oauth = config?.authenticationTemplate?.find((entry) => entry.type === "oauth"); | |
| 209 | − expect(oauth?.type === "oauth" ? [...oauth.scopes].sort() : undefined).toEqual( | |
| 208 | + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 209 | + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | |
| 210 | 210 | [ |
| 211 | 211 | "https://mail.google.com/", |
| 212 | 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 | 243 | // than v1's `oauth2` source config. |
| 244 | 244 | // removed: identityScopes assertion — that field belonged to the v1 |
| 245 | 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 | 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 | 440 | expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}"); |
| 441 | 441 | expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/"); |
| 442 | 442 | // v2: the bundled oauth scopes are carried on the oauth auth template. |
| 443 | − const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth"); | |
| 444 | − expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([ | |
| 443 | + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 444 | + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ | |
| 445 | 445 | "https://www.googleapis.com/auth/gmail.metadata", |
| 446 | 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 | 580 | // The derived oauth auth template carries the compacted/filtered set |
| 581 | 581 | // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped). |
| 582 | − const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth"); | |
| 583 | − expect(oauthTemplate?.type === "oauth" ? [...oauthTemplate.scopes].sort() : undefined).toEqual( | |
| 582 | + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 583 | + expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual( | |
| 584 | 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 | 625 | slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME), |
| 626 | − type: "oauth", | |
| 626 | + kind: "oauth2", | |
| 627 | 627 | authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, |
| 628 | 628 | tokenUrl: GOOGLE_OAUTH_TOKEN_URL, |
| 629 | 629 | scopes: Object.keys(scopes), |
| @@ -67,9 +67,13 @@ export { · 66 unmodified lines | ||
| 67 | 67 | OperationId, |
| 68 | 68 | HttpMethod, |
| 69 | 69 | ParameterLocation, |
| 70 | − variable, | |
| 70 | + TOKEN_VARIABLE, | |
| 71 | + normalizeOpenApiAuthInputs, | |
| 71 | 72 | type Authentication, |
| 73 | + type AuthenticationInput, | |
| 72 | 74 | type APIKeyAuthentication, |
| 73 | − type AuthenticationVariable, | |
| 74 | − type AuthenticationTemplateValue, | |
| 75 | 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 | 36 | } from "@executor-js/plugin-openapi/testing"; |
| 37 | 37 | |
| 38 | 38 | import { openApiPlugin } from "./plugin"; |
| 39 | −import { variable, type Authentication } from "./types"; | |
| 39 | +import { type AuthenticationInput } from "./types"; | |
| 40 | 40 | |
| 41 | 41 | // --------------------------------------------------------------------------- |
| 42 | 42 | // Test API — a single endpoint that echoes the Authorization header so the |
| @@ -68,10 +68,10 @@ const serveVercel = () => · 25 unmodified lines | ||
| 68 | 68 | serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive }); |
| 69 | 69 | |
| 70 | 70 | // Bearer template: the connection value renders into `Authorization: Bearer …`. |
| 71 | −const bearerTemplate: Authentication = { | |
| 71 | +const bearerTemplate: AuthenticationInput = { | |
| 72 | 72 | slug: AuthTemplateSlug.make("bearer"), |
| 73 | 73 | type: "apiKey", |
| 74 | − headers: { authorization: ["Bearer ", variable("token")] }, | |
| 74 | + headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] }, | |
| 75 | 75 | }; |
| 76 | 76 | |
| 77 | 77 | const testPlugins = () => |
| @@ -32,7 +32,7 @@ import { · 31 unmodified lines | ||
| 32 | 32 | } from "@executor-js/sdk/testing"; |
| 33 | 33 | |
| 34 | 34 | import { openApiPlugin } from "./plugin"; |
| 35 | −import { variable, type Authentication } from "./types"; | |
| 35 | +import { type AuthenticationInput } from "./types"; | |
| 36 | 36 | import { |
| 37 | 37 | addOpenApiTestConnection, |
| 38 | 38 | makeOpenApiHttpApiTestSourceConfig, |
| @@ -154,16 +154,16 @@ const servePluginTestApi = () => · 115 unmodified lines | ||
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | // An apiKey auth template that places the connection value into `x-api-key`. |
| 157 | −const apiKeyTemplate: Authentication = { | |
| 157 | +const apiKeyTemplate: AuthenticationInput = { | |
| 158 | 158 | slug: AuthTemplateSlug.make("apiKey"), |
| 159 | 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 | 163 | // An oauth template — the connection value renders as a bearer token. |
| 164 | −const oauthTemplate: Authentication = { | |
| 164 | +const oauthTemplate: AuthenticationInput = { | |
| 165 | 165 | slug: AuthTemplateSlug.make("oauth"), |
| 166 | − type: "oauth", | |
| 166 | + kind: "oauth2", | |
| 167 | 167 | authorizationUrl: "https://auth.example.test/authorize", |
| 168 | 168 | tokenUrl: "https://auth.example.test/token", |
| 169 | 169 | scopes: ["read"], |
| @@ -13,7 +13,6 @@ import { · 12 unmodified lines | ||
| 13 | 13 | mergeAuthTemplates, |
| 14 | 14 | tool, |
| 15 | 15 | type AuthMethodDescriptor, |
| 16 | − type AuthPlacementDescriptor, | |
| 17 | 16 | type Integration, |
| 18 | 17 | type IntegrationConfig, |
| 19 | 18 | type IntegrationRecord, |
| @@ -44,8 +43,9 @@ import { annotationsForOperation, invokeWithLayer } from "./invoke"; · 24 unmodified lines | ||
| 44 | 43 | import { previewSpec, type SpecPreview } from "./preview"; |
| 45 | 44 | import { openApiPresets } from "./presets"; |
| 46 | 45 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; |
| 47 | −import type { Authentication, AuthenticationTemplateValue } from "./types"; | |
| 48 | −import { OperationBinding, TOKEN_VARIABLE } from "./types"; | |
| 46 | +import type { Authentication } from "./types"; | |
| 47 | +import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; | |
| 48 | +import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | |
| 49 | 49 | |
| 50 | 50 | // --------------------------------------------------------------------------- |
| 51 | 51 | // Plugin config |
| @@ -154,8 +154,9 @@ export interface OpenApiSpecConfig { · 102 unmodified lines | ||
| 154 | 154 | readonly headers?: Record<string, string>; |
| 155 | 155 | /** Static query params applied to every request. */ |
| 156 | 156 | readonly queryParams?: Record<string, string>; |
| 157 | − /** Auth methods a connection's value renders through. */ | |
| 158 | − readonly authenticationTemplate?: readonly Authentication[]; | |
| 157 | + /** Auth methods a connection's value renders through — canonical | |
| 158 | + * placements or the request-shaped authoring dialect. */ | |
| 159 | + readonly authenticationTemplate?: readonly AuthenticationInput[]; | |
| 159 | 160 | } |
| 160 | 161 | |
| 161 | 162 | export interface OpenApiExtensionFailure { |
| @@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines | ||
| 169 | 170 | * already exists, replaces) the integration's existing template array. A |
| 170 | 171 | * custom apiKey method with no `slug` is assigned a generated `custom_<id>` |
| 171 | 172 | * slug that is collision-checked against the existing template. */ |
| 172 | − readonly authenticationTemplate: readonly Authentication[]; | |
| 173 | + readonly authenticationTemplate: readonly AuthenticationInput[]; | |
| 173 | 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 | 299 | const AuthenticationSchema = Schema.Union([ |
| 307 | 300 | Schema.Struct({ |
| 308 | 301 | slug: Schema.String, |
| 309 | − type: Schema.Literal("apiKey"), | |
| 310 | − headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)), | |
| 311 | − queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)), | |
| 312 | − }), | |
| 313 | − Schema.Struct({ | |
| 314 | − slug: Schema.String, | |
| 315 | − type: Schema.Literal("oauth"), | |
| 302 | + kind: Schema.Literal("oauth2"), | |
| 316 | 303 | authorizationUrl: Schema.String, |
| 317 | 304 | tokenUrl: Schema.String, |
| 318 | 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 | 313 | const AddSourceInputSchema = Schema.Struct({ |
| @@ -525,53 +516,10 @@ const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | un · 202 unmodified lines | ||
| 525 | 516 | // Declared auth methods — project the stored `authenticationTemplate` into the |
| 526 | 517 | // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's |
| 527 | 518 | // `authMethodsFromConfig` (in the React auth-method-config module) on the |
| 528 | −// server so the catalog field is consistent. OpenAPI also renders its own | |
| 529 | −// accounts slot, so this projection is consistency work, not a fix; an absent / | |
| 530 | −// malformed config yields `[]` with no regression. | |
| 531 | −// | |
| 532 | −// Placement prefix extraction: a header/query slot serializes the credential as | |
| 533 | −// `name -> [prefix, variable("token")]`; the leading literals before the | |
| 534 | −// `variable("token")` part are the prefix (empty for a bare `[token]`). | |
| 519 | +// server so the catalog field is consistent. apikey/none projection comes from | |
| 520 | +// the shared model; the oauth method carries the stored endpoints + scopes. | |
| 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 | 523 | export const describeOpenApiAuthMethods = ( |
| 576 | 524 | record: IntegrationRecord, |
| 577 | 525 | ): readonly AuthMethodDescriptor[] => { |
| @@ -579,13 +527,12 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line | ||
| 579 | 527 | if (!config) return []; |
| 580 | 528 | return (config.authenticationTemplate ?? []).map( |
| 581 | 529 | (template: Authentication): AuthMethodDescriptor => { |
| 582 | − const slug = String(template.slug); | |
| 583 | − if (template.type === "oauth") { | |
| 530 | + if (template.kind === "oauth2") { | |
| 584 | 531 | return { |
| 585 | − id: slug, | |
| 532 | + id: String(template.slug), | |
| 586 | 533 | label: "OAuth2", |
| 587 | 534 | kind: "oauth", |
| 588 | − template: slug, | |
| 535 | + template: String(template.slug), | |
| 589 | 536 | oauth: { |
| 590 | 537 | authorizationUrl: template.authorizationUrl, |
| 591 | 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); | |
| 597 | − return { | |
| 598 | − id: slug, | |
| 599 | − label: apiKeyLabel(slug, placements), | |
| 600 | − kind: "apikey", | |
| 601 | − template: slug, | |
| 602 | − placements, | |
| 603 | − }; | |
| 543 | + return describeApiKeyAuthMethod(template); | |
| 604 | 544 | }, |
| 605 | 545 | ); |
| 606 | 546 | }; |
| @@ -798,7 +738,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 191 unmodified lines | ||
| 798 | 738 | // Google Discovery converter derived from the spec (the bundle add |
| 799 | 739 | // path relies on this — it has no preview to detect auth from). |
| 800 | 740 | ...(config.authenticationTemplate |
| 801 | − ? { authenticationTemplate: config.authenticationTemplate } | |
| 741 | + ? { | |
| 742 | + authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), | |
| 743 | + } | |
| 802 | 744 | : resolved.authenticationTemplate |
| 803 | 745 | ? { authenticationTemplate: resolved.authenticationTemplate } |
| 804 | 746 | : {}), |
| @@ -891,13 +833,11 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 86 unmodified lines | ||
| 891 | 833 | const current = decodeOpenApiIntegrationConfig(record.config); |
| 892 | 834 | if (!current) return [] as readonly Authentication[]; |
| 893 | 835 | |
| 836 | + const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | |
| 894 | 837 | const merged = |
| 895 | 838 | input.mode === "replace" |
| 896 | − ? input.authenticationTemplate | |
| 897 | − : mergeAuthTemplates( | |
| 898 | − current.authenticationTemplate ?? [], | |
| 899 | − input.authenticationTemplate, | |
| 900 | − ); | |
| 839 | + ? incoming | |
| 840 | + : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | |
| 901 | 841 | |
| 902 | 842 | const next: OpenApiIntegrationConfig = { |
| 903 | 843 | ...current, |
| @@ -959,7 +899,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 55 unmodified lines | ||
| 959 | 899 | headers: input.headers, |
| 960 | 900 | queryParams: input.queryParams, |
| 961 | 901 | authenticationTemplate: input.authenticationTemplate as |
| 962 | − | readonly Authentication[] | |
| 902 | + | readonly AuthenticationInput[] | |
| 963 | 903 | | undefined, |
| 964 | 904 | }) |
| 965 | 905 | .pipe( |
| @@ -1074,12 +1014,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 108 unmodified lines | ||
| 1074 | 1014 | if (missing.length > 0) { |
| 1075 | 1015 | return openApiAuthToolFailure({ |
| 1076 | 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 | 1020 | message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`, |
| 1079 | 1021 | owner: credential.owner, |
| 1080 | 1022 | integration, |
| 1081 | 1023 | connection: String(credential.connection), |
| 1082 | − credentialKind: template.type === "oauth" ? "oauth" : "secret", | |
| 1024 | + credentialKind: template.kind === "oauth2" ? "oauth" : "secret", | |
| 1083 | 1025 | }); |
| 1084 | 1026 | } |
| 1085 | 1027 | const rendered = renderAuthTemplate(template, credential.values); |
| @@ -1,43 +1,58 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | −import type { AuthTemplateSlug } from "@executor-js/sdk/shared"; | |
| 3 | −import type { OAuthAuthentication } from "@executor-js/sdk/shared"; | |
| 2 | +import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared"; | |
| 3 | +import { | |
| 4 | + apiKeyMethodFromAuthTemplate, | |
| 5 | + isApiKeyAuthTemplate, | |
| 6 | + type ApiKeyAuthMethod, | |
| 7 | + type ApiKeyAuthTemplate, | |
| 8 | +} from "@executor-js/sdk/http-auth"; | |
| 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 | |
| 9 | −// credential goes on the outbound request (headers / query params) via the | |
| 10 | −// `variable()` templating below. That placement is why it lives with the | |
| 11 | −// openapi plugin rather than in core. The oauth template is mechanism-intrinsic | |
| 12 | −// and comes from core (`OAuthAuthentication`); an integration's `Authentication` | |
| 13 | −// union composes the two. Client credentials (clientId/secret) live on the core | |
| 14 | −// `OAuthClient`, not here. | |
| 13 | +// The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`, | |
| 14 | +// the same shape the graphql/mcp plugins store): N header/query placements, | |
| 15 | +// each rendered from its own credential input. The oauth template is | |
| 16 | +// mechanism-intrinsic and comes from core (`OAuthAuthentication`, keyed | |
| 17 | +// `kind: "oauth2"` with stored endpoints+scopes); an integration's | |
| 18 | +// `Authentication` union composes the two. Client credentials | |
| 19 | +// (clientId/secret) live on the core `OAuthClient`, not here. | |
| 20 | +// | |
| 21 | +// Pre-canonical stored templates (`type: "apiKey"` with `variable()`-templated | |
| 22 | +// header/query records) are rewritten by the one-off config migration | |
| 23 | +// (`migrate-config.ts`) — runtime code knows only this model. | |
| 15 | 24 | // --------------------------------------------------------------------------- |
| 16 | 25 | |
| 17 | −export type AuthenticationVariable = { | |
| 18 | − readonly type: "variable"; | |
| 19 | − readonly name: string; | |
| 20 | −}; | |
| 21 | − | |
| 22 | −/** A literal string, or a parts-array mixing literals and variable refs. */ | |
| 23 | −export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[]; | |
| 26 | +export { TOKEN_VARIABLE } from "@executor-js/sdk/http-auth"; | |
| 24 | 27 | |
| 25 | −export const variable = (name: string): AuthenticationVariable => ({ | |
| 26 | − type: "variable", | |
| 27 | − name, | |
| 28 | −}); | |
| 28 | +export type APIKeyAuthentication = ApiKeyAuthMethod; | |
| 29 | 29 | |
| 30 | −/** The variable name the resolved credential value renders into. */ | |
| 31 | −export const TOKEN_VARIABLE = "token" as const; | |
| 30 | +/** Every method is keyed by `kind` — `kind: "oauth2"` | `kind: "apikey"`. */ | |
| 31 | +export type Authentication = OAuthAuthentication | APIKeyAuthentication; | |
| 32 | 32 | |
| 33 | −export type APIKeyAuthentication = { | |
| 34 | − readonly slug: AuthTemplateSlug; | |
| 35 | − readonly type: "apiKey"; | |
| 36 | − readonly headers?: Record<string, AuthenticationTemplateValue>; | |
| 37 | − readonly queryParams?: Record<string, AuthenticationTemplateValue>; | |
| 33 | +/** What auth inputs accept: oauth templates (wire-typed: plain slug) plus the | |
| 34 | + * request-shaped apikey dialect (`type: "apiKey"`, headers/queryParams | |
| 35 | + * records) — the ONE apikey authoring shape. Stored configs and the catalog | |
| 36 | + * read as canonical placements; `apiKeyAuthTemplateFromMethod` serializes | |
| 37 | + * them back for read-modify-write flows. */ | |
| 38 | +export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & { | |
| 39 | + readonly slug: string; | |
| 38 | 40 | }; |
| 39 | − | |
| 40 | −export type Authentication = OAuthAuthentication | APIKeyAuthentication; | |
| 41 | +export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate; | |
| 42 | + | |
| 43 | +/** Expand the request-shaped dialect into canonical placements and brand the | |
| 44 | + * oauth slugs. A dialect entry without a slug gets a blank one — | |
| 45 | + * `mergeAuthTemplates` backfills `custom_<id>`. */ | |
| 46 | +export const normalizeOpenApiAuthInputs = ( | |
| 47 | + inputs: readonly AuthenticationInput[], | |
| 48 | +): readonly Authentication[] => | |
| 49 | + inputs.map((input): Authentication => { | |
| 50 | + if (!isApiKeyAuthTemplate(input)) { | |
| 51 | + return { ...input, slug: AuthTemplateSlug.make(input.slug) }; | |
| 52 | + } | |
| 53 | + const method = apiKeyMethodFromAuthTemplate(input); | |
| 54 | + return { ...method, slug: method.slug ?? "" }; | |
| 55 | + }); | |
| 41 | 56 | |
| 42 | 57 | // --------------------------------------------------------------------------- |
| 43 | 58 | // Branded IDs |
| @@ -31,6 +31,10 @@ export interface Placement { · 30 unmodified lines | ||
| 31 | 31 | * each get a distinct variable. Two placements sharing a variable share one |
| 32 | 32 | * value. */ |
| 33 | 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 | 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 | 46 | * `source` distinguishes integration-declared ("spec") methods from |
| 43 | 47 | * user-defined ("custom") ones. `template` is the auth-template slug the |
| 44 | 48 | * method applies a connection through. */ |
| 45 | −export type AuthMethodKind = "oauth" | "apikey" | "custom" | "none"; | |
| 49 | +export type AuthMethodKind = "oauth" | "apikey" | "none"; | |
| 46 | 50 | |
| 47 | 51 | /** Provider OAuth endpoints/scopes an `oauth` method declares, used to pre-fill |
| 48 | 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 | 171 | name: placement.name, |
| 168 | 172 | prefix: placement.prefix, |
| 169 | 173 | ...(placement.variable ? { variable: placement.variable } : {}), |
| 174 | + ...(placement.literal !== undefined ? { literal: placement.literal } : {}), | |
| 170 | 175 | }), |
| 171 | 176 | ) |
| 172 | 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 | +} | |