From 187d060640fe806c852bf5dae925d50810c943a9 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 01:40:57 -0400 Subject: [PATCH 1/5] fix: proxy API through Cloudflare Pages Function to fix iOS Safari guest sessions iOS Safari's ITP blocks Set-Cookie headers from cross-origin fetch() requests, even with SameSite=None; Secure, causing guest session tokens to never be stored. Adds a catch-all Pages Function that proxies /api/* to the backend API_URL so all requests are same-origin and cookies are treated as first-party. --- packages/web/functions/api/[[path]].ts | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/web/functions/api/[[path]].ts diff --git a/packages/web/functions/api/[[path]].ts b/packages/web/functions/api/[[path]].ts new file mode 100644 index 0000000..48cbc3e --- /dev/null +++ b/packages/web/functions/api/[[path]].ts @@ -0,0 +1,65 @@ +// 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 + const resHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) { + resHeaders.append(key, value); + } + }); + + return new Response(upstream.body, { + status: upstream.status, + headers: resHeaders, + }); +}; From 44ebc79c294d47349ce55e905b2e85cbaedb4a20 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 01:44:19 -0400 Subject: [PATCH 2/5] fix: update deploy workflow to proxy API through Pages Function --- .github/workflows/production.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f7c9ed0..0143ffd 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -110,7 +110,7 @@ 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: Deploy to Cloudflare Pages @@ -118,4 +118,6 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy packages/web/dist --project-name=tabby --branch=main + command: pages deploy packages/web/dist --project-name=tabby --branch=main --functions-dir=packages/web/functions + env: + API_URL: ${{ secrets.PROD_API_GATEWAY_URL }} From 0c81e9eeb5a4bb5065147897a7035d0844b7e8f3 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 01:54:41 -0400 Subject: [PATCH 3/5] fix: use workingDirectory for wrangler to pick up functions dir --- .github/workflows/production.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 0143ffd..8aa8b8e 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -118,6 +118,7 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy packages/web/dist --project-name=tabby --branch=main --functions-dir=packages/web/functions + workingDirectory: packages/web + command: pages deploy dist --project-name=tabby --branch=main env: API_URL: ${{ secrets.PROD_API_GATEWAY_URL }} From b71abac0c8462d18f01bb8dffa982b909ece1924 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 13:11:43 -0400 Subject: [PATCH 4/5] fix: use SameSite=Lax for session cookies to fix iOS Chrome guest sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SameSite=None was causing iOS WebKit (used by all browsers on iPhone, including Chrome) to drop session cookies due to ITP restrictions, even for first-party cookies set via fetch() responses. Since all API calls now go through the same-origin Cloudflare Pages Function proxy, there is no need for SameSite=None — SameSite=Lax works correctly for same-site requests and is fully trusted by iOS WebKit. Also fixes the Pages Function proxy to use getAll('set-cookie') instead of forEach() when forwarding Set-Cookie headers, preventing the Cloudflare Workers runtime from collapsing multiple cookies into a single comma-joined string. --- packages/api/src/routes/auth.ts | 4 ++-- packages/api/src/routes/groups.ts | 2 +- packages/api/src/routes/members.ts | 2 +- packages/web/functions/api/[[path]].ts | 18 ++++++++++++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) 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 index 48cbc3e..c891329 100644 --- a/packages/web/functions/api/[[path]].ts +++ b/packages/web/functions/api/[[path]].ts @@ -50,16 +50,30 @@ export const onRequest: (context: { request: Request; env: Env; params: Record { + if (key.toLowerCase() === 'set-cookie') return; if (!HOP_BY_HOP.has(key.toLowerCase())) { resHeaders.append(key, value); } }); - return new Response(upstream.body, { + 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; }; From d9068a3856ac5a3f01a906e9f0dad63c33d78985 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Thu, 16 Apr 2026 13:19:53 -0400 Subject: [PATCH 5/5] fix: set Pages Function API_URL via Cloudflare API before deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env: key in the wrangler-action step only sets shell environment variables for the wrangler process — it does not configure Cloudflare Pages Function runtime environment variables. Added an explicit PATCH call to the Cloudflare Pages API to set API_URL before deploying, so the proxy function correctly resolves the upstream API Gateway URL. --- .github/workflows/production.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 8aa8b8e..635947c 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -113,6 +113,25 @@ jobs: 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: @@ -120,5 +139,3 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: packages/web command: pages deploy dist --project-name=tabby --branch=main - env: - API_URL: ${{ secrets.PROD_API_GATEWAY_URL }}