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 (20 of 122 files). Show all

Unified · Split

20 files
  1. packages/plugins/openapi/src/api/group.ts +15 −24
  2. packages/plugins/openapi/src/api/handlers.ts +2 −14
  3. packages/plugins/openapi/src/promise.ts +6 −9
  4. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +18 −20
  5. packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx +6 −2
  6. packages/plugins/openapi/src/react/auth-method-config.test.ts +90 −83
  7. packages/plugins/openapi/src/react/auth-method-config.ts +45 −158
  8. packages/plugins/openapi/src/sdk/config.ts +21 −65
  9. packages/plugins/openapi/src/sdk/configure.test.ts +72 −27
  10. packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +6 −7
  11. packages/plugins/openapi/src/sdk/google-bundle.test.ts +2 −2
  12. packages/plugins/openapi/src/sdk/google-discovery.test.ts +5 −5
  13. packages/plugins/openapi/src/sdk/google-discovery.ts +1 −1
  14. packages/plugins/openapi/src/sdk/index.ts +7 −3
  15. packages/plugins/openapi/src/sdk/migrate-config.test.ts +225 −0
  16. packages/plugins/openapi/src/sdk/migrate-config.ts +70 −0
  17. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +3 −3
  18. packages/plugins/openapi/src/sdk/plugin.test.ts +5 −5
  19. packages/plugins/openapi/src/sdk/plugin.ts +29 −87
  20. packages/plugins/openapi/src/sdk/types.ts +45 −30
