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