From a5d682d671299bbb212f1b99dfc34c455e76701a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:14:04 +0100 Subject: [PATCH] =?UTF-8?q?Fix=20CORS=20on=20Planner=E2=86=92Journal=20cal?= =?UTF-8?q?lback=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Save to Journal button makes a cross-origin POST from planner.trails.cool to trails.cool. The browser's preflight OPTIONS request was returning HTML (no CORS headers), blocking the save flow. Add CORS headers to all responses and handle OPTIONS preflight in a loader. Origin is restricted to PLANNER_URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/routes/api.routes.$id.callback.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/journal/app/routes/api.routes.$id.callback.ts b/apps/journal/app/routes/api.routes.$id.callback.ts index e656e38..58a94ce 100644 --- a/apps/journal/app/routes/api.routes.$id.callback.ts +++ b/apps/journal/app/routes/api.routes.$id.callback.ts @@ -3,9 +3,27 @@ import type { Route } from "./+types/api.routes.$id.callback"; import { verifyRouteToken } from "~/lib/jwt.server"; import { updateRoute, getRoute } from "~/lib/routes.server"; +const PLANNER_ORIGIN = process.env.PLANNER_URL ?? "http://localhost:3001"; + +function corsHeaders() { + return { + "Access-Control-Allow-Origin": PLANNER_ORIGIN, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +export async function loader({ request }: Route.LoaderArgs) { + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + return data({ error: "Method not allowed" }, { status: 405, headers: corsHeaders() }); +} + export async function action({ params, request }: Route.ActionArgs) { if (request.method !== "POST") { - return data({ error: "Method not allowed" }, { status: 405 }); + return data({ error: "Method not allowed" }, { status: 405, headers: corsHeaders() }); } // Verify JWT token from Authorization header or body @@ -13,7 +31,7 @@ export async function action({ params, request }: Route.ActionArgs) { const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) { - return data({ error: "Missing authorization token" }, { status: 401 }); + return data({ error: "Missing authorization token" }, { status: 401, headers: corsHeaders() }); } try { @@ -21,17 +39,17 @@ export async function action({ params, request }: Route.ActionArgs) { // Verify token is for this route if (routeId !== params.id) { - return data({ error: "Token not valid for this route" }, { status: 403 }); + return data({ error: "Token not valid for this route" }, { status: 403, headers: corsHeaders() }); } if (!permissions.includes("write")) { - return data({ error: "Token does not have write permission" }, { status: 403 }); + return data({ error: "Token does not have write permission" }, { status: 403, headers: corsHeaders() }); } // Get route to verify it exists const route = await getRoute(params.id); if (!route) { - return data({ error: "Route not found" }, { status: 404 }); + return data({ error: "Route not found" }, { status: 404, headers: corsHeaders() }); } // Parse GPX from request body @@ -39,14 +57,14 @@ export async function action({ params, request }: Route.ActionArgs) { const { gpx } = body as { gpx: string }; if (!gpx) { - return data({ error: "Missing GPX data" }, { status: 400 }); + return data({ error: "Missing GPX data" }, { status: 400, headers: corsHeaders() }); } // Update route with new GPX (creates new version) await updateRoute(params.id, route.ownerId, { gpx }); - return data({ success: true, routeId: params.id }); + return data({ success: true, routeId: params.id }, { headers: corsHeaders() }); } catch (e) { - return data({ error: (e as Error).message }, { status: 401 }); + return data({ error: (e as Error).message }, { status: 401, headers: corsHeaders() }); } }