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>
893 lines
35 KiB
Markdown
893 lines
35 KiB
Markdown
# 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 3–4; `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
|