diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f7c9ed0..635947c 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -110,12 +110,32 @@ jobs: - name: Build React PWA run: npm run build --workspace=packages/web env: - VITE_API_URL: ${{ secrets.PROD_API_GATEWAY_URL }} + VITE_API_URL: "" VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + - name: Set Pages Function environment variables + run: | + curl -s -X PATCH \ + "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/tabby" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{ + "deployment_configs": { + "production": { + "env_vars": { + "API_URL": { + "value": "${{ secrets.PROD_API_GATEWAY_URL }}", + "type": "plain_text" + } + } + } + } + }' | jq -e '.success' + - name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3.14.1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy packages/web/dist --project-name=tabby --branch=main + workingDirectory: packages/web + command: pages deploy dist --project-name=tabby --branch=main diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index a58fc91..68fa304 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -25,7 +25,7 @@ export async function authRoutes(fastify: FastifyInstance) { reply.setCookie('oauth_state', JSON.stringify({ state, redirect: redirect ?? '/' }), { httpOnly: true, path: '/', - sameSite: process.env['NODE_ENV'] === 'prod' ? 'none' : 'lax', + sameSite: 'lax', secure: process.env['NODE_ENV'] === 'prod', maxAge: 600, // 10 minutes }); @@ -170,7 +170,7 @@ export async function authRoutes(fastify: FastifyInstance) { reply.setCookie(SESSION_COOKIE, sessionToken, { httpOnly: true, path: '/', - sameSite: process.env['NODE_ENV'] === 'prod' ? 'none' : 'lax', + sameSite: 'lax', secure: process.env['NODE_ENV'] === 'prod', maxAge: 60 * 60 * 24 * 30, // 30 days }); diff --git a/packages/api/src/routes/groups.ts b/packages/api/src/routes/groups.ts index 0c96e51..8e35e24 100644 --- a/packages/api/src/routes/groups.ts +++ b/packages/api/src/routes/groups.ts @@ -64,7 +64,7 @@ export async function groupRoutes(fastify: FastifyInstance) { reply.setCookie(SESSION_COOKIE, sessionToken, { httpOnly: true, path: '/', - sameSite: process.env['NODE_ENV'] === 'prod' ? 'none' : 'lax', + sameSite: 'lax', secure: process.env['NODE_ENV'] === 'prod', maxAge: 60 * 60 * 24 * 30, }); diff --git a/packages/api/src/routes/members.ts b/packages/api/src/routes/members.ts index 14bcd6c..fb96eed 100644 --- a/packages/api/src/routes/members.ts +++ b/packages/api/src/routes/members.ts @@ -101,7 +101,7 @@ export async function memberRoutes(fastify: FastifyInstance) { reply.setCookie(SESSION_COOKIE, sessionToken, { httpOnly: true, path: '/', - sameSite: process.env['NODE_ENV'] === 'prod' ? 'none' : 'lax', + sameSite: 'lax', secure: process.env['NODE_ENV'] === 'prod', maxAge: 60 * 60 * 24 * 30, // 30 days }); diff --git a/packages/web/functions/api/[[path]].ts b/packages/web/functions/api/[[path]].ts new file mode 100644 index 0000000..c891329 --- /dev/null +++ b/packages/web/functions/api/[[path]].ts @@ -0,0 +1,79 @@ +// Cloudflare Pages Function: proxy all /api/* requests to the backend API. +// This keeps everything on the same origin (tabby.pages.dev) so session cookies +// work on iOS Safari, which blocks cross-site cookies from fetch() requests. +// +// Required env var in Cloudflare Pages dashboard: API_URL (e.g. https://tabby-api.onrender.com) + +interface Env { + API_URL: string; +} + +// Headers that must not be forwarded to/from the upstream server +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', +]); + +export const onRequest: (context: { request: Request; env: Env; params: Record }) => Promise = async (context) => { + const { request, env } = context; + const apiBase = env.API_URL?.replace(/\/$/, ''); + + if (!apiBase) { + return new Response(JSON.stringify({ error: 'API_URL not configured' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(request.url); + const target = new URL(url.pathname + url.search, apiBase); + + // Forward request headers, stripping hop-by-hop + const reqHeaders = new Headers(); + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) { + reqHeaders.set(key, value); + } + }); + + const hasBody = !['GET', 'HEAD'].includes(request.method); + const upstream = await fetch(target.toString(), { + method: request.method, + headers: reqHeaders, + ...(hasBody && { body: request.body }), + redirect: 'manual', + }); + + // Forward response headers, stripping hop-by-hop. + // Set-Cookie must be handled separately via getAll() because forEach() in the + // Cloudflare Workers Headers implementation collapses multiple Set-Cookie values + // into a single comma-joined string, which breaks cookie parsing. + const resHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (key.toLowerCase() === 'set-cookie') return; + if (!HOP_BY_HOP.has(key.toLowerCase())) { + resHeaders.append(key, value); + } + }); + + const response = new Response(upstream.body, { + status: upstream.status, + headers: resHeaders, + }); + + // Append each Set-Cookie individually to preserve separate header entries. + // getAll() is a Cloudflare Workers extension to the Headers API that returns + // an array of values for a given header name, preserving duplicates. + const cookies = (upstream.headers as unknown as { getAll(name: string): string[] }).getAll('set-cookie'); + for (const cookie of cookies) { + response.headers.append('set-cookie', cookie); + } + + return response; +};