From 01e285223286e248ac077fcb1d86e1ebd8e7da91 Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Tue, 6 Oct 2026 17:30:02 +0200 Subject: [PATCH 1/3] fix(landing): pin Polar API version to 2026-10 on checkout Polar asked integrations to lock an API version with the Polar-Version header; without it requests float to whatever version is Current, which changes every quarterly release. @polar-sh/astro (latest 0.7.6) can't set headers and sits on the unversioned SDK, so replace it with @polar-sh/sdk@1 and call checkouts.create through the versioned client from @polar-sh/sdk/2026-10, which sends Polar-Version: 2026-10 on every request. Behavior of /api/checkout is unchanged (400 without products, 302 to the Polar checkout, 500 on failure). Adds unit tests covering the header, request body, redirect and error paths. Co-Authored-By: Claude Sonnet 5.5 --- landing/package.json | 2 +- landing/src/pages/api/checkout.ts | 37 ++++++++++-- landing/test/checkout.test.ts | 98 +++++++++++++++++++++++++++++++ pnpm-lock.yaml | 53 +++-------------- 4 files changed, 138 insertions(+), 52 deletions(-) create mode 100644 landing/test/checkout.test.ts diff --git a/landing/package.json b/landing/package.json index fd11730b5..b2ff6a21e 100644 --- a/landing/package.json +++ b/landing/package.json @@ -15,7 +15,7 @@ "dependencies": { "@astrojs/mdx": "^8.0.2", "@astrojs/vercel": "^11.0.9", - "@polar-sh/astro": "^0.7.6", + "@polar-sh/sdk": "^1.0.2", "astro": "^7.3.5", "shepherd.js": "workspace:*" }, diff --git a/landing/src/pages/api/checkout.ts b/landing/src/pages/api/checkout.ts index b3335570f..683c84976 100644 --- a/landing/src/pages/api/checkout.ts +++ b/landing/src/pages/api/checkout.ts @@ -1,8 +1,35 @@ -import { Checkout } from '@polar-sh/astro'; +import { createPolar } from '@polar-sh/sdk/2026-10'; +import type { APIRoute } from 'astro'; export const prerender = false; -export const GET = Checkout({ - accessToken: import.meta.env.POLAR_ACCESS_TOKEN ?? '', - successUrl: 'https://docs.shepherdjs.dev' -}); +// The checkout id placeholder is substituted by Polar after payment. +const SUCCESS_URL = 'https://docs.shepherdjs.dev/?checkoutId={CHECKOUT_ID}'; + +export const GET: APIRoute = async ({ url }) => { + const products = url.searchParams.getAll('products'); + + if (products.length === 0) { + return Response.json( + { error: 'Missing products in query params' }, + { status: 400 } + ); + } + + try { + // Importing from the versioned subpath pins `Polar-Version: 2026-10` on every + // request. To move to a newer API version, change the import path above. + const polar = createPolar({ + accessToken: import.meta.env.POLAR_ACCESS_TOKEN ?? '' + }); + const checkout = await polar.checkouts.create({ + products, + success_url: SUCCESS_URL + }); + + return Response.redirect(checkout.url); + } catch (error) { + console.error(error); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/landing/test/checkout.test.ts b/landing/test/checkout.test.ts new file mode 100644 index 000000000..64a0a859c --- /dev/null +++ b/landing/test/checkout.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '../src/pages/api/checkout'; + +type Context = Parameters[0]; + +const callGet = (search: string) => + GET({ + url: new URL(`http://localhost/api/checkout${search}`) + } as Context) as Promise; + +const polarCheckoutResponse = () => + new Response(JSON.stringify({ url: 'https://polar.sh/checkout/abc' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + +describe('GET /api/checkout', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => polarCheckoutResponse()); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('POLAR_ACCESS_TOKEN', 'test-token'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('creates a checkout with a pinned Polar API version and redirects to it', async () => { + const response = await callGet('?products=prod_123'); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe( + 'https://polar.sh/checkout/abc' + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, init] = fetchMock.mock.calls[0] as [ + string | URL, + RequestInit + ]; + expect(String(requestUrl)).toContain('https://api.polar.sh/v1/checkouts/'); + + const headers = new Headers(init.headers); + expect(headers.get('Polar-Version')).toBe('2026-10'); + expect(headers.get('Authorization')).toBe('Bearer test-token'); + + const body = JSON.parse(init.body as string); + expect(body.products).toEqual(['prod_123']); + expect(body.success_url).toBe( + 'https://docs.shepherdjs.dev/?checkoutId={CHECKOUT_ID}' + ); + }); + + it('passes through multiple products', async () => { + await callGet('?products=a&products=b'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string | URL, RequestInit]; + expect(JSON.parse(init.body as string).products).toEqual(['a', 'b']); + }); + + it('returns 400 without calling Polar when products are missing', async () => { + const response = await callGet(''); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Missing products in query params' + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 500 when Polar responds with an error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.mockResolvedValue( + new Response('{"detail":"boom"}', { status: 500 }) + ); + + const response = await callGet('?products=prod_123'); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Internal server error' }); + }); + + it('returns 500 when the request to Polar fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.mockRejectedValue(new Error('network down')); + + const response = await callGet('?products=prod_123'); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Internal server error' }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5745843db..c796a6ad9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,9 +109,9 @@ importers: '@astrojs/vercel': specifier: ^11.0.9 version: 11.0.9(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(encoding@0.1.13)(react@19.2.8)(rollup@4.63.5)(supports-color@10.2.2)(ws@8.21.3) - '@polar-sh/astro': - specifier: ^0.7.6 - version: 0.7.6(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)) + '@polar-sh/sdk': + specifier: ^1.0.2 + version: 1.0.2 astro: specifier: ^7.3.5 version: 7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) @@ -2309,17 +2309,9 @@ packages: resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} engines: {node: '>=12'} - '@polar-sh/adapter-utils@0.4.6': - resolution: {integrity: sha512-F2HsGmRdhczxyFk7bmTZR81gBf/Hj5XfOGe/6S5aGWeJ1YftcWEK8Of+aZijxXv82xktEn7usS+7tfKb4YXghQ==} - - '@polar-sh/astro@0.7.6': - resolution: {integrity: sha512-egMU8LPDhrKbUxJBpgx4DiqupWqGLtim5ZfepX228l7QiwLEeyNxMKD5rBLFmGWHN6UiAmgkoh9A74KhOHda7w==} - engines: {node: '>=16'} - peerDependencies: - astro: ^5.0.0 - - '@polar-sh/sdk@0.47.1': - resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==} + '@polar-sh/sdk@1.0.2': + resolution: {integrity: sha512-c3jdqYloVfcejKsAqYfYcW4bG7KkXj5bXkgdsdx4GHlJPzHFV5tysfeZGf9V7dJReLBirAluchsuHWiAIBco8w==} + engines: {node: '>=22'} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2812,9 +2804,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@stablelib/base64@1.0.1': - resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4438,9 +4427,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-sha256@1.3.0: - resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -7206,9 +7192,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - standardwebhooks@1.0.0: - resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} - starlight-typedoc@0.23.1: resolution: {integrity: sha512-H45Mxv4mZ1xUMhUNjTR4CwKDgKKHZboQA5v5+vTVMdjlCX02Xo7veTB7+F/ivmMHPmQ7SV5auF2BTlU/EpQIoQ==} engines: {node: '>=22.12.0'} @@ -11040,20 +11023,7 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@polar-sh/adapter-utils@0.4.6': - dependencies: - '@polar-sh/sdk': 0.47.1 - - '@polar-sh/astro@0.7.6(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))': - dependencies: - '@polar-sh/adapter-utils': 0.4.6 - '@polar-sh/sdk': 0.47.1 - astro: 7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) - - '@polar-sh/sdk@0.47.1': - dependencies: - standardwebhooks: 1.0.0 - zod: 4.4.3 + '@polar-sh/sdk@1.0.2': {} '@polka/url@1.0.0-next.29': {} @@ -11409,8 +11379,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@stablelib/base64@1.0.1': {} - '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.3.3': @@ -13488,8 +13456,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-sha256@1.3.0: {} - fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -16677,11 +16643,6 @@ snapshots: stackback@0.0.2: {} - standardwebhooks@1.0.0: - dependencies: - '@stablelib/base64': 1.0.1 - fast-sha256: 1.3.0 - starlight-typedoc@0.23.1(@astrojs/starlight@0.42.4(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(supports-color@10.2.2)(typescript@5.9.3))(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(typedoc-plugin-markdown@4.13.1(typedoc@0.28.20(typescript@5.9.3)))(typedoc@0.28.20(typescript@5.9.3)): dependencies: '@astrojs/starlight': 0.42.4(astro@7.3.5(@types/node@26.2.0)(@vercel/functions@3.9.1(ws@8.21.3))(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(supports-color@10.2.2)(typescript@5.9.3) From f0c4ed361e3d3479e10c0a4f1bd201a6dbe7388b Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Tue, 6 Oct 2026 17:43:18 +0200 Subject: [PATCH 2/3] fix(landing): reject empty product ids on checkout `?products=` makes getAll('products') return [''], which passed the length check and sent an empty product id to Polar, surfacing as a 500. Return the same 400 for blank ids and cover it in the tests. Co-Authored-By: Claude Sonnet 5.5 --- landing/src/pages/api/checkout.ts | 2 +- landing/test/checkout.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/landing/src/pages/api/checkout.ts b/landing/src/pages/api/checkout.ts index 683c84976..eb76b362e 100644 --- a/landing/src/pages/api/checkout.ts +++ b/landing/src/pages/api/checkout.ts @@ -9,7 +9,7 @@ const SUCCESS_URL = 'https://docs.shepherdjs.dev/?checkoutId={CHECKOUT_ID}'; export const GET: APIRoute = async ({ url }) => { const products = url.searchParams.getAll('products'); - if (products.length === 0) { + if (products.length === 0 || products.some((id) => id.trim() === '')) { return Response.json( { error: 'Missing products in query params' }, { status: 400 } diff --git a/landing/test/checkout.test.ts b/landing/test/checkout.test.ts index 64a0a859c..1d087863a 100644 --- a/landing/test/checkout.test.ts +++ b/landing/test/checkout.test.ts @@ -74,6 +74,19 @@ describe('GET /api/checkout', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it.each(['?products=', '?products=%20', '?products=a&products='])( + 'returns 400 without calling Polar when a product id is empty (%s)', + async (search) => { + const response = await callGet(search); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Missing products in query params' + }); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + it('returns 500 when Polar responds with an error', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); fetchMock.mockResolvedValue( From 3dbd48a41a784d0cf1071a63ac3d0485777c9693 Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Tue, 6 Oct 2026 17:49:03 +0200 Subject: [PATCH 3/3] fix(landing): build shepherd.js as part of the landing build landing imports shepherd.js, whose dist is only produced by the root `prepare` script. That script runs only when `pnpm install` actually changes something, so a Vercel build with an unchanged lockfile (the install logs "Already up to date") skips it and `astro check` then fails with "Cannot find module 'shepherd.js'". Every earlier landing deploy happened to change the lockfile. Build shepherd.js explicitly before `astro check` so the build no longer depends on that side effect. Co-Authored-By: Claude Sonnet 5.5 --- landing/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/package.json b/landing/package.json index b2ff6a21e..3d3968414 100644 --- a/landing/package.json +++ b/landing/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro check && astro build", + "build": "pnpm -F shepherd.js build && astro check && astro build", "preview": "astro preview", "astro": "astro", "test": "vitest run",