diff --git a/docs/plans/2026-03-29-openapi-pure-package-design.md b/docs/plans/2026-03-29-openapi-pure-package-design.md new file mode 100644 index 000000000..22f1d70dd --- /dev/null +++ b/docs/plans/2026-03-29-openapi-pure-package-design.md @@ -0,0 +1,57 @@ +# Design: Make `packages/openapi` a Pure Utility (No Transport) + +## Problem + +`packages/openapi` currently owns HTTP infrastructure: `transport.ts` (proxy logic, undici) and +direct `fetch` calls in `specFetchHelper.ts` and `listFnResolver.ts`. This caused a duplication +of proxy logic between `openapi/transport.ts` and `source-stripe/src/transport.ts` when proxy +support was added in commit `e4149eef`. + +The package should be a pure Stripe OpenAPI utility — it may know _what_ to fetch (Stripe-specific +GitHub URLs, commit SHA resolution, version mapping, pagination patterns) but it should not own +_how_ to make HTTP calls (proxy config, undici, NO_PROXY logic). + +## Decision + +Keep all existing logic in `packages/openapi` — including `specFetchHelper.ts` (the GitHub/Stripe +spec-fetching logic is Stripe-specific and belongs here). Remove the transport infrastructure by +injecting a `fetch` function from the caller instead. + +## Design + +### API changes + +```ts +// specFetchHelper.ts +resolveOpenApiSpec(config: ResolveSpecConfig, fetch: typeof globalThis.fetch): Promise + +// listFnResolver.ts +buildListFn(apiKey: string, apiPath: string, fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string): ListFn +buildRetrieveFn(apiKey: string, apiPath: string, fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string): RetrieveFn +``` + +`fetch` is a **required** parameter — making it optional would silently fall back to +`globalThis.fetch`, which breaks behind Stripe's proxy and defeats the purpose. + +### Files removed from `packages/openapi` + +- `transport.ts` — deleted entirely +- `undici` — removed from `package.json` + +### Call sites updated in `packages/source-stripe` + +- `src/index.ts` — passes `fetchWithProxy` to `resolveOpenApiSpec` +- `src/resourceRegistry.ts` — passes `fetchWithProxy` to `buildListFn` / `buildRetrieveFn` + +### Tests + +- `packages/openapi/__tests__/transport.test.ts` — deleted (tests a file that no longer exists) +- `packages/openapi/__tests__/specFetchHelper.test.ts` — update proxy test to pass a mock fetch +- `packages/openapi/__tests__/listFnResolver.test.ts` — update proxy test to pass a mock fetch + +## What does NOT change + +- `specFetchHelper.ts` stays in `packages/openapi` — the GitHub repo, commit SHA resolution, and + version-to-date mapping are Stripe-specific OpenAPI knowledge, not source-stripe business logic +- `source-stripe/src/transport.ts` is untouched +- No behavioral changes — proxy behaviour is identical, just the plumbing moves to the caller diff --git a/packages/openapi/__tests__/listFnResolver.test.ts b/packages/openapi/__tests__/listFnResolver.test.ts index 18823e26b..693590c9e 100644 --- a/packages/openapi/__tests__/listFnResolver.test.ts +++ b/packages/openapi/__tests__/listFnResolver.test.ts @@ -1,13 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildListFn, buildRetrieveFn, discoverListEndpoints } from '../listFnResolver' import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec' describe('discoverListEndpoints', () => { - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - it('maps table names to their API paths', () => { const endpoints = discoverListEndpoints(minimalStripeOpenApiSpec) @@ -93,53 +88,33 @@ describe('discoverListEndpoints', () => { expect(endpoints.size).toBe(0) }) - it('routes list and retrieve fetches through the proxy helper', async () => { - const originalHttpsProxy = process.env.HTTPS_PROXY - process.env.HTTPS_PROXY = 'http://proxy.example.test:8080' - - const fetchMock = vi.fn(async (_input: URL | string, init?: RequestInit) => { - expect(init?.dispatcher).toBeDefined() - return new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) - }) - vi.stubGlobal('fetch', fetchMock) - - try { - const list = buildListFn('sk_test_fake', '/v1/customers') - const retrieve = buildRetrieveFn('sk_test_fake', '/v1/customers') - - await list({ limit: 1 }) - await retrieve('cus_123') - - expect(fetchMock).toHaveBeenCalledTimes(2) - } finally { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY - } else { - process.env.HTTPS_PROXY = originalHttpsProxy - } - } + it('uses the injected fetch for list and retrieve calls', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) + ) + const list = buildListFn('sk_test_fake', '/v1/customers', fetchMock) + const retrieve = buildRetrieveFn('sk_test_fake', '/v1/customers', fetchMock) + await list({ limit: 1 }) + await retrieve('cus_123') + expect(fetchMock).toHaveBeenCalledTimes(2) }) - it('bypasses the proxy for localhost base URLs', async () => { - const originalHttpsProxy = process.env.HTTPS_PROXY - process.env.HTTPS_PROXY = 'http://proxy.example.test:8080' - - const fetchMock = vi.fn(async (_input: URL | string, init?: RequestInit) => { - expect(init?.dispatcher).toBeUndefined() - return new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) - }) - vi.stubGlobal('fetch', fetchMock) - - try { - const list = buildListFn('sk_test_fake', '/v1/customers', undefined, 'http://localhost:12111') - await list({ limit: 1 }) - expect(fetchMock).toHaveBeenCalledTimes(1) - } finally { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY - } else { - process.env.HTTPS_PROXY = originalHttpsProxy - } - } + it('uses the injected fetch for localhost base URLs', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ data: [], has_more: false }), { status: 200 }) + ) + const list = buildListFn( + 'sk_test_fake', + '/v1/customers', + fetchMock, + undefined, + 'http://localhost:12111' + ) + await list({ limit: 1 }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:12111'), + expect.anything() + ) }) }) diff --git a/packages/openapi/__tests__/specFetchHelper.test.ts b/packages/openapi/__tests__/specFetchHelper.test.ts index b62042e83..a6bb5a788 100644 --- a/packages/openapi/__tests__/specFetchHelper.test.ts +++ b/packages/openapi/__tests__/specFetchHelper.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveOpenApiSpec } from '../specFetchHelper' import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec' @@ -10,23 +10,20 @@ async function createTempDir(prefix: string): Promise { } describe('resolveOpenApiSpec', () => { - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - it('prefers explicit local spec path over cache and network', async () => { const tempDir = await createTempDir('openapi-explicit') const specPath = path.join(tempDir, 'spec3.json') await fs.writeFile(specPath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8') - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) + const fetchMock = vi.fn().mockRejectedValue(new Error('fetch should not be called')) - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - openApiSpecPath: specPath, - cacheDir: tempDir, - }) + const result = await resolveOpenApiSpec( + { + apiVersion: '2020-08-27', + openApiSpecPath: specPath, + cacheDir: tempDir, + }, + fetchMock + ) expect(result.source).toBe('explicit_path') expect(fetchMock).not.toHaveBeenCalled() @@ -37,13 +34,15 @@ describe('resolveOpenApiSpec', () => { const tempDir = await createTempDir('openapi-cache') const cachePath = path.join(tempDir, '2020-08-27.spec3.sdk.json') await fs.writeFile(cachePath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8') - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) + const fetchMock = vi.fn().mockRejectedValue(new Error('fetch should not be called')) - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) + const result = await resolveOpenApiSpec( + { + apiVersion: '2020-08-27', + cacheDir: tempDir, + }, + fetchMock + ) expect(result.source).toBe('cache') expect(result.cachePath).toBe(cachePath) @@ -60,12 +59,14 @@ describe('resolveOpenApiSpec', () => { } return new Response(JSON.stringify(minimalStripeOpenApiSpec), { status: 200 }) }) - vi.stubGlobal('fetch', fetchMock) - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) + const result = await resolveOpenApiSpec( + { + apiVersion: '2020-08-27', + cacheDir: tempDir, + }, + fetchMock + ) expect(result.source).toBe('github') expect(result.commitSha).toBe('abc123def456') @@ -76,36 +77,23 @@ describe('resolveOpenApiSpec', () => { await fs.rm(tempDir, { recursive: true, force: true }) }) - it('uses the configured proxy for GitHub fetches', async () => { + it('uses the injected fetch for GitHub fetches', async () => { const tempDir = await createTempDir('openapi-fetch-proxy') - const originalHttpsProxy = process.env.HTTPS_PROXY - process.env.HTTPS_PROXY = 'http://proxy.example.test:8080' - - const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { - expect(init?.dispatcher).toBeDefined() - + const fetchMock = vi.fn(async (input: URL | string) => { const url = String(input) if (url.includes('/commits')) { return new Response(JSON.stringify([{ sha: 'abc123def456' }]), { status: 200 }) } return new Response(JSON.stringify(minimalStripeOpenApiSpec), { status: 200 }) }) - vi.stubGlobal('fetch', fetchMock) - try { - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) - + const result = await resolveOpenApiSpec( + { apiVersion: '2020-08-27', cacheDir: tempDir }, + fetchMock + ) expect(result.source).toBe('github') expect(fetchMock).toHaveBeenCalledTimes(2) } finally { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY - } else { - process.env.HTTPS_PROXY = originalHttpsProxy - } await fs.rm(tempDir, { recursive: true, force: true }) } }) @@ -116,10 +104,13 @@ describe('resolveOpenApiSpec', () => { await fs.writeFile(specPath, JSON.stringify({ openapi: '3.0.0' }), 'utf8') await expect( - resolveOpenApiSpec({ - apiVersion: '2020-08-27', - openApiSpecPath: specPath, - }) + resolveOpenApiSpec( + { + apiVersion: '2020-08-27', + openApiSpecPath: specPath, + }, + vi.fn().mockRejectedValue(new Error('fetch should not be called')) + ) ).rejects.toThrow(/components|schemas/i) await fs.rm(tempDir, { recursive: true, force: true }) }) @@ -127,13 +118,15 @@ describe('resolveOpenApiSpec', () => { it('fails fast when GitHub resolution fails and no explicit spec path is set', async () => { const tempDir = await createTempDir('openapi-fail-fast') const fetchMock = vi.fn(async () => new Response('boom', { status: 500 })) - vi.stubGlobal('fetch', fetchMock) await expect( - resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) + resolveOpenApiSpec( + { + apiVersion: '2020-08-27', + cacheDir: tempDir, + }, + fetchMock + ) ).rejects.toThrow(/Failed to resolve Stripe OpenAPI commit/) await fs.rm(tempDir, { recursive: true, force: true }) }) diff --git a/packages/openapi/__tests__/transport.test.ts b/packages/openapi/__tests__/transport.test.ts deleted file mode 100644 index c8059b8ec..000000000 --- a/packages/openapi/__tests__/transport.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getProxyUrl, - getProxyUrlForTarget, - shouldBypassProxy, - withFetchProxy, -} from '../transport.js' - -describe('getProxyUrl', () => { - it('prefers HTTPS_PROXY over HTTP_PROXY', () => { - expect( - getProxyUrl({ - HTTPS_PROXY: 'http://secure-proxy.example.test:8080', - HTTP_PROXY: 'http://fallback-proxy.example.test:8080', - }) - ).toBe('http://secure-proxy.example.test:8080') - }) - - it('returns undefined when no proxy env var is set', () => { - expect(getProxyUrl({})).toBeUndefined() - }) -}) - -describe('getProxyUrlForTarget', () => { - it('returns the proxy for external targets', () => { - expect( - getProxyUrlForTarget('https://api.stripe.com/v1/customers', { - HTTPS_PROXY: 'http://proxy.example.test:8080', - }) - ).toBe('http://proxy.example.test:8080') - }) - - it('bypasses the proxy for localhost and NO_PROXY matches', () => { - expect( - getProxyUrlForTarget('http://localhost:12111/v1/customers', { - HTTPS_PROXY: 'http://proxy.example.test:8080', - }) - ).toBeUndefined() - - expect( - getProxyUrlForTarget('https://sync-engine-srv.service.envoy/health', { - HTTPS_PROXY: 'http://proxy.example.test:8080', - NO_PROXY: '.service.envoy,10.0.0.0/8', - }) - ).toBeUndefined() - - expect( - getProxyUrlForTarget('http://10.42.0.15:8080/health', { - HTTPS_PROXY: 'http://proxy.example.test:8080', - NO_PROXY: '.service.envoy,10.0.0.0/8', - }) - ).toBeUndefined() - }) -}) - -describe('shouldBypassProxy', () => { - it('supports wildcard-style domain matches', () => { - expect( - shouldBypassProxy('https://api.internal.stripe.com', { - NO_PROXY: '.stripe.com', - }) - ).toBe(true) - }) -}) - -describe('withFetchProxy', () => { - it('adds a dispatcher when a proxy env var is set', () => { - const init = withFetchProxy( - { - headers: { Accept: 'application/json' }, - }, - { - HTTPS_PROXY: 'http://proxy.example.test:8080', - } - ) - - expect(init.headers).toEqual({ Accept: 'application/json' }) - expect(init.dispatcher).toBeDefined() - }) - - it('leaves request init unchanged when no proxy env var is set', () => { - const init: RequestInit = { method: 'POST' } - - expect(withFetchProxy(init, {})).toBe(init) - }) -}) diff --git a/packages/openapi/listFnResolver.ts b/packages/openapi/listFnResolver.ts index 50a1db319..65acabde1 100644 --- a/packages/openapi/listFnResolver.ts +++ b/packages/openapi/listFnResolver.ts @@ -1,6 +1,5 @@ import type { OpenApiSchemaObject, OpenApiSpec } from './types.js' import { OPENAPI_RESOURCE_TABLE_ALIASES } from './runtimeMappings.js' -import { fetchWithProxy } from './transport.js' const SCHEMA_REF_PREFIX = '#/components/schemas/' @@ -219,6 +218,7 @@ function authHeaders(apiKey: string): Record { export function buildListFn( apiKey: string, apiPath: string, + fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string ): ListFn { @@ -233,7 +233,7 @@ export function buildListFn( const headers = authHeaders(apiKey) if (apiVersion) headers['Stripe-Version'] = apiVersion - const response = await fetchWithProxy(`${base}${apiPath}?${qs}`, { headers }) + const response = await fetch(`${base}${apiPath}?${qs}`, { headers }) const body = (await response.json()) as { data: unknown[] next_page_url?: string | null @@ -254,7 +254,7 @@ export function buildListFn( } } - const response = await fetchWithProxy(`${base}${apiPath}?${qs}`, { + const response = await fetch(`${base}${apiPath}?${qs}`, { headers: authHeaders(apiKey), }) const body = (await response.json()) as { data: unknown[]; has_more: boolean } @@ -268,6 +268,7 @@ export function buildListFn( export function buildRetrieveFn( apiKey: string, apiPath: string, + fetch: typeof globalThis.fetch, apiVersion?: string, baseUrl?: string ): RetrieveFn { @@ -278,13 +279,13 @@ export function buildRetrieveFn( const headers = authHeaders(apiKey) if (apiVersion) headers['Stripe-Version'] = apiVersion - const response = await fetchWithProxy(`${base}${apiPath}/${id}`, { headers }) + const response = await fetch(`${base}${apiPath}/${id}`, { headers }) return await response.json() } } return async (id) => { - const response = await fetchWithProxy(`${base}${apiPath}/${id}`, { + const response = await fetch(`${base}${apiPath}/${id}`, { headers: authHeaders(apiKey), }) return await response.json() diff --git a/packages/openapi/package.json b/packages/openapi/package.json index e318054c3..168c083e8 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -17,9 +17,7 @@ "files": [ "dist" ], - "dependencies": { - "undici": "^7.16.0" - }, + "dependencies": {}, "devDependencies": { "@types/node": "^24.5.0", "vitest": "^3.2.4" diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index cb1a8e49d..e576ed2bc 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -3,7 +3,6 @@ import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { OpenApiSpec, ResolveSpecConfig, ResolvedOpenApiSpec } from './types.js' -import { fetchWithProxy } from './transport.js' const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') @@ -11,7 +10,10 @@ const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') // Update this constant and bundled-spec.json together when bumping. export const BUNDLED_API_VERSION = '2026-03-25.dahlia' -export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise { +export async function resolveOpenApiSpec( + config: ResolveSpecConfig, + fetch: typeof globalThis.fetch +): Promise { const apiVersion = config.apiVersion if (!apiVersion || !/^\d{4}-\d{2}-\d{2}(\.\w+)?$/.test(apiVersion)) { throw new Error( @@ -54,9 +56,9 @@ export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise { +async function resolveLatestCommitSha(fetch: typeof globalThis.fetch): Promise { const url = new URL('https://api.github.com/repos/stripe/openapi/commits') url.searchParams.set('path', 'latest/openapi.spec3.sdk.json') url.searchParams.set('per_page', '1') - const response = await fetchWithProxy(url, { headers: githubHeaders() }) + const response = await fetch(url, { headers: githubHeaders() }) if (!response.ok) { throw new Error( `Failed to resolve latest Stripe OpenAPI commit (${response.status} ${response.statusText})` @@ -138,14 +140,17 @@ async function resolveLatestCommitSha(): Promise { return typeof commitSha === 'string' && commitSha.length > 0 ? commitSha : null } -async function resolveCommitShaForApiVersion(apiVersion: string): Promise { +async function resolveCommitShaForApiVersion( + apiVersion: string, + fetch: typeof globalThis.fetch +): Promise { const until = `${extractDatePart(apiVersion)}T23:59:59Z` const url = new URL('https://api.github.com/repos/stripe/openapi/commits') url.searchParams.set('path', 'latest/openapi.spec3.sdk.json') url.searchParams.set('until', until) url.searchParams.set('per_page', '1') - const response = await fetchWithProxy(url, { headers: githubHeaders() }) + const response = await fetch(url, { headers: githubHeaders() }) if (!response.ok) { throw new Error( `Failed to resolve Stripe OpenAPI commit (${response.status} ${response.statusText})` @@ -157,9 +162,12 @@ async function resolveCommitShaForApiVersion(apiVersion: string): Promise 0 ? commitSha : null } -async function fetchSpecForCommit(commitSha: string): Promise { +async function fetchSpecForCommit( + commitSha: string, + fetch: typeof globalThis.fetch +): Promise { const url = `https://raw.githubusercontent.com/stripe/openapi/${commitSha}/latest/openapi.spec3.sdk.json` - const response = await fetchWithProxy(url, { headers: githubHeaders() }) + const response = await fetch(url, { headers: githubHeaders() }) if (!response.ok) { throw new Error( `Failed to download Stripe OpenAPI spec for commit ${commitSha} (${response.status} ${response.statusText})` diff --git a/packages/openapi/transport.ts b/packages/openapi/transport.ts deleted file mode 100644 index 26e0f34c3..000000000 --- a/packages/openapi/transport.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { ProxyAgent } from 'undici' - -export type TransportEnv = Record -type ProxyTarget = URL | string - -const proxyAgents = new Map() - -export function getProxyUrl(env: TransportEnv = process.env): string | undefined { - for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) { - const value = env[key]?.trim() - if (value) { - return value - } - } - return undefined -} - -function getNoProxy(env: TransportEnv = process.env): string | undefined { - for (const key of ['NO_PROXY', 'no_proxy']) { - const value = env[key]?.trim() - if (value) { - return value - } - } - return undefined -} - -function parseTargetUrl(target: ProxyTarget): URL | null { - if (target instanceof URL) { - return target - } - - try { - return new URL(target) - } catch { - return null - } -} - -function isLoopbackHost(hostname: string): boolean { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' -} - -function parseIpv4(hostname: string): number | null { - const parts = hostname.split('.') - if (parts.length !== 4) { - return null - } - - let value = 0 - for (const part of parts) { - if (!/^\d+$/.test(part)) { - return null - } - const octet = Number(part) - if (octet < 0 || octet > 255) { - return null - } - value = (value << 8) | octet - } - - return value >>> 0 -} - -function matchesIpv4Cidr(hostname: string, cidr: string): boolean { - const [range, prefixText] = cidr.split('/', 2) - if (!range || !prefixText) { - return false - } - - const hostValue = parseIpv4(hostname) - const rangeValue = parseIpv4(range) - const prefix = Number(prefixText) - if (hostValue === null || rangeValue === null || !Number.isInteger(prefix)) { - return false - } - if (prefix < 0 || prefix > 32) { - return false - } - - if (prefix === 0) { - return true - } - - const mask = (0xffffffff << (32 - prefix)) >>> 0 - return (hostValue & mask) === (rangeValue & mask) -} - -function matchesNoProxyRule(hostname: string, rawRule: string): boolean { - const rule = rawRule.trim().toLowerCase() - if (!rule) { - return false - } - if (rule === '*') { - return true - } - if (rule.includes('/')) { - return matchesIpv4Cidr(hostname, rule) - } - - const normalizedRule = rule.startsWith('*.') ? rule.slice(1) : rule - const exactRule = normalizedRule.startsWith('.') ? normalizedRule.slice(1) : normalizedRule - if (hostname === exactRule) { - return true - } - - const suffixRule = normalizedRule.startsWith('.') ? normalizedRule : `.${normalizedRule}` - return hostname.endsWith(suffixRule) -} - -export function shouldBypassProxy(target: ProxyTarget, env: TransportEnv = process.env): boolean { - const url = parseTargetUrl(target) - if (!url) { - return false - } - - const hostname = url.hostname.toLowerCase() - if (!hostname) { - return false - } - if (isLoopbackHost(hostname)) { - return true - } - - const noProxy = getNoProxy(env) - if (!noProxy) { - return false - } - - return noProxy.split(',').some((rule) => matchesNoProxyRule(hostname, rule)) -} - -export function getProxyUrlForTarget( - target: ProxyTarget, - env: TransportEnv = process.env -): string | undefined { - const proxyUrl = getProxyUrl(env) - if (!proxyUrl || shouldBypassProxy(target, env)) { - return undefined - } - return proxyUrl -} - -function getProxyAgent(proxyUrl: string): ProxyAgent { - let agent = proxyAgents.get(proxyUrl) - if (!agent) { - agent = new ProxyAgent(proxyUrl) - proxyAgents.set(proxyUrl, agent) - } - return agent -} - -type ProxyAwareRequestInit = RequestInit & { dispatcher?: ProxyAgent } - -export function withFetchProxy( - init: RequestInit = {}, - env: TransportEnv = process.env -): ProxyAwareRequestInit { - const proxyUrl = getProxyUrl(env) - if (!proxyUrl) { - return init - } - - return { - ...init, - dispatcher: getProxyAgent(proxyUrl), - } -} - -export function fetchWithProxy( - input: URL | string, - init: RequestInit = {}, - env: TransportEnv = process.env -): Promise { - const proxyUrl = getProxyUrlForTarget(input, env) - if (!proxyUrl) { - return fetch(input, init) - } - - return fetch(input, { - ...init, - dispatcher: getProxyAgent(proxyUrl), - } as ProxyAwareRequestInit) -} diff --git a/packages/source-stripe/src/index.ts b/packages/source-stripe/src/index.ts index 9754460c1..20d8d26b1 100644 --- a/packages/source-stripe/src/index.ts +++ b/packages/source-stripe/src/index.ts @@ -23,6 +23,10 @@ import type { ResourceConfig } from './types.js' import { makeClient } from './client.js' import type { RateLimiter } from './rate-limiter.js' import { createInMemoryRateLimiter, DEFAULT_MAX_RPS } from './rate-limiter.js' +import { fetchWithProxy } from './transport.js' + +const apiFetch: typeof globalThis.fetch = (input, init) => + fetchWithProxy(input as URL | string, init ?? {}) // MARK: - Spec @@ -148,9 +152,10 @@ export function createStripeSource( }, async discover({ config }) { - const resolved = await resolveOpenApiSpec({ - apiVersion: config.api_version ?? '2020-08-27', - }) + const resolved = await resolveOpenApiSpec( + { apiVersion: config.api_version ?? '2020-08-27' }, + apiFetch + ) const registry = buildResourceRegistry( resolved.spec, config.api_key, @@ -210,9 +215,10 @@ export function createStripeSource( const rateLimiter = externalRateLimiter ?? createInMemoryRateLimiter(config.rate_limit ?? DEFAULT_MAX_RPS) const stripe = makeClient(config) - const resolved = await resolveOpenApiSpec({ - apiVersion: config.api_version ?? '2020-08-27', - }) + const resolved = await resolveOpenApiSpec( + { apiVersion: config.api_version ?? '2020-08-27' }, + apiFetch + ) const registry = buildResourceRegistry( resolved.spec, config.api_key, diff --git a/packages/source-stripe/src/openapi/index.ts b/packages/source-stripe/src/openapi/index.ts index c8e25f834..a972f5743 100644 --- a/packages/source-stripe/src/openapi/index.ts +++ b/packages/source-stripe/src/openapi/index.ts @@ -6,5 +6,4 @@ export { RUNTIME_RESOURCE_ALIASES, } from './specParser.js' export { OPENAPI_COMPATIBILITY_COLUMNS } from './runtimeMappings.js' -export { resolveOpenApiSpec } from './specFetchHelper.js' export { parsedTableToJsonSchema } from './jsonSchemaConverter.js' diff --git a/packages/source-stripe/src/openapi/specFetchHelper.test.ts b/packages/source-stripe/src/openapi/specFetchHelper.test.ts deleted file mode 100644 index a3074d6f5..000000000 --- a/packages/source-stripe/src/openapi/specFetchHelper.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import fs from 'node:fs/promises' -import os from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveOpenApiSpec } from './specFetchHelper.js' -import { minimalStripeOpenApiSpec } from './fixtures/minimalSpec.js' - -async function createTempDir(prefix: string): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)) -} - -describe('resolveOpenApiSpec', () => { - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - - it('prefers explicit local spec path over cache and network', async () => { - const tempDir = await createTempDir('openapi-explicit') - const specPath = path.join(tempDir, 'spec3.json') - await fs.writeFile(specPath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8') - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - openApiSpecPath: specPath, - cacheDir: tempDir, - }) - - expect(result.source).toBe('explicit_path') - expect(fetchMock).not.toHaveBeenCalled() - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - it('uses cache by api version when available', async () => { - const tempDir = await createTempDir('openapi-cache') - const cachePath = path.join(tempDir, '2020-08-27.spec3.json') - await fs.writeFile(cachePath, JSON.stringify(minimalStripeOpenApiSpec), 'utf8') - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) - - expect(result.source).toBe('cache') - expect(result.cachePath).toBe(cachePath) - expect(fetchMock).not.toHaveBeenCalled() - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - it('fetches from GitHub when cache misses and persists cache', async () => { - const tempDir = await createTempDir('openapi-fetch') - const fetchMock = vi.fn(async (input: URL | string) => { - const url = String(input) - if (url.includes('/commits')) { - return new Response(JSON.stringify([{ sha: 'abc123def456' }]), { status: 200 }) - } - return new Response(JSON.stringify(minimalStripeOpenApiSpec), { status: 200 }) - }) - vi.stubGlobal('fetch', fetchMock) - - const result = await resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) - - expect(result.source).toBe('github') - expect(result.commitSha).toBe('abc123def456') - - const cached = await fs.readFile(path.join(tempDir, '2020-08-27.spec3.json'), 'utf8') - expect(JSON.parse(cached)).toMatchObject({ openapi: '3.0.0' }) - expect(fetchMock).toHaveBeenCalledTimes(2) - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - it('throws for malformed explicit spec files', async () => { - const tempDir = await createTempDir('openapi-malformed') - const specPath = path.join(tempDir, 'spec3.json') - await fs.writeFile(specPath, JSON.stringify({ openapi: '3.0.0' }), 'utf8') - - await expect( - resolveOpenApiSpec({ - apiVersion: '2020-08-27', - openApiSpecPath: specPath, - }) - ).rejects.toThrow(/components|schemas/i) - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - it('fails fast when GitHub resolution fails and no explicit spec path is set', async () => { - const tempDir = await createTempDir('openapi-fail-fast') - const fetchMock = vi.fn(async () => new Response('boom', { status: 500 })) - vi.stubGlobal('fetch', fetchMock) - - await expect( - resolveOpenApiSpec({ - apiVersion: '2020-08-27', - cacheDir: tempDir, - }) - ).rejects.toThrow(/Failed to resolve Stripe OpenAPI commit/) - await fs.rm(tempDir, { recursive: true, force: true }) - }) -}) diff --git a/packages/source-stripe/src/openapi/specFetchHelper.ts b/packages/source-stripe/src/openapi/specFetchHelper.ts deleted file mode 100644 index 9f88f9cac..000000000 --- a/packages/source-stripe/src/openapi/specFetchHelper.ts +++ /dev/null @@ -1,155 +0,0 @@ -import os from 'node:os' -import fs from 'node:fs/promises' -import path from 'node:path' -import type { OpenApiSpec, ResolveSpecConfig, ResolvedOpenApiSpec } from './types.js' -import { fetchWithProxy } from '../transport.js' - -const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache') - -export async function resolveOpenApiSpec(config: ResolveSpecConfig): Promise { - const apiVersion = config.apiVersion - if (!apiVersion || !/^\d{4}-\d{2}-\d{2}$/.test(apiVersion)) { - throw new Error(`Invalid Stripe API version "${apiVersion}". Expected YYYY-MM-DD.`) - } - - if (config.openApiSpecPath) { - const explicitSpec = await readSpecFromPath(config.openApiSpecPath) - return { - apiVersion, - spec: explicitSpec, - source: 'explicit_path', - cachePath: config.openApiSpecPath, - } - } - - const cacheDir = config.cacheDir ?? DEFAULT_CACHE_DIR - const cachePath = getCachePath(cacheDir, apiVersion) - const cachedSpec = await tryReadCachedSpec(cachePath) - if (cachedSpec) { - return { - apiVersion, - spec: cachedSpec, - source: 'cache', - cachePath, - } - } - - const commitSha = await resolveCommitShaForApiVersion(apiVersion) - if (!commitSha) { - throw new Error( - `Could not resolve Stripe OpenAPI commit for API version ${apiVersion} and no local spec path was provided.` - ) - } - - const spec = await fetchSpecForCommit(commitSha) - validateOpenApiSpec(spec) - await tryWriteCache(cachePath, spec) - - return { - apiVersion, - spec, - source: 'github', - cachePath, - commitSha, - } -} - -async function readSpecFromPath(openApiSpecPath: string): Promise { - const raw = await fs.readFile(openApiSpecPath, 'utf8') - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (error) { - throw new Error( - `Failed to parse OpenAPI spec at ${openApiSpecPath}: ${error instanceof Error ? error.message : String(error)}` - ) - } - validateOpenApiSpec(parsed) - return parsed -} - -async function tryReadCachedSpec(cachePath: string): Promise { - try { - const raw = await fs.readFile(cachePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - validateOpenApiSpec(parsed) - return parsed - } catch { - return null - } -} - -async function tryWriteCache(cachePath: string, spec: OpenApiSpec): Promise { - try { - await fs.mkdir(path.dirname(cachePath), { recursive: true }) - await fs.writeFile(cachePath, JSON.stringify(spec), 'utf8') - } catch { - // Best effort only. Cache writes should never block migration flow. - } -} - -function getCachePath(cacheDir: string, apiVersion: string): string { - const safeVersion = apiVersion.replace(/[^0-9a-zA-Z_-]/g, '_') - return path.join(cacheDir, `${safeVersion}.spec3.json`) -} - -async function resolveCommitShaForApiVersion(apiVersion: string): Promise { - const until = `${apiVersion}T23:59:59Z` - const url = new URL('https://api.github.com/repos/stripe/openapi/commits') - url.searchParams.set('path', 'openapi/spec3.json') - url.searchParams.set('until', until) - url.searchParams.set('per_page', '1') - - const response = await fetchWithProxy(url, { headers: githubHeaders() }) - if (!response.ok) { - throw new Error( - `Failed to resolve Stripe OpenAPI commit (${response.status} ${response.statusText})` - ) - } - - const json = (await response.json()) as Array<{ sha?: string }> - const commitSha = json[0]?.sha - return typeof commitSha === 'string' && commitSha.length > 0 ? commitSha : null -} - -async function fetchSpecForCommit(commitSha: string): Promise { - const url = `https://raw.githubusercontent.com/stripe/openapi/${commitSha}/openapi/spec3.json` - const response = await fetchWithProxy(url, { headers: githubHeaders() }) - if (!response.ok) { - throw new Error( - `Failed to download Stripe OpenAPI spec for commit ${commitSha} (${response.status} ${response.statusText})` - ) - } - - const spec = (await response.json()) as unknown - validateOpenApiSpec(spec) - return spec -} - -function validateOpenApiSpec(spec: unknown): asserts spec is OpenApiSpec { - if (!spec || typeof spec !== 'object') { - throw new Error('OpenAPI spec is not an object') - } - const candidate = spec as Partial - if (typeof candidate.openapi !== 'string' || candidate.openapi.trim().length === 0) { - throw new Error('OpenAPI spec is missing the "openapi" field') - } - if (!candidate.components || typeof candidate.components !== 'object') { - throw new Error('OpenAPI spec is missing "components"') - } - if (!candidate.components.schemas || typeof candidate.components.schemas !== 'object') { - throw new Error('OpenAPI spec is missing "components.schemas"') - } -} - -function githubHeaders(): HeadersInit { - const headers: Record = { - Accept: 'application/vnd.github+json', - 'User-Agent': 'stripe-sync-engine-openapi', - } - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN - if (token) { - headers.Authorization = `Bearer ${token}` - } - return headers -} diff --git a/packages/source-stripe/src/resourceRegistry.ts b/packages/source-stripe/src/resourceRegistry.ts index 84a33fb51..20bb81613 100644 --- a/packages/source-stripe/src/resourceRegistry.ts +++ b/packages/source-stripe/src/resourceRegistry.ts @@ -9,6 +9,10 @@ import { resolveTableName, OPENAPI_RESOURCE_TABLE_ALIASES, } from '@stripe/sync-openapi' +import { fetchWithProxy } from './transport.js' + +const apiFetch: typeof globalThis.fetch = (input, init) => + fetchWithProxy(input as URL | string, init ?? {}) /** * The default set of table names synced when no explicit selection is made. @@ -78,8 +82,8 @@ export function buildResourceRegistry( supportsLimit: endpoint.supportsLimit, sync: true, dependencies: [], - listFn: buildListFn(apiKey, endpoint.apiPath, apiVersion, baseUrl), - retrieveFn: buildRetrieveFn(apiKey, endpoint.apiPath, apiVersion, baseUrl), + listFn: buildListFn(apiKey, endpoint.apiPath, apiFetch, apiVersion, baseUrl), + retrieveFn: buildRetrieveFn(apiKey, endpoint.apiPath, apiFetch, apiVersion, baseUrl), nestedResources: children.length > 0 ? children : undefined, } registry[tableName] = config diff --git a/packages/util-postgres/src/sslConfigFromConnectionString.test.ts b/packages/util-postgres/src/sslConfigFromConnectionString.test.ts index 194c71842..34463a0e9 100644 --- a/packages/util-postgres/src/sslConfigFromConnectionString.test.ts +++ b/packages/util-postgres/src/sslConfigFromConnectionString.test.ts @@ -49,6 +49,24 @@ describe('sslConfigFromConnectionString', () => { it('invalid URL → false', () => { expect(sslConfigFromConnectionString('not-a-url')).toBe(false) }) + + it('sslmode=prefer → throws', () => { + expect(() => + sslConfigFromConnectionString('postgres://user:pass@host:5432/mydb?sslmode=prefer') + ).toThrow(/Unsupported Postgres sslmode "prefer"/) + }) + + it('sslmode=allow → throws', () => { + expect(() => + sslConfigFromConnectionString('postgres://user:pass@host:5432/mydb?sslmode=allow') + ).toThrow(/Unsupported Postgres sslmode "allow"/) + }) + + it('unknown sslmode → throws', () => { + expect(() => + sslConfigFromConnectionString('postgres://user:pass@host:5432/mydb?sslmode=bogus') + ).toThrow(/Unsupported Postgres sslmode "bogus"/) + }) }) describe('stripSslParams', () => { diff --git a/packages/util-postgres/src/sslConfigFromConnectionString.ts b/packages/util-postgres/src/sslConfigFromConnectionString.ts index 145c293e6..a76d79edd 100644 --- a/packages/util-postgres/src/sslConfigFromConnectionString.ts +++ b/packages/util-postgres/src/sslConfigFromConnectionString.ts @@ -22,7 +22,8 @@ export function stripSslParams(connStr: string): string { /** * Maps the `sslmode` query parameter from a Postgres connection string to a pg - * `ssl` option. Defaults to `false` (no SSL) when no sslmode is present. + * `ssl` option. Throws for unrecognised sslmode values instead of silently + * disabling SSL. * * @param sslCaPem - PEM-encoded CA certificate. Required for `verify-ca` / * `verify-full` to trust a private CA (e.g. RDS, internal DBs). If omitted @@ -32,24 +33,27 @@ export function sslConfigFromConnectionString( connStr: string, { sslCaPem }: { sslCaPem?: string } = {} ): PgSslConfig { + let sslmode: string | null try { - const sslmode = new URL(connStr).searchParams.get('sslmode') - if (sslmode === 'disable') return false - if (sslmode === 'require') return { rejectUnauthorized: false } - if (sslmode === 'verify-full') { - return { rejectUnauthorized: true, ...(sslCaPem ? { ca: sslCaPem } : {}) } - } - if (sslmode === 'verify-ca') { - // verify-ca checks CA trust but skips hostname verification — useful when - // connecting through a proxy or pgbouncer where the hostname doesn't match. - return { - rejectUnauthorized: true, - ...(sslCaPem ? { ca: sslCaPem } : {}), - checkServerIdentity: () => undefined, - } - } - return false + sslmode = new URL(connStr).searchParams.get('sslmode') } catch { return false } + if (sslmode === null || sslmode === 'disable') return false + if (sslmode === 'require') return { rejectUnauthorized: false } + if (sslmode === 'verify-full') { + return { rejectUnauthorized: true, ...(sslCaPem ? { ca: sslCaPem } : {}) } + } + if (sslmode === 'verify-ca') { + // verify-ca checks CA trust but skips hostname verification — useful when + // connecting through a proxy or pgbouncer where the hostname doesn't match. + return { + rejectUnauthorized: true, + ...(sslCaPem ? { ca: sslCaPem } : {}), + checkServerIdentity: () => undefined, + } + } + throw new Error( + `Unsupported Postgres sslmode "${sslmode}". Use disable, require, verify-ca, or verify-full.` + ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ce19a9f6..12206b524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,10 +364,6 @@ importers: version: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) packages/openapi: - dependencies: - undici: - specifier: ^7.16.0 - version: 7.24.6 devDependencies: '@types/node': specifier: ^24.5.0