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:
parent
f47eca565c
commit
40e541fcce
12 changed files with 287 additions and 11 deletions
27
apps/journal/app/lib/jwt.server.ts
Normal file
27
apps/journal/app/lib/jwt.server.ts
Normal 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[],
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
52
apps/journal/app/routes/api.routes.$id.callback.ts
Normal file
52
apps/journal/app/routes/api.routes.$id.callback.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue