- road-type-coloring: add proper ## Purpose/## Requirements structure (redirect file) - planner-journal-handoff: add inline #### Scenario: blocks to each requirement; add SHALL to JWT token requirement - osm-tile-overlays: add SHALL keyword to profile-aware requirement body - shared-packages: add #### Scenario: blocks to ui, api, db, jobs, sentry-config requirements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
12 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=…¬es=…&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:
- Authenticate the requesting user via the Journal session. Return 401 if unauthenticated.
- Load the route and verify the requesting user is the route owner. Return 403 otherwise.
- Issue a JWT callback token scoped to that route (see Token section).
- Construct
callbackUrl = <journalOrigin>/api/routes/:id/callback. - POST to
<PLANNER_URL>/api/sessionswith JSON body:{ "callbackUrl": "<journalOrigin>/api/routes/:id/callback", "callbackToken": "<jwt>", "gpx": "<gpx string or omitted if route has no GPX>" } - Parse the Planner session response to obtain
{ sessionId, url, initialWaypoints, initialNoGoAreas, initialNotes }. - Build a redirect URL:
<PLANNER_URL><session.url>?returnUrl=<journalRouteUrl>[&waypoints=…][&noGoAreas=…][¬es=…]. Only include the optional params when the Planner returned non-empty values for them. - Return
{ url: "<redirect url>" }to the browser.
The Journal MUST return 502 if the Planner session creation request fails.
Scenario: Successful handoff initiation
- WHEN an authenticated route owner clicks "Edit in Planner"
- THEN the Journal POSTs to the Planner's
/api/sessions, receives a session URL, and returns a redirect URL to the browser
Scenario: Unauthenticated user
- WHEN an unauthenticated user triggers the edit-in-planner action
- THEN the Journal returns 401
Requirement: Planner creates a session
POST /api/sessions MUST:
- Accept
callbackUrl,callbackToken, andgpxfrom the JSON body. All three are optional; the Planner can also be used without a Journal connection. - Create a new session row (UUID) in the
planner.sessionstable, storingcallbackUrlandcallbackTokenverbatim. - If
gpxis provided, parse it with@trails-cool/gpx:- Extract waypoints →
initialWaypoints(omitted if empty). - Extract no-go areas →
initialNoGoAreas(omitted if empty). - Extract
gpxData.description→initialNotes(omitted if absent). - If GPX parsing throws, silently ignore and continue with an empty session.
- Extract waypoints →
- Return HTTP 201:
Omit{ "sessionId": "<uuid>", "url": "/session/<uuid>", "initialWaypoints": […], "initialNoGoAreas": […], "initialNotes": "<string>" }initialWaypoints,initialNoGoAreas, andinitialNoteswhen absent.
Scenario: Session created with GPX
- WHEN the Journal POSTs a valid GPX string to
/api/sessions - THEN the Planner parses waypoints, no-go areas, and notes and returns them in the 201 response
Scenario: Invalid GPX silently ignored
- WHEN the Journal POSTs a malformed GPX string
- THEN the Planner creates an empty session and returns HTTP 201 with no initial planning data
Requirement: Planner session page initialises route data
GET /session/:id (server loader) MUST:
- Look up the session by ID. Return 404 if not found or already closed.
- Expose
callbackUrlandcallbackTokento 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")frominitialWaypointsdoc.getArray("noGoAreas")frominitialNoGoAreasdoc.getText("notes")frominitialNotes
Population is gated on waypoints.length === 0 to avoid duplicating data on reconnect.
The callbackUrl, callbackToken, and returnUrl are passed as props to SessionView → SaveToJournalButton. They are not stored in the Yjs document.
Scenario: Session page loads with route data
- WHEN a browser navigates to
/session/:id?waypoints=…¬es=…&returnUrl=… - THEN on first Yjs sync the document is populated with the provided waypoints and notes
Scenario: Reconnect does not duplicate data
- WHEN the Yjs WebSocket reconnects and fires
syncedagain - THEN the initialization guard prevents re-inserting the initial data
Requirement: Planner saves the route back to the Journal
SaveToJournalButton MUST:
- Only render when both
callbackUrlandcallbackTokenare present. - 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().
- Waypoints from
- 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.
- Notes round-trip as the GPX
- POST to
callbackUrl:POST <callbackUrl> Authorization: Bearer <callbackToken> Content-Type: application/json { "gpx": "<gpx string>" } - On success, show a "saved" confirmation and a "Return to Journal" link targeting
returnUrl. - On failure, surface the error message from the response body.
Scenario: Save to Journal
- WHEN a user clicks "Save to Journal"
- THEN the Planner generates GPX from the current Yjs state and POSTs it to the callback URL with the Bearer token
Requirement: Journal callback endpoint writes a new version
POST /api/routes/:id/callback MUST:
- Respond to CORS preflight (
OPTIONS) with:Access-Control-Allow-Origin: <PLANNER_URL>Access-Control-Allow-Methods: POST, OPTIONSAccess-Control-Allow-Headers: Content-Type, Authorization
- Require a
Bearer <token>in theAuthorizationheader. Return 401 if absent. - Verify the JWT (HS256, issuer = Journal origin). Return 401 on verification failure.
- Confirm the token's
route_idclaim matches:id. Return 403 if not. - Confirm the token's
permissionsarray includes"write". Return 403 if not. - Confirm the route exists. Return 404 if not.
- Parse
{ gpx }from the JSON request body. Return 400 if absent. - Call
updateRoute(routeId, ownerId, { gpx }), which creates a new route version. Return 400 if GPX validation fails (GpxValidationError). - Return
{ success: true, routeId }on success.
All responses include CORS headers so the browser can read them.
Scenario: Valid callback saves new version
- WHEN the Planner POSTs a valid GPX with a valid Bearer token to the callback endpoint
- THEN the Journal writes a new route version and returns
{ success: true }
Scenario: Expired or tampered token
- WHEN the JWT is expired or the signature is invalid
- THEN the Journal returns HTTP 401
Requirement: JWT callback token
The Journal SHALL issue 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.
Scenario: Token claims verified on callback
- WHEN the callback endpoint receives a request
- THEN it verifies the HS256 signature, checks
route_idmatches the URL param, and confirmspermissionsincludes"write"
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/sessionswith 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=…¬es=…&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
callbackUrlwith the JWT inAuthorization: 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
gpxfield from the/api/sessionsPOST body - AND the Planner creates an empty session and returns no
initialWaypoints,initialNoGoAreas, orinitialNotes - 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/sessionshandler 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
verifyRouteTokenthrows - 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
syncedagain - THEN the initialization guard (
waypoints.length === 0 && !initializedWaypoints.current) prevents re-inserting the initial data