trails/apps/journal/app/routes/api.routes.$id.callback.ts
Ullrich Schäfer 7a1dca378f
journal: branded ownership loading for routes and activities
Ownership was checked ad hoc: some handlers loaded-and-compared
ownerId, some lib mutators enforced it in WHERE clauses and silently
no-op'd for non-owners, and nothing tied the two together. Two real
authorization bugs hid in the gaps: linkActivityToRoute ignored its
ownerId parameter entirely (any logged-in user could relink any
activity), and createRouteFromActivity loaded any activity without an
ownership check (a non-owner could copy a private activity's GPX into
their own route). PUT /api/v1/routes/:id also returned ok:true for
non-owners without updating anything.

ownership.server.ts is now the single enforcement point:

- loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with
  their own error vocabulary) and requireOwnedRoute /
  requireOwnedActivity (throwing data() 404/403 for web handlers; 404
  by default so guessed ids don't leak existence)
- the returned entities carry an Owned<> brand; mutators (updateRoute,
  deleteRoute, deleteActivity, updateActivityVisibility,
  linkActivityToRoute, createRouteFromActivity) now require an
  OwnedRef, so skipping the check is a compile error
- vouchOwnership is the explicit, greppable escape hatch for the one
  non-session authorization path (the Planner JWT callback)
- WHERE ownerId clauses stay in the mutators as defense in depth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:35:00 +02:00

77 lines
2.8 KiB
TypeScript

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";
import { vouchOwnership } from "~/lib/ownership.server";
import { GpxValidationError } from "~/lib/gpx-save.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, headers: corsHeaders() });
}
// 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, headers: corsHeaders() });
}
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, headers: corsHeaders() });
}
if (!permissions.includes("write")) {
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, headers: corsHeaders() });
}
// 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, headers: corsHeaders() });
}
// Update route with new GPX (creates new version). Authorization
// here comes from the verified single-use route token, not a
// session — vouchOwnership marks that explicitly.
await updateRoute(vouchOwnership(route), { gpx });
return data({ success: true, routeId: params.id }, { headers: corsHeaders() });
} catch (e) {
if (e instanceof GpxValidationError) {
return data({ error: e.message }, { status: 400, headers: corsHeaders() });
}
return data({ error: (e as Error).message }, { status: 401, headers: corsHeaders() });
}
}