Implement Planner-Journal handoff (Group 9)

JWT-based handoff between Journal and Planner:

Journal side:
- JWT token generation scoped to route_id with 7-day expiry (jose)
- "Edit in Planner" button on route detail page — generates JWT,
  redirects to Planner /new with callback URL, token, and GPX
- Callback endpoint (POST /api/routes/:id/callback) validates JWT
  and creates new route version from received GPX

Planner side:
- /new route accepts callback, token, returnUrl, gpx params
- Creates session with callback metadata, initializes with GPX
- "Save to Journal" button POSTs GPX with Bearer token to callback
- "Return to Journal" link shown after successful save

All 6 Group 9 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 22:33:37 +01:00 committed by GitHub
parent f47eca565c
commit 40e541fcce
12 changed files with 287 additions and 11 deletions

View file

@ -0,0 +1,27 @@
import { SignJWT, jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production",
);
const ISSUER = process.env.ORIGIN ?? "http://localhost:3000";
export async function createRouteToken(routeId: string, permissions: string[] = ["read", "write"]): Promise<string> {
return new SignJWT({ route_id: routeId, permissions })
.setProtectedHeader({ alg: "HS256" })
.setIssuer(ISSUER)
.setExpirationTime("7d")
.setIssuedAt()
.sign(JWT_SECRET);
}
export async function verifyRouteToken(token: string): Promise<{ routeId: string; permissions: string[] }> {
const { payload } = await jwtVerify(token, JWT_SECRET, {
issuer: ISSUER,
});
return {
routeId: payload.route_id as string,
permissions: payload.permissions as string[],
};
}

View file

@ -12,5 +12,6 @@ export default [
route("routes/new", "routes/routes.new.tsx"),
route("routes/:id", "routes/routes.$id.tsx"),
route("routes/:id/edit", "routes/routes.$id.edit.tsx"),
route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"),
route("users/:username", "routes/users.$username.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,52 @@
import { data } from "react-router";
import type { Route } from "./+types/api.routes.$id.callback";
import { verifyRouteToken } from "~/lib/jwt.server";
import { updateRoute, getRoute } from "~/lib/routes.server";
export async function action({ params, request }: Route.ActionArgs) {
if (request.method !== "POST") {
return data({ error: "Method not allowed" }, { status: 405 });
}
// Verify JWT token from Authorization header or body
const authHeader = request.headers.get("Authorization");
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!token) {
return data({ error: "Missing authorization token" }, { status: 401 });
}
try {
const { routeId, permissions } = await verifyRouteToken(token);
// Verify token is for this route
if (routeId !== params.id) {
return data({ error: "Token not valid for this route" }, { status: 403 });
}
if (!permissions.includes("write")) {
return data({ error: "Token does not have write permission" }, { status: 403 });
}
// Get route to verify it exists
const route = await getRoute(params.id);
if (!route) {
return data({ error: "Route not found" }, { status: 404 });
}
// Parse GPX from request body
const body = await request.json();
const { gpx } = body as { gpx: string };
if (!gpx) {
return data({ error: "Missing GPX data" }, { status: 400 });
}
// Update route with new GPX (creates new version)
await updateRoute(params.id, route.ownerId, { gpx });
return data({ success: true, routeId: params.id });
} catch (e) {
return data({ error: (e as Error).message }, { status: 401 });
}
}

View file

@ -2,6 +2,7 @@ import { data, redirect } from "react-router";
import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { createRouteToken } from "~/lib/jwt.server";
export async function loader({ params, request }: Route.LoaderArgs) {
@ -73,6 +74,29 @@ export async function action({ params, request }: Route.ActionArgs) {
});
}
if (intent === "edit-in-planner") {
const route = await getRouteWithVersions(params.id);
if (!route) return data({ error: "Route not found" }, { status: 404 });
const token = await createRouteToken(params.id);
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const callbackUrl = `${origin}/api/routes/${params.id}/callback`;
const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001";
const plannerParams = new URLSearchParams({
callback: callbackUrl,
token,
returnUrl: `${origin}/routes/${params.id}`,
});
// If route has GPX, include it
if (route.gpx) {
plannerParams.set("gpx", encodeURIComponent(route.gpx));
}
return redirect(`${plannerUrl}/new?${plannerParams}`);
}
return data({ error: "Unknown action" }, { status: 400 });
}
@ -95,6 +119,15 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
</div>
{isOwner && (
<div className="flex gap-2">
<form method="post">
<input type="hidden" name="intent" value="edit-in-planner" />
<button
type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Edit in Planner
</button>
</form>
<a
href={`/routes/${route.id}/edit`}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"

View file

@ -23,6 +23,7 @@
"@trails-cool/ui": "workspace:*",
"drizzle-orm": "catalog:",
"isbot": "^5.1.0",
"jose": "^6.2.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"