trails/docs/architecture.md
Ullrich Schäfer f58fe99cde
Add GitHub Actions CI, wire ESLint, lock in Caddy
CI pipeline with parallel jobs for fast feedback:
- typecheck, lint, unit tests, build run in parallel
- e2e tests run after build
- Playwright artifacts uploaded on failure

ESLint wired into both apps with sensible defaults:
- underscore-prefixed unused vars allowed
- root package.json marked as type: module

Architecture updated: Caddy as reverse proxy with auto HTTPS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:55:12 +01:00

30 KiB

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).
  • 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):

{
  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

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
  • Map preview image (auto-generated)
  • Link to full view on trails.cool (or the self hosted instance)
  • Photo attachments
  • GPX as attachment

Mastodon users can:

  • See activities in their timeline
  • Like activities (federates back)
  • Comment on activities (federates back)

Route-specific federation (collaboration invites, version updates) uses custom ActivityPub extensions not visible in Mastodon.

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)

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)

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

  • Server: CX21 (2 vCPU, 4 GB RAM, 40 GB SSD) - ~5 EUR/month
  • Storage Box: 1 TB for RD5 segments + media - ~3.20 EUR/month
  • Infrastructure as Code: Terraform (Hetzner provider) + Docker Compose
  • CI/CD: GitHub Actions
  • Monitoring: Grafana + Prometheus + Loki (flagship only)
  • Error tracking: Sentry

Services

planner.trails.cool    -> Planner frontend + Yjs sync + BRouter
trails.cool            -> Journal frontend + API
api.trails.cool        -> ActivityPub endpoints
cdn.trails.cool        -> Media + map tiles (optional)

Estimated Costs (100 users)

  • Hetzner CX21: 5 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
  specs/                - OpenSpec specifications
  docker/
    brouter/            - BRouter Docker image + segment management
  docs/                 - Documentation

Tooling: Turborepo for monorepo management, pnpm workspaces.

OpenSpec specs live in specs/ 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 and stored in the specs/ 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)

Planner MVP:

  • Collaborative waypoint editing (Yjs)
  • BRouter integration (route computation)
  • Map display (Leaflet + OSM overlays)
  • Session sharing (shareable link)
  • Profile selection (bike/hike)
  • Elevation profile display
  • GPX export

Journal MVP:

  • User accounts (local, no federation)
  • Route CRUD
  • Start Planner session from route (callback integration)
  • GPX import/export
  • Basic profile page
  • Activity feed (own activities)

Phase 2: Social & Federation (Months 3-6)

  • ActivityPub federation
  • Following/followers
  • Likes and comments
  • Activity import (Strava/Garmin GPX/FIT upload)
  • Photo attachments on activities
  • Mastodon compatibility
  • Route sharing permissions
  • Route versioning

Phase 3: Scale & Mobile (Months 6-12)

  • Mobile app (Capacitor or native)
  • Offline route editing (WASM + cached segments)
  • Multi-day route planning
  • Route recommendations
  • Clubs/groups
  • CDN for map segments (mobile offline)

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:

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
    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 (activity.*). 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 (sourced from OSM Overpass API or pre-cached)
  • 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
  2. brouter-web dependencies: Review https://github.com/nrenner/brouter-web for proven library choices (map rendering, elevation charts, etc.)