From 87d97000f47e1872cc7e3331b995ba380859685e Mon Sep 17 00:00:00 2001 From: Oleksandr Zhuravlov Date: Mon, 13 Jul 2026 21:22:04 +0300 Subject: [PATCH] fix(openapi): don't emit a cookie apiKey as a silent header key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stitch gen openapi`'s `deriveAuth` matched every `type: apiKey` scheme, but core's `apiKey()` only models a header (default) or query key — `name` locates the key in both arms. An `in: cookie` scheme fell into the default (header) arm and was emitted as a header key: wrong transport, silently, with no warning. Guard the branch to `in` of header/query/undefined so a cookie scheme falls through to the existing "not auto-mapped — set client.ts auth manually" warning instead. Adds header/query/cookie coverage to gen-openapi.spec.ts. Carved out of the #470 contract-freeze sweep so this correctness fix can land and be reviewed on its own, ahead of the mechanical rename bulk. Co-Authored-By: Claude Opus 4.8 --- packages/openapi/src/gen-openapi.ts | 11 ++++++- packages/openapi/test/gen-openapi.spec.ts | 40 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/openapi/src/gen-openapi.ts b/packages/openapi/src/gen-openapi.ts index 39e89e0c..2851d121 100644 --- a/packages/openapi/src/gen-openapi.ts +++ b/packages/openapi/src/gen-openapi.ts @@ -916,7 +916,16 @@ function deriveAuth( expr: `basic({ user: env('API_USER'), pass: env('API_PASSWORD') })`, imports: ['basic', 'env'], }; - if (scheme.type === 'apiKey') { + if ( + scheme.type === 'apiKey' && + (scheme.in === 'header' || + scheme.in === 'query' || + scheme.in === undefined) + ) { + // Core's `apiKey()` only has header (default) and query arms — `name` locates the key in + // both. Header is the default, so `in: 'header'` is never emitted; a query scheme gets the + // `in: 'query'` discriminant. An `in: 'cookie'` scheme has no arm, so it must NOT land here + // (it would silently become a header key) — it falls through to the not-auto-mapped warning. const where = scheme.in === 'query' ? `in: 'query', ` : ''; const nm = scheme.name ? `name: ${JSON.stringify(scheme.name)}, ` : ''; return { diff --git a/packages/openapi/test/gen-openapi.spec.ts b/packages/openapi/test/gen-openapi.spec.ts index e11205fc..d43dcf61 100644 --- a/packages/openapi/test/gen-openapi.spec.ts +++ b/packages/openapi/test/gen-openapi.spec.ts @@ -201,6 +201,46 @@ describe('planGen — naming, typing, auth, notice', () => { /runtime validation \+ drift are OFF/, ); }); + + // apiKey auth. Core's `apiKey()` only models a header (default) or query key; a `cookie` apiKey + // has no arm. The generator must emit the header/query forms but let `cookie` fall through to + // the not-auto-mapped warning — never silently emit it as a header key. + const apiKeyDoc = (loc: 'header' | 'query' | 'cookie'): OpenApiDoc => ({ + openapi: '3.0.0', + info: { title: 'T', version: '1' }, + servers: [{ url: 'https://api.example.com' }], + security: [{ apiKeyAuth: [] }], + components: { + securitySchemes: { + apiKeyAuth: { type: 'apiKey', in: loc, name: 'X-API-Key' }, + }, + }, + paths: { '/ping': { get: { operationId: 'ping', responses: {} } } }, + }); + + test('apiKey in header → header key (no `in:`), no warning', () => { + const r = planGen(apiKeyDoc('header'), { all: true }); + const c = file(r, 'client.ts') ?? ''; + expect(c).toMatch( + /auth: apiKey\(\{ name: "X-API-Key", value: env\('API_KEY'\) \}\)/, + ); + expect(c).not.toMatch(/in: 'query'/); + expect(r.warnings.join('\n')).not.toMatch(/not auto-mapped/); + }); + + test("apiKey in query → `in: 'query'` discriminant emitted", () => { + const r = planGen(apiKeyDoc('query'), { all: true }); + expect(file(r, 'client.ts') ?? '').toMatch( + /auth: apiKey\(\{ in: 'query', name: "X-API-Key", value: env\('API_KEY'\) \}\)/, + ); + }); + + test('apiKey in cookie → not auto-mapped, no silent header key', () => { + const r = planGen(apiKeyDoc('cookie'), { all: true }); + // Must NOT emit an apiKey() call at all — cookie has no core arm. + expect(file(r, 'client.ts') ?? '').not.toMatch(/apiKey\(/); + expect(r.warnings.join('\n')).toMatch(/not auto-mapped/); + }); }); // The codegen turns an UNTRUSTED OpenAPI document into TS source the developer compiles. Spec text