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 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
3import {4import {
4 InternalError,5 InternalError,
5 IntegrationAlreadyExistsError,6 IntegrationAlreadyExistsError,
@@ -46,29 +47,19 @@ const OpenApiSpecInputPayload = Schema.Union([ · 40 unmodified lines
46 }),47 }),
47]);48]);
48 49
49const AuthenticationVariablePayload = Schema.Struct({50const OAuthTemplatePayload = Schema.Struct({
50 type: Schema.Literal("variable"),51 slug: Schema.String,
51 name: Schema.String,52 kind: Schema.Literal("oauth2"),
53 authorizationUrl: Schema.String,
54 tokenUrl: Schema.String,
55 scopes: Schema.Array(Schema.String),
52});56});
53const AuthenticationTemplateValuePayload = Schema.Union([57
54 Schema.String,58/** Auth INPUTS: oauth templates + the request-shaped apikey dialect. */
55 Schema.Array(Schema.Union([Schema.String, AuthenticationVariablePayload])),59const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
56]);60
57const AuthenticationPayload = Schema.Union([61/** Auth in RESPONSES: the canonical stored shapes (placements). */
58 Schema.Struct({62const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
59 slug: Schema.String,
60 type: Schema.Literal("apiKey"),
61 headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
62 queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValuePayload)),
63 }),
64 Schema.Struct({
65 slug: Schema.String,
66 type: Schema.Literal("oauth"),
67 authorizationUrl: Schema.String,
68 tokenUrl: Schema.String,
69 scopes: Schema.Array(Schema.String),
70 }),
71]);
72 63
73const AddSpecPayload = Schema.Struct({64const AddSpecPayload = Schema.Struct({
74 spec: OpenApiSpecInputPayload,65 spec: OpenApiSpecInputPayload,
@@ -119,13 +110,13 @@ const OpenApiConfigView = Schema.Struct({ · 44 unmodified lines
119 baseUrl: Schema.optional(Schema.String),110 baseUrl: Schema.optional(Schema.String),
120 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),111 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
121 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),112 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
122 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),113 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
123});114});
124 115
125// The configure result — the merged `authenticationTemplate` after the new116// The configure result — the merged `authenticationTemplate` after the new
126// custom methods were appended/replaced.117// custom methods were appended/replaced.
127const ConfigureResponse = Schema.Struct({118const ConfigureResponse = Schema.Struct({
128 authenticationTemplate: Schema.Array(AuthenticationPayload),119 authenticationTemplate: Schema.Array(AuthenticationResponse),
129});120});
130 121
131// ---------------------------------------------------------------------------122// ---------------------------------------------------------------------------
packages/plugins/openapi/src/api/handlers.ts+2−14
@@ -2,8 +2,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup, capture } from "@executor-js/api";4import { addGroup, capture } from "@executor-js/api";
5import { AuthTemplateSlug } from "@executor-js/sdk/shared";
6import type { Authentication } from "../sdk";
7import type { OpenApiPluginExtension } from "../sdk/plugin";5import type { OpenApiPluginExtension } from "../sdk/plugin";
8import { OpenApiGroup } from "./group";6import { OpenApiGroup } from "./group";
9 7
@@ -60,12 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 50 unmodified lines
60 baseUrl: payload.baseUrl,58 baseUrl: payload.baseUrl,
61 headers: payload.headers ? { ...payload.headers } : undefined,59 headers: payload.headers ? { ...payload.headers } : undefined,
62 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,60 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
63 authenticationTemplate: payload.authenticationTemplate?.map(61 authenticationTemplate: payload.authenticationTemplate,
64 (entry): Authentication => ({
65 ...entry,
66 slug: AuthTemplateSlug.make(entry.slug),
67 }),
68 ),
69 });62 });
70 }),63 }),
71 ),64 ),
@@ -115,12 +108,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 43 unmodified lines
115 Effect.gen(function* () {108 Effect.gen(function* () {
116 const ext = yield* OpenApiExtensionService;109 const ext = yield* OpenApiExtensionService;
117 const authenticationTemplate = yield* ext.configure(params.slug, {110 const authenticationTemplate = yield* ext.configure(params.slug, {
118 authenticationTemplate: payload.authenticationTemplate.map(111 authenticationTemplate: payload.authenticationTemplate,
119 (entry): Authentication => ({
120 ...entry,
121 slug: AuthTemplateSlug.make(entry.slug),
122 }),
123 ),
124 mode: payload.mode ?? "merge",112 mode: payload.mode ?? "merge",
125 });113 });
126 return { authenticationTemplate: [...authenticationTemplate] };114 return { authenticationTemplate: [...authenticationTemplate] };
packages/plugins/openapi/src/promise.ts+6−9
@@ -7,12 +7,9 @@ export type { · 6 unmodified lines
7 OpenApiPreviewInput,7 OpenApiPreviewInput,
8} from "./sdk/plugin";8} from "./sdk/plugin";
9 9
10// Auth-template authoring helpers. `variable("token")` marks where a10// Auth-template authoring helpers. Author apikey methods as canonical
11// connection's resolved credential renders into an `apiKey` template.11// placements, or request-shaped: `headers: { Authorization: ["Bearer ",
12export { variable, TOKEN_VARIABLE } from "./sdk/types";12// variable("token")] }` — both normalize to the same stored model.
13export type {13export { TOKEN_VARIABLE } from "./sdk/types";
14 Authentication,14export type { Authentication, AuthenticationInput, APIKeyAuthentication } from "./sdk/types";
15 APIKeyAuthentication,15export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
16 AuthenticationVariable,
17 AuthenticationTemplateValue,
18} from "./sdk/types";
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−20
@@ -34,7 +34,11 @@ import { · 33 unmodified lines
34 useSlugAlreadyExists,34 useSlugAlreadyExists,
35} from "@executor-js/react/lib/integration-add";35} from "@executor-js/react/lib/integration-add";
36 36
37import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";37import {
38 authenticationFromEditorValue,
39 editorValueFromAuthentication,
40 openApiWireAuthInput,
41} from "./auth-method-config";
38import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";42import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
39import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";43import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
40import { GoogleProductPicker } from "./GoogleProductPicker";44import { GoogleProductPicker } from "./GoogleProductPicker";
@@ -45,13 +49,7 @@ import { · 4 unmodified lines
45 type GoogleOpenApiPreset,49 type GoogleOpenApiPreset,
46} from "../sdk/google-presets";50} from "../sdk/google-presets";
47import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";51import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
48import {52import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";
49 type APIKeyAuthentication,
50 type Authentication,
51 type ServerInfo,
52 TOKEN_VARIABLE,
53 variable,
54} from "../sdk/types";
55import { expandServerUrlOptions } from "../sdk/openapi-utils";53import { expandServerUrlOptions } from "../sdk/openapi-utils";
56 54
57const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";55const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
@@ -172,14 +170,16 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi · 114 unmodified lines
172const apiKeyTemplateFromHeaderPreset = (170const apiKeyTemplateFromHeaderPreset = (
173 preset: HeaderPreset,171 preset: HeaderPreset,
174 slug: AuthTemplateSlug,172 slug: AuthTemplateSlug,
175): APIKeyAuthentication => {173): APIKeyAuthentication => ({
176 const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {};174 slug,
177 for (const headerName of preset.secretHeaders) {175 kind: "apikey",
176 // Every secret header shares the one credential input (the canonical
177 // `token`, stored as an absent placement variable).
178 placements: preset.secretHeaders.map((headerName) => {
178 const prefix = headerPrefix(preset, headerName);179 const prefix = headerPrefix(preset, headerName);
179 headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)];180 return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
180 }181 }),
181 return { slug, type: "apiKey", headers };182});
182};
183 183
184const oauthTemplateFromPreset = (184const oauthTemplateFromPreset = (
185 preset: OAuth2Preset,185 preset: OAuth2Preset,
@@ -188,7 +188,7 @@ const oauthTemplateFromPreset = ( · 2 unmodified lines
188 scopes: readonly string[],188 scopes: readonly string[],
189): OAuthAuthentication => ({189): OAuthAuthentication => ({
190 slug,190 slug,
191 type: "oauth",191 kind: "oauth2",
192 authorizationUrl: resolveOAuthUrl(192 authorizationUrl: resolveOAuthUrl(
193 Option.getOrElse(preset.authorizationUrl, () => ""),193 Option.getOrElse(preset.authorizationUrl, () => ""),
194 baseUrl,194 baseUrl,
@@ -454,10 +454,8 @@ export default function AddOpenApiSource(props: { · 259 unmodified lines
454 baseUrl: resolvedBaseUrl,454 baseUrl: resolvedBaseUrl,
455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0455 ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0
456 ? {456 ? {
457 authenticationTemplate: editedAuthenticationTemplate.map((entry) => ({457 // Serialize to the wire input dialect (apikey → request-shaped).
458 ...entry,458 authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput),
459 slug: String(entry.slug),
460 })),
461 }459 }
462 : {}),460 : {}),
463 },461 },
packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx+6−2
@@ -18,7 +18,11 @@ import { · 17 unmodified lines
18} from "@executor-js/react/lib/custom-auth-methods";18} from "@executor-js/react/lib/custom-auth-methods";
19 19
20import { openApiConfigAtom, openapiConfigure } from "./atoms";20import { openApiConfigAtom, openapiConfigure } from "./atoms";
21import { authMethodsFromConfig, templateFromPlacements } from "./auth-method-config";21import {
22 authMethodsFromConfig,
23 templateFromPlacements,
24 openApiWireAuthInput,
25} from "./auth-method-config";
22import { googleAudienceWarningsForUrls } from "../sdk/google-presets";26import { googleAudienceWarningsForUrls } from "../sdk/google-presets";
23import type { Authentication } from "../sdk/types";27import type { Authentication } from "../sdk/types";
24 28
@@ -82,7 +86,7 @@ export default function OpenApiAccountsPanel(props: { · 57 unmodified lines
82 const exit = await doConfigure({86 const exit = await doConfigure({
83 params: { slug },87 params: { slug },
84 payload: {88 payload: {
85 authenticationTemplate: input.authenticationTemplate,89 authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
86 ...(input.mode ? { mode: input.mode } : {}),90 ...(input.mode ? { mode: input.mode } : {}),
87 },91 },
88 reactivityKeys: integrationWriteKeys,92 reactivityKeys: integrationWriteKeys,
packages/plugins/openapi/src/react/auth-method-config.test.ts+90−83
@@ -1,111 +1,118 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { AuthTemplateSlug } from "@executor-js/sdk/shared";2import { AuthTemplateSlug } from "@executor-js/sdk/shared";
3import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
4 3
5import {4import {
5 authMethodsFromConfig,
6 authenticationFromEditorValue,6 authenticationFromEditorValue,
7 editorValueFromAuthentication,7 editorValueFromAuthentication,
8 templateFromPlacements,8 templateFromPlacements,
9} from "./auth-method-config";9} from "./auth-method-config";
10import { TOKEN_VARIABLE, variable, type Authentication } from "../sdk/types";10import type { Authentication } from "../sdk/types";
11 11
12describe("editorValueFromAuthentication", () => {12describe("authMethodsFromConfig", () => {
13 it("maps a bearer apiKey header template → an apikey editor value with prefix", () => {13 it("projects oauth templates with their stored endpoints + scopes", () => {
14 const template: Authentication = {14 const methods = authMethodsFromConfig([
15 slug: AuthTemplateSlug.make("apikey-0"),15 {
16 type: "apiKey",16 slug: AuthTemplateSlug.make("oauth"),
17 headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },17 kind: "oauth2",
18 };18 authorizationUrl: "https://x.example/auth",
19 expect(editorValueFromAuthentication(template)).toEqual({19 tokenUrl: "https://x.example/token",
20 kind: "apikey",20 scopes: ["read"],
21 placements: [21 },
22 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },22 ]);
23 ],23 expect(methods[0]).toMatchObject({
24 });24 id: "oauth",
25 });
26
27 it("maps a query apiKey template → an apikey editor value (empty prefix)", () => {
28 const template: Authentication = {
29 slug: AuthTemplateSlug.make("apikey-1"),
30 type: "apiKey",
31 queryParams: { api_key: [variable(TOKEN_VARIABLE)] },
32 };
33 expect(editorValueFromAuthentication(template)).toEqual({
34 kind: "apikey",
35 placements: [{ carrier: "query", name: "api_key", prefix: "", variable: "token" }],
36 });
37 });
38
39 it("maps an oauth template → an oauth editor value carrying endpoints + scopes", () => {
40 const template: Authentication = {
41 slug: AuthTemplateSlug.make("oauth-google"),
42 type: "oauth",
43 authorizationUrl: "https://accounts.example.com/o/oauth2/auth",
44 tokenUrl: "https://oauth2.example.com/token",
45 scopes: ["openid", "email"],
46 };
47 expect(editorValueFromAuthentication(template)).toEqual({
48 kind: "oauth",25 kind: "oauth",
49 authorizationUrl: "https://accounts.example.com/o/oauth2/auth",26 source: "spec",
50 tokenUrl: "https://oauth2.example.com/token",27 oauth: {
51 scopes: ["openid", "email"],28 authorizationUrl: "https://x.example/auth",
29 tokenUrl: "https://x.example/token",
30 scopes: ["read"],
31 },
52 });32 });
53 });33 });
54});
55 34
56describe("authenticationFromEditorValue", () => {35 it("projects apikey methods, multi-placement and multi-variable intact", () => {
57 it("returns null for a 'none' value (nothing to register)", () => {36 const methods = authMethodsFromConfig([
58 expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();37 {
38 slug: "custom_dd",
39 kind: "apikey",
40 placements: [
41 { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
42 { carrier: "query", name: "team_id", variable: "team_id" },
43 ],
44 },
45 ]);
46 expect(methods[0]).toMatchObject({ id: "custom_dd", kind: "apikey", source: "custom" });
47 expect(methods[0]?.placements).toEqual([
48 { carrier: "header", name: "DD-API-KEY", prefix: "", variable: "dd_api_key" },
49 { carrier: "query", name: "team_id", prefix: "", variable: "team_id" },
50 ]);
59 });51 });
52});
60 53
61 it("builds an apiKey template from placements, stamping the slug", () => {54describe("editor round-trip", () => {
62 const value: AuthTemplateEditorValue = {55 it("apikey stored → editor → stored preserves placements and shared token input", () => {
56 const stored: Authentication = {
57 slug: "bearer",
63 kind: "apikey",58 kind: "apikey",
64 placements: [{ carrier: "header", name: "X-Token", prefix: "" }],59 placements: [
60 { carrier: "header", name: "Authorization", prefix: "Bearer " },
61 { carrier: "query", name: "token" },
62 ],
65 };63 };
66 expect(authenticationFromEditorValue(value, "custom_x")).toEqual(64 const editor = editorValueFromAuthentication(stored);
67 templateFromPlacements(value.placements, "custom_x"),65 const back = authenticationFromEditorValue(editor, "bearer");
68 );66 expect(back).toEqual(stored);
69 });67 });
70 68
71 it("builds an oauth template from an oauth value", () => {69 it("oauth stored → editor carries endpoints + scopes", () => {
72 const value: AuthTemplateEditorValue = {70 expect(
71 editorValueFromAuthentication({
72 slug: AuthTemplateSlug.make("oauth"),
73 kind: "oauth2",
74 authorizationUrl: "https://x.example/auth",
75 tokenUrl: "https://x.example/token",
76 scopes: ["a", "b"],
77 }),
78 ).toEqual({
73 kind: "oauth",79 kind: "oauth",
74 authorizationUrl: "https://a.example.com/auth",80 authorizationUrl: "https://x.example/auth",
75 tokenUrl: "https://a.example.com/token",81 tokenUrl: "https://x.example/token",
76 scopes: ["read"],82 scopes: ["a", "b"],
77 };
78 expect(authenticationFromEditorValue(value, "oauth-0")).toEqual({
79 slug: AuthTemplateSlug.make("oauth-0"),
80 type: "oauth",
81 authorizationUrl: "https://a.example.com/auth",
82 tokenUrl: "https://a.example.com/token",
83 scopes: ["read"],
84 });83 });
85 });84 });
85
86 it("none editor value yields no method", () => {
87 expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
88 });
86});89});
87 90
88describe("round-trip (apiKey + oauth)", () => {91describe("templateFromPlacements", () => {
89 it("apiKey: editorValue ∘ authentication is identity for the placement shape", () => {92 it("multi-placement custom methods assign distinct name-derived variables", () => {
90 const template: Authentication = {93 expect(
91 slug: AuthTemplateSlug.make("apikey-0"),94 templateFromPlacements(
92 type: "apiKey",95 [
93 headers: { Authorization: ["Bearer ", variable(TOKEN_VARIABLE)] },96 { carrier: "header", name: "DD-API-KEY", prefix: "" },
94 };97 { carrier: "header", name: "DD-APPLICATION-KEY", prefix: "" },
95 const value = editorValueFromAuthentication(template);98 ],
96 const back = authenticationFromEditorValue(value, "apikey-0");99 "custom_dd",
97 expect(back).toEqual(template);100 ),
101 ).toEqual({
102 slug: "custom_dd",
103 kind: "apikey",
104 placements: [
105 { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
106 { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
107 ],
108 });
98 });109 });
99 110
100 it("oauth: editorValue ∘ authentication preserves endpoints + scopes", () => {111 it("a lone placement keeps the canonical token input (stored absent)", () => {
101 const template: Authentication = {112 expect(templateFromPlacements([{ carrier: "query", name: "api_key", prefix: "" }])).toEqual({
102 slug: AuthTemplateSlug.make("oauth-x"),113 slug: "",
103 type: "oauth",114 kind: "apikey",
104 authorizationUrl: "https://a.example.com/auth",115 placements: [{ carrier: "query", name: "api_key" }],
105 tokenUrl: "https://a.example.com/token",116 });
106 scopes: ["read", "write"],
107 };
108 const back = authenticationFromEditorValue(editorValueFromAuthentication(template), "oauth-x");
109 expect(back).toEqual(template);
110 });117 });
111});118});
packages/plugins/openapi/src/react/auth-method-config.ts+45−158
@@ -1,192 +1,79 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// OpenAPI ↔ generic auth-method converters.2// OpenAPI ↔ generic auth-method converters — a thin oauth adapter over the
3//3// shared codec (`@executor-js/react/lib/shared-auth-method-codec`). The
4// The generic Accounts hub speaks in plugin-agnostic `AuthMethod` /4// apikey/none paths (multi-placement, multi-variable) live in the shared
5// `Placement` values (`@executor-js/react/lib/auth-placements`). The OpenAPI5// codec; OpenAPI only contributes its oauth flavor: stored endpoints + scopes
6// plugin stores auth as the HTTP-transport-specific `APIKeyAuthentication`6// (`kind: "oauth2"`, the core `OAuthAuthentication` shape) that pre-fill the client-registration form.
7// template (header/query slots templated with `variable("token")`). These two
8// converters bridge the wire template to/from the generic placement model, so
9// they live with the OpenAPI plugin — they touch the openapi sdk
10// `Authentication` types and would pull transport specifics into core.
11// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
12 8
13import { AuthTemplateSlug } from "@executor-js/sdk/shared";9import { AuthTemplateSlug } from "@executor-js/sdk/shared";
14import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";10import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
15import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
16
17import {12import {
18 TOKEN_VARIABLE,13 authMethodFromSharedTemplate,
19 variable,14 editorPlacementsFromWire,
20 type APIKeyAuthentication,15 editorValueFromSharedMethod,
21 type Authentication,16 wireAuthInputFromShared,
22 type AuthenticationTemplateValue,17 wirePlacementsFromEditor,
23} from "../sdk/types";18} from "@executor-js/react/lib/shared-auth-method-codec";
24 19
25// ---------------------------------------------------------------------------20import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types";
26// Template value → placement prefix.
27//
28// A header/query slot serializes the credential as `name -> [prefix, token]`,
29// where `prefix` is the leading literal string before the `variable("token")`
30// part. A bare `[token]` (or string-only) value has an empty prefix.
31// ---------------------------------------------------------------------------
32 21
33const isVariablePart = (part: string | { readonly type: "variable"; readonly name: string }) =>22/** Serialize a canonical method into the wire input union (apikey → the
34 typeof part !== "string" && part.type === "variable";23 * request-shaped dialect; oauth passes through). */
24export const openApiWireAuthInput = (method: Authentication): AuthenticationInput =>
25 method.kind === "oauth2" ? method : (wireAuthInputFromShared(method) as AuthenticationInput);
35 26
36/** Extract the literal prefix preceding the credential variable, plus the27export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] =>
37 * variable name that placement renders from (`token` for single-input methods,28 editorPlacementsFromWire(template.placements);
38 * a distinct name per input for multi-input ones). */
39const parseTemplateValue = (
40 value: AuthenticationTemplateValue,
41): { readonly prefix: string; readonly variable: string } => {
42 if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
43 const parts: string[] = [];
44 for (const part of value) {
45 if (isVariablePart(part)) {
46 return { prefix: parts.join(""), variable: (part as { readonly name: string }).name };
47 }
48 if (typeof part === "string") parts.push(part);
49 }
50 return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
51};
52 29
53export const placementsFromApiKey = (template: APIKeyAuthentication): Placement[] => {30const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): AuthMethod => {
54 const placements: Placement[] = [];31 const slug = String(template.slug);
55 for (const [name, value] of Object.entries(template.headers ?? {})) {32 return {
56 const { prefix, variable } = parseTemplateValue(value);33 id: slug,
57 placements.push({ carrier: "header", name, prefix, variable });34 label: "OAuth2",
58 }35 kind: "oauth",
59 for (const [name, value] of Object.entries(template.queryParams ?? {})) {36 source: slug.startsWith("custom_") ? "custom" : "spec",
60 const { prefix, variable } = parseTemplateValue(value);37 template: AuthTemplateSlug.make(slug),
61 placements.push({ carrier: "query", name, prefix, variable });38 placements: [],
62 }39 // Carry the integration's declared endpoints/scopes so the
63 return placements;40 // client-registration form pre-fills them.
64};41 oauth: {
65 42 authorizationUrl: template.authorizationUrl,
66// ---------------------------------------------------------------------------43 tokenUrl: template.tokenUrl,
67// Templates → generic methods.44 scopes: template.scopes,
68// ---------------------------------------------------------------------------45 },
69 46 };
70const labelForApiKey = (slug: string, placements: readonly Placement[]): string => {
71 const first = placements[0];
72 if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
73 return `API key (${slug})`;
74};47};
75 48
76/** Map each stored auth template to a generic `AuthMethod`. */49/** Map each stored auth template to a generic `AuthMethod`. */
77export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {50export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] {
78 return templates.map((template: Authentication): AuthMethod => {51 return templates.map((template: Authentication): AuthMethod => {
79 const slug = String(template.slug);52 if (template.kind === "oauth2") return oauthAuthMethod(template);
80 const source: "spec" | "custom" = slug.startsWith("custom_") ? "custom" : "spec";53 return authMethodFromSharedTemplate(template);
81 if (template.type === "oauth") {
82 return {
83 id: slug,
84 label: "OAuth2",
85 kind: "oauth",
86 source,
87 template: AuthTemplateSlug.make(slug),
88 placements: [],
89 // Carry the integration's declared endpoints/scopes so the
90 // client-registration form pre-fills them.
91 oauth: {
92 authorizationUrl: template.authorizationUrl,
93 tokenUrl: template.tokenUrl,
94 scopes: template.scopes,
95 },
96 };
97 }
98 const placements = placementsFromApiKey(template);
99 return {
100 id: slug,
101 label: labelForApiKey(slug, placements),
102 kind: "apikey",
103 source,
104 template: AuthTemplateSlug.make(slug),
105 placements,
106 };
107 });54 });
108}55}
109 56
110// ---------------------------------------------------------------------------57/** Build an apikey method from generic placements. When `slug` is omitted the
111// Generic placements → apiKey template (inverse).58 * backend assigns a `custom_<id>` slug. */
112//
113// Each placement becomes a header/query slot whose value is
114// `prefix ? [prefix, variable("token")] : [variable("token")]`. A custom method
115// may omit `slug`; the backend backfills `custom_<id>`.
116// ---------------------------------------------------------------------------
117
118/** Slugify a placement name into a variable identifier: `DD-API-KEY` →
119 * `dd_api_key`. */
120const slugifyVariable = (name: string): string =>
121 name
122 .toLowerCase()
123 .replace(/[^a-z0-9]+/g, "_")
124 .replace(/^_+|_+$/g, "");
125
126/** Assign an input variable to each (named) placement. A lone input is the
127 * canonical `token`; multiple inputs each get their own distinct variable so a
128 * connection can carry a different value per location. An explicit
129 * `placement.variable` (from a round-trip) is honored. */
130const assignVariables = (placements: readonly Placement[]): Map<Placement, string> => {
131 const named = placements.filter((p) => p.name);
132 const out = new Map<Placement, string>();
133 if (named.length <= 1) {
134 for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE);
135 return out;
136 }
137 const taken = new Set<string>();
138 for (const p of named) {
139 const base = slugifyVariable(p.name) || "input";
140 let candidate = p.variable ?? base;
141 let n = 2;
142 while (taken.has(candidate)) candidate = `${base}_${n++}`;
143 taken.add(candidate);
144 out.set(p, candidate);
145 }
146 return out;
147};
148
149const valueFromPlacement = (placement: Placement, varName: string): AuthenticationTemplateValue =>
150 placement.prefix ? [placement.prefix, variable(varName)] : [variable(varName)];
151
152/** Build an `APIKeyAuthentication` template from generic placements. When
153 * `slug` is omitted the backend assigns a `custom_<id>` slug. */
154export function templateFromPlacements(59export function templateFromPlacements(
155 placements: readonly Placement[],60 placements: readonly Placement[],
156 slug?: string,61 slug?: string,
157): APIKeyAuthentication {62): APIKeyAuthentication {
158 const variables = assignVariables(placements);
159 const headers: Record<string, AuthenticationTemplateValue> = {};
160 const queryParams: Record<string, AuthenticationTemplateValue> = {};
161 for (const placement of placements) {
162 if (!placement.name) continue;
163 const varName = variables.get(placement) ?? TOKEN_VARIABLE;
164 if (placement.carrier === "header") {
165 headers[placement.name] = valueFromPlacement(placement, varName);
166 } else {
167 queryParams[placement.name] = valueFromPlacement(placement, varName);
168 }
169 }
170 return {63 return {
171 slug: AuthTemplateSlug.make(slug ?? ""),64 slug: slug ?? "",
172 type: "apiKey",65 kind: "apikey",
173 ...(Object.keys(headers).length > 0 ? { headers } : {}),66 placements: wirePlacementsFromEditor(placements),
174 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
175 };67 };
176}68}
177 69
178// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
179// Stored `Authentication` ⇆ generic `AuthTemplateEditorValue`.71// Stored `Authentication` ⇆ generic `AuthTemplateEditorValue`.
180//
181// The shared add-time `AuthTemplateEditor` edits a plugin-agnostic value; these
182// bridge it to/from the OpenAPI wire template so a spec-detected method seeds an
183// editable default and the user's edits round-trip back to `Authentication[]`
184// on submit. apiKey → placements; oauth → endpoints + scopes.
185// ---------------------------------------------------------------------------72// ---------------------------------------------------------------------------
186 73
187/** Convert one stored `Authentication` template into a generic editor value. */74/** Convert one stored `Authentication` template into a generic editor value. */
188export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {75export function editorValueFromAuthentication(template: Authentication): AuthTemplateEditorValue {
189 if (template.type === "oauth") {76 if (template.kind === "oauth2") {
190 return {77 return {
191 kind: "oauth",78 kind: "oauth",
192 authorizationUrl: template.authorizationUrl ?? "",79 authorizationUrl: template.authorizationUrl ?? "",
@@ -194,7 +81,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 1 unmodified line
194 scopes: template.scopes ?? [],81 scopes: template.scopes ?? [],
195 };82 };
196 }83 }
197 return { kind: "apikey", placements: placementsFromApiKey(template) };84 return editorValueFromSharedMethod(template);
198}85}
199 86
200/** Build an `OAuthAuthentication` template from a generic oauth editor value. */87/** Build an `OAuthAuthentication` template from a generic oauth editor value. */
@@ -203,7 +90,7 @@ const oauthTemplateFromEditorValue = ( · 2 unmodified lines
203 slug?: string,90 slug?: string,
204): Authentication => ({91): Authentication => ({
205 slug: AuthTemplateSlug.make(slug ?? ""),92 slug: AuthTemplateSlug.make(slug ?? ""),
206 type: "oauth",93 kind: "oauth2",
207 authorizationUrl: value.authorizationUrl,94 authorizationUrl: value.authorizationUrl,
208 tokenUrl: value.tokenUrl,95 tokenUrl: value.tokenUrl,
209 scopes: [...value.scopes],96 scopes: [...value.scopes],
packages/plugins/openapi/src/sdk/config.ts+21−65
@@ -1,7 +1,12 @@
1import { Option, Schema } from "effect";1import { Option, Schema } from "effect";
2import {
3 ApiKeyAuthMethod,
4 TOKEN_VARIABLE,
5 renderAuthPlacements,
6 requiredPlacementVariables,
7} from "@executor-js/sdk/http-auth";
2 8
3import type { Authentication, AuthenticationTemplateValue, AuthenticationVariable } from "./types";9import type { Authentication } from "./types";
4import { TOKEN_VARIABLE } from "./types";
5 10
6// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
7// OpenAPI integration config — the opaque blob stored on the catalog12// OpenAPI integration config — the opaque blob stored on the catalog
@@ -15,35 +20,15 @@ import { TOKEN_VARIABLE } from "./types"; · 7 unmodified lines
15// - the auth templates a connection's value is rendered through.20// - the auth templates a connection's value is rendered through.
16// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
17 22
18const AuthenticationVariableSchema = Schema.Struct({
19 type: Schema.Literal("variable"),
20 name: Schema.String,
21});
22
23const AuthenticationTemplateValueSchema = Schema.Union([
24 Schema.String,
25 Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
26]);
27
28const APIKeyAuthenticationSchema = Schema.Struct({
29 slug: Schema.String,
30 type: Schema.Literal("apiKey"),
31 headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
32 queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
33});
34
35const OAuthAuthenticationSchema = Schema.Struct({23const OAuthAuthenticationSchema = Schema.Struct({
36 slug: Schema.String,24 slug: Schema.String,
37 type: Schema.Literal("oauth"),25 kind: Schema.Literal("oauth2"),
38 authorizationUrl: Schema.String,26 authorizationUrl: Schema.String,
39 tokenUrl: Schema.String,27 tokenUrl: Schema.String,
40 scopes: Schema.Array(Schema.String),28 scopes: Schema.Array(Schema.String),
41});29});
42 30
43export const AuthenticationSchema = Schema.Union([31export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]);
44 OAuthAuthenticationSchema,
45 APIKeyAuthenticationSchema,
46]);
47 32
48export const OpenApiIntegrationConfigSchema = Schema.Struct({33export const OpenApiIntegrationConfigSchema = Schema.Struct({
49 /** Inlined OpenAPI document text (resolved + parsed source of truth). */34 /** Inlined OpenAPI document text (resolved + parsed source of truth). */
@@ -80,66 +65,37 @@ export const decodeOpenApiIntegrationConfig = (value: unknown): OpenApiIntegrati · 30 unmodified lines
80 65
81// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
82// Template rendering — "auth state derived into the auth-template format"67// Template rendering — "auth state derived into the auth-template format"
83// (D11). The resolved credential value renders into the template's68// (D11). An apiKey method renders through the shared placements renderer; an
84// `variable("token")` slots, identically for apiKey and oauth (the oauth value69// oauth template (no explicit placement) renders a bearer `authorization`
85// IS the access token). Returns the headers + query params to apply.70// header from the `token` input (the access token).
86// ---------------------------------------------------------------------------71// ---------------------------------------------------------------------------
87 72
88const isVariable = (part: string | AuthenticationVariable): part is AuthenticationVariable =>
89 typeof part !== "string";
90
91const renderTemplateValue = (
92 template: AuthenticationTemplateValue,
93 values: Record<string, string | null>,
94): string => {
95 if (typeof template === "string") return template;
96 return template.map((part) => (isVariable(part) ? (values[part.name] ?? "") : part)).join("");
97};
98
99export interface RenderedAuth {73export interface RenderedAuth {
100 readonly headers: Record<string, string>;74 readonly headers: Record<string, string>;
101 readonly queryParams: Record<string, string>;75 readonly queryParams: Record<string, string>;
102}76}
103 77
104/** Render an auth template against a connection's resolved input `values`78/** Render an auth template against a connection's resolved input `values`
105 * (`variable → value`). For an apiKey template, each `variable("<name>")` is79 * (`variable → value`). Each placement substitutes from its own entry, so a
106 * substituted from its own entry, so a method with two distinct inputs (e.g.80 * method with two distinct inputs (e.g. Datadog) fills each header from a
107 * Datadog) fills each header from a different value. For an oauth template (no81 * different value. */
108 * explicit placement), render a bearer Authorization header from `token`. */
109export const renderAuthTemplate = (82export const renderAuthTemplate = (
110 template: Authentication,83 template: Authentication,
111 values: Record<string, string | null>,84 values: Record<string, string | null>,
112): RenderedAuth => {85): RenderedAuth => {
113 if (template.type === "oauth") {86 if (template.kind === "oauth2") {
114 return {87 return {
115 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },88 headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` },
116 queryParams: {},89 queryParams: {},
117 };90 };
118 }91 }
119 const headers: Record<string, string> = {};92 return renderAuthPlacements(template.placements, values);
120 const queryParams: Record<string, string> = {};
121 for (const [name, tmpl] of Object.entries(template.headers ?? {})) {
122 headers[name] = renderTemplateValue(tmpl, values);
123 }
124 for (const [name, tmpl] of Object.entries(template.queryParams ?? {})) {
125 queryParams[name] = renderTemplateValue(tmpl, values);
126 }
127 return { headers, queryParams };
128};93};
129 94
130/** The distinct input variables a template references — the inputs a connection95/** The distinct input variables a template references — the inputs a connection
131 * must supply. An oauth template needs `token`; an apiKey template needs every96 * must supply. An oauth template needs `token`; an apiKey method needs every
132 * `variable("<name>")` across its placements. */97 * variable across its placements. */
133export const requiredTemplateVariables = (template: Authentication): readonly string[] => {98export const requiredTemplateVariables = (template: Authentication): readonly string[] => {
134 if (template.type === "oauth") return [TOKEN_VARIABLE];99 if (template.kind === "oauth2") return [TOKEN_VARIABLE];
135 const names = new Set<string>();100 return requiredPlacementVariables(template.placements);
136 const collect = (tmpl: AuthenticationTemplateValue): void => {
137 if (typeof tmpl === "string") return;
138 for (const part of tmpl) {
139 if (isVariable(part)) names.add(part.name);
140 }
141 };
142 for (const tmpl of Object.values(template.headers ?? {})) collect(tmpl);
143 for (const tmpl of Object.values(template.queryParams ?? {})) collect(tmpl);
144 return [...names];
145};101};
packages/plugins/openapi/src/sdk/configure.test.ts+72−27
@@ -5,7 +5,7 @@ · 4 unmodified lines
5// integration's opaque config. These tests exercise the extension method5// integration's opaque config. These tests exercise the extension method
6// directly (the same path the HTTP `configure` handler calls):6// directly (the same path the HTTP `configure` handler calls):
7// - round-trip: add a custom apiKey method → `getConfig` shows it with the7// - round-trip: add a custom apiKey method → `getConfig` shows it with the
8// correct header/query placement + `variable("token")` slot,8// correct header/query placements,
9// - slug generation: a method with no slug is assigned `custom_<id>`,9// - slug generation: a method with no slug is assigned `custom_<id>`,
10// - slug uniqueness/dedupe: a method whose slug matches an existing entry10// - slug uniqueness/dedupe: a method whose slug matches an existing entry
11// replaces it rather than duplicating, and two slug-less methods in one call11// replaces it rather than duplicating, and two slug-less methods in one call
@@ -28,7 +28,8 @@ import { · 16 unmodified lines
28import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";28import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
29 29
30import { openApiPlugin } from "./plugin";30import { openApiPlugin } from "./plugin";
31import { variable, type APIKeyAuthentication, type Authentication } from "./types";31import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types";
32import { variable } from "@executor-js/sdk/http-auth";
32import {33import {
33 makeOpenApiHttpApiTestSourceConfig,34 makeOpenApiHttpApiTestSourceConfig,
34 serveOpenApiHttpApiTestServer,35 serveOpenApiHttpApiTestServer,
@@ -71,9 +72,10 @@ const specText = () => { · 36 unmodified lines
71 return spec.url;72 return spec.url;
72};73};
73 74
74// A custom apiKey method that places the connection value into `x-api-key`.75// A custom apiKey method that places the connection value into `x-api-key`,
75const customApiKey: APIKeyAuthentication = {76// authored request-shaped (the one apikey input dialect).
76 slug: AuthTemplateSlug.make("my_custom"),77const customApiKey: AuthenticationInput = {
78 slug: "my_custom",
77 type: "apiKey",79 type: "apiKey",
78 headers: { "x-api-key": [variable("token")] },80 headers: { "x-api-key": [variable("token")] },
79};81};
@@ -81,9 +83,9 @@ const customApiKey: APIKeyAuthentication = { · 1 unmodified line
81// Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that83// Build an apiKey method WITHOUT a slug — simulating an untyped JSON caller that
82// omits it, which `configure` should backfill with a generated `custom_<id>`.84// omits it, which `configure` should backfill with a generated `custom_<id>`.
83// The cast is confined to this one boundary helper.85// The cast is confined to this one boundary helper.
84const sluglessApiKey = (placement: Omit<APIKeyAuthentication, "slug" | "type">): Authentication =>86const sluglessApiKey = (
85 // lint-allow-double-cast: fixture — simulates a JSON caller omitting `slug`; configure backfills it87 template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
86 ({ type: "apiKey", ...placement }) as unknown as Authentication;88): AuthenticationInput => ({ type: "apiKey", ...template });
87 89
88describe("OpenAPI Plugin — configure (custom auth method)", () => {90describe("OpenAPI Plugin — configure (custom auth method)", () => {
89 it.effect("adds a custom apiKey method and getConfig reflects it", () =>91 it.effect("adds a custom apiKey method and getConfig reflects it", () =>
@@ -107,10 +109,10 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
107 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];109 const template = (config?.authenticationTemplate ?? []) as readonly Authentication[];
108 expect(template).toHaveLength(1);110 expect(template).toHaveLength(1);
109 const entry = template[0] as APIKeyAuthentication;111 const entry = template[0] as APIKeyAuthentication;
110 expect(entry.type).toBe("apiKey");112 expect(entry.kind).toBe("apikey");
111 expect(String(entry.slug)).toBe("my_custom");113 expect(String(entry.slug)).toBe("my_custom");
112 // The header placement preserves the `variable("token")` slot verbatim.114 // The placement preserves the canonical `token` input (stored absent).
113 expect(entry.headers).toEqual({ "x-api-key": [variable("token")] });115 expect(entry.placements).toEqual([{ carrier: "header", name: "x-api-key" }]);
114 }),116 }),
115 ),117 ),
116 );118 );
@@ -120,8 +122,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
120 Effect.gen(function* () {122 Effect.gen(function* () {
121 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));123 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
122 124
123 const seedTemplate: Authentication = {125 const seedTemplate: AuthenticationInput = {
124 slug: AuthTemplateSlug.make("seed"),126 slug: "seed",
125 type: "apiKey",127 type: "apiKey",
126 headers: { authorization: ["Bearer ", variable("token")] },128 headers: { authorization: ["Bearer ", variable("token")] },
127 };129 };
@@ -151,9 +153,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 23 unmodified lines
151 slug: "cfg_genslug",153 slug: "cfg_genslug",
152 });154 });
153 155
154 const slugless = sluglessApiKey({156 const slugless = sluglessApiKey({ headers: { "x-api-key": [variable("token")] } });
155 headers: { "x-api-key": [variable("token")] },
156 });
157 157
158 const merged = yield* executor.openapi.configure("cfg_genslug", {158 const merged = yield* executor.openapi.configure("cfg_genslug", {
159 authenticationTemplate: [slugless],159 authenticationTemplate: [slugless],
@@ -177,17 +177,13 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 17 unmodified lines
177 });177 });
178 178
179 // Re-submit the same slug with a different placement → replace in place.179 // Re-submit the same slug with a different placement → replace in place.
180 const replacement: APIKeyAuthentication = {180 const replacement: AuthenticationInput = {
181 slug: AuthTemplateSlug.make("my_custom"),181 slug: "my_custom",
182 type: "apiKey",182 type: "apiKey",
183 headers: { "x-other": [variable("token")] },183 headers: { "x-other": [variable("token")] },
184 };184 };
185 const slugless = sluglessApiKey({185 const slugless = sluglessApiKey({ queryParams: { api_key: [variable("token")] } });
186 queryParams: { api_key: [variable("token")] },186 const slugless2 = sluglessApiKey({ queryParams: { token: [variable("token")] } });
187 });
188 const slugless2 = sluglessApiKey({
189 queryParams: { token: [variable("token")] },
190 });
191 187
192 const merged = yield* executor.openapi.configure("cfg_dedupe", {188 const merged = yield* executor.openapi.configure("cfg_dedupe", {
193 authenticationTemplate: [replacement, slugless, slugless2],189 authenticationTemplate: [replacement, slugless, slugless2],
@@ -205,7 +201,7 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 11 unmodified lines
205 const replaced = merged.find(201 const replaced = merged.find(
206 (m: Authentication) => String(m.slug) === "my_custom",202 (m: Authentication) => String(m.slug) === "my_custom",
207 ) as APIKeyAuthentication;203 ) as APIKeyAuthentication;
208 expect(replaced.headers).toEqual({ "x-other": [variable("token")] });204 expect(replaced.placements).toEqual([{ carrier: "header", name: "x-other" }]);
209 }),205 }),
210 ),206 ),
211 );207 );
@@ -215,8 +211,8 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 3 unmodified lines
215 Effect.gen(function* () {211 Effect.gen(function* () {
216 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));212 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
217 213
218 const seedTemplate: Authentication = {214 const seedTemplate: AuthenticationInput = {
219 slug: AuthTemplateSlug.make("seed"),215 slug: "seed",
220 type: "apiKey",216 type: "apiKey",
221 headers: { authorization: ["Bearer ", variable("token")] },217 headers: { authorization: ["Bearer ", variable("token")] },
222 };218 };
@@ -273,6 +269,55 @@ describe("OpenAPI Plugin — configure (custom auth method)", () => { · 50 unmodified lines
273 ),269 ),
274 );270 );
275 271
272 it.effect("the request-shaped dialect configures a method and renders on the wire", () =>
273 Effect.scoped(
274 Effect.gen(function* () {
275 const server = yield* servePluginTestApi();
276 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
277
278 yield* executor.openapi.addSpec({
279 spec: { kind: "blob", value: server.specJson },
280 slug: "cfg_authored",
281 baseUrl: server.baseUrl,
282 });
283
284 // Authored request-shaped; stored canonical.
285 yield* executor.openapi.configure("cfg_authored", {
286 authenticationTemplate: [
287 {
288 slug: "bearer",
289 type: "apiKey",
290 headers: { authorization: ["Bearer ", variable("token")] },
291 },
292 ],
293 });
294
295 const config = yield* executor.openapi.getConfig("cfg_authored");
296 expect(config?.authenticationTemplate?.[0]).toEqual({
297 slug: "bearer",
298 kind: "apikey",
299 placements: [{ carrier: "header", name: "authorization", prefix: "Bearer " }],
300 });
301
302 yield* executor.connections.create({
303 owner: "org",
304 name: ConnectionName.make("main"),
305 integration: IntegrationSlug.make("cfg_authored"),
306 template: AuthTemplateSlug.make("bearer"),
307 value: "authored-secret",
308 });
309
310 const result = unwrapInvocation(
311 yield* executor.execute(
312 ToolAddress.make("tools.cfg_authored.org.main.items.echoHeaders"),
313 {},
314 ),
315 ).data as { authorization?: string };
316 expect(result.authorization).toBe("Bearer authored-secret");
317 }),
318 ),
319 );
320
276 it.effect("getConfig returns null for an unknown integration", () =>321 it.effect("getConfig returns null for an unknown integration", () =>
277 Effect.scoped(322 Effect.scoped(
278 Effect.gen(function* () {323 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+6−7
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7} from "@executor-js/sdk/core";7} from "@executor-js/sdk/core";
8 8
9import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";9import { describeOpenApiAuthMethods, describeOpenApiIntegrationDisplay } from "./plugin";
10import { variable, type Authentication } from "./types";10import { type Authentication } from "./types";
11 11
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13// `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`13// `describeOpenApiAuthMethods` projects the stored `authenticationTemplate[]`
@@ -33,8 +33,8 @@ describe("describeOpenApiAuthMethods", () => { · 19 unmodified lines
33 recordWith([33 recordWith([
34 {34 {
35 slug: AuthTemplateSlug.make("bearer"),35 slug: AuthTemplateSlug.make("bearer"),
36 type: "apiKey",36 kind: "apikey",
37 headers: { Authorization: ["Bearer ", variable("token")] },37 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
38 },38 },
39 ]),39 ]),
40 );40 );
@@ -45,9 +45,8 @@ describe("describeOpenApiAuthMethods", () => { · 4 unmodified lines
45 label: "API key (Authorization)",45 label: "API key (Authorization)",
46 kind: "apikey",46 kind: "apikey",
47 template: "bearer",47 template: "bearer",
48 placements: [48 // The canonical `token` input is stored (and projected) as absent.
49 { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "token" },49 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
50 ],
51 },50 },
52 ]);51 ]);
53 });52 });
@@ -57,7 +56,7 @@ describe("describeOpenApiAuthMethods", () => { · 3 unmodified lines
57 recordWith([56 recordWith([
58 {57 {
59 slug: AuthTemplateSlug.make("oauth"),58 slug: AuthTemplateSlug.make("oauth"),
60 type: "oauth",59 kind: "oauth2",
61 authorizationUrl: "https://auth.example/authorize",60 authorizationUrl: "https://auth.example/authorize",
62 tokenUrl: "https://auth.example/token",61 tokenUrl: "https://auth.example/token",
63 scopes: ["read", "write"],62 scopes: ["read", "write"],
packages/plugins/openapi/src/sdk/google-bundle.test.ts+2−2
@@ -205,8 +205,8 @@ describe("Google bundle add flow", () => { · 204 unmodified lines
205 // collapses under `https://mail.google.com/`, so the requested consent205 // collapses under `https://mail.google.com/`, so the requested consent
206 // is clean rather than the raw per-method union.206 // is clean rather than the raw per-method union.
207 const config = yield* executor.openapi.getConfig("google");207 const config = yield* executor.openapi.getConfig("google");
208 const oauth = config?.authenticationTemplate?.find((entry) => entry.type === "oauth");208 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209 expect(oauth?.type === "oauth" ? [...oauth.scopes].sort() : undefined).toEqual(209 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210 [210 [
211 "https://mail.google.com/",211 "https://mail.google.com/",
212 "https://www.googleapis.com/auth/calendar",212 "https://www.googleapis.com/auth/calendar",
packages/plugins/openapi/src/sdk/google-discovery.test.ts+5−5
@@ -243,7 +243,7 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI · 242 unmodified lines
243 // than v1's `oauth2` source config.243 // than v1's `oauth2` source config.
244 // removed: identityScopes assertion — that field belonged to the v1244 // removed: identityScopes assertion — that field belonged to the v1
245 // OAuth2SourceConfig slot model, which no longer exists in v2.245 // OAuth2SourceConfig slot model, which no longer exists in v2.
246 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");246 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
247 expect(oauthTemplate).toBeDefined();247 expect(oauthTemplate).toBeDefined();
248 }),248 }),
249);249);
@@ -440,8 +440,8 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( · 190 unmodified lines
440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");441 expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/");
442 // v2: the bundled oauth scopes are carried on the oauth auth template.442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444 expect(oauthTemplate?.type === "oauth" ? oauthTemplate.scopes : undefined).toEqual([444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445 "https://www.googleapis.com/auth/gmail.metadata",445 "https://www.googleapis.com/auth/gmail.metadata",
446 "https://www.googleapis.com/auth/chat.spaces.readonly",446 "https://www.googleapis.com/auth/chat.spaces.readonly",
447 ]);447 ]);
@@ -579,8 +579,8 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent · 131 unmodified lines
579 579
580 // The derived oauth auth template carries the compacted/filtered set580 // The derived oauth auth template carries the compacted/filtered set
581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.type === "oauth");582 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583 expect(oauthTemplate?.type === "oauth" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(583 expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584 [...expectedConsentScopes].sort(),584 [...expectedConsentScopes].sort(),
585 );585 );
586 586
packages/plugins/openapi/src/sdk/google-discovery.ts+1−1
@@ -623,7 +623,7 @@ const googleOauthTemplate = (scopes: Record<string, string>): readonly Authentic · 622 unmodified lines
623 [623 [
624 {624 {
625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626 type: "oauth",626 kind: "oauth2",
627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629 scopes: Object.keys(scopes),629 scopes: Object.keys(scopes),
packages/plugins/openapi/src/sdk/index.ts+7−3
@@ -67,9 +67,13 @@ export { · 66 unmodified lines
67 OperationId,67 OperationId,
68 HttpMethod,68 HttpMethod,
69 ParameterLocation,69 ParameterLocation,
70 variable,70 TOKEN_VARIABLE,
71 normalizeOpenApiAuthInputs,
71 type Authentication,72 type Authentication,
73 type AuthenticationInput,
72 type APIKeyAuthentication,74 type APIKeyAuthentication,
73 type AuthenticationVariable,
74 type AuthenticationTemplateValue,
75} from "./types";75} from "./types";
76// Request-shaped authoring: `headers: { Authorization: ["Bearer ", variable("token")] }`.
77export { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
78
79export { migrateOpenApiAuthConfig } from "./migrate-config";
packages/plugins/openapi/src/sdk/migrate-config.test.ts+225−0
@@ -0,0 +1,225 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { renderAuthTemplate } from "./config";
4import { migrateOpenApiAuthConfig } from "./migrate-config";
5import type { Authentication } from "./types";
6
7// ---------------------------------------------------------------------------
8// The one-off config migration rewrites the retired `variable()`-templated
9// apiKey shape into canonical placements. Slugs and variable names are
10// preserved verbatim (`connection.template` / `item_ids` contracts), and the
11// EQUIVALENCE tests pin that a migrated template renders byte-identically to
12// what the legacy renderer produced for the same values.
13// ---------------------------------------------------------------------------
14
15const BASE = { spec: "{}", baseUrl: "https://api.example.com" } as const;
16
17describe("migrateOpenApiAuthConfig", () => {
18 it("rewrites a single-input bearer template (prefix + token)", () => {
19 const migrated = migrateOpenApiAuthConfig({
20 ...BASE,
21 authenticationTemplate: [
22 {
23 slug: "bearer",
24 type: "apiKey",
25 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
26 },
27 ],
28 });
29 expect(migrated).toEqual({
30 ...BASE,
31 authenticationTemplate: [
32 {
33 slug: "bearer",
34 kind: "apikey",
35 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
36 },
37 ],
38 });
39 });
40
41 it("rewrites a multi-input template (Datadog), variables preserved verbatim", () => {
42 const migrated = migrateOpenApiAuthConfig({
43 ...BASE,
44 authenticationTemplate: [
45 {
46 slug: "datadog",
47 type: "apiKey",
48 headers: {
49 "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
50 "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
51 },
52 queryParams: { site: [{ type: "variable", name: "site" }] },
53 },
54 ],
55 });
56 expect(migrated).toEqual({
57 ...BASE,
58 authenticationTemplate: [
59 {
60 slug: "datadog",
61 kind: "apikey",
62 placements: [
63 { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" },
64 { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" },
65 { carrier: "query", name: "site", variable: "site" },
66 ],
67 },
68 ],
69 });
70 });
71
72 it("rewrites literal-only template values into literal placements", () => {
73 const migrated = migrateOpenApiAuthConfig({
74 ...BASE,
75 authenticationTemplate: [
76 {
77 slug: "versioned",
78 type: "apiKey",
79 headers: {
80 "X-Api-Version": "2023-01-01",
81 Authorization: ["Bearer ", { type: "variable", name: "token" }],
82 },
83 },
84 ],
85 }) as { authenticationTemplate: readonly unknown[] };
86 expect(migrated.authenticationTemplate[0]).toEqual({
87 slug: "versioned",
88 kind: "apikey",
89 placements: [
90 { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" },
91 { carrier: "header", name: "Authorization", prefix: "Bearer " },
92 ],
93 });
94 });
95
96 it('re-keys the retired `type: "oauth"` spelling to `kind: "oauth2"`', () => {
97 const migrated = migrateOpenApiAuthConfig({
98 ...BASE,
99 authenticationTemplate: [
100 {
101 slug: "oauth",
102 type: "oauth",
103 authorizationUrl: "https://x.example/auth",
104 tokenUrl: "https://x.example/token",
105 scopes: ["read"],
106 },
107 {
108 slug: "key",
109 type: "apiKey",
110 queryParams: { key: [{ type: "variable", name: "token" }] },
111 },
112 ],
113 }) as { authenticationTemplate: readonly unknown[] };
114 expect(migrated.authenticationTemplate[0]).toEqual({
115 slug: "oauth",
116 kind: "oauth2",
117 authorizationUrl: "https://x.example/auth",
118 tokenUrl: "https://x.example/token",
119 scopes: ["read"],
120 });
121 });
122
123 it("is idempotent — canonical configs return null", () => {
124 const canonical = {
125 ...BASE,
126 authenticationTemplate: [
127 {
128 slug: "bearer",
129 kind: "apikey",
130 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
131 },
132 ],
133 };
134 expect(migrateOpenApiAuthConfig(canonical)).toBeNull();
135 const lifted = migrateOpenApiAuthConfig({
136 ...BASE,
137 authenticationTemplate: [
138 { slug: "b", type: "apiKey", headers: { A: [{ type: "variable", name: "token" }] } },
139 ],
140 });
141 expect(migrateOpenApiAuthConfig(lifted)).toBeNull();
142 });
143
144 it("leaves template-less, foreign, and unmigratable blobs untouched", () => {
145 expect(migrateOpenApiAuthConfig({ ...BASE })).toBeNull();
146 expect(migrateOpenApiAuthConfig({ transport: "remote", endpoint: "x" })).toBeNull();
147 expect(migrateOpenApiAuthConfig(null)).toBeNull();
148 expect(
149 migrateOpenApiAuthConfig({ ...BASE, authenticationTemplate: [{ garbage: true }] }),
150 ).toBeNull();
151 });
152});
153
154// ---------------------------------------------------------------------------
155// Equivalence — the migrated template renders EXACTLY what the legacy
156// renderer produced. Expected outputs below are the legacy renderer's
157// behavior, written out by hand so the old code can stay deleted.
158// ---------------------------------------------------------------------------
159
160describe("legacy render equivalence", () => {
161 const migrateTemplate = (legacy: unknown): Authentication => {
162 const migrated = migrateOpenApiAuthConfig({
163 ...BASE,
164 authenticationTemplate: [legacy],
165 }) as { authenticationTemplate: readonly Authentication[] };
166 return migrated.authenticationTemplate[0]!;
167 };
168
169 it("bearer header (prefix + token)", () => {
170 const template = migrateTemplate({
171 slug: "bearer",
172 type: "apiKey",
173 headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] },
174 });
175 expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
176 headers: { Authorization: "Bearer tok_1" },
177 queryParams: {},
178 });
179 });
180
181 it("two distinct inputs across headers and query (Datadog-style)", () => {
182 const template = migrateTemplate({
183 slug: "datadog",
184 type: "apiKey",
185 headers: {
186 "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }],
187 "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }],
188 },
189 queryParams: { team: [{ type: "variable", name: "team" }] },
190 });
191 expect(
192 renderAuthTemplate(template, { dd_api_key: "a", dd_application_key: "b", team: "t" }),
193 ).toEqual({
194 headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" },
195 queryParams: { team: "t" },
196 });
197 });
198
199 it("literal-only value renders verbatim with no credential", () => {
200 const template = migrateTemplate({
201 slug: "versioned",
202 type: "apiKey",
203 headers: {
204 "X-Api-Version": "2023-01-01",
205 Authorization: ["Bearer ", { type: "variable", name: "token" }],
206 },
207 });
208 expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({
209 headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" },
210 queryParams: {},
211 });
212 });
213
214 it("bare query token (no prefix)", () => {
215 const template = migrateTemplate({
216 slug: "key",
217 type: "apiKey",
218 queryParams: { api_key: [{ type: "variable", name: "token" }] },
219 });
220 expect(renderAuthTemplate(template, { token: "k1" })).toEqual({
221 headers: {},
222 queryParams: { api_key: "k1" },
223 });
224 });
225});
packages/plugins/openapi/src/sdk/migrate-config.ts+70−0
@@ -0,0 +1,70 @@
1// ---------------------------------------------------------------------------
2// One-off config migration — rewrite pre-canonical OpenAPI integration
3// `config` blobs into the canonical placements model. Runs once per database
4// (boot for local/selfhost, out-of-band script for cloud); runtime code
5// decodes ONLY the canonical shape (`decodeOpenApiIntegrationConfig`).
6//
7// The retired shape is the `variable()`-templated apiKey:
8// { slug, type: "apiKey", headers/queryParams: Record<name,
9// string | (string | {type:"variable", name})[]> }
10// Each entry becomes one placement: prefix = literals before the first
11// variable, the variable name preserved VERBATIM (it keys the connections'
12// stored `item_ids`; the canonical `token` is stored as absent), a
13// literal-only value becomes a `literal` placement. OAuth templates
14// re-key from the retired `type: "oauth"` spelling to `kind: "oauth2"`.
15// ---------------------------------------------------------------------------
16
17import { Option } from "effect";
18import {
19 apiKeyMethodFromLegacyTemplate,
20 decodeLegacyApiKeyTemplate,
21} from "@executor-js/sdk/http-auth";
22
23import { decodeOpenApiIntegrationConfig } from "./config";
24
25const isCanonicalEntry = (entry: unknown): boolean =>
26 typeof entry === "object" &&
27 entry !== null &&
28 "kind" in entry &&
29 ((entry as { kind: unknown }).kind === "apikey" ||
30 (entry as { kind: unknown }).kind === "oauth2");
31
32/** The retired oauth spelling: `type: "oauth"` instead of `kind: "oauth2"`. */
33const isLegacyOAuthEntry = (
34 entry: unknown,
35): entry is { readonly type: "oauth" } & Record<string, unknown> =>
36 typeof entry === "object" &&
37 entry !== null &&
38 "type" in entry &&
39 (entry as { type: unknown }).type === "oauth";
40
41const migrateEntry = (entry: unknown): unknown | null => {
42 if (isCanonicalEntry(entry)) return entry;
43 if (isLegacyOAuthEntry(entry)) {
44 const { type: _type, ...rest } = entry;
45 return { ...rest, kind: "oauth2" };
46 }
47 const legacy = Option.getOrNull(decodeLegacyApiKeyTemplate(entry));
48 if (legacy !== null) return apiKeyMethodFromLegacyTemplate(legacy);
49 return null;
50};
51
52/** Rewrite a stored OpenAPI integration config blob into the canonical shape.
53 * Returns the rewritten config, or `null` when no rewrite is needed (already
54 * canonical, no auth templates, or not this plugin's shape). Idempotent. */
55export const migrateOpenApiAuthConfig = (config: unknown): unknown | null => {
56 if (typeof config !== "object" || config === null) return null;
57 if (!("spec" in config)) return null;
58 if (!("authenticationTemplate" in config)) return null;
59
60 const entries = (config as { authenticationTemplate: unknown }).authenticationTemplate;
61 if (!Array.isArray(entries)) return null;
62
63 const migrated = entries.map(migrateEntry);
64 if (migrated.some((entry) => entry === null)) return null;
65 const changed = migrated.some((entry, index) => entry !== entries[index]);
66 if (!changed) return null;
67
68 const next = { ...config, authenticationTemplate: migrated };
69 return decodeOpenApiIntegrationConfig(next) !== null ? next : null;
70};
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+3−3
@@ -36,7 +36,7 @@ import { · 35 unmodified lines
36} from "@executor-js/plugin-openapi/testing";36} from "@executor-js/plugin-openapi/testing";
37 37
38import { openApiPlugin } from "./plugin";38import { openApiPlugin } from "./plugin";
39import { variable, type Authentication } from "./types";39import { type AuthenticationInput } from "./types";
40 40
41// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
42// Test API — a single endpoint that echoes the Authorization header so the42// Test API — a single endpoint that echoes the Authorization header so the
@@ -68,10 +68,10 @@ const serveVercel = () => · 25 unmodified lines
68 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });68 serveOpenApiHttpApiTestServer({ api: VercelApi, handlersLayer: ProjectsGroupLive });
69 69
70// Bearer template: the connection value renders into `Authorization: Bearer …`.70// Bearer template: the connection value renders into `Authorization: Bearer …`.
71const bearerTemplate: Authentication = {71const bearerTemplate: AuthenticationInput = {
72 slug: AuthTemplateSlug.make("bearer"),72 slug: AuthTemplateSlug.make("bearer"),
73 type: "apiKey",73 type: "apiKey",
74 headers: { authorization: ["Bearer ", variable("token")] },74 headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
75};75};
76 76
77const testPlugins = () =>77const testPlugins = () =>
packages/plugins/openapi/src/sdk/plugin.test.ts+5−5
@@ -32,7 +32,7 @@ import { · 31 unmodified lines
32} from "@executor-js/sdk/testing";32} from "@executor-js/sdk/testing";
33 33
34import { openApiPlugin } from "./plugin";34import { openApiPlugin } from "./plugin";
35import { variable, type Authentication } from "./types";35import { type AuthenticationInput } from "./types";
36import {36import {
37 addOpenApiTestConnection,37 addOpenApiTestConnection,
38 makeOpenApiHttpApiTestSourceConfig,38 makeOpenApiHttpApiTestSourceConfig,
@@ -154,16 +154,16 @@ const servePluginTestApi = () => · 115 unmodified lines
154 });154 });
155 155
156// An apiKey auth template that places the connection value into `x-api-key`.156// An apiKey auth template that places the connection value into `x-api-key`.
157const apiKeyTemplate: Authentication = {157const apiKeyTemplate: AuthenticationInput = {
158 slug: AuthTemplateSlug.make("apiKey"),158 slug: AuthTemplateSlug.make("apiKey"),
159 type: "apiKey",159 type: "apiKey",
160 headers: { "x-api-key": [variable("token")] },160 headers: { "x-api-key": [{ type: "variable" as const, name: "token" }] },
161};161};
162 162
163// An oauth template — the connection value renders as a bearer token.163// An oauth template — the connection value renders as a bearer token.
164const oauthTemplate: Authentication = {164const oauthTemplate: AuthenticationInput = {
165 slug: AuthTemplateSlug.make("oauth"),165 slug: AuthTemplateSlug.make("oauth"),
166 type: "oauth",166 kind: "oauth2",
167 authorizationUrl: "https://auth.example.test/authorize",167 authorizationUrl: "https://auth.example.test/authorize",
168 tokenUrl: "https://auth.example.test/token",168 tokenUrl: "https://auth.example.test/token",
169 scopes: ["read"],169 scopes: ["read"],
packages/plugins/openapi/src/sdk/plugin.ts+29−87
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
13 mergeAuthTemplates,13 mergeAuthTemplates,
14 tool,14 tool,
15 type AuthMethodDescriptor,15 type AuthMethodDescriptor,
16 type AuthPlacementDescriptor,
17 type Integration,16 type Integration,
18 type IntegrationConfig,17 type IntegrationConfig,
19 type IntegrationRecord,18 type IntegrationRecord,
@@ -44,8 +43,9 @@ import { annotationsForOperation, invokeWithLayer } from "./invoke"; · 24 unmodified lines
44import { previewSpec, type SpecPreview } from "./preview";43import { previewSpec, type SpecPreview } from "./preview";
45import { openApiPresets } from "./presets";44import { openApiPresets } from "./presets";
46import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";45import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
47import type { Authentication, AuthenticationTemplateValue } from "./types";46import type { Authentication } from "./types";
48import { OperationBinding, TOKEN_VARIABLE } from "./types";47import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";
48import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
49 49
50// ---------------------------------------------------------------------------50// ---------------------------------------------------------------------------
51// Plugin config51// Plugin config
@@ -154,8 +154,9 @@ export interface OpenApiSpecConfig { · 102 unmodified lines
154 readonly headers?: Record<string, string>;154 readonly headers?: Record<string, string>;
155 /** Static query params applied to every request. */155 /** Static query params applied to every request. */
156 readonly queryParams?: Record<string, string>;156 readonly queryParams?: Record<string, string>;
157 /** Auth methods a connection's value renders through. */157 /** Auth methods a connection's value renders through — canonical
158 readonly authenticationTemplate?: readonly Authentication[];158 * placements or the request-shaped authoring dialect. */
159 readonly authenticationTemplate?: readonly AuthenticationInput[];
159}160}
160 161
161export interface OpenApiExtensionFailure {162export interface OpenApiExtensionFailure {
@@ -169,7 +170,7 @@ export interface OpenApiConfigureInput { · 7 unmodified lines
169 * already exists, replaces) the integration's existing template array. A170 * already exists, replaces) the integration's existing template array. A
170 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`171 * custom apiKey method with no `slug` is assigned a generated `custom_<id>`
171 * slug that is collision-checked against the existing template. */172 * slug that is collision-checked against the existing template. */
172 readonly authenticationTemplate: readonly Authentication[];173 readonly authenticationTemplate: readonly AuthenticationInput[];
173 readonly mode?: "merge" | "replace";174 readonly mode?: "merge" | "replace";
174}175}
175 176
@@ -295,28 +296,18 @@ const OpenApiSpecInputSchema = Schema.Union([ · 119 unmodified lines
295 }),296 }),
296]);297]);
297 298
298const AuthenticationVariableSchema = Schema.Struct({
299 type: Schema.Literal("variable"),
300 name: Schema.String,
301});
302const AuthenticationTemplateValueSchema = Schema.Union([
303 Schema.String,
304 Schema.Array(Schema.Union([Schema.String, AuthenticationVariableSchema])),
305]);
306const AuthenticationSchema = Schema.Union([299const AuthenticationSchema = Schema.Union([
307 Schema.Struct({300 Schema.Struct({
308 slug: Schema.String,301 slug: Schema.String,
309 type: Schema.Literal("apiKey"),302 kind: Schema.Literal("oauth2"),
310 headers: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
311 queryParams: Schema.optional(Schema.Record(Schema.String, AuthenticationTemplateValueSchema)),
312 }),
313 Schema.Struct({
314 slug: Schema.String,
315 type: Schema.Literal("oauth"),
316 authorizationUrl: Schema.String,303 authorizationUrl: Schema.String,
317 tokenUrl: Schema.String,304 tokenUrl: Schema.String,
318 scopes: Schema.Array(Schema.String),305 scopes: Schema.Array(Schema.String),
319 }),306 }),
307 // Credential methods are authored request-shaped — the ONE apikey input
308 // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",
309 // variable("token")] }, queryParams: { … } }`.
310 ApiKeyAuthTemplate,
320]);311]);
321 312
322const AddSourceInputSchema = Schema.Struct({313const AddSourceInputSchema = Schema.Struct({
@@ -525,53 +516,10 @@ const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | un · 202 unmodified lines
525// Declared auth methods — project the stored `authenticationTemplate` into the516// Declared auth methods — project the stored `authenticationTemplate` into the
526// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's517// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's
527// `authMethodsFromConfig` (in the React auth-method-config module) on the518// `authMethodsFromConfig` (in the React auth-method-config module) on the
528// server so the catalog field is consistent. OpenAPI also renders its own519// server so the catalog field is consistent. apikey/none projection comes from
529// accounts slot, so this projection is consistency work, not a fix; an absent /520// the shared model; the oauth method carries the stored endpoints + scopes.
530// malformed config yields `[]` with no regression.
531//
532// Placement prefix extraction: a header/query slot serializes the credential as
533// `name -> [prefix, variable("token")]`; the leading literals before the
534// `variable("token")` part are the prefix (empty for a bare `[token]`).
535// ---------------------------------------------------------------------------521// ---------------------------------------------------------------------------
536 522
537/** Extract the literal prefix preceding the credential variable, plus the
538 * variable name a placement renders from (`token` for single-input methods, a
539 * distinct name per input for multi-input ones). */
540const parseTemplateValue = (
541 value: AuthenticationTemplateValue,
542): { readonly prefix: string; readonly variable: string } => {
543 if (typeof value === "string") return { prefix: "", variable: TOKEN_VARIABLE };
544 const parts: string[] = [];
545 for (const part of value) {
546 if (typeof part !== "string" && part.type === "variable") {
547 return { prefix: parts.join(""), variable: part.name };
548 }
549 if (typeof part === "string") parts.push(part);
550 }
551 return { prefix: parts.join(""), variable: TOKEN_VARIABLE };
552};
553
554const placementsFromAuthentication = (
555 template: Extract<Authentication, { readonly type: "apiKey" }>,
556): readonly AuthPlacementDescriptor[] => {
557 const placements: AuthPlacementDescriptor[] = [];
558 for (const [name, value] of Object.entries(template.headers ?? {})) {
559 const { prefix, variable } = parseTemplateValue(value);
560 placements.push({ carrier: "header", name, prefix, variable });
561 }
562 for (const [name, value] of Object.entries(template.queryParams ?? {})) {
563 const { prefix, variable } = parseTemplateValue(value);
564 placements.push({ carrier: "query", name, prefix, variable });
565 }
566 return placements;
567};
568
569const apiKeyLabel = (slug: string, placements: readonly AuthPlacementDescriptor[]): string => {
570 const first = placements[0];
571 if (first) return `API key (${first.name || (first.carrier === "header" ? "header" : "query")})`;
572 return `API key (${slug})`;
573};
574
575export const describeOpenApiAuthMethods = (523export const describeOpenApiAuthMethods = (
576 record: IntegrationRecord,524 record: IntegrationRecord,
577): readonly AuthMethodDescriptor[] => {525): readonly AuthMethodDescriptor[] => {
@@ -579,13 +527,12 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
579 if (!config) return [];527 if (!config) return [];
580 return (config.authenticationTemplate ?? []).map(528 return (config.authenticationTemplate ?? []).map(
581 (template: Authentication): AuthMethodDescriptor => {529 (template: Authentication): AuthMethodDescriptor => {
582 const slug = String(template.slug);530 if (template.kind === "oauth2") {
583 if (template.type === "oauth") {
584 return {531 return {
585 id: slug,532 id: String(template.slug),
586 label: "OAuth2",533 label: "OAuth2",
587 kind: "oauth",534 kind: "oauth",
588 template: slug,535 template: String(template.slug),
589 oauth: {536 oauth: {
590 authorizationUrl: template.authorizationUrl,537 authorizationUrl: template.authorizationUrl,
591 tokenUrl: template.tokenUrl,538 tokenUrl: template.tokenUrl,
@@ -593,14 +540,7 @@ export const describeOpenApiAuthMethods = ( · 1 unmodified line
593 },540 },
594 };541 };
595 }542 }
596 const placements = placementsFromAuthentication(template);543 return describeApiKeyAuthMethod(template);
597 return {
598 id: slug,
599 label: apiKeyLabel(slug, placements),
600 kind: "apikey",
601 template: slug,
602 placements,
603 };
604 },544 },
605 );545 );
606};546};
@@ -798,7 +738,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 191 unmodified lines
798 // Google Discovery converter derived from the spec (the bundle add738 // Google Discovery converter derived from the spec (the bundle add
799 // path relies on this — it has no preview to detect auth from).739 // path relies on this — it has no preview to detect auth from).
800 ...(config.authenticationTemplate740 ...(config.authenticationTemplate
801 ? { authenticationTemplate: config.authenticationTemplate }741 ? {
742 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
743 }
802 : resolved.authenticationTemplate744 : resolved.authenticationTemplate
803 ? { authenticationTemplate: resolved.authenticationTemplate }745 ? { authenticationTemplate: resolved.authenticationTemplate }
804 : {}),746 : {}),
@@ -891,13 +833,11 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 86 unmodified lines
891 const current = decodeOpenApiIntegrationConfig(record.config);833 const current = decodeOpenApiIntegrationConfig(record.config);
892 if (!current) return [] as readonly Authentication[];834 if (!current) return [] as readonly Authentication[];
893 835
836 const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
894 const merged =837 const merged =
895 input.mode === "replace"838 input.mode === "replace"
896 ? input.authenticationTemplate839 ? incoming
897 : mergeAuthTemplates(840 : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
898 current.authenticationTemplate ?? [],
899 input.authenticationTemplate,
900 );
901 841
902 const next: OpenApiIntegrationConfig = {842 const next: OpenApiIntegrationConfig = {
903 ...current,843 ...current,
@@ -959,7 +899,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 55 unmodified lines
959 headers: input.headers,899 headers: input.headers,
960 queryParams: input.queryParams,900 queryParams: input.queryParams,
961 authenticationTemplate: input.authenticationTemplate as901 authenticationTemplate: input.authenticationTemplate as
962 | readonly Authentication[]902 | readonly AuthenticationInput[]
963 | undefined,903 | undefined,
964 })904 })
965 .pipe(905 .pipe(
@@ -1074,12 +1014,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 108 unmodified lines
1074 if (missing.length > 0) {1014 if (missing.length > 0) {
1075 return openApiAuthToolFailure({1015 return openApiAuthToolFailure({
1076 code:1016 code:
1077 template.type === "oauth" ? "oauth_connection_missing" : "connection_value_missing",1017 template.kind === "oauth2"
1018 ? "oauth_connection_missing"
1019 : "connection_value_missing",
1078 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,1020 message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`,
1079 owner: credential.owner,1021 owner: credential.owner,
1080 integration,1022 integration,
1081 connection: String(credential.connection),1023 connection: String(credential.connection),
1082 credentialKind: template.type === "oauth" ? "oauth" : "secret",1024 credentialKind: template.kind === "oauth2" ? "oauth" : "secret",
1083 });1025 });
1084 }1026 }
1085 const rendered = renderAuthTemplate(template, credential.values);1027 const rendered = renderAuthTemplate(template, credential.values);
packages/plugins/openapi/src/sdk/types.ts+45−30
@@ -1,43 +1,58 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import type { AuthTemplateSlug } from "@executor-js/sdk/shared";2import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
3import type { OAuthAuthentication } from "@executor-js/sdk/shared";3import {
4 apiKeyMethodFromAuthTemplate,
5 isApiKeyAuthTemplate,
6 type ApiKeyAuthMethod,
7 type ApiKeyAuthTemplate,
8} from "@executor-js/sdk/http-auth";
4 9
5// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
6// Auth-template model (ported from the v2 scaffold `openapi/types.ts`).11// Auth-template model.
7//12//
8// The apiKey template is HTTP-transport-specific: it declares where the user's13// The apiKey method is the SHARED placements model (`@executor-js/sdk/http-auth`,
9// credential goes on the outbound request (headers / query params) via the14// the same shape the graphql/mcp plugins store): N header/query placements,
10// `variable()` templating below. That placement is why it lives with the15// each rendered from its own credential input. The oauth template is
11// openapi plugin rather than in core. The oauth template is mechanism-intrinsic16// mechanism-intrinsic and comes from core (`OAuthAuthentication`, keyed
12// and comes from core (`OAuthAuthentication`); an integration's `Authentication`17// `kind: "oauth2"` with stored endpoints+scopes); an integration's
13// union composes the two. Client credentials (clientId/secret) live on the core18// `Authentication` union composes the two. Client credentials
14// `OAuthClient`, not here.19// (clientId/secret) live on the core `OAuthClient`, not here.
20//
21// Pre-canonical stored templates (`type: "apiKey"` with `variable()`-templated
22// header/query records) are rewritten by the one-off config migration
23// (`migrate-config.ts`) — runtime code knows only this model.
15// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
16 25
17export type AuthenticationVariable = {26export { TOKEN_VARIABLE } from "@executor-js/sdk/http-auth";
18 readonly type: "variable";
19 readonly name: string;
20};
21
22/** A literal string, or a parts-array mixing literals and variable refs. */
23export type AuthenticationTemplateValue = string | readonly (string | AuthenticationVariable)[];
24 27
25export const variable = (name: string): AuthenticationVariable => ({28export type APIKeyAuthentication = ApiKeyAuthMethod;
26 type: "variable",
27 name,
28});
29 29
30/** The variable name the resolved credential value renders into. */30/** Every method is keyed by `kind` — `kind: "oauth2"` | `kind: "apikey"`. */
31export const TOKEN_VARIABLE = "token" as const;31export type Authentication = OAuthAuthentication | APIKeyAuthentication;
32 32
33export type APIKeyAuthentication = {33/** What auth inputs accept: oauth templates (wire-typed: plain slug) plus the
34 readonly slug: AuthTemplateSlug;34 * request-shaped apikey dialect (`type: "apiKey"`, headers/queryParams
35 readonly type: "apiKey";35 * records) — the ONE apikey authoring shape. Stored configs and the catalog
36 readonly headers?: Record<string, AuthenticationTemplateValue>;36 * read as canonical placements; `apiKeyAuthTemplateFromMethod` serializes
37 readonly queryParams?: Record<string, AuthenticationTemplateValue>;37 * them back for read-modify-write flows. */
38export type OAuthAuthenticationInput = Omit<OAuthAuthentication, "slug"> & {
39 readonly slug: string;
38};40};
39 41export type AuthenticationInput = OAuthAuthenticationInput | ApiKeyAuthTemplate;
40export type Authentication = OAuthAuthentication | APIKeyAuthentication;42
43/** Expand the request-shaped dialect into canonical placements and brand the
44 * oauth slugs. A dialect entry without a slug gets a blank one —
45 * `mergeAuthTemplates` backfills `custom_<id>`. */
46export const normalizeOpenApiAuthInputs = (
47 inputs: readonly AuthenticationInput[],
48): readonly Authentication[] =>
49 inputs.map((input): Authentication => {
50 if (!isApiKeyAuthTemplate(input)) {
51 return { ...input, slug: AuthTemplateSlug.make(input.slug) };
52 }
53 const method = apiKeyMethodFromAuthTemplate(input);
54 return { ...method, slug: method.slug ?? "" };
55 });
41 56
42// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
43// Branded IDs58// Branded IDs