trails/openspec/specs/planner-journal-handoff/spec.md
Ullrich Schäfer 47eb2615ec
Fix high-severity spec drift across 10 specs
- rate-limiting: correct BRouter limit to 300/hour (was 60); add Overpass
  rate-limit requirement (120/min per IP)
- security-hardening: BROUTER_AUTH_TOKEN lives in secrets.app.env, not infra.env
- account-management: email-change verification does not re-auth; existing
  session stays valid
- planner-journal-handoff: full rewrite — documents the actual JWT callback
  architecture (edit-in-planner → POST /api/sessions → callback endpoint),
  token claims, notes round-trip via GPX <metadata><desc>, session lifecycle
- route-drag-reshape: rewrite to describe permanent segment midpoint handles
  (not proximity hover ghost marker); click-to-insert + waypoint drag model
- route-splitting: rewrite to match midpoint handle model; notes geometric
  midpoint placement (not cursor-snapped)
- road-type-coloring: redirect to route-coloring (all requirements already
  covered there)
- osm-tile-overlays: mark profile-aware auto-enable as not yet implemented
  (profileOverlayDefaults exported but not wired)
- osm-poi-overlays: zoom threshold is 10 not 12; user override persistence
  marked as not yet implemented
- shared-packages: add all 7 missing packages (map-core, fit, api, db, jobs,
  sentry-config, correct map description); document map vs map-core boundary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:07:03 +02:00

10 KiB

Purpose

Round-trip GPX exchange between Planner and Journal so a Journal user can open a saved route in the Planner, edit it collaboratively, and save it back as a new version — without the Planner ever holding user credentials.

Actors

  • Journal — authenticated web app; owns routes, issues tokens, hosts the callback endpoint.
  • Planner — stateless collaborative editor; receives initial route data; posts the result back.
  • User's browser — navigates between the two apps via a redirect URL.

Flow overview

Journal (POST /api/routes/:id/edit-in-planner)
  → Planner (POST /api/sessions)          [server-to-server]
  ← { sessionId, url, initialWaypoints, initialNoGoAreas, initialNotes }
  → Browser redirect to /session/:id?waypoints=…&notes=…&returnUrl=…
  → User edits route
  → SaveToJournalButton (POST callbackUrl, Bearer token, { gpx })   [browser-to-Journal]
  ← { success: true }
  → "Return to Journal" link shown

Requirements

Requirement: Journal initiates the handoff

When a Journal user triggers "Edit in Planner", the Journal action (POST /api/routes/:id/edit-in-planner) MUST:

  1. Authenticate the requesting user via the Journal session. Return 401 if unauthenticated.
  2. Load the route and verify the requesting user is the route owner. Return 403 otherwise.
  3. Issue a JWT callback token scoped to that route (see Token section).
  4. Construct callbackUrl = <journalOrigin>/api/routes/:id/callback.
  5. POST to <PLANNER_URL>/api/sessions with JSON body:
    {
      "callbackUrl": "<journalOrigin>/api/routes/:id/callback",
      "callbackToken": "<jwt>",
      "gpx": "<gpx string or omitted if route has no GPX>"
    }
    
  6. Parse the Planner session response to obtain { sessionId, url, initialWaypoints, initialNoGoAreas, initialNotes }.
  7. Build a redirect URL: <PLANNER_URL><session.url>?returnUrl=<journalRouteUrl>[&waypoints=…][&noGoAreas=…][&notes=…]. Only include the optional params when the Planner returned non-empty values for them.
  8. Return { url: "<redirect url>" } to the browser.

The Journal MUST return 502 if the Planner session creation request fails.


Requirement: Planner creates a session

POST /api/sessions MUST:

  1. Accept callbackUrl, callbackToken, and gpx from the JSON body. All three are optional; the Planner can also be used without a Journal connection.
  2. Create a new session row (UUID) in the planner.sessions table, storing callbackUrl and callbackToken verbatim.
  3. If gpx is provided, parse it with @trails-cool/gpx:
    • Extract waypoints → initialWaypoints (omitted if empty).
    • Extract no-go areas → initialNoGoAreas (omitted if empty).
    • Extract gpxData.descriptioninitialNotes (omitted if absent).
    • If GPX parsing throws, silently ignore and continue with an empty session.
  4. Return HTTP 201:
    {
      "sessionId": "<uuid>",
      "url": "/session/<uuid>",
      "initialWaypoints": [],
      "initialNoGoAreas": [],
      "initialNotes": "<string>"
    }
    
    Omit initialWaypoints, initialNoGoAreas, and initialNotes when absent.

Requirement: Planner session page initialises route data

GET /session/:id (server loader) MUST:

  1. Look up the session by ID. Return 404 if not found or already closed.
  2. Expose callbackUrl and callbackToken to the client component (from the session row).

The client component MUST read the following URL search params from the redirect URL the Journal built:

Param Type Purpose
waypoints JSON array Initial waypoint list
noGoAreas JSON array Initial no-go polygon list
notes string Initial route notes
returnUrl string "Return to Journal" link target

On first Yjs sync (synced event), if the Yjs document is empty the client populates:

  • doc.getArray("waypoints") from initialWaypoints
  • doc.getArray("noGoAreas") from initialNoGoAreas
  • doc.getText("notes") from initialNotes

Population is gated on waypoints.length === 0 to avoid duplicating data on reconnect.

The callbackUrl, callbackToken, and returnUrl are passed as props to SessionViewSaveToJournalButton. They are not stored in the Yjs document.


