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