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>
57 lines
2.1 KiB
TypeScript
57 lines
2.1 KiB
TypeScript
import { data } from "react-router";
|
|
import { getOrigin } from "~/lib/config.server";
|
|
import type { Route } from "./+types/api.routes.$id.edit-in-planner";
|
|
import { getSessionUser } from "~/lib/auth/session.server";
|
|
import { requireOwnedRoute } from "~/lib/ownership.server";
|
|
import { createRouteToken } from "~/lib/jwt.server";
|
|
|
|
export async function action({ params, request }: Route.ActionArgs) {
|
|
const user = await getSessionUser(request);
|
|
if (!user) return data({ error: "Not authenticated" }, { status: 401 });
|
|
|
|
const route = await requireOwnedRoute(params.id, user.id, { notOwnerStatus: 403 });
|
|
|
|
const token = await createRouteToken(params.id);
|
|
const origin = getOrigin();
|
|
const callbackUrl = `${origin}/api/routes/${params.id}/callback`;
|
|
const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001";
|
|
const returnUrl = `${origin}/routes/${params.id}`;
|
|
|
|
// Create Planner session via API (POST body, not URL params)
|
|
const sessionResp = await fetch(`${plannerUrl}/api/sessions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
callbackUrl,
|
|
callbackToken: token,
|
|
gpx: route.gpx ?? undefined,
|
|
}),
|
|
});
|
|
|
|
if (!sessionResp.ok) {
|
|
return data({ error: "Failed to create Planner session" }, { status: 502 });
|
|
}
|
|
|
|
const session = (await sessionResp.json()) as {
|
|
url: string;
|
|
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }>;
|
|
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
|
|
initialNotes?: string;
|
|
};
|
|
|
|
// Encode planning data in URL params
|
|
const urlParams = new URLSearchParams({ returnUrl });
|
|
if (session.initialWaypoints?.length) {
|
|
urlParams.set("waypoints", JSON.stringify(session.initialWaypoints));
|
|
}
|
|
if (session.initialNoGoAreas?.length) {
|
|
urlParams.set("noGoAreas", JSON.stringify(session.initialNoGoAreas));
|
|
}
|
|
if (session.initialNotes) {
|
|
urlParams.set("notes", session.initialNotes);
|
|
}
|
|
|
|
return data({
|
|
url: `${plannerUrl}${session.url}?${urlParams}`,
|
|
});
|
|
}
|