packages/plugins/openapi/src/api/group.ts+15−24
@@ -1,5 +1,6 @@
11 import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
22 import { Schema } from "effect";
3+import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
34 import {
45 InternalError,
56 IntegrationAlreadyExistsError,
@@ -46,29 +47,19 @@ const OpenApiSpecInputPayload = Schema.Union([ · 40 unmodified lines
4647 }),
4748 ]);
4849
49−const AuthenticationVariablePayload = Schema.Struct({
50− type: Schema.Literal("variable"),
51− name: Schema.String,
50+const OAuthTemplatePayload = Schema.Struct({
51+ slug: Schema.String,
52+ kind: Schema.Literal("oauth2"),
53+ authorizationUrl: Schema.String,
54+ tokenUrl: Schema.String,
55+ scopes: Schema.Array(Schema.String),
5256 });
53−const AuthenticationTemplateValuePayload = Schema.Union([
54− Schema.String,
55− Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])),
56−]);
57−const AuthenticationPayload = Schema.Union([
58− Schema.Struct({
59− slug: Schema.String,
60− type: Schema.Literal("apiKey"),
61− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
62− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
63− }),
64− Schema.Struct({
65− slug: Schema.String,
66− type: Schema.Literal("oauth"),
67− authorizationUrl: Schema.String,
68− tokenUrl: Schema.String,
69− scopes: Schema.Array(Schema.String),
70− }),
71−]);
57+
58+/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */
59+const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
60+
61+/** Auth in RESPONSES: the canonical stored shapes (placements). */
62+const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
7263
7364 const AddSpecPayload = Schema.Struct({
7465 spec: OpenApiSpecInputPayload,
@@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines
119110 baseUrl: Schema.optional(Schema.String),
120111 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
121112 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
122− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
113+ authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
123114 });
124115
125116 // The configure result — the merged `authenticationTemplate` after the new
126117 // custom methods were appended/replaced.
127118 const ConfigureResponse = Schema.Struct({
128− authenticationTemplate: Schema.Array(AuthenticationPayload),
119+ authenticationTemplate: Schema.Array(AuthenticationResponse),
129120 });
130121
131122 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+2−14
@@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
22 import { Context, Effect } from "effect";
33
44 import { addGroup, capture } from "@executor-js/api";
5−import { AuthTemplateSlug } from "@executor-js/sdk/shared";
6−import type { Authentication } from "../sdk";
75 import type { OpenApiPluginExtension } from "../sdk/plugin";
86 import { OpenApiGroup } from "./group";
97
@@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines
6058 baseUrl: payload.baseUrl,
6159 headers: payload.headers ? { ...payload.headers } : undefined,
6260 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
63− authenticationTemplate: payload.authenticationTemplate?.map(
64− (entry): Authentication => ({
65− ...entry,
66− slug: AuthTemplateSlug.make(entry.slug),
67− }),
68− ),
61+ authenticationTemplate: payload.authenticationTemplate,
6962 });
7063 }),
7164 ),
@@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
115108 Effect.gen(function* () {
116109 const ext = yield* OpenApiExtensionService;
117110 const authenticationTemplate = yield* ext.configure(params.slug, {
118− authenticationTemplate: payload.authenticationTemplate.map(
119− (entry): Authentication => ({
120− ...entry,
121− slug: AuthTemplateSlug.make(entry.slug),
122− }),
123− ),
111+ authenticationTemplate: payload.authenticationTemplate,
124112 mode: payload.mode ?? "merge",
125113 });
126114 return { authenticationTemplate: [...authenticationTemplate] };
packages/plugins/openapi/src/promise.ts+6−9
@@ -7,12 +7,9 @@ export type { · 6 unmodified lines
77 OpenApiPreviewInput,
88 } from "./sdk/plugin";
99
10−// Auth-template authoring helpers. `variable("token")` marks where a
11−// connection's resolved credential renders into an `apiKey` template.
12−export { variable, TOKEN_VARIABLE } from "./sdk/types";
13−export type {
14− Authentication,
15− APIKeyAuthentication,
16− AuthenticationVariable,
17− AuthenticationTemplateValue,
18−} from "./sdk/types";
10+// Auth-template authoring helpers. Author apikey methods as canonical
11+// placements, or request-shaped: `headers: { Authorization: ["Bearer ",
12+// variable("token")] }` — both normalize to the same stored model.
13+export { TOKEN_VARIABLE } from "./sdk/types";
14+export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types";
15+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−20
@@ -34,7 +34,11 @@ import { · 33 unmodified lines
3434 useSlugAlreadyExists,
3535 } from "@executor-js/react/lib/integration-add";
3636
37−import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";
37+import {
38+ authenticationFromEditorValue,
39+ editorValueFromAuthentication,
40+ openApiWireAuthInput,
41+} from "./auth-method-config";
3842 import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
3943 import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
4044 import { GoogleProductPicker } from "./GoogleProductPicker";
@@ -45,13 +49,7 @@ import { · 4 unmodified lines
4549 type GoogleOpenApiPreset,
4650 } from "../sdk/google-presets";
4751 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
48−import {
49− type APIKeyAuthentication,
50− type Authentication,
51− type ServerInfo,
52− TOKEN_VARIABLE,
53− variable,
54−} from "../sdk/types";
52+import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
5553 import { expandServerUrlOptions } from "../sdk/openapi-utils";
5654
5755 const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
@@ -172,14 +170,16 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi · 114 unmodified lines
172170 const apiKeyTemplateFromHeaderPreset = (
173171 preset: HeaderPreset,
174172 slug: AuthTemplateSlug,
175−): APIKeyAuthentication => {
176− const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {};
177− for (const headerName of preset.secretHeaders) {
173+): APIKeyAuthentication => ({
174+ slug,
175+ kind: "apikey",
176+ // Every secret header shares the one credential input (the canonical
177+ // `token`, stored as an absent placement variable).
178+ placements: preset.secretHeaders.map((headerName) => {
178179 const prefix = headerPrefix(preset, headerName);
179− headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)];
180− }
181− return { slug, type: "apiKey", headers };
182−};
180+ return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
181+ }),
182+});
183183
184184 const oauthTemplateFromPreset = (
185185 preset: OAuth2Preset,
@@ -188,7 +188,7 @@ const oauthTemplateFromPreset = ( · 2 unmodified lines
188188 scopes: readonly string[],
189189 ): OAuthAuthentication => ({
190190 slug,
191− type: "oauth",
191+ kind: "oauth2",
192192 authorizationUrl: resolveOAuthUrl(
193193 Option.getOrElse(preset.authorizationUrl, () => ""),
194194 baseUrl,
@@ -454,10 +454,8 @@ export default function AddOpenApiSource(props: { · 259 unmodified lines
454454 baseUrl: resolvedBaseUrl,
455455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
456456 ? {
457− authenticationTemplate: editedAuthenticationTemplate.map((entry) => ({
458− ...entry,
459− slug: String(entry.slug),
460− })),
457+ // Serialize to the wire input dialect (apikey → request-shaped).
458+ authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),
461459 }
462460 : {}),
463461 },
packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx+6−2
@@ -18,7 +18,11 @@ import { · 17 unmodified lines
1818 } from "@executor-js/react/lib/custom-auth-methods";
1919
2020 import { openApiConfigAtom, openapiConfigure } from "./atoms";
21−import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";
21+import {
22+ authMethodsFromConfig,
23+ templateFromPlacements,
24+ openApiWireAuthInput,
25+} from "./auth-method-config";
2226 import { googleAudienceWarningsForUrls } from "../sdk/google-presets";
2327 import type { Authentication } from "../sdk/types";
2428
@@ -82,7 +86,7 @@ export default function OpenApiAccountsPanel(props: { · 57 unmodified lines
8286 const exit = await doConfigure({
8387 params: { slug },
8488 payload: {
85− authenticationTemplate: input.authenticationTemplate,
89+ authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
8690 ...(input.mode ? { mode: input.mode } : {}),
8791 },
8892 reactivityKeys: integrationWriteKeys,
packages/plugins/openapi/src/react/auth-method-config.test.ts+90−83
@@ -1,111 +1,118 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3−import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
43
54 import {
5+ authMethodsFromConfig,
66 authenticationFromEditorValue,
77 editorValueFromAuthentication,
88 templateFromPlacements,
99 } from "./auth-method-config";
10−import { TOKEN_VARIABLE, variable, type Authentication } from "../sdk/types";
10+import type { Authentication } from "../sdk/types";
1111
12−describe("editorValueFromAuthentication", () => {
13− it("maps a bearer apiKey header template → an apikey editor value with prefix", () => {
14− const template: Authentication = {
15− slug: AuthTemplateSlug.make("apikey-0"),
16− type: "apiKey",
17− headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },
18− };
19− expect(editorValueFromAuthentication(template)).toEqual({
20− kind: "apikey",
21− placements: [
22− { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },
23− ],
24− });
25− });
26−
27− it("maps a query apiKey template → an apikey editor value (empty prefix)", () => {
28− const template: Authentication = {
29− slug: AuthTemplateSlug.make("apikey-1"),
30− type: "apiKey",
31− queryParams: { api_key: [variable(TOKEN_VARIABLE)] },
32− };
33− expect(editorValueFromAuthentication(template)).toEqual({
34− kind: "apikey",
35− placements: [{ carrier: "query", name: "api_key", prefix: "", variable: "token" }],
36− });
37− });
38−
39− it("maps an oauth template → an oauth editor value carrying endpoints + scopes", () => {
40− const template: Authentication = {
41− slug: AuthTemplateSlug.make("oauth-google"),
42− type: "oauth",
43− authorizationUrl: "https://accounts.example.com/o/oauth2/auth",
44− tokenUrl: "https://oauth2.example.com/token",
45− scopes: ["openid", "email"],
46− };
47− expect(editorValueFromAuthentication(template)).toEqual({
12+describe("authMethodsFromConfig", () => {
13+ it("projects oauth templates with their stored endpoints + scopes", () => {
14+ const methods = authMethodsFromConfig([
15+ {
16+ slug: AuthTemplateSlug.make("oauth"),
17+ kind: "oauth2",
18+ authorizationUrl: "https://x.example/auth",
19+ tokenUrl: "https://x.example/token",
20+ scopes: ["read"],
21+ },
22+ ]);
23+ expect(methods[0]).toMatchObject({
24+ id: "oauth",
4825 kind: "oauth",
49− authorizationUrl: "https://accounts.example.com/o/oauth2/auth",
50− tokenUrl: "https://oauth2.example.com/token",
51− scopes: ["openid", "email"],
26+ source: "spec",
27+ oauth: {
28+ authorizationUrl: "https://x.example/auth",
29+ tokenUrl: "https://x.example/token",
30+ scopes: ["read"],
31+ },
5232 });
5333 });
54−});
5534
56−describe("authenticationFromEditorValue", () => {
57− it("returns null for a 'none' value (nothing to register)", () => {
58− expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
35+ it("projects apikey methods, multi-placement and multi-variable intact", () => {
36+ const methods = authMethodsFromConfig([
37+ {
38+ slug: "custom_dd",
39+ kind: "apikey",
40+ placements: [
41+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
42+ { carrier: "query", name: "team_id", variable: "team_id" },
43+ ],
44+ },
45+ ]);
46+ expect(methods[0]).toMatchObject({ id: "custom_dd", kind: "apikey", source: "custom" });
47+ expect(methods[0]?.placements).toEqual([
48+ { carrier: "header", name: "DD-API-KEY", prefix: "", variable: "dd_api_key" },
49+ { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
50+ ]);
5951 });
52+});
6053
61− it("builds an apiKey template from placements, stamping the slug", () => {
62− const value: AuthTemplateEditorValue = {
54+describe("editor round-trip", () => {
55+ it("apikey stored → editor → stored preserves placements and shared token input", () => {
56+ const stored: Authentication = {
57+ slug: "bearer",
6358 kind: "apikey",
64− placements: [{ carrier: "header", name: "X-Token", prefix: "" }],
59+ placements: [
60+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
61+ { carrier: "query", name: "token" },
62+ ],
6563 };
66− expect(authenticationFromEditorValue(value, "custom_x")).toEqual(
67− templateFromPlacements(value.placements, "custom_x"),
68− );
64+ const editor = editorValueFromAuthentication(stored);
65+ const back = authenticationFromEditorValue(editor, "bearer");
66+ expect(back).toEqual(stored);
6967 });
7068
71− it("builds an oauth template from an oauth value", () => {
72− const value: AuthTemplateEditorValue = {
69+ it("oauth stored → editor carries endpoints + scopes", () => {
70+ expect(
71+ editorValueFromAuthentication({
72+ slug: AuthTemplateSlug.make("oauth"),
73+ kind: "oauth2",
74+ authorizationUrl: "https://x.example/auth",
75+ tokenUrl: "https://x.example/token",
76+ scopes: ["a", "b"],
77+ }),
78+ ).toEqual({
7379 kind: "oauth",
74− authorizationUrl: "https://a.example.com/auth",
75− tokenUrl: "https://a.example.com/token",
76− scopes: ["read"],
77− };
78− expect(authenticationFromEditorValue(value, "oauth-0")).toEqual({
79− slug: AuthTemplateSlug.make("oauth-0"),
80− type: "oauth",
81− authorizationUrl: "https://a.example.com/auth",
82− tokenUrl: "https://a.example.com/token",
83− scopes: ["read"],
80+ authorizationUrl: "https://x.example/auth",
81+ tokenUrl: "https://x.example/token",
82+ scopes: ["a", "b"],
8483 });
8584 });
85+
86+ it("none editor value yields no method", () => {
87+ expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
88+ });
8689 });
8790
88−describe("round-trip (apiKey + oauth)", () => {
89− it("apiKey: editorValue ∘ authentication is identity for the placement shape", () => {
90− const template: Authentication = {
91− slug: AuthTemplateSlug.make("apikey-0"),
92− type: "apiKey",
93− headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },
94− };
95− const value = editorValueFromAuthentication(template);
96− const back = authenticationFromEditorValue(value, "apikey-0");
97− expect(back).toEqual(template);
91+describe("templateFromPlacements", () => {
92+ it("multi-placement custom methods assign distinct name-derived variables", () => {
93+ expect(
94+ templateFromPlacements(
95+ [
96+ { carrier: "header", name: "DD-API-KEY", prefix: "" },
97+ { carrier: "header", name: "DD-APPLICATION-KEY", prefix: "" },
98+ ],
99+ "custom_dd",
100+ ),
101+ ).toEqual({
102+ slug: "custom_dd",
103+ kind: "apikey",
104+ placements: [
105+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
106+ { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
107+ ],
108+ });
98109 });
99110
100− it("oauth: editorValue ∘ authentication preserves endpoints + scopes", () => {
101− const template: Authentication = {
102− slug: AuthTemplateSlug.make("oauth-x"),
103− type: "oauth",
104− authorizationUrl: "https://a.example.com/auth",
105− tokenUrl: "https://a.example.com/token",
106− scopes: ["read", "write"],
107− };
108− const back = authenticationFromEditorValue(editorValueFromAuthentication(template), "oauth-x");
109− expect(back).toEqual(template);
111+ it("a lone placement keeps the canonical token input (stored absent)", () => {
112+ expect(templateFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }])).toEqual({
113+ slug: "",
114+ kind: "apikey",
115+ placements: [{ carrier: "query", name: "api_key" }],
116+ });
110117 });
111118 });
packages/plugins/openapi/src/react/auth-method-config.ts+45−158
@@ -1,192 +1,79 @@
11 // ---------------------------------------------------------------------------
2−// OpenAPI ↔ generic auth-method converters.
3−//
4−// The generic Accounts hub speaks in plugin-agnostic `AuthMethod` /
5−// `Placement` values (`@executor-js/react/lib/auth-placements`). The OpenAPI
6−// plugin stores auth as the HTTP-transport-specific `APIKeyAuthentication`
7−// template (header/query slots templated with `variable("token")`). These two
8−// converters bridge the wire template to/from the generic placement model, so
9−// they live with the OpenAPI plugin — they touch the openapi sdk
10−// `Authentication` types and would pull transport specifics into core.
2+// OpenAPI ↔ generic auth-method converters — a thin oauth adapter over the
3+// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4+// apikey/none paths (multi-placement, multi-variable) live in the shared
5+// codec; OpenAPI only contributes its oauth flavor: stored endpoints + scopes
6+// (`kind: "oauth2"`, the core `OAuthAuthentication` shape) that pre-fill the client-registration form.
117 // ---------------------------------------------------------------------------
128
139 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1410 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
1511 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
16−
1712 import {
18− TOKEN_VARIABLE,
19− variable,
20− type APIKeyAuthentication,
21− type Authentication,
22− type AuthenticationTemplateValue,
23−} from "../sdk/types";
13+ authMethodFromSharedTemplate,
14+ editorPlacementsFromWire,
15+ editorValueFromSharedMethod,
16+ wireAuthInputFromShared,
17+ wirePlacementsFromEditor,
18+} from "@executor-js/react/lib/shared-auth-method-codec";
2419
25−// ---------------------------------------------------------------------------
26−// Template value → placement prefix.
27−//
28−// A header/query slot serializes the credential as `name -> [prefix, token]`,
29−// where `prefix` is the leading literal string before the `variable("token")`
30−// part. A bare `[token]` (or string-only) value has an empty prefix.
31−// ---------------------------------------------------------------------------
20+import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types";
3221
33−const isVariablePart = (part: string | { readonly type: "variable"; readonly name: string }) =>
34− typeof part !== "string" && part.type === "variable";
22+/** Serialize a canonical method into the wire input union (apikey → the
23+ * request-shaped dialect; oauth passes through). */
24+export const openApiWireAuthInput = (method: Authentication): AuthenticationInput =>
25+ method.kind === "oauth2" ? method : (wireAuthInputFromShared(method) as AuthenticationInput);
3526
36−/** Extract the literal prefix preceding the credential variable, plus the
37− * variable name that placement renders from (`token` for single-input methods,
38− * a distinct name per input for multi-input ones). */
39−const parseTemplateValue = (
40− value: AuthenticationTemplateValue,
41−): { readonly prefix: string; readonly variable: string } => {
42− if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
43− const parts: string[] = [];
44− for (const part of value) {
45− if (isVariablePart(part)) {
46− return { prefix: parts.join(""), variable: (part as { readonly name: string }).name };
47− }
48− if (typeof part === "string") parts.push(part);
49− }
50− return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
51−};
27+export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] =>
28+ editorPlacementsFromWire(template.placements);
5229
53−export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => {
54− const placements: Placement[] = [];
55− for (const [name, value] of Object.entries(template.headers ?? {})) {
56− const { prefix, variable } = parseTemplateValue(value);
57− placements.push({ carrier: "header", name, prefix, variable });
58− }
59− for (const [name, value] of Object.entries(template.queryParams ?? {})) {
60− const { prefix, variable } = parseTemplateValue(value);
61− placements.push({ carrier: "query", name, prefix, variable });
62− }
63− return placements;
64−};
65−
66−// ---------------------------------------------------------------------------
67−// Templates → generic methods.
68−// ---------------------------------------------------------------------------
69−
70−const labelForApiKey = (slug: string, placements: readonly Placement[]): string => {
71− const first = placements[0];
72− if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
73− return `API key (${slug})`;
30+const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): AuthMethod => {
31+ const slug = String(template.slug);
32+ return {
33+ id: slug,
34+ label: "OAuth2",
35+ kind: "oauth",
36+ source: slug.startsWith("custom_") ? "custom" : "spec",
37+ template: AuthTemplateSlug.make(slug),
38+ placements: [],
39+ // Carry the integration's declared endpoints/scopes so the
40+ // client-registration form pre-fills them.
41+ oauth: {
42+ authorizationUrl: template.authorizationUrl,
43+ tokenUrl: template.tokenUrl,
44+ scopes: template.scopes,
45+ },
46+ };
7447 };
7548
7649 /** Map each stored auth template to a generic `AuthMethod`. */
7750 export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {
7851 return templates.map((template: Authentication): AuthMethod => {
79− const slug = String(template.slug);
80− const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";
81− if (template.type === "oauth") {
82− return {
83− id: slug,
84− label: "OAuth2",
85− kind: "oauth",
86− source,
87− template: AuthTemplateSlug.make(slug),
88− placements: [],
89− // Carry the integration's declared endpoints/scopes so the
90− // client-registration form pre-fills them.
91− oauth: {
92− authorizationUrl: template.authorizationUrl,
93− tokenUrl: template.tokenUrl,
94− scopes: template.scopes,
95− },
96− };
97− }
98− const placements = placementsFromApiKey(template);
99− return {
100− id: slug,
101− label: labelForApiKey(slug, placements),
102− kind: "apikey",
103− source,
104− template: AuthTemplateSlug.make(slug),
105− placements,
106− };
52+ if (template.kind === "oauth2") return oauthAuthMethod(template);
53+ return authMethodFromSharedTemplate(template);
10754 });
10855 }
10956
110−// ---------------------------------------------------------------------------
111−// Generic placements → apiKey template (inverse).
112−//
113−// Each placement becomes a header/query slot whose value is
114−// `prefix ? [prefix, variable("token")] : [variable("token")]`. A custom method
115−// may omit `slug`; the backend backfills `custom_<id>`.
116−// ---------------------------------------------------------------------------
117−
118−/** Slugify a placement name into a variable identifier: `DD-API-KEY` →
119− * `dd_api_key`. */
120−const slugifyVariable = (name: string): string =>
121− name
122− .toLowerCase()
123− .replace(/[^a-z0-9]+/g, "_")
124− .replace(/^_+|_+$/g, "");
125−
126−/** Assign an input variable to each (named) placement. A lone input is the
127− * canonical `token`; multiple inputs each get their own distinct variable so a
128− * connection can carry a different value per location. An explicit
129− * `placement.variable` (from a round-trip) is honored. */
130−const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => {
131− const named = placements.filter((p) => p.name);
132− const out = new Map<Placement, string>();
133− if (named.length <= 1) {
134− for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE);
135− return out;
136− }
137− const taken = new Set<string>();
138− for (const p of named) {
139− const base = slugifyVariable(p.name) || "input";
140− let candidate = p.variable ?? base;
141− let n = 2;
142− while (taken.has(candidate)) candidate = `${base}_${n++}`;
143− taken.add(candidate);
144− out.set(p, candidate);
145− }
146− return out;
147−};
148−
149−const valueFromPlacement = (placement: Placement, varName: string): AuthenticationTemplateValue =>
150− placement.prefix ? [placement.prefix, variable(varName)] : [variable(varName)];
151−
152−/** Build an `APIKeyAuthentication` template from generic placements. When
153− * `slug` is omitted the backend assigns a `custom_<id>` slug. */
57+/** Build an apikey method from generic placements. When `slug` is omitted the
58+ * backend assigns a `custom_<id>` slug. */
15459 export function templateFromPlacements(
15560 placements: readonly Placement[],
15661 slug?: string,
15762 ): APIKeyAuthentication {
158− const variables = assignVariables(placements);
159− const headers: Record<string, AuthenticationTemplateValue> = {};
160− const queryParams: Record<string, AuthenticationTemplateValue> = {};
161− for (const placement of placements) {
162− if (!placement.name) continue;
163− const varName = variables.get(placement) ?? TOKEN_VARIABLE;
164− if (placement.carrier === "header") {
165− headers[placement.name] = valueFromPlacement(placement, varName);
166− } else {
167− queryParams[placement.name] = valueFromPlacement(placement, varName);
168− }
169− }
17063 return {
171− slug: AuthTemplateSlug.make(slug ?? ""),
172− type: "apiKey",
173− ...(Object.keys(headers).length > 0 ? { headers } : {}),
174− ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
64+ slug: slug ?? "",
65+ kind: "apikey",
66+ placements: wirePlacementsFromEditor(placements),
17567 };
17668 }
17769
17870 // ---------------------------------------------------------------------------
17971 // Stored `Authentication` ⇆ generic `AuthTemplateEditorValue`.
180−//
181−// The shared add-time `AuthTemplateEditor` edits a plugin-agnostic value; these
182−// bridge it to/from the OpenAPI wire template so a spec-detected method seeds an
183−// editable default and the user's edits round-trip back to `Authentication[]`
184−// on submit. apiKey → placements; oauth → endpoints + scopes.
18572 // ---------------------------------------------------------------------------
18673
18774 /** Convert one stored `Authentication` template into a generic editor value. */
18875 export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {
189− if (template.type === "oauth") {
76+ if (template.kind === "oauth2") {
19077 return {
19178 kind: "oauth",
19279 authorizationUrl: template.authorizationUrl ?? "",
@@ -194,7 +81,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 1 unmodified line
19481 scopes: template.scopes ?? [],
19582 };
19683 }
197− return { kind: "apikey", placements: placementsFromApiKey(template) };
84+ return editorValueFromSharedMethod(template);
19885 }
19986
20087 /** Build an `OAuthAuthentication` template from a generic oauth editor value. */
@@ -203,7 +90,7 @@ const oauthTemplateFromEditorValue = ( · 2 unmodified lines
20390 slug?: string,
20491 ): Authentication => ({
20592 slug: AuthTemplateSlug.make(slug ?? ""),
206− type: "oauth",
93+ kind: "oauth2",
20794 authorizationUrl: value.authorizationUrl,
20895 tokenUrl: value.tokenUrl,
20996 scopes: [...value.scopes],
packages/plugins/openapi/src/sdk/config.ts+21−65
@@ -1,7 +1,12 @@
11 import { Option, Schema } from "effect";
2+import {
3+ ApiKeyAuthMethod,
4+ TOKEN_VARIABLE,
5+ renderAuthPlacements,
6+ requiredPlacementVariables,
7+} from "@executor-js/sdk/http-auth";
28
3−import type { Authentication, AuthenticationTemplateValue, AuthenticationVariable } from "./types";
4−import { TOKEN_VARIABLE } from "./types";
9+import type { Authentication } from "./types";
510
611 // ---------------------------------------------------------------------------
712 // OpenAPI integration config — the opaque blob stored on the catalog
@@ -15,35 +20,15 @@ import { TOKEN_VARIABLE } from "./types"; · 7 unmodified lines
1520 // - the auth templates a connection's value is rendered through.
1621 // ---------------------------------------------------------------------------
1722
18−const AuthenticationVariableSchema = Schema.Struct({
19− type: Schema.Literal("variable"),
20− name: Schema.String,
21−});
22−
23−const AuthenticationTemplateValueSchema = Schema.Union([
24− Schema.String,
25− Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
26−]);
27−
28−const APIKeyAuthenticationSchema = Schema.Struct({
29− slug: Schema.String,
30− type: Schema.Literal("apiKey"),
31− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
32− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
33−});
34−
3523 const OAuthAuthenticationSchema = Schema.Struct({
3624 slug: Schema.String,
37− type: Schema.Literal("oauth"),
25+ kind: Schema.Literal("oauth2"),
3826 authorizationUrl: Schema.String,
3927 tokenUrl: Schema.String,
4028 scopes: Schema.Array(Schema.String),
4129 });
4230
43−export const AuthenticationSchema = Schema.Union([
44− OAuthAuthenticationSchema,
45− APIKeyAuthenticationSchema,
46−]);
31+export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]);
4732
4833 export const OpenApiIntegrationConfigSchema = Schema.Struct({
4934 /** Inlined OpenAPI document text (resolved + parsed source of truth). */
@@ -80,66 +65,37 @@ export const decodeOpenApiIntegrationConfig = (value: unknown): OpenApiIntegrati · 30 unmodified lines
8065
8166 // ---------------------------------------------------------------------------
8267 // Template rendering — "auth state derived into the auth-template format"
83−// (D11). The resolved credential value renders into the template's
84−// `variable("token")` slots, identically for apiKey and oauth (the oauth value
85−// IS the access token). Returns the headers + query params to apply.
68+// (D11). An apiKey method renders through the shared placements renderer; an
69+// oauth template (no explicit placement) renders a bearer `authorization`
70+// header from the `token` input (the access token).
8671 // ---------------------------------------------------------------------------
8772
88−const isVariable = (part: string | AuthenticationVariable): part is AuthenticationVariable =>
89− typeof part !== "string";
90−
91−const renderTemplateValue = (
92− template: AuthenticationTemplateValue,
93− values: Record<string, string | null>,
94−): string => {
95− if (typeof template === "string") return template;
96− return template.map((part) => (isVariable(part) ? (values[part.name] ?? "") : part)).join("");
97−};
98−
9973 export interface RenderedAuth {
10074 readonly headers: Record<string, string>;
10175 readonly queryParams: Record<string, string>;
10276 }
10377
10478 /** Render an auth template against a connection's resolved input `values`
105− * (`variable → value`). For an apiKey template, each `variable("<name>")` is
106− * substituted from its own entry, so a method with two distinct inputs (e.g.
107− * Datadog) fills each header from a different value. For an oauth template (no
108− * explicit placement), render a bearer Authorization header from `token`. */
79+ * (`variable → value`). Each placement substitutes from its own entry, so a
80+ * method with two distinct inputs (e.g. Datadog) fills each header from a
81+ * different value. */
10982 export const renderAuthTemplate = (
11083 template: Authentication,
11184 values: Record<string, string | null>,
11285 ): RenderedAuth => {
113− if (template.type === "oauth") {
86+ if (template.kind === "oauth2") {
11487 return {
11588 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },
11689 queryParams: {},
11790 };
11891 }
119− const headers: Record<string, string> = {};
120− const queryParams: Record<string, string> = {};
121− for (const [name, tmpl] of Object.entries(template.headers ?? {})) {
122− headers[name] = renderTemplateValue(tmpl, values);
123− }
124− for (const [name, tmpl] of Object.entries(template.queryParams ?? {})) {
125− queryParams[name] = renderTemplateValue(tmpl, values);
126− }
127− return { headers, queryParams };
92+ return renderAuthPlacements(template.placements, values);
12893 };
12994
13095 /** The distinct input variables a template references — the inputs a connection
131− * must supply. An oauth template needs `token`; an apiKey template needs every
132− * `variable("<name>")` across its placements. */
96+ * must supply. An oauth template needs `token`; an apiKey method needs every
97+ * variable across its placements. */
13398 export const requiredTemplateVariables = (template: Authentication): readonly string[] => {
134− if (template.type === "oauth") return [TOKEN_VARIABLE];
135− const names = new Set<string>();
136− const collect = (tmpl: AuthenticationTemplateValue): void => {
137− if (typeof tmpl === "string") return;
138− for (const part of tmpl) {
139− if (isVariable(part)) names.add(part.name);
140− }
141− };
142− for (const tmpl of Object.values(template.headers ?? {})) collect(tmpl);
143− for (const tmpl of Object.values(template.queryParams ?? {})) collect(tmpl);
144− return [...names];
99+ if (template.kind === "oauth2") return [TOKEN_VARIABLE];
100+ return requiredPlacementVariables(template.placements);
145101 };
packages/plugins/openapi/src/sdk/configure.test.ts+72−27
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 // integration's opaque config. These tests exercise the extension method
66 // directly (the same path the HTTP `configure` handler calls):
77 // - round-trip: add a custom apiKey method → `getConfig` shows it with the
8−// correct header/query placement + `variable("token")` slot,
8+// correct header/query placements,
99 // - slug generation: a method with no slug is assigned `custom_<id>`,
1010 // - slug uniqueness/dedupe: a method whose slug matches an existing entry
1111 // replaces it rather than duplicating, and two slug-less methods in one call
@@ -28,7 +28,8 @@ import { · 16 unmodified lines
2828 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
2929
3030 import { openApiPlugin } from "./plugin";
31−import { variable, type APIKeyAuthentication, type Authentication } from "./types";
31+import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types";
32+import { variable } from "@executor-js/sdk/http-auth";
3233 import {
3334 makeOpenApiHttpApiTestSourceConfig,
3435 serveOpenApiHttpApiTestServer,
@@ -71,9 +72,10 @@ const specText = () => { · 36 unmodified lines
7172 return spec.url;
7273 };
7374
74−// A custom apiKey method that places the connection value into `x-api-key`.
75−const customApiKey: APIKeyAuthentication = {
76− slug: AuthTemplateSlug.make("my_custom"),
75+// A custom apiKey method that places the connection value into `x-api-key`,
76+// authored request-shaped (the one apikey input dialect).
77+const customApiKey: AuthenticationInput = {
78+ slug: "my_custom",
7779 type: "apiKey",
7880 headers: { "x-api-key": [variable("token")] },
7981 };
@@ -81,9 +83,9 @@ const customApiKey: APIKeyAuthentication = { · 1 unmodified line
8183 // Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that
8284 // omits it, which `configure` should backfill with a generated `custom_<id>`.
8385 // The cast is confined to this one boundary helper.
84−const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication =>
85− // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it
86− ({ type: "apiKey", ...placement }) as unknown as Authentication;
86+const sluglessApiKey = (
87+ template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
88+): AuthenticationInput => ({ type: "apiKey", ...template });
8789
8890 describe("OpenAPI Plugin — configure (custom auth method)", () => {
8991 it.effect("adds a custom apiKey method and getConfig reflects it", () =>
@@ -107,10 +109,10 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
107109 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];
108110 expect(template).toHaveLength(1);
109111 const entry = template[0] as APIKeyAuthentication;
110− expect(entry.type).toBe("apiKey");
112+ expect(entry.kind).toBe("apikey");
111113 expect(String(entry.slug)).toBe("my_custom");
112− // The header placement preserves the `variable("token")` slot verbatim.
113− expect(entry.headers).toEqual({ "x-api-key": [variable("token")] });
114+ // The placement preserves the canonical `token` input (stored absent).
115+ expect(entry.placements).toEqual([{ carrier: "header", name: "x-api-key" }]);
114116 }),
115117 ),
116118 );
@@ -120,8 +122,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
120122 Effect.gen(function* () {
121123 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
122124
123− const seedTemplate: Authentication = {
124− slug: AuthTemplateSlug.make("seed"),
125+ const seedTemplate: AuthenticationInput = {
126+ slug: "seed",
125127 type: "apiKey",
126128 headers: { authorization: ["Bearer ", variable("token")] },
127129 };
@@ -151,9 +153,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 23 unmodified lines
151153 slug: "cfg_genslug",
152154 });
153155
154− const slugless = sluglessApiKey({
155− headers: { "x-api-key": [variable("token")] },
156− });
156+ const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } });
157157
158158 const merged = yield* executor.openapi.configure("cfg_genslug", {
159159 authenticationTemplate: [slugless],
@@ -177,17 +177,13 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
177177 });
178178
179179 // Re-submit the same slug with a different placement → replace in place.
180− const replacement: APIKeyAuthentication = {
181− slug: AuthTemplateSlug.make("my_custom"),
180+ const replacement: AuthenticationInput = {
181+ slug: "my_custom",
182182 type: "apiKey",
183183 headers: { "x-other": [variable("token")] },
184184 };
185− const slugless = sluglessApiKey({
186− queryParams: { api_key: [variable("token")] },
187− });
188− const slugless2 = sluglessApiKey({
189− queryParams: { token: [variable("token")] },
190− });
185+ const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } });
186+ const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } });
191187
192188 const merged = yield* executor.openapi.configure("cfg_dedupe", {
193189 authenticationTemplate: [replacement, slugless, slugless2],
@@ -205,7 +201,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 11 unmodified lines
205201 const replaced = merged.find(
206202 (m: Authentication) => String(m.slug) === "my_custom",
207203 ) as APIKeyAuthentication;
208− expect(replaced.headers).toEqual({ "x-other": [variable("token")] });
204+ expect(replaced.placements).toEqual([{ carrier: "header", name: "x-other" }]);
209205 }),
210206 ),
211207 );
@@ -215,8 +211,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
215211 Effect.gen(function* () {
216212 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
217213
218− const seedTemplate: Authentication = {
219− slug: AuthTemplateSlug.make("seed"),
214+ const seedTemplate: AuthenticationInput = {
215+ slug: "seed",
220216 type: "apiKey",
221217 headers: { authorization: ["Bearer ", variable("token")] },
222218 };
@@ -273,6 +269,55 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 50 unmodified lines
273269 ),
274270 );
275271
272+ it.effect("the request-shaped dialect configures a method and renders on the wire", () =>
273+ Effect.scoped(
274+ Effect.gen(function* () {
275+ const server = yield* servePluginTestApi();
276+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
277+
278+ yield* executor.openapi.addSpec({
279+ spec: { kind: "blob", value: server.specJson },
280+ slug: "cfg_authored",
281+ baseUrl: server.baseUrl,
282+ });
283+
284+ // Authored request-shaped; stored canonical.
285+ yield* executor.openapi.configure("cfg_authored", {
286+ authenticationTemplate: [
287+ {
288+ slug: "bearer",
289+ type: "apiKey",
290+ headers: { authorization: ["Bearer ", variable("token")] },
291+ },
292+ ],
293+ });
294+
295+ const config = yield* executor.openapi.getConfig("cfg_authored");
296+ expect(config?.authenticationTemplate?.[0]).toEqual({
297+ slug: "bearer",
298+ kind: "apikey",
299+ placements: [{ carrier: "header", name: "authorization", prefix: "Bearer " }],
300+ });
301+
302+ yield* executor.connections.create({
303+ owner: "org",
304+ name: ConnectionName.make("main"),
305+ integration: IntegrationSlug.make("cfg_authored"),
306+ template: AuthTemplateSlug.make("bearer"),
307+ value: "authored-secret",
308+ });
309+
310+ const result = unwrapInvocation(
311+ yield* executor.execute(
312+ ToolAddress.make("tools.cfg_authored.org.main.items.echoHeaders"),
313+ {},
314+ ),
315+ ).data as { authorization?: string };
316+ expect(result.authorization).toBe("Bearer authored-secret");
317+ }),
318+ ),
319+ );
320+
276321 it.effect("getConfig returns null for an unknown integration", () =>
277322 Effect.scoped(
278323 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+6−7
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 } from "@executor-js/sdk/core";
88
99 import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";
10−import { variable, type Authentication } from "./types";
10+import { type Authentication } from "./types";
1111
1212 // ---------------------------------------------------------------------------
1313 // `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`
@@ -33,8 +33,8 @@ describe("describeOpenApiAuthMethods", () => { · 19 unmodified lines
3333 recordWith([
3434 {
3535 slug: AuthTemplateSlug.make("bearer"),
36− type: "apiKey",
37− headers: { Authorization: ["Bearer ", variable("token")] },
36+ kind: "apikey",
37+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
3838 },
3939 ]),
4040 );
@@ -45,9 +45,8 @@ describe("describeOpenApiAuthMethods", () => { · 4 unmodified lines
4545 label: "API key (Authorization)",
4646 kind: "apikey",
4747 template: "bearer",
48− placements: [
49− { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },
50− ],
48+ // The canonical `token` input is stored (and projected) as absent.
49+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
5150 },
5251 ]);
5352 });
@@ -57,7 +56,7 @@ describe("describeOpenApiAuthMethods", () => { · 3 unmodified lines
5756 recordWith([
5857 {
5958 slug: AuthTemplateSlug.make("oauth"),
60− type: "oauth",
59+ kind: "oauth2",
6160 authorizationUrl: "https://auth.example/authorize",
6261 tokenUrl: "https://auth.example/token",
6362 scopes: ["read", "write"],
packages/plugins/openapi/src/sdk/google-bundle.test.ts+2−2
@@ -205,8 +205,8 @@ describe("Google bundle add flow", () => { · 204 unmodified lines
205205 // collapses under `https://mail.google.com/`, so the requested consent
206206 // is clean rather than the raw per-method union.
207207 const config = yield* executor.openapi.getConfig("google");
208− const oauth = config?.authenticationTemplate?.find((entry) => entry.type === "oauth");
209− expect(oauth?.type === "oauth" ? [...oauth.scopes].sort() : undefined).toEqual(
208+ const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209+ expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210210 [
211211 "https://mail.google.com/",
212212 "https://www.googleapis.com/auth/calendar",
packages/plugins/openapi/src/sdk/google-discovery.test.ts+5−5
@@ -243,7 +243,7 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI · 242 unmodified lines
243243 // than v1's `oauth2` source config.
244244 // removed: identityScopes assertion — that field belonged to the v1
245245 // OAuth2SourceConfig slot model, which no longer exists in v2.
246− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
246+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
247247 expect(oauthTemplate).toBeDefined();
248248 }),
249249 );
@@ -440,8 +440,8 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 190 unmodified lines
440440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");
442442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
444− expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([
443+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444+ expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445445 "https://www.googleapis.com/auth/gmail.metadata",
446446 "https://www.googleapis.com/auth/chat.spaces.readonly",
447447 ]);
@@ -579,8 +579,8 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent · 131 unmodified lines
579579
580580 // The derived oauth auth template carries the compacted/filtered set
581581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582− const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");
583− expect(oauthTemplate?.type === "oauth" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
582+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583+ expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584584 [...expectedConsentScopes].sort(),
585585 );
586586
packages/plugins/openapi/src/sdk/google-discovery.ts+1−1
@@ -623,7 +623,7 @@ const googleOauthTemplate = (scopes: Record<string, string>): readonly Authentic · 622 unmodified lines
623623 [
624624 {
625625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626− type: "oauth",
626+ kind: "oauth2",
627627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629629 scopes: Object.keys(scopes),
packages/plugins/openapi/src/sdk/index.ts+7−3
@@ -67,9 +67,13 @@ export { · 66 unmodified lines
6767 OperationId,
6868 HttpMethod,
6969 ParameterLocation,
70− variable,
70+ TOKEN_VARIABLE,
71+ normalizeOpenApiAuthInputs,
7172 type Authentication,
73+ type AuthenticationInput,
7274 type APIKeyAuthentication,
73− type AuthenticationVariable,
74− type AuthenticationTemplateValue,
7575 } from "./types";
76+// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
77+export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
78+
79+export { migrateOpenApiAuthConfig } from "./migrate-config";
packages/plugins/openapi/src/sdk/migrate-config.test.ts+225−0
@@ -0,0 +1,225 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { renderAuthTemplate } from "./config";
4+import { migrateOpenApiAuthConfig } from "./migrate-config";
5+import type { Authentication } from "./types";
6+
7+// ---------------------------------------------------------------------------
8+// The one-off config migration rewrites the retired `variable()`-templated
9+// apiKey shape into canonical placements. Slugs and variable names are
10+// preserved verbatim (`connection.template` / `item_ids` contracts), and the
11+// EQUIVALENCE tests pin that a migrated template renders byte-identically to
12+// what the legacy renderer produced for the same values.
13+// ---------------------------------------------------------------------------
14+
15+const BASE = { spec: "{}", baseUrl: "https://api.example.com" } as const;
16+
17+describe("migrateOpenApiAuthConfig", () => {
18+ it("rewrites a single-input bearer template (prefix + token)", () => {
19+ const migrated = migrateOpenApiAuthConfig({
20+ ...BASE,
21+ authenticationTemplate: [
22+ {
23+ slug: "bearer",
24+ type: "apiKey",
25+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
26+ },
27+ ],
28+ });
29+ expect(migrated).toEqual({
30+ ...BASE,
31+ authenticationTemplate: [
32+ {
33+ slug: "bearer",
34+ kind: "apikey",
35+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
36+ },
37+ ],
38+ });
39+ });
40+
41+ it("rewrites a multi-input template (Datadog), variables preserved verbatim", () => {
42+ const migrated = migrateOpenApiAuthConfig({
43+ ...BASE,
44+ authenticationTemplate: [
45+ {
46+ slug: "datadog",
47+ type: "apiKey",
48+ headers: {
49+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
50+ "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
51+ },
52+ queryParams: { site: [{ type: "variable", name: "site" }] },
53+ },
54+ ],
55+ });
56+ expect(migrated).toEqual({
57+ ...BASE,
58+ authenticationTemplate: [
59+ {
60+ slug: "datadog",
61+ kind: "apikey",
62+ placements: [
63+ { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
64+ { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
65+ { carrier: "query", name: "site", variable: "site" },
66+ ],
67+ },
68+ ],
69+ });
70+ });
71+
72+ it("rewrites literal-only template values into literal placements", () => {
73+ const migrated = migrateOpenApiAuthConfig({
74+ ...BASE,
75+ authenticationTemplate: [
76+ {
77+ slug: "versioned",
78+ type: "apiKey",
79+ headers: {
80+ "X-Api-Version": "2023-01-01",
81+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
82+ },
83+ },
84+ ],
85+ }) as { authenticationTemplate: readonly unknown[] };
86+ expect(migrated.authenticationTemplate[0]).toEqual({
87+ slug: "versioned",
88+ kind: "apikey",
89+ placements: [
90+ { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
91+ { carrier: "header", name: "Authorization", prefix: "Bearer " },
92+ ],
93+ });
94+ });
95+
96+ it('re-keys the retired `type: "oauth"` spelling to `kind: "oauth2"`', () => {
97+ const migrated = migrateOpenApiAuthConfig({
98+ ...BASE,
99+ authenticationTemplate: [
100+ {
101+ slug: "oauth",
102+ type: "oauth",
103+ authorizationUrl: "https://x.example/auth",
104+ tokenUrl: "https://x.example/token",
105+ scopes: ["read"],
106+ },
107+ {
108+ slug: "key",
109+ type: "apiKey",
110+ queryParams: { key: [{ type: "variable", name: "token" }] },
111+ },
112+ ],
113+ }) as { authenticationTemplate: readonly unknown[] };
114+ expect(migrated.authenticationTemplate[0]).toEqual({
115+ slug: "oauth",
116+ kind: "oauth2",
117+ authorizationUrl: "https://x.example/auth",
118+ tokenUrl: "https://x.example/token",
119+ scopes: ["read"],
120+ });
121+ });
122+
123+ it("is idempotent — canonical configs return null", () => {
124+ const canonical = {
125+ ...BASE,
126+ authenticationTemplate: [
127+ {
128+ slug: "bearer",
129+ kind: "apikey",
130+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
131+ },
132+ ],
133+ };
134+ expect(migrateOpenApiAuthConfig(canonical)).toBeNull();
135+ const lifted = migrateOpenApiAuthConfig({
136+ ...BASE,
137+ authenticationTemplate: [
138+ { slug: "b", type: "apiKey", headers: { A: [{ type: "variable", name: "token" }] } },
139+ ],
140+ });
141+ expect(migrateOpenApiAuthConfig(lifted)).toBeNull();
142+ });
143+
144+ it("leaves template-less, foreign, and unmigratable blobs untouched", () => {
145+ expect(migrateOpenApiAuthConfig({ ...BASE })).toBeNull();
146+ expect(migrateOpenApiAuthConfig({ transport: "remote", endpoint: "x" })).toBeNull();
147+ expect(migrateOpenApiAuthConfig(null)).toBeNull();
148+ expect(
149+ migrateOpenApiAuthConfig({ ...BASE, authenticationTemplate: [{ garbage: true }] }),
150+ ).toBeNull();
151+ });
152+});
153+
154+// ---------------------------------------------------------------------------
155+// Equivalence — the migrated template renders EXACTLY what the legacy
156+// renderer produced. Expected outputs below are the legacy renderer's
157+// behavior, written out by hand so the old code can stay deleted.
158+// ---------------------------------------------------------------------------
159+
160+describe("legacy render equivalence", () => {
161+ const migrateTemplate = (legacy: unknown): Authentication => {
162+ const migrated = migrateOpenApiAuthConfig({
163+ ...BASE,
164+ authenticationTemplate: [legacy],
165+ }) as { authenticationTemplate: readonly Authentication[] };
166+ return migrated.authenticationTemplate[0]!;
167+ };
168+
169+ it("bearer header (prefix + token)", () => {
170+ const template = migrateTemplate({
171+ slug: "bearer",
172+ type: "apiKey",
173+ headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
174+ });
175+ expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
176+ headers: { Authorization: "Bearer tok_1" },
177+ queryParams: {},
178+ });
179+ });
180+
181+ it("two distinct inputs across headers and query (Datadog-style)", () => {
182+ const template = migrateTemplate({
183+ slug: "datadog",
184+ type: "apiKey",
185+ headers: {
186+ "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
187+ "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
188+ },
189+ queryParams: { team: [{ type: "variable", name: "team" }] },
190+ });
191+ expect(
192+ renderAuthTemplate(template, { dd_api_key: "a", dd_application_key: "b", team: "t" }),
193+ ).toEqual({
194+ headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" },
195+ queryParams: { team: "t" },
196+ });
197+ });
198+
199+ it("literal-only value renders verbatim with no credential", () => {
200+ const template = migrateTemplate({
201+ slug: "versioned",
202+ type: "apiKey",
203+ headers: {
204+ "X-Api-Version": "2023-01-01",
205+ Authorization: ["Bearer ", { type: "variable", name: "token" }],
206+ },
207+ });
208+ expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
209+ headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" },
210+ queryParams: {},
211+ });
212+ });
213+
214+ it("bare query token (no prefix)", () => {
215+ const template = migrateTemplate({
216+ slug: "key",
217+ type: "apiKey",
218+ queryParams: { api_key: [{ type: "variable", name: "token" }] },
219+ });
220+ expect(renderAuthTemplate(template, { token: "k1" })).toEqual({
221+ headers: {},
222+ queryParams: { api_key: "k1" },
223+ });
224+ });
225+});
packages/plugins/openapi/src/sdk/migrate-config.ts+70−0
@@ -0,0 +1,70 @@
1+// ---------------------------------------------------------------------------
2+// One-off config migration — rewrite pre-canonical OpenAPI integration
3+// `config` blobs into the canonical placements model. Runs once per database
4+// (boot for local/selfhost, out-of-band script for cloud); runtime code
5+// decodes ONLY the canonical shape (`decodeOpenApiIntegrationConfig`).
6+//
7+// The retired shape is the `variable()`-templated apiKey:
8+// { slug, type: "apiKey", headers/queryParams: Record<name,
9+// string | (string | {type:"variable", name})[]> }
10+// Each entry becomes one placement: prefix = literals before the first
11+// variable, the variable name preserved VERBATIM (it keys the connections'
12+// stored `item_ids`; the canonical `token` is stored as absent), a
13+// literal-only value becomes a `literal` placement. OAuth templates
14+// re-key from the retired `type: "oauth"` spelling to `kind: "oauth2"`.
15+// ---------------------------------------------------------------------------
16+
17+import { Option } from "effect";
18+import {
19+ apiKeyMethodFromLegacyTemplate,
20+ decodeLegacyApiKeyTemplate,
21+} from "@executor-js/sdk/http-auth";
22+
23+import { decodeOpenApiIntegrationConfig } from "./config";
24+
25+const isCanonicalEntry = (entry: unknown): boolean =>
26+ typeof entry === "object" &&
27+ entry !== null &&
28+ "kind" in entry &&
29+ ((entry as { kind: unknown }).kind === "apikey" ||
30+ (entry as { kind: unknown }).kind === "oauth2");
31+
32+/** The retired oauth spelling: `type: "oauth"` instead of `kind: "oauth2"`. */
33+const isLegacyOAuthEntry = (
34+ entry: unknown,
35+): entry is { readonly type: "oauth" } & Record<string, unknown> =>
36+ typeof entry === "object" &&
37+ entry !== null &&
38+ "type" in entry &&
39+ (entry as { type: unknown }).type === "oauth";
40+
41+const migrateEntry = (entry: unknown): unknown | null => {
42+ if (isCanonicalEntry(entry)) return entry;
43+ if (isLegacyOAuthEntry(entry)) {
44+ const { type: _type, ...rest } = entry;
45+ return { ...rest, kind: "oauth2" };
46+ }
47+ const legacy = Option.getOrNull(decodeLegacyApiKeyTemplate(entry));
48+ if (legacy !== null) return apiKeyMethodFromLegacyTemplate(legacy);
49+ return null;
50+};
51+
52+/** Rewrite a stored OpenAPI integration config blob into the canonical shape.
53+ * Returns the rewritten config, or `null` when no rewrite is needed (already
54+ * canonical, no auth templates, or not this plugin's shape). Idempotent. */
55+export const migrateOpenApiAuthConfig = (config: unknown): unknown | null => {
56+ if (typeof config !== "object" || config === null) return null;
57+ if (!("spec" in config)) return null;
58+ if (!("authenticationTemplate" in config)) return null;
59+
60+ const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate;
61+ if (!Array.isArray(entries)) return null;
62+
63+ const migrated = entries.map(migrateEntry);
64+ if (migrated.some((entry) => entry === null)) return null;
65+ const changed = migrated.some((entry, index) => entry !== entries[index]);
66+ if (!changed) return null;
67+
68+ const next = { ...config, authenticationTemplate: migrated };
69+ return decodeOpenApiIntegrationConfig(next) !== null ? next : null;
70+};
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+3−3
@@ -36,7 +36,7 @@ import { · 35 unmodified lines
3636 } from "@executor-js/plugin-openapi/testing";
3737
3838 import { openApiPlugin } from "./plugin";
39−import { variable, type Authentication } from "./types";
39+import { type AuthenticationInput } from "./types";
4040
4141 // ---------------------------------------------------------------------------
4242 // Test API — a single endpoint that echoes the Authorization header so the
@@ -68,10 +68,10 @@ const serveVercel = () => · 25 unmodified lines
6868 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });
6969
7070 // Bearer template: the connection value renders into `Authorization: Bearer …`.
71−const bearerTemplate: Authentication = {
71+const bearerTemplate: AuthenticationInput = {
7272 slug: AuthTemplateSlug.make("bearer"),
7373 type: "apiKey",
74− headers: { authorization: ["Bearer ", variable("token")] },
74+ headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
7575 };
7676
7777 const testPlugins = () =>
packages/plugins/openapi/src/sdk/plugin.test.ts+5−5
@@ -32,7 +32,7 @@ import { · 31 unmodified lines
3232 } from "@executor-js/sdk/testing";
3333
3434 import { openApiPlugin } from "./plugin";
35−import { variable, type Authentication } from "./types";
35+import { type AuthenticationInput } from "./types";
3636 import {
3737 addOpenApiTestConnection,
3838 makeOpenApiHttpApiTestSourceConfig,
@@ -154,16 +154,16 @@ const servePluginTestApi = () => · 115 unmodified lines
154154 });
155155
156156 // An apiKey auth template that places the connection value into `x-api-key`.
157−const apiKeyTemplate: Authentication = {
157+const apiKeyTemplate: AuthenticationInput = {
158158 slug: AuthTemplateSlug.make("apiKey"),
159159 type: "apiKey",
160− headers: { "x-api-key": [variable("token")] },
160+ headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] },
161161 };
162162
163163 // An oauth template — the connection value renders as a bearer token.
164−const oauthTemplate: Authentication = {
164+const oauthTemplate: AuthenticationInput = {
165165 slug: AuthTemplateSlug.make("oauth"),
166− type: "oauth",
166+ kind: "oauth2",
167167 authorizationUrl: "https://auth.example.test/authorize",
168168 tokenUrl: "https://auth.example.test/token",
169169 scopes: ["read"],
packages/plugins/openapi/src/sdk/plugin.ts+29−87
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
1313 mergeAuthTemplates,
1414 tool,
1515 type AuthMethodDescriptor,
16− type AuthPlacementDescriptor,
1716 type Integration,
1817 type IntegrationConfig,
1918 type IntegrationRecord,
@@ -44,8 +43,9 @@ import { annotationsForOperation, invokeWithLayer } from "./invoke"; · 24 unmodified lines
4443 import { previewSpec, type SpecPreview } from "./preview";
4544 import { openApiPresets } from "./presets";
4645 import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
47−import type { Authentication, AuthenticationTemplateValue } from "./types";
48−import { OperationBinding, TOKEN_VARIABLE } from "./types";
46+import type { Authentication } from "./types";
47+import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
48+import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
4949
5050 // ---------------------------------------------------------------------------
5151 // Plugin config
@@ -154,8 +154,9 @@ export interface OpenApiSpecConfig { · 102 unmodified lines
154154 readonly headers?: Record<string, string>;
155155 /** Static query params applied to every request. */
156156 readonly queryParams?: Record<string, string>;
157− /** Auth methods a connection's value renders through. */
158− readonly authenticationTemplate?: readonly Authentication[];
157+ /** Auth methods a connection's value renders through — canonical
158+ * placements or the request-shaped authoring dialect. */
159+ readonly authenticationTemplate?: readonly AuthenticationInput[];
159160 }
160161
161162 export interface OpenApiExtensionFailure {
@@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines
169170 * already exists, replaces) the integration's existing template array. A
170171 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
171172 * slug that is collision-checked against the existing template. */
172− readonly authenticationTemplate: readonly Authentication[];
173+ readonly authenticationTemplate: readonly AuthenticationInput[];
173174 readonly mode?: "merge" | "replace";
174175 }
175176
@@ -295,28 +296,18 @@ const OpenApiSpecInputSchema = Schema.Union([ · 119 unmodified lines
295296 }),
296297 ]);
297298
298−const AuthenticationVariableSchema = Schema.Struct({
299− type: Schema.Literal("variable"),
300− name: Schema.String,
301−});
302−const AuthenticationTemplateValueSchema = Schema.Union([
303− Schema.String,
304− Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
305−]);
306299 const AuthenticationSchema = Schema.Union([
307300 Schema.Struct({
308301 slug: Schema.String,
309− type: Schema.Literal("apiKey"),
310− headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
311− queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
312− }),
313− Schema.Struct({
314− slug: Schema.String,
315− type: Schema.Literal("oauth"),
302+ kind: Schema.Literal("oauth2"),
316303 authorizationUrl: Schema.String,
317304 tokenUrl: Schema.String,
318305 scopes: Schema.Array(Schema.String),
319306 }),
307+ // Credential methods are authored request-shaped — the ONE apikey input
308+ // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
309+ // variable("token")] }, queryParams: { … } }`.
310+ ApiKeyAuthTemplate,
320311 ]);
321312
322313 const AddSourceInputSchema = Schema.Struct({
@@ -525,53 +516,10 @@ const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | un · 202 unmodified lines
525516 // Declared auth methods — project the stored `authenticationTemplate` into the
526517 // catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
527518 // `authMethodsFromConfig` (in the React auth-method-config module) on the
528−// server so the catalog field is consistent. OpenAPI also renders its own
529−// accounts slot, so this projection is consistency work, not a fix; an absent /
530−// malformed config yields `[]` with no regression.
531−//
532−// Placement prefix extraction: a header/query slot serializes the credential as
533−// `name -> [prefix, variable("token")]`; the leading literals before the
534−// `variable("token")` part are the prefix (empty for a bare `[token]`).
519+// server so the catalog field is consistent. apikey/none projection comes from
520+// the shared model; the oauth method carries the stored endpoints + scopes.
535521 // ---------------------------------------------------------------------------
536522
537−/** Extract the literal prefix preceding the credential variable, plus the
538− * variable name a placement renders from (`token` for single-input methods, a
539− * distinct name per input for multi-input ones). */
540−const parseTemplateValue = (
541− value: AuthenticationTemplateValue,
542−): { readonly prefix: string; readonly variable: string } => {
543− if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
544− const parts: string[] = [];
545− for (const part of value) {
546− if (typeof part !== "string" && part.type === "variable") {
547− return { prefix: parts.join(""), variable: part.name };
548− }
549− if (typeof part === "string") parts.push(part);
550− }
551− return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
552−};
553−
554−const placementsFromAuthentication = (
555− template: Extract<Authentication, { readonly type: "apiKey" }>,
556−): readonly AuthPlacementDescriptor[] => {
557− const placements: AuthPlacementDescriptor[] = [];
558− for (const [name, value] of Object.entries(template.headers ?? {})) {
559− const { prefix, variable } = parseTemplateValue(value);
560− placements.push({ carrier: "header", name, prefix, variable });
561− }
562− for (const [name, value] of Object.entries(template.queryParams ?? {})) {
563− const { prefix, variable } = parseTemplateValue(value);
564− placements.push({ carrier: "query", name, prefix, variable });
565− }
566− return placements;
567−};
568−
569−const apiKeyLabel = (slug: string, placements: readonly AuthPlacementDescriptor[]): string => {
570− const first = placements[0];
571− if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
572− return `API key (${slug})`;
573−};
574−
575523 export const describeOpenApiAuthMethods = (
576524 record: IntegrationRecord,
577525 ): readonly AuthMethodDescriptor[] => {
@@ -579,13 +527,12 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
579527 if (!config) return [];
580528 return (config.authenticationTemplate ?? []).map(
581529 (template: Authentication): AuthMethodDescriptor => {
582− const slug = String(template.slug);
583− if (template.type === "oauth") {
530+ if (template.kind === "oauth2") {
584531 return {
585− id: slug,
532+ id: String(template.slug),
586533 label: "OAuth2",
587534 kind: "oauth",
588− template: slug,
535+ template: String(template.slug),
589536 oauth: {
590537 authorizationUrl: template.authorizationUrl,
591538 tokenUrl: template.tokenUrl,
@@ -593,14 +540,7 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
593540 },
594541 };
595542 }
596− const placements = placementsFromAuthentication(template);
597− return {
598− id: slug,
599− label: apiKeyLabel(slug, placements),
600− kind: "apikey",
601− template: slug,
602− placements,
603− };
543+ return describeApiKeyAuthMethod(template);
604544 },
605545 );
606546 };
@@ -798,7 +738,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 191 unmodified lines
798738 // Google Discovery converter derived from the spec (the bundle add
799739 // path relies on this — it has no preview to detect auth from).
800740 ...(config.authenticationTemplate
801− ? { authenticationTemplate: config.authenticationTemplate }
741+ ? {
742+ authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
743+ }
802744 : resolved.authenticationTemplate
803745 ? { authenticationTemplate: resolved.authenticationTemplate }
804746 : {}),
@@ -891,13 +833,11 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 86 unmodified lines
891833 const current = decodeOpenApiIntegrationConfig(record.config);
892834 if (!current) return [] as readonly Authentication[];
893835
836+ const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
894837 const merged =
895838 input.mode === "replace"
896− ? input.authenticationTemplate
897− : mergeAuthTemplates(
898− current.authenticationTemplate ?? [],
899− input.authenticationTemplate,
900− );
839+ ? incoming
840+ : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
901841
902842 const next: OpenApiIntegrationConfig = {
903843 ...current,
@@ -959,7 +899,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 55 unmodified lines
959899 headers: input.headers,
960900 queryParams: input.queryParams,
961901 authenticationTemplate: input.authenticationTemplate as
962− | readonly Authentication[]
902+ | readonly AuthenticationInput[]
963903 | undefined,
964904 })
965905 .pipe(
@@ -1074,12 +1014,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 108 unmodified lines
10741014 if (missing.length > 0) {
10751015 return openApiAuthToolFailure({
10761016 code:
1077− template.type === "oauth" ? "oauth_connection_missing" : "connection_value_missing",
1017+ template.kind === "oauth2"
1018+ ? "oauth_connection_missing"
1019+ : "connection_value_missing",
10781020 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
10791021 owner: credential.owner,
10801022 integration,
10811023 connection: String(credential.connection),
1082− credentialKind: template.type === "oauth" ? "oauth" : "secret",
1024+ credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
10831025 });
10841026 }
10851027 const rendered = renderAuthTemplate(template, credential.values);
packages/plugins/openapi/src/sdk/types.ts+45−30
@@ -1,43 +1,58 @@
11 import { Schema } from "effect";
2−import type { AuthTemplateSlug } from "@executor-js/sdk/shared";
3−import type { OAuthAuthentication } from "@executor-js/sdk/shared";
2+import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
3+import {
4+ apiKeyMethodFromAuthTemplate,
5+ isApiKeyAuthTemplate,
6+ type ApiKeyAuthMethod,
7+ type ApiKeyAuthTemplate,
8+} from "@executor-js/sdk/http-auth";
49
510 // ---------------------------------------------------------------------------
6−// Auth-template model (ported from the v2 scaffold `openapi/types.ts`).
11+// Auth-template model.
712 //
8−// The apiKey template is HTTP-transport-specific: it declares where the user's
9−// credential goes on the outbound request (headers / query params) via the
10−// `variable()` templating below. That placement is why it lives with the
11−// openapi plugin rather than in core. The oauth template is mechanism-intrinsic
12−// and comes from core (`OAuthAuthentication`); an integration's `Authentication`
13−// union composes the two. Client credentials (clientId/secret) live on the core
14−// `OAuthClient`, not here.
13+// The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`,
14+// the same shape the graphql/mcp plugins store): N header/query placements,
15+// each rendered from its own credential input. The oauth template is
16+// mechanism-intrinsic and comes from core (`OAuthAuthentication`, keyed
17+// `kind: "oauth2"` with stored endpoints+scopes); an integration's
18+// `Authentication` union composes the two. Client credentials
19+// (clientId/secret) live on the core `OAuthClient`, not here.
20+//
21+// Pre-canonical stored templates (`type: "apiKey"` with `variable()`-templated
22+// header/query records) are rewritten by the one-off config migration
23+// (`migrate-config.ts`) — runtime code knows only this model.
1524 // ---------------------------------------------------------------------------
1625
17−export type AuthenticationVariable = {
18− readonly type: "variable";
19− readonly name: string;
20−};
21−
22−/** A literal string, or a parts-array mixing literals and variable refs. */
23−export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
26+export { TOKEN_VARIABLE } from "@executor-js/sdk/http-auth";
2427
25−export const variable = (name: string): AuthenticationVariable => ({
26− type: "variable",
27− name,
28−});
28+export type APIKeyAuthentication = ApiKeyAuthMethod;
2929
30−/** The variable name the resolved credential value renders into. */
31−export const TOKEN_VARIABLE = "token" as const;
30+/** Every method is keyed by `kind` — `kind: "oauth2"` | `kind: "apikey"`. */
31+export type Authentication = OAuthAuthentication | APIKeyAuthentication;
3232
33−export type APIKeyAuthentication = {
34− readonly slug: AuthTemplateSlug;
35− readonly type: "apiKey";
36− readonly headers?: Record<string, AuthenticationTemplateValue>;
37− readonly queryParams?: Record<string, AuthenticationTemplateValue>;
33+/** What auth inputs accept: oauth templates (wire-typed: plain slug) plus the
34+ * request-shaped apikey dialect (`type: "apiKey"`, headers/queryParams
35+ * records) — the ONE apikey authoring shape. Stored configs and the catalog
36+ * read as canonical placements; `apiKeyAuthTemplateFromMethod` serializes
37+ * them back for read-modify-write flows. */
38+export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & {
39+ readonly slug: string;
3840 };
39−
40−export type Authentication = OAuthAuthentication | APIKeyAuthentication;
41+export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate;
42+
43+/** Expand the request-shaped dialect into canonical placements and brand the
44+ * oauth slugs. A dialect entry without a slug gets a blank one —
45+ * `mergeAuthTemplates` backfills `custom_<id>`. */
46+export const normalizeOpenApiAuthInputs = (
47+ inputs: readonly AuthenticationInput[],
48+): readonly Authentication[] =>
49+ inputs.map((input): Authentication => {
50+ if (!isApiKeyAuthTemplate(input)) {
51+ return { ...input, slug: AuthTemplateSlug.make(input.slug) };
52+ }
53+ const method = apiKeyMethodFromAuthTemplate(input);
54+ return { ...method, slug: method.slug ?? "" };
55+ });
4156
4257 // ---------------------------------------------------------------------------
4358 // Branded IDs