trails/docs/architecture.md
Ullrich Schäfer 881991ca18
feat(journal): federation protocol doc + delivery observability
Task group 4 of federation-hardening.

4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.

4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
  (delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
  drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
  /api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
  regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.

Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:43 +02:00

893 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# trails.cool - Architecture Plan
## Vision
A federated, self-hostable platform for collaborative route planning and social
activity sharing for outdoor enthusiasts. Two independent but integrated products:
1. **Planner** - Stateless collaborative route editor (like Etherpad for routes)
2. **Journal** - Federated social platform for routes and activities (like Mastodon for trails)
## Project Philosophy
These principles inform key architectural and product decisions:
- **Privacy by design**: The Planner collects no personal data and is minimal
in storage. This is out of respect for BRouter and bikerouter.de which
inspire this project. The Journal is equally mindful — all data collection
is documented in a user-visible, always-up-to-date privacy manifest.
- **Data ownership**: Users own their data. The Journal provides easy export
of all tracks and activities, migration to self-hosted instances, and clear
documentation of the data format for each entity so users can build custom
visualizations and tools around their data.
- **Open source (MIT)**: The code is MIT licensed — both because of the
commitment to the open web, and out of respect for the open-source projects
that inspire trails.cool (BRouter, bikerouter.de, brouter-web, Organic Maps,
Endurain, wanderer).
- **AI-assisted development**: AI (Claude Code) and spec-driven development
(OpenSpec) are core to how this project is built. Human contributions are
very welcome. This is also an experiment in how far AI-assisted development
can bring such a project.
- **Internationalization from day one**: Use react-i18next for all user-facing
strings. Start with English + German, community can contribute more.
### Instance Administration
The Journal supports instance administration:
- Open/close user registration
- User management (suspend/ban users)
- Federation management (block instances)
- Instance-level settings (name, description, rules)
- Moderation tools for reported content
Domain: trails.cool
## Product Separation
### Planner (planner.trails.cool)
- **Stateless**: No user accounts, no persistent user data storage
- **Privacy-first**: Planner never collects or stores user data — reflects the
privacy philosophy of the BRouter ecosystem. No tracking, no analytics on
user routes. Sessions are anonymous by default.
- **Collaborative**: Real-time editing via Yjs CRDTs
- **Session-based**: Shareable links, guests can join without accounts
- **BRouter-powered**: Server-side routing with OSM data
- **Ephemeral**: Sessions expire after configurable period (default 7 days)
- **Invocable**: Journal passes GPX + callback URL to start a session
Data model (Yjs CRDT document):
```typescript
{
waypoints: Y.Array<{lat, lon, name, isDayBreak?: boolean}>,
profile: Y.Map<string, any>,
noGoAreas: Y.Array<Polygon>,
notes: Y.Text,
settings: Y.Map<string, any>
}
// Day breaks: waypoints marked with isDayBreak=true split the route into
// days/stages. Reordering waypoints doesn't break day assignments —
// days are derived from the sequence of day-break markers.
// The entire route is planned as one continuous route. Day breaks are
// just visual/logical split points for multi-day display.
```
Tech stack:
- Frontend: TypeScript + React + Tailwind + React Router 7 + Leaflet + OSM
- CRDT sync: Yjs + y-websocket
- Routing: BRouter (Java) wrapped as HTTP API
- Map data: RD5 segments (~750 MB for Germany, ~3 GB for Europe)
### Journal (trails.cool / self-hostable)
- **Stateful**: User accounts, persistent storage, media
- **Federated**: ActivityPub via Fedify (fedify.dev)
- **Self-hostable**: Docker Compose deployment
- **Mastodon-compatible**: Activities visible in Mastodon, likes/comments federate back
Features:
- Route CRUD (create, read, update, delete)
- Activity import (from Garmin, Strava, Wahoo via GPX/FIT upload)
- Activity export (to Garmin, Strava, Wahoo — later phase)
- Social: Following, likes, comments
- Photo sharing (as part of activities only, not standalone)
- Multi-day routes (bikepacking trips)
- Route versioning (sequential, with Yjs-based conflict resolution)
- GPX import/export
- Routes as blueprints: one route can have many linked activities
Tech stack:
- Frontend: TypeScript + React + Tailwind + React Router 7 + Leaflet + OSM
- Backend: React Router 7 (Remix stack) + Fedify for ActivityPub
- Database: PostgreSQL + PostGIS (spatial queries for route discovery)
- Media: S3-compatible (Garage for self-hosters — https://garagehq.deuxfleurs.fr/)
- Auth: Federated identity (@user@instance.com)
## Architecture Diagram
```
+-----------------------------------------------------+
| trails.cool (Flagship Instance) |
| |
| +------------------+ +-------------------+ |
| | Planner | | Journal | |
| | (Stateless) |<---->| (Stateful) | |
| | | | | |
| | - Yjs sync | | - PostgreSQL | |
| | - BRouter API | | - S3 media | |
| | - Ephemeral | | - ActivityPub | |
| +------------------+ +-------------------+ |
| ^ ^ |
| | | |
| +----------+----------------+ |
| | Auth/Identity |
| | (for initiated sessions) |
+--------------------+---------------------------------+
|
| ActivityPub Federation
|
+--------------------v---------------------------------+
| bob.trails.xyz (Self-hosted) |
| |
| +------------------+ +-------------------+ |
| | (No Planner) | | Journal | |
| | Uses |<---->| (Same codebase) | |
| | trails.cool | | | |
| | planner | | - PostgreSQL + | |
| +------------------+ | PostGIS | |
| | - Garage (S3) | |
| +-------------------+ |
+------------------------------------------------------+
```
## Data Flow: Collaborative Route Editing
### Scenario 1: Alice creates a new route
1. Alice opens Journal, clicks "New Route"
2. Journal creates route record, opens Planner with callback URL
3. Planner creates Yjs session, Alice edits waypoints
4. BRouter computes route from waypoints
5. Alice clicks "Save" -> Planner sends GPX to callback URL
6. Journal stores GPX as route v1
### Scenario 2: Alice invites Bob to collaborate
1. Alice shares Planner session link with Bob
2. Bob joins session (no account needed for planning)
3. Both edit waypoints in real-time via Yjs
4. One user acts as "routing host" (talks to BRouter API)
5. Route updates are broadcast to all session participants
6. When done, GPX is saved back to Alice's Journal instance
### Scenario 3: Bob (self-hosted) edits Alice's shared route
1. Bob's Journal receives Alice's shared route via ActivityPub
2. Bob clicks "Edit" -> Bob's instance opens trails.cool Planner
3. Planner loads latest GPX from Alice's instance
4. Bob edits, saves -> New version stored on Alice's instance
5. Update federates via ActivityPub
### Scenario 4: Cross-instance collaboration
1. Alice (trails.cool) starts planning session from her route
2. Federation notifies Bob (bob.trails.xyz) that a shared session is open
3. Bob joins Planner session via link
4. Both edit in real-time
5. Save stores GPX back to Alice's instance (route owner)
6. Bob's instance caches updated route via ActivityPub
## ActivityPub Integration
The concrete wire protocol — actor discovery, object/activity types with
JSON examples, addressing, signatures, deduplication, delivery retry, and
moderation — is documented in [FEDERATION.md](../FEDERATION.md) at the repo
root. This section is the higher-level design intent.
### Federated Activities
- `Create` Route - Publishing a new route
- `Update` Route - Updating an existing route
- `Create` Activity - Completed ride/hike with GPS trace
- `Like` Activity - Liking someone's activity
- `Create` Note (on Activity) - Commenting
- `Follow` / `Accept` - Following other users
### Mastodon Compatibility
Completed activities appear as posts with:
- Text description ✅ shipped (`social-federation`: HTML content + stats
+ PropertyValue metadata, dereferenceable Note objects)
- Map preview image (auto-generated) — captured in
`docs/ideas/fediverse-enhancements.md`
- Link to full view on trails.cool (or the self hosted instance) ✅ shipped
- Photo attachments — with `activity-photos`
- GPX as attachment — needs a public GPX endpoint for activities first
(see `fediverse-enhancements.md`)
Mastodon users can:
- See activities in their timeline ✅ shipped (verified live 2026-06-07)
- Like activities (federates back) — captured as "fediverse kudos" in
`fediverse-enhancements.md`; v1's narrow inbox drops Like/Announce
by design
- Comment on activities (federates back) — captured in
`fediverse-enhancements.md`; gated on moderation tooling
(`docs/ideas/instance-administration.md`)
Route-specific federation (collaboration invites, version updates) uses
custom ActivityPub extensions not visible in Mastodon — spec'd as
`openspec/changes/route-federation/`.
Interop lessons from the first live soak (2026-06-06/07, recorded in
`openspec/changes/social-federation/design.md`): Mastodon requires
`attachment` arrays (bare objects are silently ignored), records
received `Delete`s as permanent tombstones, gives inbox deliveries a
10-second timeout, and never backfills outbox history — every new
outgoing shape must be verified against a real instance, not the spec.
## Route Sharing & Permissions
### Visibility Levels
- **Private**: Only owner can see the route
- **Public**: Anyone can view the route (readonly)
- **Shared**: Specific invited users can view the route
### Permission Matrix
| Action | Owner | Shared (view) | Shared (edit) | Public | Guest (via link) |
|--------|-------|---------------|---------------|--------|-----------------|
| View route | yes | yes | yes | yes | no |
| Export GPX | yes | yes | yes | yes | no |
| Start edit session | yes | no | yes | no | no |
| Join edit session | yes | no | yes | no | yes* |
| Delete route | yes | no | no | no | no |
| Change permissions | yes | no | no | no | no |
| Fork route (copy) | yes | yes | yes | yes | no |
Notes:
- "Shared (edit)" users are explicitly granted edit rights by the owner
- There is no "public editable" — edit access requires explicit invitation
- *Owners can always share a direct link to an active edit session that
anyone (including guests) can join — this bypasses the permission matrix
for joining that specific session only
- Guests (no account) can join Planner sessions via link, but cannot
save routes to a Journal instance
- Forking creates an independent copy on the user's own instance
## Activity Sharing & Participants
When multiple people do the same activity together (e.g., a group ride),
they can tag each other as participants:
- Activity creator can **tag other users** as participants
- Tagged users receive a notification and can confirm/decline
- Confirmed participants see the activity linked in their own profile
- Each participant can attach their own GPS trace and photos to the
shared activity (their recording may differ slightly)
- ActivityPub: Participant tagging federates across instances
(Alice on trails.cool tags Bob on bob.trails.xyz)
- Mastodon: Appears as mentions (`"Rode with @bob@bob.trails.xyz"`)
This is similar to how photos work on social media — you can be tagged
in someone else's activity, and it shows on your profile too.
## Multi-Day Route Support
### Route Planning (Planner)
Routes are planned as **one continuous route**. Day splits are marked by
flagging specific waypoints as day-break points:
- The entire route is planned end-to-end in a single Planner session
- Specific waypoints are marked as `isDayBreak: true` (e.g., overnight stops)
- Days are derived from the sequence of day-break markers
- Reordering waypoints automatically recalculates day assignments
- Each day/stage gets its own distance and elevation stats
- GPX export uses track segments per day
### Activities (Journal)
Multi-day trips are modeled as an **Activity Collection**:
- A multi-day activity is a collection linking individual day-activities
- Each day-activity has its own GPS trace, photos, and description
- The collection references the planned route as a whole
- This allows tracking a 5-day bikepacking trip as one entity while
recording each day separately (possibly from different devices/apps)
- Individual day-activities can be imported from Garmin/Strava per day
Note: Exact data model for collections TBD — needs more design work.
## Import/Export
### Import Sources
- GPX files (manual upload)
- FIT files (Garmin devices)
- External tools (bikerouter.de, Komoot, etc.)
- Activity platforms (Strava, Garmin Connect, Wahoo) - OAuth or file upload
### Export Formats
- GPX (primary)
- GeoJSON
- KML
## Self-Hosting
### Minimal Docker Compose (Journal only)
```yaml
services:
journal:
image: ghcr.io/trails-cool/journal:latest
ports: ["3000:3000"]
environment:
DOMAIN: bob.trails.xyz
PLANNER_URL: https://planner.trails.cool
DATABASE_URL: postgres://trails:trails@postgres/trails
S3_ENDPOINT: http://garage:3900
S3_BUCKET: trails-media
depends_on: [postgres, garage]
postgres:
image: postgis/postgis:16-3.4
volumes: ["pgdata:/var/lib/postgresql/data"]
garage:
image: dxflrs/garage:v1.0
volumes: ["media:/var/lib/garage"]
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
depends_on: [journal]
volumes:
pgdata:
media:
caddy_data:
```
Caddy is the reverse proxy for all instances. It provides automatic HTTPS
via Let's Encrypt with zero configuration beyond the Caddyfile:
```
# Caddyfile (self-hosted example)
bob.trails.xyz {
reverse_proxy journal:3000
}
```
### With Self-Hosted Planner (Advanced)
```yaml
services:
# ... above services plus:
planner:
image: ghcr.io/trails-cool/planner:latest
ports: ["3001:3001"]
environment:
BROUTER_URL: http://brouter:17777
depends_on: [brouter]
brouter:
image: ghcr.io/trails-cool/brouter:latest
volumes:
- segments:/data/segments
# Segments can be pulled from:
# - https://brouter.de/brouter/segments4/ (official, weekly updates)
# - A trails.cool CDN mirror (later)
ports: ["17777:17777"]
volumes:
segments: # Download RD5 files here (~750 MB Germany, ~3 GB Europe)
```
## Infrastructure (trails.cool flagship)
### Hosting: Hetzner (Cloud + Robot)
trails.cool runs on two hosts in the same Falkenstein datacenter,
bridged via a Hetzner vSwitch (VLAN 4000) to a private network:
- **Flagship** — Hetzner Cloud cx23 (2 vCPU, 4 GB RAM, 40 GB SSD).
Runs Journal, Planner, Postgres+PostGIS, Caddy, Prometheus, Loki,
Grafana, and exporters. vSwitch IP `10.0.0.2`.
- **BRouter host** — Hetzner Dedicated (operator-owned shared box,
currently `ullrich.is`; 3 TB RAID, 32 GB RAM). Runs only BRouter +
a Caddy auth sidecar + scoped cAdvisor/Promtail sidecars in a
`~trails/brouter/` compose project under a non-root `trails` user.
vSwitch IP `10.0.1.10`. BRouter covers the full planet
(~10 GB RD5 tiles) with an 8 GB JVM heap.
Planner → BRouter traffic crosses the vSwitch; a shared-secret
`X-BRouter-Auth` header enforced by the Caddy sidecar prevents any
other process on the dedicated host from reaching BRouter even if
they share the private network.
BRouter container metrics and logs are scraped/shipped from the
dedicated host to the flagship's Prometheus and Loki over the same
vSwitch. Filtering (cAdvisor `--whitelisted_container_labels`, Promtail
relabel-drop) keeps trails.cool observability scoped to trails
containers only — none of the operator's other workloads on the
shared host are ingested.
- Storage Box: 1 TB for backups - ~3.20 EUR/month
- Infrastructure as Code: Terraform (Hetzner Cloud provider); Hetzner
Robot side (dedicated server) is operator-managed
- Docker Compose for runtime orchestration on both hosts
- CI/CD: GitHub Actions — three workflows (`cd-apps`, `cd-infra`,
`cd-brouter`) with different SSH targets and deploy users
- Monitoring: Grafana + Prometheus + Loki on flagship, scraping both
hosts
- Error tracking: Sentry
### Services
```
planner.trails.cool -> Planner frontend + Yjs sync + BRouter
trails.cool -> Journal frontend + API + ActivityPub endpoints
cdn.trails.cool -> Media + map tiles (optional, future)
```
ActivityPub lives on the main domain per Resolved Decision #18 (single
domain per instance) — there is no `api.trails.cool`. Staging and PR
previews additionally live under `*.staging.trails.cool` (see
CLAUDE.md "Staging & Previews").
### Estimated Costs (100 users)
- Hetzner cx23: ~6 EUR/month
- Storage: 3.20 EUR/month
- Domain: ~10 EUR/year
- Backups: ~2 EUR/month
- Total: ~12 EUR/month
## GitHub Repository Structure
GitHub organization: **github.com/trails-cool**
### Monorepo vs Multi-Repo
**Recommendation: Monorepo** (single repo for planner + Journal)
Pros:
- **Shared components**: React component library, TypeScript types, and utilities
shared between Planner and Journal without publishing npm packages
- **Atomic changes**: A change to the Route interface updates both apps in one PR
- **Simpler CI/CD**: One pipeline builds and deploys both apps
- **Claude Code / OpenSpec**: Specs and AI-assisted development work best when
the full context is in one repo
- **Tooling**: Turborepo/Nx handles monorepo builds, caching, and dependency graph
Cons:
- Larger repo size (mitigated by sparse checkouts)
- Self-hosters who only want Journal need to build from monorepo
(mitigated by publishing Docker images)
### Proposed Structure
```
github.com/trails-cool/trails
apps/
planner/ - Planner app (React Router 7)
journal/ - Journal app (React Router 7 + Fedify)
packages/
ui/ - Shared React components (map, buttons, layout)
types/ - Shared TypeScript types (Route, Activity, etc.)
map/ - Map rendering utilities (Leaflet wrappers, tile layers)
gpx/ - GPX parsing, generation, validation
i18n/ - Shared i18n config + translation strings (react-i18next)
infrastructure/ - Terraform + Docker Compose
openspec/ - OpenSpec specifications + changes
docker/
brouter/ - BRouter Docker image + segment management
docs/ - Documentation
```
Tooling: **Turborepo** for monorepo management, **pnpm** workspaces.
OpenSpec specs live in the `openspec/` directory, feeding into both apps.
This keeps specifications close to implementation and allows Claude Code
to reference specs when working on either app.
## MVP Phasing
Note: Detailed specifications for each phase will be created using
[OpenSpec](https://openspec.dev/) and stored in the `openspec/` directory
of the monorepo. This architecture plan feeds into OpenSpec as the
high-level context for generating implementation specs.
### Phase 1: Foundation (Weeks 1-8) — ✅ shipped
**Planner MVP**:
- [x] Collaborative waypoint editing (Yjs)
- [x] BRouter integration (route computation)
- [x] Map display (Leaflet + OSM overlays)
- [x] Session sharing (shareable link)
- [x] Profile selection (bike/hike)
- [x] Elevation profile display
- [x] GPX export
**Journal MVP**:
- [x] User accounts (local, no federation)
- [x] Route CRUD
- [x] Start Planner session from route (callback integration)
- [x] GPX import/export
- [x] Basic profile page
- [x] Activity feed (own activities)
### Phase 2: Social & Federation (Months 3-6) — in progress
- [x] ActivityPub federation — `social-federation` (inbound follows +
activity push delivery live on staging since 2026-06-07;
trails-to-trails outbound §6/§7 remaining)
- [x] Following/followers (`social-feed`)
- [ ] Likes and comments — `docs/ideas/social-interactions.md` (local)
+ `fediverse-enhancements.md` (federated)
- [x] Activity import (GPX upload, Komoot, Wahoo; Strava/Garmin OAuth
and FIT *import* still open)
- [ ] Photo attachments on activities — `activity-photos` change drafted
- [x] Mastodon compatibility (follow + timeline posts verified live)
- [ ] Route sharing permissions — `route-sharing` change drafted
- [x] Route versioning
- [ ] Route federation (mirroring, cross-instance edits) —
`route-federation` change drafted
### Phase 3: Scale & Mobile (Months 6-12)
- [ ] Mobile app — `mobile-app` change in progress
- [ ] Offline route editing (WASM + cached segments) — not yet captured
beyond this line
- [ ] Multi-day route planning — routes ✅ shipped; activity collections:
`docs/ideas/multi-day-collections.md`
- [ ] Route recommendations — distinct from `route-discovery` (spatial
explore); not yet captured beyond this line
- [ ] Clubs/groups — not yet captured beyond this line
- [ ] CDN for map segments (mobile offline) — not yet captured beyond
this line
## Resolved Decisions
### 1. Route Versioning: Sequential + Yjs Conflict Resolution
Sequential version numbers (v1, v2, v3). When two users edit the same route
concurrently via separate Planner sessions, conflicts are resolved using Yjs
CRDT merge semantics:
- The Planner session stores the full Yjs document state (waypoints, settings)
- When saving back to the Journal, the GPX + Yjs state vector are stored
- If a second session started from an older version, the Yjs state vectors
can be merged automatically (CRDTs are conflict-free by design)
- The merged result becomes the next sequential version
- Edge case: If edits are made via raw GPX upload (not through Planner),
no Yjs state exists — last-write-wins with the previous version archived
### 2. Route Mirroring: Accept-Based Collaboration Mirroring
Collaborator instances mirror routes they've been invited to:
1. Alice shares route with Bob → ActivityPub `Invite` activity
2. Bob's instance receives invite → shows in Bob's UI
3. Bob accepts → ActivityPub `Accept` activity
4. Alice's instance adds Bob as collaborator → sends current GPX + metadata
5. Bob's instance stores a mirror copy (read cache)
6. On route `Update`, Alice's instance sends updated GPX to all collaborators
7. If Alice's instance is down, Bob still has his cached copy
Canonical source is always the owner's instance. Mirrors are read caches
that stay in sync via ActivityPub `Update` activities.
### 3. Cross-Instance Edits: Edit on Planner, Store to Owner via Callback
When Bob edits Alice's route:
1. Bob's Journal opens Planner with Alice's GPX
2. Planner session callback points to Alice's instance API
3. On save, Planner POSTs new GPX to Alice's instance (with auth token)
4. Alice's instance creates new version, credits Bob as contributor
5. Update federates to all collaborators (including Bob's mirror)
This keeps the owner's instance as single source of truth. Bob never stores
a "draft" on his own instance — edits go directly to the canonical source.
### 4. Planner Session Lifetime
- Sessions are created on demand (from Journal or direct link)
- Session lifetime is **configurable** (default: 7 days, max: 30 days)
- Session state is saved server-side (Yjs document in PostgreSQL)
- Manual save triggers callback to Journal (stores GPX as new version)
- Session owner can **manually close** the session (notifies all participants)
- Abandoned sessions are garbage-collected after expiry
- Browser localStorage keeps a backup of unsaved Yjs state for crash recovery
- Resource usage to be observed and session defaults tuned accordingly
### 5. Frontend Framework: React + Tailwind + React Router 7
- **React** with **Tailwind CSS** for styling
- **React Router 7** (Remix stack) for both Planner and Journal
- Shared component library between Planner and Journal
- Leaflet for map rendering with OSM tiles
### 6. Journal Backend: React Router 7 + Fedify
- **React Router 7** (Remix stack) — full-stack TypeScript framework
- Server-side rendering for SEO and initial load
- API routes for ActivityPub endpoints
- Loader/action pattern for data fetching
- **Fedify** (fedify.dev) for ActivityPub protocol support
- Handles WebFinger, HTTP Signatures, inbox/outbox
- TypeScript-native, integrates well with the stack
- Avoids reimplementing ActivityPub from scratch
### 7. Database: PostgreSQL (with PostGIS)
PostgreSQL for all deployments (flagship and self-hosted). PostGIS is valuable
for trails.cool because:
- **Spatial queries**: "Find routes near me" or "routes in this bounding box"
- **Route geometry storage**: Store route linestrings as PostGIS geometries
- **Distance calculations**: "Routes within 50km of Berlin"
- **Spatial indexing**: Fast lookups for map-based route browsing
- **Overlap detection**: "Routes similar to this one"
For self-hosters, PostGIS is included in the standard `postgis/postgis` Docker
image — no extra setup needed.
Note: SQLite option dropped for simplicity. PostGIS is too valuable, and
PostgreSQL via Docker is easy enough for self-hosters.
### 8. Route Format: GPX + Metadata Envelope
GPX alone is insufficient. Routes are stored as a **metadata envelope** wrapping
the GPX:
```typescript
interface Route {
id: string;
name: string;
description: string;
gpx: string; // Canonical GPX (track + waypoints)
geometry: PostGIS.LineString; // Extracted for spatial queries
metadata: {
created: Date;
updated: Date;
owner: string; // ActivityPub actor URI
contributors: string[]; // ActivityPub actor URIs
routingProfile: string; // 'trekking', 'mtb', 'car', etc.
dayBreaks: number[]; // Waypoint indices that are day-break points
distance: number; // meters (computed from GPX)
elevation: {
gain: number; // meters
loss: number; // meters
};
tags: string[]; // user-defined tags
};
plannerState?: Uint8Array; // Yjs document state for conflict resolution
versions: RouteVersion[];
}
interface RouteVersion {
version: number;
gpx: string;
createdAt: Date;
createdBy: string; // ActivityPub actor URI
changeDescription?: string;
}
```
**Why GPX is not enough**:
- No routing preferences (bike vs car, avoid highways)
- No contributor metadata
- No version history
- No multi-day structure (GPX tracks can have segments, but no day labels)
- No spatial indexing (need PostGIS geometry extracted from GPX)
**GPX remains the interchange format**: Import/export always uses GPX.
The metadata envelope is internal storage only.
When federating via ActivityPub, routes are sent as:
- GPX attachment (for interoperability)
- JSON-LD metadata (for rich display in trails.cool instances)
### 9. Route-to-Activity Relationship: One Route, Many Activities
A Route is a **blueprint**. An Activity is a **completed instance**.
```
Route: "Sunday Black Forest Loop" (60km, 800m elevation)
├── Activity: Alice rode it on March 15 (with GPS trace, photos)
├── Activity: Alice rode it on March 22 (different weather, faster time)
└── Activity: Bob rode it on March 20 (imported from Strava)
```
Database model:
```
routes 1 ──── N activities
id id
name route_id (nullable — activities can exist without a route)
gpx actual_gpx (recorded GPS trace, differs from planned route)
... started_at
duration
photos[]
description
```
Activities can also exist **without** a route (e.g., imported from Strava with
no pre-planned route). Users can retroactively link an activity to a route,
or create a new route from an activity's GPS trace.
### 10. Routing Host: Session Initiator + Automatic Failover
The "routing host" is the client responsible for sending waypoint changes to
BRouter and broadcasting the computed route to other participants.
- **Initial host**: The user who created/started the Planner session
- **Failover**: If host disconnects, the Yjs awareness protocol detects it
and the longest-connected remaining client becomes the new host
- **Implementation**: Yjs awareness state includes a `role` field
```typescript
awareness.setLocalStateField('role', 'host' | 'participant');
// On host disconnect, participants compare join timestamps
// Lowest timestamp becomes new host
```
- **Why not everyone**: Sending every waypoint change from every client to
BRouter would cause redundant API calls and race conditions
- **Route broadcast**: Host computes route, stores result in a Y.Map field
that syncs to all participants automatically
### 11. Yjs Persistence: PostgreSQL
Store Yjs documents in PostgreSQL (not LevelDB). This keeps the Planner's
session state in the same database infrastructure, simplifies backups, and
allows querying session metadata (last activity, participant count) for
garbage collection.
The Planner service uses its own PostgreSQL schema (`planner.*`) separate
from the Journal schema (`journal.*`). On the trails.cool flagship,
both schemas live in the same PostgreSQL instance. Self-hosters who don't
run a Planner don't need the planner schema.
### 12. Cross-Instance Auth: Scoped JWT Tokens
When Alice's Journal opens a Planner session for a route:
1. Alice's instance generates a **scoped JWT token** containing:
- `iss`: Alice's instance URL (`https://trails.cool`)
- `route_id`: The route being edited
- `permissions`: `["read", "write"]`
- `exp`: Token expiry (matches session lifetime)
2. This token is passed to the Planner as part of the callback URL
3. When the Planner saves, it includes the JWT in the callback POST
4. Alice's instance validates the JWT signature and stores the new version
For cross-instance edits (Bob editing Alice's route):
- Bob's instance requests a scoped token from Alice's instance
- Alice's instance verifies Bob has edit rights, issues a token
- This uses **HTTP Signatures** (already part of ActivityPub) for
instance-to-instance trust, plus JWT for the Planner callback
### 13. Rate Limiting
For the public trails.cool Planner instance:
- **Session creation**: Max 10 sessions per IP per hour
- **BRouter API calls**: Max 60 route computations per session per hour
(debouncing on the client should keep this well under limit)
- **Concurrent sessions**: Max 50 active sessions per IP
- **Abuse detection**: Flag sessions with unusual patterns
(automated bulk routing, scraping)
- **Implementation**: Rate limiting middleware in React Router 7,
backed by Redis or in-memory store (for single-server setup)
Self-hosted Planner instances can configure their own limits.
### 14. Map Tiles & Overlays
Base layers:
- **OpenStreetMap** (default)
- **OpenTopoMap** (topographic — great for hiking)
- **CyclOSM** (cycling-focused — great for bike routes)
Overlays (toggleable):
- **OpenCampingMap** (campsites, shelters — essential for bikepacking)
- **POI overlay**: Water points, shelters, bike repair stations, etc. Served
from the instance's own `planner.pois` index via `/api/pois` — a PostGIS
table built monthly from an OSM extract (osmium filter on the BRouter host →
guarded atomic import on the flagship). No third-party POI service is
contacted; viewport coordinates never leave trails.cool. See the
`poi-index` change and `infrastructure/brouter-host/poi-extract/`.
- **Waymarked Trails** (hiking/cycling trail networks)
Implementation: Leaflet layer switcher with tile URLs. No API keys needed
for OSM-based tiles (but respect usage policies and consider setting up
a tile cache/proxy for the flagship instance).
### 15. Monitoring & Observability
For trails.cool flagship instance only (not required for self-hosters):
- **Metrics**: Prometheus (scrape Node.js and PostgreSQL exporters)
- **Dashboards**: Grafana (route computation latency, active sessions,
federation delivery, PostgreSQL performance)
- **Logging**: Loki (structured JSON logs from all services)
- **Alerting**: Grafana Alertmanager (disk space, memory, error rate)
- **Error tracking**: Sentry (frontend + backend exceptions)
Stack: Grafana + Prometheus + Loki (the "GPL stack"), self-hosted on
the same Hetzner server or a separate small instance.
PostgreSQL monitoring is especially important:
- Query performance (slow queries, index usage)
- PostGIS spatial query latency
- Connection pool utilization
- Replication lag (if we scale later)
### 16. Federation Delivery & Retry
When Alice sends an update and Bob's instance is down:
- ActivityPub uses **HTTP POST** to deliver activities to inboxes
- If delivery fails (timeout, 5xx), Fedify implements **automatic retry**
with exponential backoff:
- Retry after 1 min, 5 min, 30 min, 2 hours, 12 hours, 24 hours
- Give up after 72 hours of failures
- This is standard ActivityPub behavior (same as Mastodon)
- Bob's instance receives the update when it comes back online
- If the update is lost (instance down > 72 hours), Bob's mirror becomes
stale — a periodic "sync check" can detect and heal this
### 17. Planner Session Management (Updated)
- Session lifetime: **Configurable** (default: 7 days, max: 30 days)
- Start conservative — observe resource usage and tune
- Yjs document size in PostgreSQL is small (~10-100 KB per session)
- Main cost is WebSocket connections for active sessions
- **Manual close**: Session owner (initiator) can close the session
- Closing notifies all connected participants
- Triggers auto-save callback if Journal session
- Closed sessions become read-only briefly, then deleted
- **Garbage collection**: Cron job removes expired sessions
### 18. Domain: Single Domain per Instance
Each instance uses a single domain. No split between web app URL and
ActivityPub handle domain. Self-hosters set `DOMAIN=bob.trails.xyz` and
both the web UI and user handles (`@user@bob.trails.xyz`) use that domain.
Simpler to set up, simpler to reason about, avoids WebFinger complexity.
### 19. Route Permissions: View + Edit (Simple)
Two permission levels are sufficient:
- **View**: Can see the route and export GPX
- **Edit**: Can start/join edit sessions and create new versions
No finer-grained permissions (e.g., "edit waypoints but not profile").
Keep it simple. Can revisit if users request it.
## Remaining Open Questions
1. **Multi-day activity collections**: Exact data model for linking
day-activities into a multi-day trip collection — exploration started
in `docs/ideas/multi-day-collections.md`
2. ~~**brouter-web dependencies**: Review https://github.com/nrenner/brouter-web
for proven library choices~~ — resolved in practice: the Planner
shipped on Leaflet + its own elevation/coloring implementations
(see `road-type-coloring`, `elevation-map-interaction` specs)
## Where the rest of this vision is tracked
Everything in this document that isn't shipped lives in one of:
- `openspec/changes/` — drafted, buildable changes (`route-federation`
for Decisions #2/#3/#12/#16 and Scenarios 34; `route-sharing`,
`activity-photos`, `route-discovery`, `mobile-app`, …)
- `docs/ideas/` — pre-spec explorations (`instance-administration`,
`social-interactions`, `activity-participants`,
`multi-day-collections`, `fediverse-enhancements`, …)
- `docs/roadmap.md` — what ships when, and why