History

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

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

Rhys Sullivan committed 5 months ago

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

Showing the changes to packages/plugins/openapi/src/api (2 of 122 files). Show all

Unified · Split

2 files
  1. packages/plugins/openapi/src/api/group.ts +15 −24
  2. packages/plugins/openapi/src/api/handlers.ts +2 −14
packages/plugins/openapi/src/api/group.ts+15−24
@@ -1,5 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
3import {4import {
4 InternalError,5 InternalError,
5 IntegrationAlreadyExistsError,6 IntegrationAlreadyExistsError,
@@ -46,29 +47,19 @@ const OpenApiSpecInputPayload = Schema.Union([ · 40 unmodified lines
46 }),47 }),
47]);48]);
48 49
49const AuthenticationVariablePayload = Schema.Struct({50const OAuthTemplatePayload = Schema.Struct({
50 type: Schema.Literal("variable"),51 slug: Schema.String,
51 name: Schema.String,52 kind: Schema.Literal("oauth2"),
53 authorizationUrl: Schema.String,
54 tokenUrl: Schema.String,
55 scopes: Schema.Array(Schema.String),
52});56});
53const AuthenticationTemplateValuePayload = Schema.Union([57
54 Schema.String,58/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */
55 Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])),59const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
56]);60
57const AuthenticationPayload = Schema.Union([61/** Auth in RESPONSES: the canonical stored shapes (placements). */
58 Schema.Struct({62const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
59 slug: Schema.String,
60 type: Schema.Literal("apiKey"),
61 headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
62 queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
63 }),
64 Schema.Struct({
65 slug: Schema.String,
66 type: Schema.Literal("oauth"),
67 authorizationUrl: Schema.String,
68 tokenUrl: Schema.String,
69 scopes: Schema.Array(Schema.String),
70 }),
71]);
72 63
73const AddSpecPayload = Schema.Struct({64const AddSpecPayload = Schema.Struct({
74 spec: OpenApiSpecInputPayload,65 spec: OpenApiSpecInputPayload,
@@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines
119 baseUrl: Schema.optional(Schema.String),110 baseUrl: Schema.optional(Schema.String),
120 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),111 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
121 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),112 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
122 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),113 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
123});114});
124 115
125// The configure result — the merged `authenticationTemplate` after the new116// The configure result — the merged `authenticationTemplate` after the new
126// custom methods were appended/replaced.117// custom methods were appended/replaced.
127const ConfigureResponse = Schema.Struct({118const ConfigureResponse = Schema.Struct({
128 authenticationTemplate: Schema.Array(AuthenticationPayload),119 authenticationTemplate: Schema.Array(AuthenticationResponse),
129});120});
130 121
131// ---------------------------------------------------------------------------122// ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+2−14
@@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup, capture } from "@executor-js/api";4import { addGroup, capture } from "@executor-js/api";
5import { AuthTemplateSlug } from "@executor-js/sdk/shared";
6import type { Authentication } from "../sdk";
7import type { OpenApiPluginExtension } from "../sdk/plugin";5import type { OpenApiPluginExtension } from "../sdk/plugin";
8import { OpenApiGroup } from "./group";6import { OpenApiGroup } from "./group";
9 7
@@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines
60 baseUrl: payload.baseUrl,58 baseUrl: payload.baseUrl,
61 headers: payload.headers ? { ...payload.headers } : undefined,59 headers: payload.headers ? { ...payload.headers } : undefined,
62 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,60 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
63 authenticationTemplate: payload.authenticationTemplate?.map(61 authenticationTemplate: payload.authenticationTemplate,
64 (entry): Authentication => ({
65 ...entry,
66 slug: AuthTemplateSlug.make(entry.slug),
67 }),
68 ),
69 });62 });
70 }),63 }),
71 ),64 ),
@@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
115 Effect.gen(function* () {108 Effect.gen(function* () {
116 const ext = yield* OpenApiExtensionService;109 const ext = yield* OpenApiExtensionService;
117 const authenticationTemplate = yield* ext.configure(params.slug, {110 const authenticationTemplate = yield* ext.configure(params.slug, {
118 authenticationTemplate: payload.authenticationTemplate.map(111 authenticationTemplate: payload.authenticationTemplate,
119 (entry): Authentication => ({
120 ...entry,
121 slug: AuthTemplateSlug.make(entry.slug),
122 }),
123 ),
124 mode: payload.mode ?? "merge",112 mode: payload.mode ?? "merge",
125 });113 });
126 return { authenticationTemplate: [...authenticationTemplate] };114 return { authenticationTemplate: [...authenticationTemplate] };