Most contributors don't need HTTPS=1 locally — plain HTTP is the
default and the right choice for everything except Wahoo OAuth
callback testing. WebAuthn (passkeys), magic links, sessions, the
Terms gate, SSE all work over HTTP because the WebAuthn spec treats
localhost as a secure context regardless of scheme. CI proves the
point: the e2e suite runs over plain HTTP and passes cleanly.
The original HTTPS=1 plumbing landed in 20b91ef (2026-04-05) bundled
into a Wahoo import fix, with no inline rationale. Once you've
forgotten the reason it tends to leak into the default workflow,
which then breaks the local e2e suite (Playwright always uses HTTP
baseURL; an https ORIGIN env mismatches what it sends) and creates
unnecessary divergence from CI.
Documenting the single legitimate use case so the next contributor
(or future-me) doesn't have to re-derive it from git blame:
- apps/journal/vite.config.ts — expanded the comment near the
basic-ssl plugin to spell out:
* What works on HTTP (everything except Wahoo)
* What needs HTTPS=1 (Wahoo OAuth specifically)
* The norm: don't add new HTTPS-only paths without writing them
down here too, so the assumption stays auditable
- apps/journal/.env.example — new tracked file showing the env vars
the journal app reads, with `ORIGIN=https://localhost:3000`
marked as HTTPS-only and accompanied by an explanation of the
ORIGIN/Playwright mismatch trap. Future contributors don't have
to discover this through a failing e2e run.
- CLAUDE.md — new "Local HTTPS dev (rare)" subsection under
Development Commands. Includes the exact command for Wahoo testing
(`HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter
@trails-cool/journal dev`), the turbo-doesn't-pass-HTTPS gotcha,
and the rule of thumb: don't set ORIGIN in your .env unless you
also always run with HTTPS=1.
No code/behavior changes; documentation only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
11 KiB
Markdown
225 lines
11 KiB
Markdown
# CLAUDE.md
|
|
|
|
## Project Overview
|
|
|
|
trails.cool is a federated, self-hostable platform for outdoor enthusiasts with two apps:
|
|
|
|
- **Planner** (`apps/planner`) — Stateless collaborative route editor. Real-time editing via Yjs, routing via BRouter, no user accounts, sessions are anonymous and ephemeral.
|
|
- **Journal** (`apps/journal`) — Federated social platform for routes and activities. User accounts, ActivityPub federation via Fedify, PostgreSQL + PostGIS.
|
|
|
|
Full architecture: `docs/architecture.md`
|
|
Philosophy: `docs/philosophy.md`
|
|
OpenSpec change: `openspec/changes/phase-1-mvp/`
|
|
|
|
## Principles
|
|
|
|
- **Privacy-first**: The Planner collects zero user data. The Journal documents all data collection in a privacy manifest. Never add tracking, analytics, or data collection without updating the manifest.
|
|
- **Data ownership**: All user data must be exportable in open formats (GPX, JSON). Never create data lock-in.
|
|
- **Simplicity**: Start with the simplest thing that works. Don't add abstractions, config options, or features unless real users need them.
|
|
- **Open standards**: Use GPX, ActivityPub, OpenStreetMap, WebFinger. Don't invent proprietary formats.
|
|
- **Inclusive language**: Use "host" not "master", "allowlist" not "whitelist", etc.
|
|
|
|
## Tech Stack
|
|
|
|
- **Language**: TypeScript (strict mode)
|
|
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
|
|
- **Maps**: Leaflet + OpenStreetMap tiles
|
|
- **CRDT**: Yjs + y-websocket (Planner only)
|
|
- **Federation**: Fedify (Journal only, Phase 2)
|
|
- **Database**: PostgreSQL + PostGIS
|
|
- **Media storage**: S3-compatible (Garage)
|
|
- **Routing engine**: BRouter (Java, runs as separate Docker container)
|
|
- **i18n**: react-i18next (English + German)
|
|
- **Monorepo**: pnpm workspaces + Turborepo
|
|
|
|
## Repository Structure
|
|
|
|
```
|
|
apps/
|
|
planner/ — Planner app (React Router 7)
|
|
journal/ — Journal app (React Router 7 + Fedify)
|
|
packages/
|
|
types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
|
|
ui/ — Shared React components (Tailwind)
|
|
map/ — Leaflet map wrappers and tile layer configs
|
|
gpx/ — GPX parsing, generation, validation
|
|
i18n/ — react-i18next config + translations
|
|
infrastructure/ — Terraform + Docker Compose
|
|
openspec/ — OpenSpec specs and changes
|
|
docs/ — Architecture, philosophy, tooling docs
|
|
docker/brouter/ — BRouter Docker image
|
|
```
|
|
|
|
## Development Commands
|
|
|
|
```bash
|
|
pnpm install # Install dependencies
|
|
pnpm dev # Start both apps in dev mode
|
|
pnpm build # Build all packages and apps
|
|
pnpm typecheck # Type-check all packages
|
|
pnpm lint # Lint all packages
|
|
pnpm test # Run unit tests (vitest)
|
|
pnpm test:watch # Run unit tests in watch mode
|
|
pnpm test:e2e # Run E2E tests (playwright, requires dev servers)
|
|
pnpm test:e2e:ui # Run E2E tests with Playwright UI
|
|
pnpm dev:full # Start full stack (Docker + DB + BRouter + apps)
|
|
pnpm dev:services # Start Docker services only (PostgreSQL + BRouter)
|
|
pnpm db:push # Push Drizzle schema to local PostgreSQL
|
|
pnpm db:studio # Open Drizzle Studio (DB browser)
|
|
```
|
|
|
|
### Local HTTPS dev (rare — most contributors never need this)
|
|
|
|
The default dev loop runs the journal on plain HTTP at
|
|
`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the
|
|
Terms gate, SSE — everything works over HTTP because the WebAuthn spec
|
|
treats `localhost` as a secure context regardless of scheme. CI's e2e
|
|
suite runs over plain HTTP too.
|
|
|
|
There is exactly one feature that requires local HTTPS: **Wahoo OAuth**.
|
|
Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the
|
|
`/api/sync/connect/wahoo` callback flow can only complete against an
|
|
HTTPS dev server. To run that flow:
|
|
|
|
```bash
|
|
HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev
|
|
```
|
|
|
|
`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN
|
|
HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the
|
|
WebAuthn server expect the HTTPS origin (set this together with HTTPS=1
|
|
or you'll get origin-mismatch errors). Use `pnpm --filter` (not
|
|
`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to
|
|
its `globalPassThroughEnv` — `pnpm --filter` bypasses turbo entirely.
|
|
|
|
**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env`
|
|
unless you intend to always run with `HTTPS=1`.** Mismatched values
|
|
break the e2e suite and generic dev. See
|
|
`apps/journal/.env.example` for what each var means.
|
|
|
|
If you find yourself wanting `HTTPS=1` for any reason other than Wahoo
|
|
testing, write it down here so the assumption stays auditable —
|
|
"everything but Wahoo works over HTTP locally" is what keeps CI and
|
|
local config symmetric.
|
|
|
|
## Testing Strategy
|
|
|
|
- **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic.
|
|
Place test files next to source: `foo.ts` → `foo.test.ts`.
|
|
Uses `@testing-library/react` for component tests.
|
|
- **E2E tests** (Playwright): For browser behavior across both apps.
|
|
Tests live in `e2e/` at repo root. Scoped per app via `testMatch` in `playwright.config.ts`.
|
|
Playwright auto-starts dev servers if not already running.
|
|
|
|
**Important**: Write tests alongside implementation, not as an afterthought. When implementing a package or utility, add a co-located `*.test.ts` file. When implementing a user-facing feature, add or update E2E tests. Run `pnpm test` and `pnpm test:e2e` before committing.
|
|
|
|
## Code Conventions
|
|
|
|
- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build.
|
|
- All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings)
|
|
- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
|
|
- Map components go in `@trails-cool/map`, not in individual apps
|
|
- GPX parsing/generation goes in `@trails-cool/gpx`
|
|
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
|
|
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
|
|
|
|
## Key Architecture Decisions
|
|
|
|
- **Planner is stateless**: No user accounts, no persistent user data. Sessions are anonymous.
|
|
- **Journal is the source of truth**: Routes live on the owner's Journal instance.
|
|
- **Routing host pattern**: One client per Planner session talks to BRouter (elected via Yjs awareness).
|
|
- **JWT callbacks**: Planner saves back to Journal via scoped JWT tokens in callback URLs.
|
|
- **Sequential versioning**: Route versions are v1, v2, v3. Yjs state vectors enable conflict-free merging.
|
|
- **Single domain**: Each instance uses one domain for both web UI and ActivityPub handles.
|
|
- **Simple permissions**: View + Edit only. No fine-grained permissions.
|
|
|
|
## Git Workflow
|
|
|
|
**All changes go through pull requests.** Do not push directly to main.
|
|
|
|
### Before opening a PR
|
|
1. Run `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` — all must pass
|
|
2. Check for open PRs: `gh pr list` — avoid conflicts with in-flight work
|
|
3. Pull latest main: `git pull origin main --rebase`
|
|
|
|
### Opening a PR
|
|
- Create a feature branch: `git checkout -b <descriptive-name>`
|
|
- Keep PRs focused — one logical change per PR
|
|
- Use the merge queue to auto-merge when CI passes:
|
|
```bash
|
|
gh pr create --title "..." --body "..."
|
|
gh pr merge --merge --auto
|
|
```
|
|
|
|
### Stacking PRs (for fast local iteration)
|
|
When working on sequential tasks, stack branches locally:
|
|
```
|
|
main → feature-a → feature-b → feature-c
|
|
```
|
|
- Each branch gets its own PR
|
|
- Set the base branch correctly: `gh pr create --base feature-a`
|
|
- When feature-a merges, rebase feature-b onto main
|
|
- This keeps you unblocked while PRs are in CI
|
|
|
|
### Important: Do not push to a branch after its PR merges via merge queue
|
|
Once a PR enters the merge queue it will merge as soon as CI passes. If you
|
|
push additional commits to the branch after that, those commits are orphaned
|
|
— they won't be on main. Always check `gh pr view <number> --json state`
|
|
before pushing to an existing PR branch.
|
|
|
|
### After merging
|
|
- Update main: `git checkout main && git pull`
|
|
- Delete merged branches: `git branch -d <branch>`
|
|
- Check if stacked PRs need rebasing
|
|
|
|
### Emergency override
|
|
Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a hotfix). Document the reason in the commit message.
|
|
|
|
## Deployment
|
|
|
|
Three separate CD workflows triggered by path:
|
|
|
|
| Workflow | Triggers on | Deploys | Target |
|
|
|----------|-------------|---------|--------|
|
|
| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) |
|
|
| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) |
|
|
| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) |
|
|
|
|
### Hosts
|
|
|
|
trails.cool runs on two Hetzner boxes in the same Falkenstein datacenter:
|
|
|
|
- **Flagship** — Hetzner Cloud `cx23`, public IP + vSwitch IP `10.0.0.2`. Runs Journal, Planner, Postgres, Caddy, Prometheus, Loki, Grafana.
|
|
- **BRouter host** — Hetzner Dedicated `ullrich.is`, public IP `176.9.150.227` + vSwitch IP `10.0.1.10`. Shared self-hosted box; trails.cool owns only a non-root `trails` user with docker-group rights, scoped to `~trails/brouter/`. SSH is on port **2232**.
|
|
|
|
The two hosts are bridged via Hetzner vSwitch #80672 (VLAN 4000). Planner → BRouter traffic crosses it; BRouter → Loki traffic (for log shipping) crosses it back.
|
|
|
|
### Secrets
|
|
All secrets are SOPS-encrypted: `infrastructure/secrets.app.env` (apps + BRouter shared token), `infrastructure/secrets.infra.env` (flagship infra only). Edit with `sops infrastructure/secrets.app.env`. GitHub Actions secrets: `AGE_SECRET_KEY`, `DEPLOY_HOST` / `DEPLOY_SSH_KEY` (flagship), `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` / `BROUTER_DEPLOY_SSH_PORT` (dedicated).
|
|
|
|
### Full restart
|
|
To restart **all** containers on the flagship (not just the ones a workflow normally touches):
|
|
```bash
|
|
gh workflow run cd-infra.yml -f restart_all=true
|
|
```
|
|
|
|
### Server access
|
|
```bash
|
|
# Flagship — root, standard port, deploy key
|
|
ssh -i ~/.ssh/trails-cool-deploy root@trails.cool
|
|
|
|
# BRouter host — trails user, non-standard port, different deploy key
|
|
ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is
|
|
```
|
|
|
|
### Grafana
|
|
`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org)
|
|
|
|
## OpenSpec Workflow
|
|
|
|
Specs live in `openspec/`. Use these slash commands:
|
|
|
|
- `/opsx:propose` — Create a new change with proposal, design, specs, and tasks
|
|
- `/opsx:apply` — Implement tasks from an existing change
|
|
- `/opsx:explore` — Think through ideas before proposing
|
|
- `/opsx:archive` — Archive a completed change
|