fix(planner): keep journal callback token off the client (#2 Phase A)

The Save-to-Journal flow had the browser fetch the journal with a
\`Bearer \${callbackToken}\` header. The JWT was visible in DevTools,
exfiltratable via any XSS or browser extension, and the planner's
\`loader\` shipped it down to the client as part of the page payload.

Now:

- **New action**: \`POST /api/save-to-journal\` (\`routes/api.save-to-journal.ts\`).
  Body: \`{ sessionId, gpx }\`. The action loads \`callbackUrl\` +
  \`callbackToken\` from \`planner.sessions\` (set at /new time when the
  user came from the journal), POSTs to the journal server-to-server
  with the Bearer, and forwards the response.

- **\`SaveToJournalButton\`**: drops the \`callbackUrl\` + \`callbackToken\`
  props. Takes \`sessionId\` only and POSTs to the planner action.

- **\`session.\$id.tsx\` loader**: stops returning \`callbackUrl\` /
  \`callbackToken\` to the client. Returns a single \`hasJournalCallback\`
  boolean so the button still knows whether to render.

- **\`SessionView\`**: same prop simplification.

Trust model is unchanged: the same \`sessionId\` that grants Yjs
membership grants save authority. Knowing the URL = ability to act.
The action only adds a server-side hop so the JWT never reaches
browser JS.

Phase B (jti single-use enforcement on the journal side) follows in
a separate PR — needs a journal DB column + verifier change.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-26 00:29:24 +02:00
parent 3dcc17152b
commit 0917de6080
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 101 additions and 21 deletions

View file

@ -148,15 +148,20 @@ function SidebarTabs({ yjs, routeStats, days, onWaypointHover, onWaypointSelect
interface SessionViewProps {
sessionId: string;
callbackUrl?: string;
callbackToken?: string;
/**
* True when the session was created with a journal callback URL +
* token (i.e. the user came in from /journal/.../edit-in-planner).
* The actual URL + token live server-side; the browser only needs
* to know whether to render the Save-to-Journal button.
*/
hasJournalCallback?: boolean;
returnUrl?: string;
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>;
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
initialNotes?: string;
}
export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) {
export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) {
const { t } = useTranslation("planner");
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes);
@ -253,11 +258,10 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{callbackUrl && callbackToken && (
{hasJournalCallback && (
<SaveToJournalButton
yjs={yjs}
callbackUrl={callbackUrl}
callbackToken={callbackToken}
sessionId={sessionId}
returnUrl={returnUrl}
/>
)}