// --------------------------------------------------------------------------- // Shared auth-method codec — the one bridge between the canonical // placements-based wire model (`@executor-js/sdk/http-auth`: apikey/none methods // every protocol plugin stores) and the client's presentational shapes // (`AuthMethod`, `Placement`, `AuthTemplateEditorValue`). // // OAuth methods are the only per-plugin wire variant (openapi stores // endpoints+scopes, graphql a header override, mcp discovers at connect), so // each plugin contributes a small OAUTH ADAPTER and composes everything else // from here. This replaces the three per-plugin codec modules that each // reimplemented (and disagreed on) the apikey path — MCP silently dropped all // but the first placement, GraphQL split one method into N single-placement // templates. // --------------------------------------------------------------------------- import { AuthTemplateSlug } from "@executor-js/sdk/shared"; import { TOKEN_VARIABLE, apiKeyAuthTemplateFromMethod, apiKeyMethodLabel, type ApiKeyAuthMethod, type ApiKeyAuthTemplate, type AuthPlacement, type NoneAuthMethod, } from "@executor-js/sdk/http-auth"; import type { AuthTemplateEditorValue } from "../components/auth-template-editor"; import type { AuthMethod, Placement } from "./auth-placements"; import type { AuthMethodsCodec } from "./custom-auth-methods"; export type SharedAuthMethod = NoneAuthMethod | ApiKeyAuthMethod; /** A method input the backend slugs itself (custom-method create flow). */ export type SharedAuthMethodInput = | { readonly slug?: string; readonly kind: "none" } | { readonly slug?: string; readonly kind: "apikey"; readonly label?: string; readonly placements: readonly AuthPlacement[]; }; export const isSharedAuthMethod = (template: { readonly kind: string; }): template is SharedAuthMethod => template.kind === "apikey" || template.kind === "none"; // --------------------------------------------------------------------------- // Wire placements ⇆ editor placements. // --------------------------------------------------------------------------- /** Wire → editor. The variable is made explicit (`token` when absent) so a * round-trip preserves which placements share a credential input. */ export const editorPlacementsFromWire = ( placements: readonly AuthPlacement[], ): readonly Placement[] => placements.map( (placement: AuthPlacement): Placement => ({ carrier: placement.carrier, name: placement.name, prefix: placement.prefix ?? "", ...(placement.literal !== undefined ? { literal: placement.literal } : { variable: placement.variable ?? TOKEN_VARIABLE }), }), ); /** Slugify a placement name into a variable identifier: `DD-API-KEY` → * `dd_api_key`. */ const slugifyVariable = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); /** Assign an input variable to each named credential placement. A lone input * is the canonical `token`; multiple inputs each get their own distinct * variable so a connection carries a different value per location. An * explicit `placement.variable` (from a round-trip) is honored — that is how * two placements keep SHARING one input across an edit. */ const assignVariables = (placements: readonly Placement[]): Map => { const named = placements.filter((p: Placement) => p.name.trim() && p.literal === undefined); const out = new Map(); if (named.length <= 1) { for (const p of named) out.set(p, p.variable ?? TOKEN_VARIABLE); return out; } // Explicit variables are kept VERBATIM — and may repeat: that is how two // placements share one credential input. Only derived names dedupe. const taken = new Set(); for (const p of named) { if (p.variable) { out.set(p, p.variable); taken.add(p.variable); } } for (const p of named) { if (p.variable) continue; const base = slugifyVariable(p.name) || "input"; let candidate = base; let n = 2; while (taken.has(candidate)) candidate = `${base}_${n++}`; taken.add(candidate); out.set(p, candidate); } return out; }; /** Editor → wire. Unnamed placements are dropped; the canonical `token` * variable is stored as absent (the wire convention single-input connections * depend on — their stored values are keyed `token`). */ export const wirePlacementsFromEditor = ( placements: readonly Placement[], ): readonly AuthPlacement[] => { const variables = assignVariables(placements); return placements .filter((p: Placement) => p.name.trim().length > 0 || p.literal !== undefined) .map((p: Placement): AuthPlacement => { const variable = variables.get(p); return { carrier: p.carrier, name: p.name.trim(), ...(p.prefix ? { prefix: p.prefix } : {}), ...(p.literal !== undefined ? { literal: p.literal } : variable && variable !== TOKEN_VARIABLE ? { variable } : {}), }; }); }; // --------------------------------------------------------------------------- // Method ⇆ editor value / presentational method. // --------------------------------------------------------------------------- /** Convert one stored apikey/none method into the generic editor value. */ export const editorValueFromSharedMethod = (method: SharedAuthMethod): AuthTemplateEditorValue => method.kind === "apikey" ? { kind: "apikey", placements: editorPlacementsFromWire(method.placements) } : { kind: "none" }; /** Convert an apikey/none editor value back into a method input (no slug — * the backend assigns one). Returns null for oauth values: those convert * through the plugin's oauth adapter. An apikey value with no usable * placements degrades to `none`. */ export const sharedMethodInputFromEditorValue = ( value: AuthTemplateEditorValue, ): SharedAuthMethodInput | null => { if (value.kind === "oauth") return null; if (value.kind === "none") return { kind: "none" }; const placements = wirePlacementsFromEditor(value.placements); return placements.length > 0 ? { kind: "apikey", placements } : { kind: "none" }; }; /** Serialize an apikey method (stored or slug-optional) into the wire input * dialect — auth INPUTS accept only the request-shaped template; stored * configs and the catalog read as placements. Non-apikey arms pass through * untouched. */ export const wireAuthInputFromShared = ( method: | T | (Omit & { readonly slug?: string; readonly kind: "apikey" }), ): T | ApiKeyAuthTemplate => method.kind === "apikey" ? apiKeyAuthTemplateFromMethod( method as Omit & { readonly slug?: string }, ) : (method as T); const sourceOf = (slug: string): "spec" | "custom" => slug.startsWith("custom_") ? "custom" : "spec"; /** Project one stored apikey/none method into the presentational `AuthMethod` * the hub renders (mirrors the server's catalog projection). */ export const authMethodFromSharedTemplate = (method: SharedAuthMethod): AuthMethod => { if (method.kind === "apikey") { return { id: method.slug, label: apiKeyMethodLabel(method), kind: "apikey", source: sourceOf(method.slug), template: AuthTemplateSlug.make(method.slug), placements: editorPlacementsFromWire(method.placements), }; } return { id: method.slug, label: "No authentication", kind: "none", source: sourceOf(method.slug), template: AuthTemplateSlug.make(method.slug), placements: [], }; }; // --------------------------------------------------------------------------- // The codec factory for `useCustomMethodActions` — apikey/none handled here, // oauth delegated to the plugin's adapter. // --------------------------------------------------------------------------- export interface SharedCodecOAuthAdapter { readonly isOAuth: (template: SharedAuthMethod | TOAuth) => template is TOAuth; readonly toAuthMethod: (template: TOAuth) => AuthMethod; } export function makeSharedAuthMethodCodec( adapter: SharedCodecOAuthAdapter, ): AuthMethodsCodec { return { toAuthMethods: (templates) => templates.map((template) => adapter.isOAuth(template) ? adapter.toAuthMethod(template) : authMethodFromSharedTemplate(template), ), templatesFromPlacements: (placements) => { const wire = wirePlacementsFromEditor(placements); if (wire.length === 0) return []; // Slug-less: the backend's merge assigns `custom_`. return [{ slug: "", kind: "apikey", placements: wire } as ApiKeyAuthMethod]; }, slugOf: (template) => template.slug, }; }