- 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>
238 lines
10 KiB
Markdown
238 lines
10 KiB
Markdown
## 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:
|
|
|
|
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:
|
|
```json
|
|
{
|
|
"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=…][¬es=…]`. 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.description` → `initialNotes` (omitted if absent).
|
|
- If GPX parsing throws, silently ignore and continue with an empty session.
|
|
4. Return HTTP 201:
|
|
```json
|
|
{
|
|
"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 `SessionView` → `SaveToJournalButton`. 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=…¬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 `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
|