Requirement: Planner saves the route back to the Journal

SaveToJournalButton MUST:

  1. Only render when both callbackUrl and callbackToken are present.
  2. On activation, read the current Yjs state:
    • Waypoints from doc.getArray("waypoints").
    • Computed track geometry (GeoJSON) from doc.getMap("routeData").get("geojson") — this is the BRouter output stored by the routing client.
    • No-go areas from doc.getArray("noGoAreas").
    • Notes text from doc.getText("notes").toString().
  3. Generate a GPX string via generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }).
    • Notes round-trip as the GPX <metadata><desc> element.
    • No-go areas are embedded as GPX extensions on the track.
  4. POST to callbackUrl:
    POST <callbackUrl>
    Authorization: Bearer <callbackToken>
    Content-Type: application/json
    
    { "gpx": "<gpx string>" }
    
  5. On success, show a "saved" confirmation and a "Return to Journal" link targeting returnUrl.
  6. On failure, surface the error message from the response body.

Requirement: Journal callback endpoint writes a new version

POST /api/routes/:id/callback MUST:

  1. Respond to CORS preflight (OPTIONS) with:
    • Access-Control-Allow-Origin: <PLANNER_URL>
    • Access-Control-Allow-Methods: POST, OPTIONS
    • Access-Control-Allow-Headers: Content-Type, Authorization
  2. Require a Bearer <token> in the Authorization header. Return 401 if absent.
  3. Verify the JWT (HS256, issuer = Journal origin). Return 401 on verification failure.
  4. Confirm the token's route_id claim matches :id. Return 403 if not.
  5. Confirm the token's permissions array includes "write". Return 403 if not.
  6. Confirm the route exists. Return 404 if not.
  7. Parse { gpx } from the JSON request body. Return 400 if absent.
  8. Call updateRoute(routeId, ownerId, { gpx }), which creates a new route version. Return 400 if GPX validation fails (GpxValidationError).
  9. Return { success: true, routeId } on success.

All responses include CORS headers so the browser can read them.


Requirement: JWT callback token

Tokens are HS256 JWTs signed with JWT_SECRET (env var; defaults to "dev-jwt-secret-change-in-production" in development).

Claim Value
iss Journal origin URL
iat Issue time
exp Issue time + 7 days
route_id Route ID string
permissions ["read", "write"]

The Planner stores the token opaquely in the planner.sessions table and presents it verbatim in the Authorization: Bearer header on callback. The Planner never inspects the token contents.


Notes flow (round-trip)

Journal route.description
  → GPX <metadata><desc> (via Journal's stored GPX)
  → Planner POST /api/sessions body.gpx
  → parse: gpxData.description → initialNotes
  → URL param ?notes=<string>
  → Yjs doc.getText("notes")
  → generateGpx({ description: notes })
  → GPX <metadata><desc>
  → Journal callback body.gpx
  → updateRoute stores new GPX version

Notes are plain text. No Markdown processing occurs in the handoff layer.


Session lifecycle

Sessions are stored in planner.sessions (PostgreSQL). A session is considered active while closed = false. The session expiry job removes rows older than 7 days (configurable). Closing a session (closed = true) also removes its in-memory Yjs document. There is no automatic session close triggered by saving back to the Journal; the session remains open and the user can continue editing and save again.


Scenarios

Scenario: Full round-trip with notes and no-go areas

  • GIVEN a Journal route with GPX containing waypoints, a computed track, no-go area extensions, and a <metadata><desc> with notes text
  • WHEN the owner clicks "Edit in Planner"
  • THEN the Journal POSTs to /api/sessions with the full GPX
  • AND the Planner parses waypoints, no-go areas, and notes from the GPX
  • AND the browser is redirected to /session/:id?waypoints=…&noGoAreas=…&notes=…&returnUrl=…
  • AND on first sync the Yjs document is populated with all three data types
  • WHEN the user edits the route and clicks "Save to Journal"
  • THEN the Planner generates GPX embedding the current waypoints, computed track, no-go areas, and notes in <metadata><desc>
  • AND POSTs it to callbackUrl with the JWT in Authorization: Bearer
  • AND the Journal verifies the token, writes a new route version, and returns { success: true }
  • AND the "Return to Journal" link is shown

Scenario: Route without GPX

  • GIVEN a Journal route with no stored GPX
  • WHEN the owner clicks "Edit in Planner"
  • THEN the Journal omits the gpx field from the /api/sessions POST body
  • AND the Planner creates an empty session and returns no initialWaypoints, initialNoGoAreas, or initialNotes
  • AND the browser is redirected to /session/:id?returnUrl=… with no planning data params
  • AND the Yjs document starts empty

Scenario: Invalid GPX in session creation

  • GIVEN the Journal sends a malformed GPX string
  • WHEN the Planner's POST /api/sessions handler attempts to parse it
  • THEN the parse error is silently swallowed
  • AND the session is created with no initial planning data
  • AND HTTP 201 is returned as normal

Scenario: Expired or tampered token on callback

  • GIVEN the JWT is expired or the signature is invalid
  • WHEN the Planner POSTs to the callback endpoint
  • THEN verifyRouteToken throws
  • AND the Journal returns HTTP 401 with { error: "<message>" }

Scenario: Reconnect does not duplicate data

  • GIVEN a session that was already populated and has an active Yjs document
  • WHEN the Yjs WebSocket reconnects and fires synced again
  • THEN the initialization guard (waypoints.length === 0 && !initializedWaypoints.current) prevents re-inserting the initial data