From 57094323d2fa6bcc6c8457fd7b9dcb224aef84f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 16:56:01 +0100 Subject: [PATCH 001/764] SOPS+age secrets, split CD workflows, GitHub OAuth for Grafana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets: - Add .sops.yaml with age encryption config - Add encrypted secrets.app.env (app secrets) and secrets.infra.env (Grafana OAuth) - CD decrypts at deploy time with AGE_SECRET_KEY — all other secrets move out of GitHub Actions into version-controlled encrypted files Split CD: - cd-apps.yml: triggered by apps/packages changes, builds Docker images, deploys apps - cd-infra.yml: triggered by infrastructure/ changes, copies configs, restarts services - Remove monolithic cd.yml Grafana auth: - GitHub OAuth (trails-cool org), disable login form - Remove Caddy basic_auth block and all GRAFANA_* env vars/secrets Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 109 +++++++++++++++ .github/workflows/cd-brouter.yml | 66 +++++++++ .github/workflows/cd-infra.yml | 81 +++++++++++ .github/workflows/cd.yml | 131 ------------------ .sops.yaml | 3 + infrastructure/Caddyfile | 3 - infrastructure/docker-compose.yml | 10 +- infrastructure/secrets.app.env | 15 ++ infrastructure/secrets.infra.env | 10 ++ .../changes/sops-age-split-cd/.openspec.yaml | 2 + openspec/changes/sops-age-split-cd/design.md | 126 +++++++++++++++++ .../changes/sops-age-split-cd/proposal.md | 44 ++++++ .../specs/infrastructure/spec.md | 27 ++++ .../specs/secret-management/spec.md | 16 +++ openspec/changes/sops-age-split-cd/tasks.md | 36 +++++ 15 files changed, 541 insertions(+), 138 deletions(-) create mode 100644 .github/workflows/cd-apps.yml create mode 100644 .github/workflows/cd-brouter.yml create mode 100644 .github/workflows/cd-infra.yml delete mode 100644 .github/workflows/cd.yml create mode 100644 .sops.yaml create mode 100644 infrastructure/secrets.app.env create mode 100644 infrastructure/secrets.infra.env create mode 100644 openspec/changes/sops-age-split-cd/.openspec.yaml create mode 100644 openspec/changes/sops-age-split-cd/design.md create mode 100644 openspec/changes/sops-age-split-cd/proposal.md create mode 100644 openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md create mode 100644 openspec/changes/sops-age-split-cd/specs/secret-management/spec.md create mode 100644 openspec/changes/sops-age-split-cd/tasks.md diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml new file mode 100644 index 0000000..0fa9bfe --- /dev/null +++ b/.github/workflows/cd-apps.yml @@ -0,0 +1,109 @@ +name: CD Apps + +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +concurrency: + group: deploy-apps + cancel-in-progress: true + +jobs: + build-images: + name: Build & Push Docker Images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + app: [journal, planner] + steps: + - uses: actions/checkout@v6 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Decrypt app secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env + source /tmp/secrets.env + + - uses: docker/build-push-action@v7 + with: + context: . + file: apps/${{ matrix.app }}/Dockerfile + push: true + tags: | + ghcr.io/trails-cool/${{ matrix.app }}:latest + ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} + build-args: | + SENTRY_AUTH_TOKEN=${{ env.SENTRY_AUTH_TOKEN }} + SENTRY_RELEASE=${{ github.sha }} + + deploy: + name: Deploy Apps + needs: [build-images] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/app.env + echo "SENTRY_RELEASE=${{ github.sha }}" >> /tmp/app.env + echo "DOMAIN=trails.cool" >> /tmp/app.env + + - name: Copy files to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + target: /opt/trails-cool + strip_components: 1 + + - name: Copy secrets to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "/tmp/app.env" + target: /opt/trails-cool + strip_components: 2 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/trails-cool + + # Login to ghcr.io + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN app.env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + # Pull and deploy app containers + docker compose --env-file app.env pull journal planner + docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force + docker compose --env-file app.env up -d journal planner + + # Clean up + docker image prune -af + docker compose ps diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml new file mode 100644 index 0000000..ba369df --- /dev/null +++ b/.github/workflows/cd-brouter.yml @@ -0,0 +1,66 @@ +name: CD BRouter + +on: + push: + branches: [main] + paths: + - "docker/brouter/**" + workflow_dispatch: {} + +concurrency: + group: deploy-brouter + cancel-in-progress: true + +jobs: + build: + name: Build & Push BRouter Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v7 + with: + context: docker/brouter + push: true + tags: | + ghcr.io/trails-cool/brouter:latest + ghcr.io/trails-cool/brouter:${{ github.sha }} + + deploy: + name: Deploy BRouter + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/trails-cool + + # Download BRouter segments if missing + if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then + mkdir -p /opt/trails-cool/segments + echo "Downloading Germany BRouter segments..." + for tile in E5_N45 E5_N50 E10_N45 E10_N50; do + [ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \ + wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5" + done + fi + + docker pull ghcr.io/trails-cool/brouter:latest + docker compose up -d brouter + docker image prune -af diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml new file mode 100644 index 0000000..b85c5b7 --- /dev/null +++ b/.github/workflows/cd-infra.yml @@ -0,0 +1,81 @@ +name: CD Infra + +on: + push: + branches: [main] + paths: + - "infrastructure/**" + workflow_dispatch: + inputs: + restart_all: + description: "Restart all containers (not just infra)" + type: boolean + default: false + +concurrency: + group: deploy-infra + cancel-in-progress: true + +jobs: + deploy: + name: Deploy Infrastructure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + # Merge app + infra secrets into one .env for docker-compose + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/all.env + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> /tmp/all.env + echo "DOMAIN=trails.cool" >> /tmp/all.env + + - name: Copy configs to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" + target: /opt/trails-cool + strip_components: 1 + + - name: Copy secrets to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "/tmp/all.env" + target: /opt/trails-cool + strip_components: 2 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/trails-cool + + # Merge env files: app.env (from cd-apps) + all.env (from cd-infra) + # all.env has both app + infra secrets + cp all.env .env + + # Login to ghcr.io (for pulling monitoring images) + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true + + # Restart services + if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then + docker compose --env-file .env up -d --remove-orphans + else + # Restart infra services (except Caddy — just reload its config) + docker compose --env-file .env up -d postgres prometheus loki grafana + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + fi + + docker compose ps diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 987f602..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: CD - -# Triggers: -# - push: merges via merge queue or manual gh pr merge -# - workflow_dispatch: manual trigger via gh workflow run cd.yml -on: - push: - branches: [main] - workflow_dispatch: {} - -concurrency: - group: deploy - cancel-in-progress: false - -jobs: - build-images: - name: Build & Push Docker Images - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - strategy: - matrix: - app: [journal, planner] - steps: - - uses: actions/checkout@v6 - - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: . - file: apps/${{ matrix.app }}/Dockerfile - push: true - tags: | - ghcr.io/trails-cool/${{ matrix.app }}:latest - ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} - build-args: | - SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_RELEASE=${{ github.sha }} - - build-brouter: - name: Build & Push BRouter Image - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v6 - - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: docker/brouter - push: true - tags: | - ghcr.io/trails-cool/brouter:latest - ghcr.io/trails-cool/brouter:${{ github.sha }} - - deploy: - name: Deploy to Hetzner - needs: [build-images, build-brouter] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Copy files to server - uses: appleboy/scp-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" - target: /opt/trails-cool - strip_components: 1 - - - name: Deploy via SSH - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: root - key: ${{ secrets.DEPLOY_SSH_KEY }} - script: | - cd /opt/trails-cool - - # Login to ghcr.io to pull private images - echo "${{ secrets.DEPLOY_GHCR_TOKEN }}" | docker login ghcr.io -u stigi --password-stdin - - # Download BRouter segments if missing - if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then - mkdir -p /opt/trails-cool/segments - echo "Downloading Germany BRouter segments..." - for tile in E5_N45 E5_N50 E10_N45 E10_N50; do - [ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \ - wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5" - done - fi - - # Pull latest images - export SENTRY_RELEASE="${{ github.sha }}" - export SMTP_URL="${{ secrets.SMTP_URL }}" - export SMTP_FROM="${{ secrets.SMTP_FROM }}" - export GRAFANA_USER="${{ secrets.GRAFANA_USER }}" - export GRAFANA_PASSWORD="${{ secrets.GRAFANA_PASSWORD }}" - export GRAFANA_PASSWORD_HASH='${{ secrets.GRAFANA_PASSWORD_HASH }}' - docker compose pull - - # Push database schema (starts postgres, waits for healthy) - docker compose run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force - - # Start all services - docker compose up -d --remove-orphans - - # Clean up old images to prevent disk full - docker image prune -af - - # Sync Grafana admin credentials (env vars only work on first boot) - sleep 10 - docker compose exec -T grafana grafana cli admin reset-admin-password "$GRAFANA_PASSWORD" || true - - # Verify services are running - docker compose ps diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..759e1c2 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,3 @@ +creation_rules: + - path_regex: secrets\..*\.env$ + age: age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index ed3fdba..70513bd 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -29,9 +29,6 @@ www.{$DOMAIN:trails.cool} { } grafana.internal.{$DOMAIN:trails.cool} { - basic_auth { - {$GRAFANA_USER:admin} {$GRAFANA_PASSWORD_HASH} - } reverse_proxy grafana:3000 } diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index e32352d..9c20fcf 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -8,8 +8,6 @@ services: - "443:443/udp" environment: DOMAIN: ${DOMAIN:-trails.cool} - GRAFANA_USER: ${GRAFANA_USER:-admin} - GRAFANA_PASSWORD_HASH: ${GRAFANA_PASSWORD_HASH:-} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data @@ -109,9 +107,13 @@ services: image: grafana/grafana:latest restart: unless-stopped environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} GF_SERVER_ROOT_URL: https://grafana.internal.${DOMAIN:-trails.cool} + GF_AUTH_GITHUB_ENABLED: "true" + GF_AUTH_GITHUB_CLIENT_ID: ${GF_AUTH_GITHUB_CLIENT_ID:-} + GF_AUTH_GITHUB_CLIENT_SECRET: ${GF_AUTH_GITHUB_CLIENT_SECRET:-} + GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool + GF_AUTH_GITHUB_SCOPES: user:email,read:org + GF_AUTH_DISABLE_LOGIN_FORM: "true" volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro diff --git a/infrastructure/secrets.app.env b/infrastructure/secrets.app.env new file mode 100644 index 0000000..9713047 --- /dev/null +++ b/infrastructure/secrets.app.env @@ -0,0 +1,15 @@ +#ENC[AES256_GCM,data:4AVDH0K+AknlSfn7+uBxY1bQrJaLgu7e4H9a2r0DJTw4cxYMNfDJ4qtcnOAJXQLmf9tdQMQ=,iv:eJ4Cck3LvSi3W58QhZjkmL2+Q+TYYO1w8ucSM9Z2tF0=,tag:OAiQY06xOXDRQqdtTgyVqQ==,type:comment] +#ENC[AES256_GCM,data:+hScU/d5XG/4YJfrbf7wvQgQxnivPnZiRb43u8WePdmopML3CY0J5A03qpxEJdKYNFcALB/nOd1rMjhjGLexTNjdy+pjPfs+82xlEUU=,iv:azEAINahXBDJeglTu7FSBbd0OvUy5vyiER5aYqDsMfE=,tag:fEwUHGMq5SS5J8NN3auCyw==,type:comment] +POSTGRES_PASSWORD=ENC[AES256_GCM,data:8GlpWQ8J,iv:P2ZXmY+PIzuRt7IplHIR2Fgqv3v86Js9kadlVLloyv4=,tag:Hnp2UDtrZ6J3zbLO5lydkw==,type:str] +JWT_SECRET=ENC[AES256_GCM,data:VMN5O225nhWNcjTMXySpGzejCa4Kanat0qN1FjYYGljWkHrgRSOmFEl+lDg=,iv:p+/uFO9tjfuy6dcRkijqvhPDVb9ST90WCSRiFfDJ3aU=,tag:R4dPEwVAe1u9wilvr0e67Q==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:sbyyoq+gsVZZbf/YfSAzRN8K1ny+6b4bDoWj5yjJr6qpk7dYoeYKoMGTOek=,iv:Jk81deYxkdyg6MNGMB/DiQRR27cr87CD8K7g70YDzko=,tag:7hAAxEd0DIPZcQ4IgoHrzA==,type:str] +SMTP_URL=ENC[AES256_GCM,data:o4kgOpqNnbtoAfZMM3+cnk0pHIQ/xfQ7Z2wg003qkx+7aU0JdNW8FXlxPc7QAOHT/jreIirLe9VV6K5tLMA6ICuQeA==,iv:6o06J4DR5IWdNSY9fCBZGRhE6qWnDQR/Dq9HhO4viqA=,tag:gFQs4no1VGTZSzYDfgZeCw==,type:str] +SMTP_FROM=ENC[AES256_GCM,data:pS4B7UxNc9DIyhveRV0LP3wchAFtCY1TN7NhTK9YsM92,iv:jnz4zEjU++PVC1OOyQTvr4mnDNe1hmx4oqZMLJ/7jcQ=,tag:ifclwnVpmcD8x0SLMK+ZPw==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:Bz+XkGYAJDhCN5iSSPdTkd+21BO1zc7S2rU2NvxaBzmskYve6gHt1KR+OzQ9YLuy7VkhpFlBeYKS592HrpdkCA7Sp3DZvl8=,iv:E3cyWEhDX8WCpvROJdhGhdgkKjV+mhmrj4kMpBz9hWM=,tag:BNb7rsdPQQ4pBS3UX2G4EQ==,type:str] +DEPLOY_GHCR_TOKEN=ENC[AES256_GCM,data:cmyUDf24Mt5iS8rRwjpCMPfGBjKDXW4vXQjEqlO1KWNNxO0fW5SFlg==,iv:rhKAgWZ48JGfq2vS6FJ58E9pU41s6zHYe0xo7+YapoM=,tag:LKl86akqu7E32Lp0ScDWYg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWblo1Rm9rUlFCQkVVRUVR\nSCtPR1kwMlFvRXplNmdxWVVSOEJzUVB3VlZBCk9Hb1FJSCs1R2llMkk4eGVydnlh\nUmJmU0Joa1pneEZSWFJ5L1VZRnNiZ0UKLS0tIG9aaUkzT0o5dERSekM0cjNxOXBM\nRVhnU1prNjFOa0RQdEVsSlB5QnZzMGcKFFPBeSgQozLR2zohwr/lYBRFzeAK8Hzd\nV635q8YQrXBt9ZUSYvy/b7Gse61+ynaqI7ukLfbc/cRNszQFSuMJyg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n +sops_lastmodified=2026-03-27T15:54:10Z +sops_mac=ENC[AES256_GCM,data:ESmrI1rbshwEKB3ttElBFD0vpbRQjydPBi5zlP5DNhc9ldBPFiIIFCA/v5MBquPsA4VG0C3RyKJBXrryPiADcvj/bhOWWllVlJ0OPr9GSoNFRpYRsWSTJfu7NG3wP9B24Cm/QpW2p2wYi+Z4nm0KHdHWTshxgUBBFOAOiVJPWIU=,iv:/IzX+/MP+3eTeePcmgfwRmVMAHe/WmRYBfrEFp7JOoA=,tag:nhNbuk8GlbJJMghvx8CnKw==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.9.4 diff --git a/infrastructure/secrets.infra.env b/infrastructure/secrets.infra.env new file mode 100644 index 0000000..e41e191 --- /dev/null +++ b/infrastructure/secrets.infra.env @@ -0,0 +1,10 @@ +#ENC[AES256_GCM,data:IbWmlFr0kUJNYnRkD9Rt6tPAgtE+8TrECPY3MstykO8EPIZOvkg0T1hjGsDLca5NA8cdWqWWEmSF4o7ewvk=,iv:UD1YxHEH9PxnSrkVI/Nm9WVazGk9Kh+B8uzfNqjmf3I=,tag:+mWHdGvo+OeQFa4Ak/S3Pg==,type:comment] +#ENC[AES256_GCM,data:BDOEQHz+QsQcQCzrD8XWcxREuNWELctcjsZahTmwEAYolct7EaW4G9bGI1K8BL8V5OejHvqtxgd34LHBAzTrTL66ATW/pWGY2lF/G69J4Q==,iv:4RyNDeZUZOgibkI7+aIyCJDxd/7jZMMaBIepQCbbHFA=,tag:v/mPe5ILLVS2bLUKFpOoJg==,type:comment] +GF_AUTH_GITHUB_CLIENT_ID=ENC[AES256_GCM,data:4a1+g2U6a+XlfwSLLoJFWruvOLg=,iv:h1yDpE21XPxqOGBsE+E1Xunr0eAC2ALLP9x11XMPd6Q=,tag:zKWMUrCi1b2Ir8g1BcsAcA==,type:str] +GF_AUTH_GITHUB_CLIENT_SECRET=ENC[AES256_GCM,data:LGbZHhQeLcS01UJXtZefsGUi0P8lscZRrho0HGq+hKRuoy5RO1HssA==,iv:NATDvm3fKe4zjJ36PWb/iJLVA2lJsognRLg0TZY5yw4=,tag:JYatVnyvrc0dYue2aTh55Q==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4UiszMUJMYzFlWE5Qc3Zk\nUDF2V1BIYmIwbjVydHI5OTJoRVJXdFhDZDEwCmlEQTFKRUZwYVlQbVRJMEpHL2Vj\nd21pS3BwZHc3QklCMVl4ZHFzZUJYRWMKLS0tIEtQeFQvNGxRUGhqNnBzQ0ZvaXRY\nMFZjQlhobXBKT08yc0dLVGNwd0pHbmcKEAS/YRu2NhGCZLX+nkVjo7PEu7ZV4iCW\nyO9GIsaIQcN6FWmp5IkjdeM6jkHxSk+qHCL5bKx78EgPMgzKobjgzQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n +sops_lastmodified=2026-03-27T15:40:55Z +sops_mac=ENC[AES256_GCM,data:pkYMdGTyjc6/ig/hdBD4GGeyiOkJBp/z9+w3vlT/bfHeje4FBZxjdmgykfCmNFJqov1MoysEsP0z8VZpdcSa+cjcJXVIhXcSpl4Uo/NXZuYRIuPkQztSGna+tStrpus4Zxk2SLe8IF5AFgPtuCwZvS7R7JE4KKbkKG/kfcTMrR8=,iv:SXzctdK5QEV6RZuSSxDor7MYPgEJGvSih4l3G2+lwYI=,tag:umAgJ94DM7DKVPPniCdRvw==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.9.4 diff --git a/openspec/changes/sops-age-split-cd/.openspec.yaml b/openspec/changes/sops-age-split-cd/.openspec.yaml new file mode 100644 index 0000000..a61e7c1 --- /dev/null +++ b/openspec/changes/sops-age-split-cd/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-27 diff --git a/openspec/changes/sops-age-split-cd/design.md b/openspec/changes/sops-age-split-cd/design.md new file mode 100644 index 0000000..2bfc2be --- /dev/null +++ b/openspec/changes/sops-age-split-cd/design.md @@ -0,0 +1,126 @@ +## Context + +The current CD workflow has ~10 GitHub Actions secrets passed via `export` +statements in a deploy script. Some secrets contain `$` characters that get +mangled. Grafana authentication required managing a bcrypt hash, a plaintext +password, and a username across three different systems (Caddy, Grafana, CD). +The single CD workflow rebuilds Docker images even for config-only changes. + +## Goals / Non-Goals + +**Goals:** +- All secrets in one encrypted file in the repo (SOPS + age) +- Only one GitHub secret needed for decryption (AGE_SECRET_KEY) +- App deploys (Docker build + push) separate from infra deploys (config copy) +- Grafana login via GitHub OAuth (no passwords) +- Remove Caddy basic auth for Grafana + +**Non-Goals:** +- Secret rotation automation (manual for now) +- Per-environment secret files (only production) +- Grafana RBAC / team-based access (just org membership check) +- Moving DEPLOY_SSH_KEY or GITHUB_TOKEN into SOPS (these are GitHub-native) + +## Decisions + +### D1: SOPS + age file-based encryption + +Create `.sops.yaml` at the repo root defining age as the encryption method. +Two encrypted secret files, split by deployment scope: + +- **`infrastructure/secrets.app.env`** — app secrets needed by journal/planner. + Used by both cd-apps (production) and future staging deploys. +- **`infrastructure/secrets.infra.env`** — monitoring/Grafana secrets. + Used only by cd-infra (production). Staging doesn't run Grafana. + +```yaml +# .sops.yaml +creation_rules: + - path_regex: secrets\..*\.env$ + age: +``` + +Workflow: +- **Edit secrets**: `sops infrastructure/secrets.app.env` (decrypts in editor, + re-encrypts on save) +- **CD decrypts**: Install sops + age, decrypt the relevant file(s) to a temp + `.env`, pass to `docker compose --env-file` +- **Only one GitHub secret**: `AGE_SECRET_KEY` (the private key) + +**secrets.app.env** contents (used by cd-apps + cd-infra): +- POSTGRES_PASSWORD, JWT_SECRET, SESSION_SECRET +- SMTP_URL, SMTP_FROM +- SENTRY_AUTH_TOKEN +- DEPLOY_GHCR_TOKEN + +**secrets.infra.env** contents (used by cd-infra only): +- GF_AUTH_GITHUB_CLIENT_ID, GF_AUTH_GITHUB_CLIENT_SECRET + +Secrets that stay as GitHub Actions secrets: +- DEPLOY_SSH_KEY (used by SCP/SSH actions, not by docker-compose) +- AGE_SECRET_KEY (chicken-and-egg: can't encrypt the decryption key) +- DEPLOY_HOST (not really secret, but convenient) + +### D2: Split CD into two workflows + +**cd-apps.yml** — triggered by changes to `apps/`, `packages/`, `pnpm-lock.yaml`: +1. Build and push Docker images (journal, planner, brouter) +2. SSH to server, pull images, run migrations, restart app containers + +**cd-infra.yml** — triggered by changes to `infrastructure/`: +1. Copy config files (docker-compose, Caddyfile, Prometheus, Loki, Grafana) +2. SSH to server, decrypt secrets, `docker compose up -d` + +Both workflows also trigger on `workflow_dispatch` for manual runs. +Both share the decrypt-secrets step. + +### D3: GitHub OAuth for Grafana + +Register a GitHub OAuth app at github.com/settings/applications: +- Callback URL: `https://grafana.internal.trails.cool/login/github` +- Homepage: `https://grafana.internal.trails.cool` + +Grafana config via environment variables: +```yaml +GF_AUTH_GITHUB_ENABLED: "true" +GF_AUTH_GITHUB_CLIENT_ID: +GF_AUTH_GITHUB_CLIENT_SECRET: +GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool +GF_AUTH_GITHUB_SCOPES: user:email,read:org +GF_AUTH_DISABLE_LOGIN_FORM: "true" +``` + +Remove from Caddyfile: +- `basic_auth` block for grafana.internal +- GRAFANA_USER, GRAFANA_PASSWORD, GRAFANA_PASSWORD_HASH env vars + +Remove from docker-compose.yml: +- GF_SECURITY_ADMIN_USER, GF_SECURITY_ADMIN_PASSWORD +- Caddy GRAFANA_USER, GRAFANA_PASSWORD_HASH env vars + +Remove from CD: +- grafana cli password reset step +- All GRAFANA_* secret exports + +### D4: Server-side secret decryption + +The CD deploy step: +1. Install sops + age on the runner +2. Decrypt: `SOPS_AGE_KEY=${{ secrets.AGE_SECRET_KEY }} sops -d infrastructure/secrets.env > /tmp/secrets.env` +3. SCP the decrypted `.env` to server as `/opt/trails-cool/.env` +4. `docker compose --env-file .env up -d` +5. Clean up `/tmp/secrets.env` from runner + +The server never has the age private key — secrets arrive as a plain `.env` +file via SCP (same security as current `export` approach, but now +version-controlled and auditable). + +## Risks / Trade-offs + +- **age key loss** → If the AGE_SECRET_KEY is lost, secrets can't be decrypted. + Mitigate: store a backup of the age key outside GitHub (e.g., password + manager). +- **Encrypted file merge conflicts** → SOPS encrypted files don't merge well. + Mitigate: only one person edits secrets at a time (fine for solo/small team). +- **GitHub OAuth requires internet** → If GitHub is down, Grafana login fails. + Acceptable for a monitoring dashboard. diff --git a/openspec/changes/sops-age-split-cd/proposal.md b/openspec/changes/sops-age-split-cd/proposal.md new file mode 100644 index 0000000..5ec5276 --- /dev/null +++ b/openspec/changes/sops-age-split-cd/proposal.md @@ -0,0 +1,44 @@ +## Why + +Secrets are scattered across GitHub Actions secrets with no version control, +no audit trail, and painful manual management (the Grafana password hash saga). +The CD pipeline is monolithic — changing a Grafana dashboard rebuilds both app +Docker images. And Grafana authentication requires managing bcrypt hashes and +basic auth layers. + +## What Changes + +- **SOPS + age for secrets**: Encrypt a `.env.production` file in the repo. + CD decrypts at deploy time with a single age private key stored as one + GitHub secret. All other secrets move from GitHub Actions secrets into the + encrypted file — version-controlled, diffable, auditable. +- **Split CD into apps vs infra**: Two workflows triggered by path filters. + App changes (apps/, packages/) build Docker images and deploy. Infra changes + (infrastructure/) copy configs and restart services. No unnecessary rebuilds. +- **GitHub OAuth for Grafana**: Replace Caddy basic auth + Grafana login with + GitHub OAuth. One login, restricted to the trails-cool GitHub org. Remove + GRAFANA_PASSWORD_HASH, GRAFANA_USER, GRAFANA_PASSWORD secrets entirely. + +## Capabilities + +### New Capabilities + +- `secret-management`: SOPS + age encrypted secrets in the repository with + CD decryption + +### Modified Capabilities + +- `infrastructure`: Split CD workflows, GitHub OAuth for Grafana, remove + Caddy basic auth for Grafana + +## Impact + +- **Files**: New `.env.production.enc` (encrypted), `.sops.yaml` config, + split `cd-apps.yml` and `cd-infra.yml` workflows, updated docker-compose.yml + and Caddyfile +- **Dependencies**: `sops` and `age` CLI tools in CD runner (install step) +- **GitHub secrets**: Reduced from ~10 secrets to 2 (AGE_SECRET_KEY + + DEPLOY_SSH_KEY). Everything else moves into the encrypted env file. +- **Grafana**: GitHub OAuth app registration needed (Client ID + Secret go + into the SOPS-encrypted file) +- **Caddy**: Remove basic auth block for grafana.internal, just proxy through diff --git a/openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md b/openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md new file mode 100644 index 0000000..5f69261 --- /dev/null +++ b/openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: CI/CD pipeline +GitHub Actions SHALL use separate workflows for app deployment and infrastructure deployment, with secrets decrypted from a SOPS-encrypted file. + +#### Scenario: App deployment +- **WHEN** code changes are pushed to main in apps/ or packages/ +- **THEN** the cd-apps workflow builds Docker images, pushes to ghcr.io, and deploys app containers + +#### Scenario: Infrastructure deployment +- **WHEN** changes are pushed to main in infrastructure/ +- **THEN** the cd-infra workflow copies configs and restarts infrastructure services without rebuilding app images + +#### Scenario: Secret decryption at deploy time +- **WHEN** either CD workflow runs +- **THEN** the SOPS-encrypted secrets file is decrypted and provided to docker-compose as an env file + +### Requirement: Grafana authentication +Grafana SHALL authenticate users via GitHub OAuth, restricted to the trails-cool GitHub organization. + +#### Scenario: GitHub OAuth login +- **WHEN** a user navigates to grafana.internal.trails.cool +- **THEN** they are redirected to GitHub for authentication and granted access if they are a member of the trails-cool organization + +#### Scenario: No password-based login +- **WHEN** Grafana is deployed +- **THEN** the login form is disabled and only GitHub OAuth is available diff --git a/openspec/changes/sops-age-split-cd/specs/secret-management/spec.md b/openspec/changes/sops-age-split-cd/specs/secret-management/spec.md new file mode 100644 index 0000000..b1ade56 --- /dev/null +++ b/openspec/changes/sops-age-split-cd/specs/secret-management/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Encrypted secrets in repository +Production secrets SHALL be stored as a SOPS-encrypted file in the repository, decryptable only with a single age private key. + +#### Scenario: Edit secrets +- **WHEN** a developer runs `sops infrastructure/secrets.env` +- **THEN** the file is decrypted in a temporary editor, and re-encrypted on save + +#### Scenario: CD decryption +- **WHEN** the CD workflow runs +- **THEN** the encrypted secrets file is decrypted using the AGE_SECRET_KEY GitHub secret and provided to docker-compose as an env file + +#### Scenario: Secret audit trail +- **WHEN** a secret is changed +- **THEN** the change appears in git history as a diff of the encrypted file with a commit message describing what changed diff --git a/openspec/changes/sops-age-split-cd/tasks.md b/openspec/changes/sops-age-split-cd/tasks.md new file mode 100644 index 0000000..13c05f3 --- /dev/null +++ b/openspec/changes/sops-age-split-cd/tasks.md @@ -0,0 +1,36 @@ +## 1. SOPS + age Setup + +- [x] 1.1 Generate age key pair (`age-keygen`), store public key in `.sops.yaml`, private key as `AGE_SECRET_KEY` GitHub secret +- [x] 1.2 Create `.sops.yaml` at repo root with age encryption rule for `secrets.*.env` +- [x] 1.3 Create `infrastructure/secrets.app.env` with app secrets (POSTGRES_PASSWORD, JWT_SECRET, SESSION_SECRET, SMTP_URL, SMTP_FROM, SENTRY_AUTH_TOKEN, DEPLOY_GHCR_TOKEN), encrypt with `sops -e` +- [x] 1.4 Create `infrastructure/secrets.infra.env` with infra secrets (GF_AUTH_GITHUB_CLIENT_ID, GF_AUTH_GITHUB_CLIENT_SECRET), encrypt with `sops -e` +- [ ] 1.5 Remove migrated secrets from GitHub Actions (keep only AGE_SECRET_KEY, DEPLOY_SSH_KEY, DEPLOY_HOST) + +## 2. GitHub OAuth for Grafana + +- [x] 2.1 Register GitHub OAuth app (callback: grafana.internal.trails.cool/login/github) +- [x] 2.2 Add GF_AUTH_GITHUB_CLIENT_ID and GF_AUTH_GITHUB_CLIENT_SECRET to secrets.env +- [x] 2.3 Update docker-compose.yml: add GitHub OAuth env vars to Grafana, remove GF_SECURITY_ADMIN_USER/PASSWORD +- [x] 2.4 Update Caddyfile: remove basic_auth block for grafana.internal, just reverse_proxy +- [x] 2.5 Remove Caddy GRAFANA_USER/GRAFANA_PASSWORD_HASH env vars from docker-compose.yml + +## 3. Split CD Workflows + +- [x] 3.1 Create `.github/workflows/cd-apps.yml` — triggered by apps/, packages/, pnpm-lock.yaml changes +- [x] 3.2 Create `.github/workflows/cd-infra.yml` — triggered by infrastructure/ changes +- [x] 3.3 Add sops + age install step to both workflows +- [x] 3.4 cd-apps decrypt step: `sops -d secrets.app.env > .env`, SCP to server +- [x] 3.5 cd-infra decrypt step: merge `secrets.app.env` + `secrets.infra.env` into `.env`, SCP to server +- [x] 3.6 cd-apps: build images, push, SSH deploy (pull, migrate, restart apps) +- [x] 3.7 cd-infra: copy configs, SSH deploy (restart infra services) +- [x] 3.8 Remove old `cd.yml` workflow +- [x] 3.9 Remove GRAFANA_* exports and password reset from deploy script +- [x] 3.10 Both workflows use `docker compose --env-file .env up -d` instead of inline exports + +## 4. Verify + +- [x] 4.1 Test sops encrypt/decrypt cycle locally +- [ ] 4.2 Test cd-apps deploys only on app changes (not infra) +- [ ] 4.3 Test cd-infra deploys only on infra changes (not apps) +- [ ] 4.4 Test Grafana GitHub OAuth login +- [ ] 4.5 Verify old GitHub secrets can be removed after migration From 9673496211558c4a4088b7db62a126c0c1bc7535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 17:34:28 +0100 Subject: [PATCH 002/764] Add GitHub environments to CD workflows - cd-apps: uses `production` environment for AGE_SECRET_KEY - cd-infra: uses `infra` environment for AGE_SECRET_KEY Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 2 ++ .github/workflows/cd-infra.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 0fa9bfe..7c9fc7c 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -17,6 +17,7 @@ jobs: build-images: name: Build & Push Docker Images runs-on: ubuntu-latest + environment: production permissions: contents: read packages: write @@ -55,6 +56,7 @@ jobs: name: Deploy Apps needs: [build-images] runs-on: ubuntu-latest + environment: production steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index b85c5b7..3b0520e 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -20,6 +20,7 @@ jobs: deploy: name: Deploy Infrastructure runs-on: ubuntu-latest + environment: infra steps: - uses: actions/checkout@v6 From e880e3e9d91e7c622dda15d879a7d49e97a3ad91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 17:43:30 +0100 Subject: [PATCH 003/764] Fix SCP path for decrypted secrets in CD workflows Write decrypted env files to infrastructure/ instead of /tmp/ so strip_components: 1 correctly places them on the server. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 10 +++++----- .github/workflows/cd-infra.yml | 14 ++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 7c9fc7c..a262ded 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -64,9 +64,9 @@ jobs: run: | curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 chmod +x sops-v3.9.4.linux.amd64 - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/app.env - echo "SENTRY_RELEASE=${{ github.sha }}" >> /tmp/app.env - echo "DOMAIN=trails.cool" >> /tmp/app.env + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env + echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env + echo "DOMAIN=trails.cool" >> infrastructure/app.env - name: Copy files to server uses: appleboy/scp-action@v1 @@ -84,9 +84,9 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "/tmp/app.env" + source: "infrastructure/app.env" target: /opt/trails-cool - strip_components: 2 + strip_components: 1 - name: Deploy via SSH uses: appleboy/ssh-action@v1 diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 3b0520e..82194a8 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -29,9 +29,9 @@ jobs: curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 chmod +x sops-v3.9.4.linux.amd64 # Merge app + infra secrets into one .env for docker-compose - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/all.env - SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> /tmp/all.env - echo "DOMAIN=trails.cool" >> /tmp/all.env + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env + echo "DOMAIN=trails.cool" >> infrastructure/.env - name: Copy configs to server uses: appleboy/scp-action@v1 @@ -49,9 +49,9 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "/tmp/all.env" + source: "infrastructure/.env" target: /opt/trails-cool - strip_components: 2 + strip_components: 1 - name: Deploy via SSH uses: appleboy/ssh-action@v1 @@ -62,9 +62,7 @@ jobs: script: | cd /opt/trails-cool - # Merge env files: app.env (from cd-apps) + all.env (from cd-infra) - # all.env has both app + infra secrets - cp all.env .env + # .env was placed by the SCP step (decrypted app + infra secrets) # Login to ghcr.io (for pulling monitoring images) GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) From 06916868fe7c92ee72cc29f28e9e573c7abd4d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 17:52:41 +0100 Subject: [PATCH 004/764] Fix cd-apps: extract SENTRY_AUTH_TOKEN instead of sourcing env file The decrypted secrets.app.env contains values with angle brackets (SMTP_FROM) that bash interprets as redirects. The build job only needs SENTRY_AUTH_TOKEN, so extract it with grep instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index a262ded..f36b864 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -33,12 +33,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Decrypt app secrets + - name: Decrypt Sentry auth token run: | curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 chmod +x sops-v3.9.4.linux.amd64 SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env - source /tmp/secrets.env + echo "SENTRY_AUTH_TOKEN=$(grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2-)" >> "$GITHUB_ENV" - uses: docker/build-push-action@v7 with: From 5e200c8b62d90bb41ab6939200860d69bbea53c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:01:54 +0100 Subject: [PATCH 005/764] Fix Sentry source map upload in Docker builds - Approve @sentry/cli and esbuild build scripts so postinstall runs - Use --mount=type=secret instead of ARG for SENTRY_AUTH_TOKEN (fixes Docker warning about secrets in build args) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 9 +++++---- apps/journal/Dockerfile | 9 ++++++--- apps/planner/Dockerfile | 9 ++++++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index f36b864..d102342 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -38,7 +38,8 @@ jobs: curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 chmod +x sops-v3.9.4.linux.amd64 SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env - echo "SENTRY_AUTH_TOKEN=$(grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2-)" >> "$GITHUB_ENV" + grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- > /tmp/sentry_token + echo "${{ github.sha }}" > /tmp/sentry_release - uses: docker/build-push-action@v7 with: @@ -48,9 +49,9 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} - build-args: | - SENTRY_AUTH_TOKEN=${{ env.SENTRY_AUTH_TOKEN }} - SENTRY_RELEASE=${{ github.sha }} + secrets: | + SENTRY_AUTH_TOKEN=/tmp/sentry_token + SENTRY_RELEASE=/tmp/sentry_release deploy: name: Deploy Apps diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 95efd79..5f86253 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -12,13 +12,16 @@ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile +RUN pnpm approve-builds @sentry/cli esbuild && pnpm install --frozen-lockfile FROM base AS build -ARG SENTRY_AUTH_TOKEN -ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . -RUN pnpm --filter @trails-cool/journal build +RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ + --mount=type=secret,id=SENTRY_RELEASE \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ + SENTRY_RELEASE="$(cat /run/secrets/SENTRY_RELEASE 2>/dev/null)" \ + pnpm --filter @trails-cool/journal build FROM base AS runtime RUN addgroup --system app && adduser --system --ingroup app app diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 4b299eb..0e44ad7 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -12,13 +12,16 @@ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile +RUN pnpm approve-builds @sentry/cli esbuild && pnpm install --frozen-lockfile FROM base AS build -ARG SENTRY_AUTH_TOKEN -ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . -RUN pnpm --filter @trails-cool/planner build +RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ + --mount=type=secret,id=SENTRY_RELEASE \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ + SENTRY_RELEASE="$(cat /run/secrets/SENTRY_RELEASE 2>/dev/null)" \ + pnpm --filter @trails-cool/planner build FROM base AS runtime RUN addgroup --system app && adduser --system --ingroup app app From 6b147d04ec93dba0ccfd302274a49bb726408479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:07:25 +0100 Subject: [PATCH 006/764] Use pnpm.onlyBuiltDependencies for @sentry/cli and esbuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm approve-builds in Dockerfile ran after install, so the approval wasn't picked up. Use onlyBuiltDependencies in package.json instead — this is read during install and allows build scripts to run. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/Dockerfile | 1 - apps/planner/Dockerfile | 1 - package.json | 3 ++- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 5f86253..3f29357 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -12,7 +12,6 @@ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile -RUN pnpm approve-builds @sentry/cli esbuild && pnpm install --frozen-lockfile FROM base AS build COPY --from=deps /app/ ./ diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 0e44ad7..e080d2e 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -12,7 +12,6 @@ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile -RUN pnpm approve-builds @sentry/cli esbuild && pnpm install --frozen-lockfile FROM base AS build COPY --from=deps /app/ ./ diff --git a/package.json b/package.json index 764fc87..0624e3e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "pnpm": { "overrides": { "picomatch@>=4.0.0 <4.0.4": "4.0.4" - } + }, + "onlyBuiltDependencies": ["@sentry/cli", "esbuild"] }, "devDependencies": { "@eslint/js": "^10.0.1", From 5d7b8c82e8d50fb2b366e85b3ad039cfb04c6eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:15:45 +0100 Subject: [PATCH 007/764] Fix Sentry build: disable telemetry, fix release version passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disable Sentry Vite plugin telemetry (telemetry: false) - Pass SENTRY_RELEASE as build-arg (not secret) since it's just the git sha - Keep only SENTRY_AUTH_TOKEN as Docker secret The release version was showing as *** because Docker secrets get masked by GitHub Actions. Git sha is not sensitive — safe as a build arg. The sourcemap warning from react-router is a known upstream issue (ships without sourcemaps) and is harmless. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 4 ++-- apps/journal/Dockerfile | 4 ++-- apps/journal/vite.config.ts | 1 + apps/planner/Dockerfile | 4 ++-- apps/planner/vite.config.ts | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index d102342..bbaeb6d 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -39,7 +39,6 @@ jobs: chmod +x sops-v3.9.4.linux.amd64 SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- > /tmp/sentry_token - echo "${{ github.sha }}" > /tmp/sentry_release - uses: docker/build-push-action@v7 with: @@ -49,9 +48,10 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} + build-args: | + SENTRY_RELEASE=${{ github.sha }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token - SENTRY_RELEASE=/tmp/sentry_release deploy: name: Deploy Apps diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 3f29357..5ca2f4b 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -14,12 +14,12 @@ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile FROM base AS build +ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ - --mount=type=secret,id=SENTRY_RELEASE \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ - SENTRY_RELEASE="$(cat /run/secrets/SENTRY_RELEASE 2>/dev/null)" \ + SENTRY_RELEASE="$SENTRY_RELEASE" \ pnpm --filter @trails-cool/journal build FROM base AS runtime diff --git a/apps/journal/vite.config.ts b/apps/journal/vite.config.ts index e799794..b4c8cdb 100644 --- a/apps/journal/vite.config.ts +++ b/apps/journal/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ release: { name: process.env.SENTRY_RELEASE }, sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] }, disable: !process.env.SENTRY_AUTH_TOKEN, + telemetry: false, }), ], resolve: { diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index e080d2e..695f903 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -14,12 +14,12 @@ COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile FROM base AS build +ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ - --mount=type=secret,id=SENTRY_RELEASE \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ - SENTRY_RELEASE="$(cat /run/secrets/SENTRY_RELEASE 2>/dev/null)" \ + SENTRY_RELEASE="$SENTRY_RELEASE" \ pnpm --filter @trails-cool/planner build FROM base AS runtime diff --git a/apps/planner/vite.config.ts b/apps/planner/vite.config.ts index cb4d8a8..056d23f 100644 --- a/apps/planner/vite.config.ts +++ b/apps/planner/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ release: { name: process.env.SENTRY_RELEASE }, sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] }, disable: !process.env.SENTRY_AUTH_TOKEN, + telemetry: false, }), ], resolve: { From 318f0c93c16a8f09aab500a190961e32fe29c253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:23:20 +0100 Subject: [PATCH 008/764] Strip trailing newline from Sentry auth token The token file may have a trailing newline that causes sentry-cli API requests to fail with "API request failed". Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-apps.yml | 2 +- apps/journal/Dockerfile | 2 +- apps/planner/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index bbaeb6d..ab3e130 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -38,7 +38,7 @@ jobs: curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 chmod +x sops-v3.9.4.linux.amd64 SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env - grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- > /tmp/sentry_token + grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token - uses: docker/build-push-action@v7 with: diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 5ca2f4b..b745c12 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -18,7 +18,7 @@ ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ - SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_RELEASE="$SENTRY_RELEASE" \ pnpm --filter @trails-cool/journal build diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 695f903..1fe5353 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -18,7 +18,7 @@ ARG SENTRY_RELEASE COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ - SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null)" \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_RELEASE="$SENTRY_RELEASE" \ pnpm --filter @trails-cool/planner build From 48fa94e51bf519803be6999094e906007af9e583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:35:46 +0100 Subject: [PATCH 009/764] Install ca-certificates in Docker base image for Sentry uploads node:25-slim doesn't include CA certificates, causing sentry-cli SSL errors: "unable to get local issuer certificate". Also removes debug logging from previous troubleshooting. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/Dockerfile | 1 + apps/planner/Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index b745c12..51fcd14 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,4 +1,5 @@ FROM node:25-slim AS base +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* RUN npm install -g pnpm@10.6.5 WORKDIR /app diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 1fe5353..1f6effb 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -1,4 +1,5 @@ FROM node:25-slim AS base +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* RUN npm install -g pnpm@10.6.5 WORKDIR /app From 424e692ee0ce0a34d9e895cf38e136c982387039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 18:44:14 +0100 Subject: [PATCH 010/764] Add service health monitoring: postgres, node, cAdvisor exporters + dashboard Exporters: - postgres_exporter: DB connections, transactions, cache hit ratio, query stats - node_exporter: host CPU, memory, disk, network - cAdvisor: per-container CPU and memory usage PostgreSQL: - Enable pg_stat_statements for query-level performance tracking - Track index scans vs sequential scans, cache hit ratio Dashboard (service-health.json): - DB: connections, size, transactions/s, slow queries, cache hit ratio, index usage - Host: disk gauge, CPU, memory, network I/O, disk I/O - BRouter: request latency p50/p95/p99, container CPU + memory - All containers: CPU and memory comparison Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-infra.yml | 13 +- CLAUDE.md | 26 +++ infrastructure/Caddyfile | 7 + infrastructure/docker-compose.yml | 40 ++++ .../grafana/dashboards/service-health.json | 187 ++++++++++++++++++ infrastructure/prometheus/prometheus.yml | 12 ++ .../.openspec.yaml | 0 .../2026-03-27-sops-age-split-cd}/design.md | 0 .../2026-03-27-sops-age-split-cd}/proposal.md | 0 .../specs/infrastructure/spec.md | 0 .../specs/secret-management/spec.md | 0 .../2026-03-27-sops-age-split-cd}/tasks.md | 10 +- openspec/specs/infrastructure/spec.md | 27 ++- openspec/specs/secret-management/spec.md | 16 ++ 14 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 infrastructure/grafana/dashboards/service-health.json rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/.openspec.yaml (100%) rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/design.md (100%) rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/proposal.md (100%) rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/specs/infrastructure/spec.md (100%) rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/specs/secret-management/spec.md (100%) rename openspec/changes/{sops-age-split-cd => archive/2026-03-27-sops-age-split-cd}/tasks.md (87%) create mode 100644 openspec/specs/secret-management/spec.md diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 82194a8..ccd8f36 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -68,12 +68,19 @@ jobs: GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true - # Restart services - if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then + # Check if full restart requested via: + # - workflow_dispatch input: restart_all=true + # - commit message containing [restart-all] + RESTART_ALL="${{ github.event.inputs.restart_all }}" + if [ "$RESTART_ALL" != "true" ] && echo "${{ github.event.head_commit.message }}" | grep -q '\[restart-all\]'; then + RESTART_ALL="true" + fi + + if [ "$RESTART_ALL" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else # Restart infra services (except Caddy — just reload its config) - docker compose --env-file .env up -d postgres prometheus loki grafana + docker compose --env-file .env up -d postgres prometheus loki grafana postgres-exporter node-exporter cadvisor docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile fi diff --git a/CLAUDE.md b/CLAUDE.md index c18798d..b73a560 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,32 @@ before pushing to an existing PR branch. ### 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 | +|----------|-------------|---------| +| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | +| `cd-infra.yml` | `infrastructure/` | caddy, postgres, prometheus, loki, grafana, exporters | +| `cd-brouter.yml` | `docker/brouter/` | brouter | + +### Secrets +All secrets are stored in SOPS-encrypted files (`infrastructure/secrets.app.env`, `infrastructure/secrets.infra.env`). Edit with `sops infrastructure/secrets.app.env`. Only `AGE_SECRET_KEY`, `DEPLOY_SSH_KEY`, and `DEPLOY_HOST` remain as GitHub secrets. + +### Full restart +To restart **all** containers (not just the ones a workflow normally touches), either: +- Add `[restart-all]` to the commit message +- Or trigger manually: `gh workflow run cd-infra.yml -f restart_all=true` + +### Server access +```bash +ssh -i ~/.ssh/trails-cool-deploy root@trails.cool +``` + +### Grafana +`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org) + ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index 70513bd..84d4eaf 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -1,3 +1,10 @@ +{ + servers { + metrics + } + admin 0.0.0.0:2019 +} + (security_headers) { header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 9c20fcf..8955e99 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -78,12 +78,52 @@ services: POSTGRES_DB: trails volumes: - pgdata:/var/lib/postgresql/data + command: + - "postgres" + - "-c" + - "shared_preload_libraries=pg_stat_statements" + - "-c" + - "pg_stat_statements.track=all" healthcheck: test: ["CMD-SHELL", "pg_isready -U trails"] interval: 5s timeout: 5s retries: 5 + postgres-exporter: + image: prometheuscommunity/postgres-exporter:latest + restart: unless-stopped + environment: + DATA_SOURCE_NAME: postgresql://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails?sslmode=disable + depends_on: + postgres: + condition: service_healthy + + node-exporter: + image: prom/node-exporter:latest + restart: unless-stopped + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + restart: unless-stopped + privileged: true + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + prometheus: image: prom/prometheus:latest restart: unless-stopped diff --git a/infrastructure/grafana/dashboards/service-health.json b/infrastructure/grafana/dashboards/service-health.json new file mode 100644 index 0000000..c5d68cb --- /dev/null +++ b/infrastructure/grafana/dashboards/service-health.json @@ -0,0 +1,187 @@ +{ + "dashboard": { + "title": "Service Health", + "uid": "trails-service-health", + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "title": "PostgreSQL — Active Connections", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { "expr": "pg_stat_activity_count{datname=\"trails\"}", "legendFormat": "{{state}}" } + ] + }, + { + "title": "PostgreSQL — Database Size", + "type": "stat", + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 }, + "targets": [ + { "expr": "pg_database_size_bytes{datname=\"trails\"}", "legendFormat": "trails" } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "PostgreSQL — Transactions/sec", + "type": "timeseries", + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 }, + "targets": [ + { "expr": "rate(pg_stat_database_xact_commit{datname=\"trails\"}[5m])", "legendFormat": "commits/s" }, + { "expr": "rate(pg_stat_database_xact_rollback{datname=\"trails\"}[5m])", "legendFormat": "rollbacks/s" } + ] + }, + { + "title": "PostgreSQL — Slowest Queries (pg_stat_statements)", + "type": "table", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "topk(10, pg_stat_statements_mean_time_seconds{datname=\"trails\"})", + "legendFormat": "{{query}}", + "format": "table", + "instant": true + } + ] + }, + { + "title": "PostgreSQL — Cache Hit Ratio", + "type": "gauge", + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 16 }, + "targets": [ + { "expr": "pg_stat_database_blks_hit{datname=\"trails\"} / (pg_stat_database_blks_hit{datname=\"trails\"} + pg_stat_database_blks_read{datname=\"trails\"}) * 100", "legendFormat": "hit %" } + ], + "fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100, "thresholds": { "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 90 }, { "color": "green", "value": 99 }] } } } + }, + { + "title": "PostgreSQL — Index Scans vs Sequential Scans", + "type": "timeseries", + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 16 }, + "targets": [ + { "expr": "sum(rate(pg_stat_user_tables_idx_scan[5m]))", "legendFormat": "Index scans/s" }, + { "expr": "sum(rate(pg_stat_user_tables_seq_scan[5m]))", "legendFormat": "Sequential scans/s" } + ] + }, + { + "title": "Host — Disk Usage", + "type": "gauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 24 }, + "targets": [ + { "expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"}) * 100", "legendFormat": "Used %" } + ], + "fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100, "thresholds": { "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] } } } + }, + { + "title": "Host — CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 24 }, + "targets": [ + { "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "legendFormat": "CPU %" } + ], + "fieldConfig": { "defaults": { "unit": "percent", "max": 100 } } + }, + { + "title": "Host — Memory Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 24 }, + "targets": [ + { "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", "legendFormat": "Used" }, + { "expr": "node_memory_MemAvailable_bytes", "legendFormat": "Available" } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "Host — Network I/O", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 }, + "targets": [ + { "expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|br.*|docker.*\"}[5m])", "legendFormat": "{{device}} rx" }, + { "expr": "-rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|br.*|docker.*\"}[5m])", "legendFormat": "{{device}} tx" } + ], + "fieldConfig": { "defaults": { "unit": "Bps" } } + }, + { + "title": "Host — Disk I/O", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 }, + "targets": [ + { "expr": "rate(node_disk_read_bytes_total[5m])", "legendFormat": "{{device}} read" }, + { "expr": "-rate(node_disk_written_bytes_total[5m])", "legendFormat": "{{device}} write" } + ], + "fieldConfig": { "defaults": { "unit": "Bps" } } + }, + { + "title": "BRouter — Request Latency (from Planner proxy)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, + "targets": [ + { "expr": "histogram_quantile(0.50, rate(brouter_request_duration_seconds_bucket[5m]))", "legendFormat": "p50" }, + { "expr": "histogram_quantile(0.95, rate(brouter_request_duration_seconds_bucket[5m]))", "legendFormat": "p95" }, + { "expr": "histogram_quantile(0.99, rate(brouter_request_duration_seconds_bucket[5m]))", "legendFormat": "p99" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Container — CPU Usage (all containers)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, + "targets": [ + { "expr": "rate(container_cpu_usage_seconds_total{name=~\"trails-cool.*\"}[5m]) * 100", "legendFormat": "{{name}}" } + ], + "fieldConfig": { "defaults": { "unit": "percent" } } + }, + { + "title": "Container — Memory Usage (all containers)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 48 }, + "targets": [ + { "expr": "container_memory_usage_bytes{name=~\"trails-cool.*\"}", "legendFormat": "{{name}}" } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "BRouter — Container Resources", + "type": "stat", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 48 }, + "targets": [ + { "expr": "container_memory_usage_bytes{name=~\".*brouter.*\"}", "legendFormat": "Memory" }, + { "expr": "rate(container_cpu_usage_seconds_total{name=~\".*brouter.*\"}[5m]) * 100", "legendFormat": "CPU %" } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "Caddy — Request Rate by Host", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 56 }, + "targets": [ + { "expr": "sum(rate(caddy_http_requests_total[5m])) by (server)", "legendFormat": "{{server}}" } + ] + }, + { + "title": "Caddy — Response Status Codes", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 56 }, + "targets": [ + { "expr": "sum(rate(caddy_http_responses_total[5m])) by (code)", "legendFormat": "{{code}}" } + ] + }, + { + "title": "Caddy — Request Duration by Host", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 64 }, + "targets": [ + { "expr": "histogram_quantile(0.95, sum(rate(caddy_http_request_duration_seconds_bucket[5m])) by (le, server))", "legendFormat": "p95 {{server}}" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Caddy — Active Connections", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 64 }, + "targets": [ + { "expr": "caddy_http_requests_in_flight", "legendFormat": "in-flight" } + ] + } + ] + } +} diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index f4d18e1..2bda741 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -13,6 +13,18 @@ scrape_configs: static_configs: - targets: ["planner:3001"] + - job_name: "postgres" + static_configs: + - targets: ["postgres-exporter:9187"] + + - job_name: "node" + static_configs: + - targets: ["node-exporter:9100"] + + - job_name: "cadvisor" + static_configs: + - targets: ["cadvisor:8080"] + - job_name: "caddy" static_configs: - targets: ["caddy:2019"] diff --git a/openspec/changes/sops-age-split-cd/.openspec.yaml b/openspec/changes/archive/2026-03-27-sops-age-split-cd/.openspec.yaml similarity index 100% rename from openspec/changes/sops-age-split-cd/.openspec.yaml rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/.openspec.yaml diff --git a/openspec/changes/sops-age-split-cd/design.md b/openspec/changes/archive/2026-03-27-sops-age-split-cd/design.md similarity index 100% rename from openspec/changes/sops-age-split-cd/design.md rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/design.md diff --git a/openspec/changes/sops-age-split-cd/proposal.md b/openspec/changes/archive/2026-03-27-sops-age-split-cd/proposal.md similarity index 100% rename from openspec/changes/sops-age-split-cd/proposal.md rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/proposal.md diff --git a/openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md b/openspec/changes/archive/2026-03-27-sops-age-split-cd/specs/infrastructure/spec.md similarity index 100% rename from openspec/changes/sops-age-split-cd/specs/infrastructure/spec.md rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/specs/infrastructure/spec.md diff --git a/openspec/changes/sops-age-split-cd/specs/secret-management/spec.md b/openspec/changes/archive/2026-03-27-sops-age-split-cd/specs/secret-management/spec.md similarity index 100% rename from openspec/changes/sops-age-split-cd/specs/secret-management/spec.md rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/specs/secret-management/spec.md diff --git a/openspec/changes/sops-age-split-cd/tasks.md b/openspec/changes/archive/2026-03-27-sops-age-split-cd/tasks.md similarity index 87% rename from openspec/changes/sops-age-split-cd/tasks.md rename to openspec/changes/archive/2026-03-27-sops-age-split-cd/tasks.md index 13c05f3..7f4851d 100644 --- a/openspec/changes/sops-age-split-cd/tasks.md +++ b/openspec/changes/archive/2026-03-27-sops-age-split-cd/tasks.md @@ -4,7 +4,7 @@ - [x] 1.2 Create `.sops.yaml` at repo root with age encryption rule for `secrets.*.env` - [x] 1.3 Create `infrastructure/secrets.app.env` with app secrets (POSTGRES_PASSWORD, JWT_SECRET, SESSION_SECRET, SMTP_URL, SMTP_FROM, SENTRY_AUTH_TOKEN, DEPLOY_GHCR_TOKEN), encrypt with `sops -e` - [x] 1.4 Create `infrastructure/secrets.infra.env` with infra secrets (GF_AUTH_GITHUB_CLIENT_ID, GF_AUTH_GITHUB_CLIENT_SECRET), encrypt with `sops -e` -- [ ] 1.5 Remove migrated secrets from GitHub Actions (keep only AGE_SECRET_KEY, DEPLOY_SSH_KEY, DEPLOY_HOST) +- [x] 1.5 Remove migrated secrets from GitHub Actions (keep only AGE_SECRET_KEY, DEPLOY_SSH_KEY, DEPLOY_HOST) ## 2. GitHub OAuth for Grafana @@ -30,7 +30,7 @@ ## 4. Verify - [x] 4.1 Test sops encrypt/decrypt cycle locally -- [ ] 4.2 Test cd-apps deploys only on app changes (not infra) -- [ ] 4.3 Test cd-infra deploys only on infra changes (not apps) -- [ ] 4.4 Test Grafana GitHub OAuth login -- [ ] 4.5 Verify old GitHub secrets can be removed after migration +- [x] 4.2 Test cd-apps deploys only on app changes (not infra) +- [x] 4.3 Test cd-infra deploys only on infra changes (not apps) +- [x] 4.4 Test Grafana GitHub OAuth login +- [x] 4.5 Verify old GitHub secrets can be removed after migration diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 63d1180..78a90cf 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -52,11 +52,19 @@ The infrastructure SHALL support downloading and updating Germany RD5 segments f - **THEN** RD5 segments are updated from brouter.de and the BRouter container is restarted ### Requirement: CI/CD pipeline -GitHub Actions SHALL build, deploy, and security-scan both apps on push to main. +GitHub Actions SHALL use separate workflows for app deployment and infrastructure deployment, with secrets decrypted from a SOPS-encrypted file. -#### Scenario: Push triggers deployment -- **WHEN** code is pushed to the main branch -- **THEN** GitHub Actions builds Docker images, pushes to ghcr.io/trails-cool/, and deploys to the Hetzner server +#### Scenario: App deployment +- **WHEN** code changes are pushed to main in apps/ or packages/ +- **THEN** the cd-apps workflow builds Docker images, pushes to ghcr.io, and deploys app containers + +#### Scenario: Infrastructure deployment +- **WHEN** changes are pushed to main in infrastructure/ +- **THEN** the cd-infra workflow copies configs and restarts infrastructure services without rebuilding app images + +#### Scenario: Secret decryption at deploy time +- **WHEN** either CD workflow runs +- **THEN** the SOPS-encrypted secrets file is decrypted and provided to docker-compose as an env file #### Scenario: Gitleaks scan - **WHEN** a PR is opened @@ -102,3 +110,14 @@ The system SHALL enrich Sentry events with user and session context, use route-a #### Scenario: Source maps not served to clients - **WHEN** a client requests a `.map` file from the production server - **THEN** the server SHALL return 404 (source maps are uploaded to Sentry during build, not shipped in the bundle) + +### Requirement: Grafana authentication +Grafana SHALL authenticate users via GitHub OAuth, restricted to the trails-cool GitHub organization. + +#### Scenario: GitHub OAuth login +- **WHEN** a user navigates to grafana.internal.trails.cool +- **THEN** they are redirected to GitHub for authentication and granted access if they are a member of the trails-cool organization + +#### Scenario: No password-based login +- **WHEN** Grafana is deployed +- **THEN** the login form is disabled and only GitHub OAuth is available diff --git a/openspec/specs/secret-management/spec.md b/openspec/specs/secret-management/spec.md new file mode 100644 index 0000000..abaa0ae --- /dev/null +++ b/openspec/specs/secret-management/spec.md @@ -0,0 +1,16 @@ +## Requirements + +### Requirement: Encrypted secrets in repository +Production secrets SHALL be stored as a SOPS-encrypted file in the repository, decryptable only with a single age private key. + +#### Scenario: Edit secrets +- **WHEN** a developer runs `sops infrastructure/secrets.env` +- **THEN** the file is decrypted in a temporary editor, and re-encrypted on save + +#### Scenario: CD decryption +- **WHEN** the CD workflow runs +- **THEN** the encrypted secrets file is decrypted using the AGE_SECRET_KEY GitHub secret and provided to docker-compose as an env file + +#### Scenario: Secret audit trail +- **WHEN** a secret is changed +- **THEN** the change appears in git history as a diff of the encrypted file with a commit message describing what changed From 9a7ecd89112070a3920551374d6bbcc4912d4cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 19:15:22 +0100 Subject: [PATCH 011/764] Simplify restart-all: workflow_dispatch only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove commit message [restart-all] detection — doesn't work with GitHub merge commits. Keep only the explicit workflow_dispatch input. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-infra.yml | 11 ++--------- CLAUDE.md | 7 ++++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index ccd8f36..021fdee 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -68,15 +68,8 @@ jobs: GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true - # Check if full restart requested via: - # - workflow_dispatch input: restart_all=true - # - commit message containing [restart-all] - RESTART_ALL="${{ github.event.inputs.restart_all }}" - if [ "$RESTART_ALL" != "true" ] && echo "${{ github.event.head_commit.message }}" | grep -q '\[restart-all\]'; then - RESTART_ALL="true" - fi - - if [ "$RESTART_ALL" = "true" ]; then + # Full restart: gh workflow run cd-infra.yml -f restart_all=true + if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else # Restart infra services (except Caddy — just reload its config) diff --git a/CLAUDE.md b/CLAUDE.md index b73a560..f50f611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,9 +154,10 @@ Three separate CD workflows triggered by path: All secrets are stored in SOPS-encrypted files (`infrastructure/secrets.app.env`, `infrastructure/secrets.infra.env`). Edit with `sops infrastructure/secrets.app.env`. Only `AGE_SECRET_KEY`, `DEPLOY_SSH_KEY`, and `DEPLOY_HOST` remain as GitHub secrets. ### Full restart -To restart **all** containers (not just the ones a workflow normally touches), either: -- Add `[restart-all]` to the commit message -- Or trigger manually: `gh workflow run cd-infra.yml -f restart_all=true` +To restart **all** containers (not just the ones a workflow normally touches): +```bash +gh workflow run cd-infra.yml -f restart_all=true +``` ### Server access ```bash From 424eecd274a055a350d8b41154d854bb4285efc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 19:54:33 +0100 Subject: [PATCH 012/764] Add PostgreSQL datasource to Grafana with read-only user - Create grafana_reader role with SELECT-only access on all schemas - Init script runs on postgres first boot (docker-entrypoint-initdb.d) - Grafana PostgreSQL datasource provisioned with read-only credentials - Enables SQL queries in dashboards (user count, routes, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd-infra.yml | 7 ++++ infrastructure/docker-compose.yml | 2 ++ .../provisioning/datasources/datasources.yml | 15 +++++++++ infrastructure/postgres/init-grafana-user.sql | 32 +++++++++++++++++++ infrastructure/secrets.infra.env | 15 +++++---- 5 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 infrastructure/postgres/init-grafana-user.sql diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 021fdee..dc6ffc2 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -68,6 +68,13 @@ jobs: GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true + # Setup Grafana read-only DB user (idempotent) + docker compose exec -T postgres psql -U trails -d trails -f /docker-entrypoint-initdb.d/init-grafana-user.sql 2>/dev/null || true + GRAFANA_DB_PW=$(grep GRAFANA_DB_PASSWORD .env | cut -d= -f2-) + if [ -n "$GRAFANA_DB_PW" ]; then + docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true + fi + # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 8955e99..e537029 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -78,6 +78,7 @@ services: POSTGRES_DB: trails volumes: - pgdata:/var/lib/postgresql/data + - ./postgres/init-grafana-user.sql:/docker-entrypoint-initdb.d/init-grafana-user.sql:ro command: - "postgres" - "-c" @@ -154,6 +155,7 @@ services: GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool GF_AUTH_GITHUB_SCOPES: user:email,read:org GF_AUTH_DISABLE_LOGIN_FORM: "true" + GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD:-} volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro diff --git a/infrastructure/grafana/provisioning/datasources/datasources.yml b/infrastructure/grafana/provisioning/datasources/datasources.yml index 522d4fc..d78a2cc 100644 --- a/infrastructure/grafana/provisioning/datasources/datasources.yml +++ b/infrastructure/grafana/provisioning/datasources/datasources.yml @@ -15,3 +15,18 @@ datasources: access: proxy url: http://loki:3100 editable: false + + - name: PostgreSQL + uid: postgres + type: postgres + access: proxy + url: postgres:5432 + user: grafana_reader + editable: false + jsonData: + database: trails + sslmode: disable + maxOpenConns: 2 + maxIdleConns: 1 + secureJsonData: + password: $__env{GRAFANA_DB_PASSWORD} diff --git a/infrastructure/postgres/init-grafana-user.sql b/infrastructure/postgres/init-grafana-user.sql new file mode 100644 index 0000000..099f5dc --- /dev/null +++ b/infrastructure/postgres/init-grafana-user.sql @@ -0,0 +1,32 @@ +-- Read-only user for Grafana dashboards +-- Password is set via: ALTER ROLE grafana_reader PASSWORD '' +-- Run by cd-infra deploy script after container start + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN + CREATE ROLE grafana_reader WITH LOGIN; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE trails TO grafana_reader; +GRANT USAGE ON SCHEMA public TO grafana_reader; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_reader; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO grafana_reader; + +-- Also grant on planner/journal schemas if they exist +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'planner') THEN + GRANT USAGE ON SCHEMA planner TO grafana_reader; + GRANT SELECT ON ALL TABLES IN SCHEMA planner TO grafana_reader; + ALTER DEFAULT PRIVILEGES IN SCHEMA planner GRANT SELECT ON TABLES TO grafana_reader; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'journal') THEN + GRANT USAGE ON SCHEMA journal TO grafana_reader; + GRANT SELECT ON ALL TABLES IN SCHEMA journal TO grafana_reader; + ALTER DEFAULT PRIVILEGES IN SCHEMA journal GRANT SELECT ON TABLES TO grafana_reader; + END IF; +END +$$; diff --git a/infrastructure/secrets.infra.env b/infrastructure/secrets.infra.env index e41e191..bda2951 100644 --- a/infrastructure/secrets.infra.env +++ b/infrastructure/secrets.infra.env @@ -1,10 +1,11 @@ -#ENC[AES256_GCM,data:IbWmlFr0kUJNYnRkD9Rt6tPAgtE+8TrECPY3MstykO8EPIZOvkg0T1hjGsDLca5NA8cdWqWWEmSF4o7ewvk=,iv:UD1YxHEH9PxnSrkVI/Nm9WVazGk9Kh+B8uzfNqjmf3I=,tag:+mWHdGvo+OeQFa4Ak/S3Pg==,type:comment] -#ENC[AES256_GCM,data:BDOEQHz+QsQcQCzrD8XWcxREuNWELctcjsZahTmwEAYolct7EaW4G9bGI1K8BL8V5OejHvqtxgd34LHBAzTrTL66ATW/pWGY2lF/G69J4Q==,iv:4RyNDeZUZOgibkI7+aIyCJDxd/7jZMMaBIepQCbbHFA=,tag:v/mPe5ILLVS2bLUKFpOoJg==,type:comment] -GF_AUTH_GITHUB_CLIENT_ID=ENC[AES256_GCM,data:4a1+g2U6a+XlfwSLLoJFWruvOLg=,iv:h1yDpE21XPxqOGBsE+E1Xunr0eAC2ALLP9x11XMPd6Q=,tag:zKWMUrCi1b2Ir8g1BcsAcA==,type:str] -GF_AUTH_GITHUB_CLIENT_SECRET=ENC[AES256_GCM,data:LGbZHhQeLcS01UJXtZefsGUi0P8lscZRrho0HGq+hKRuoy5RO1HssA==,iv:NATDvm3fKe4zjJ36PWb/iJLVA2lJsognRLg0TZY5yw4=,tag:JYatVnyvrc0dYue2aTh55Q==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4UiszMUJMYzFlWE5Qc3Zk\nUDF2V1BIYmIwbjVydHI5OTJoRVJXdFhDZDEwCmlEQTFKRUZwYVlQbVRJMEpHL2Vj\nd21pS3BwZHc3QklCMVl4ZHFzZUJYRWMKLS0tIEtQeFQvNGxRUGhqNnBzQ0ZvaXRY\nMFZjQlhobXBKT08yc0dLVGNwd0pHbmcKEAS/YRu2NhGCZLX+nkVjo7PEu7ZV4iCW\nyO9GIsaIQcN6FWmp5IkjdeM6jkHxSk+qHCL5bKx78EgPMgzKobjgzQ==\n-----END AGE ENCRYPTED FILE-----\n +#ENC[AES256_GCM,data:0mZOnEJyXdCzF0HYzuVFkOmGOpfsTzfHbrwmCwU7NyeaDKFpoTvH1fQW/XCcflGaWPRrKRyCEYOa4sdPnag=,iv:iiEYiIqvmvOFQTEDPFeu7fkluWf0z/v+tvDmY7L0cFE=,tag:Ds/EHF6XSGfnv8ZCa5OU6w==,type:comment] +#ENC[AES256_GCM,data:+au0EOdh5q7IY7qcP4Jko5dJz4AdDCF8ilBqKUCHb9hB8xlM2FHiyLrcf3Q0eYbAyg28xaTm+wewKDivvuoOv4grGohqezJ4Ypffm1Yg6g==,iv:zXrneKQUu+bL5oRFmz5nVdUcUolH0HDFuOSK460rmpc=,tag:WDybda6Q9WBEp+SuzfKQqw==,type:comment] +GF_AUTH_GITHUB_CLIENT_ID=ENC[AES256_GCM,data:mik8yBpOAkJXHQ8vEeMZV2Y+KY4=,iv:DNZLDWxnichM9E4fitJSQdf0lxNsneYJxleIs6CPJgs=,tag:aXCt7Ff5PggBWaXvRYv9tw==,type:str] +GF_AUTH_GITHUB_CLIENT_SECRET=ENC[AES256_GCM,data:jcGhMeBwOLZotvfExZWqyMtql1C7AC0hlus7Vl/RLvEjshPmNtO9yg==,iv:20HLImbrFMkWLQ+chInq0Ig9j2Q4NSLXFN+72jXxM/M=,tag:INKt4Icg9QI1PTD+KEfCHg==,type:str] +GRAFANA_DB_PASSWORD=ENC[AES256_GCM,data:o1G5rDo1G4RiOGBbd7KDcqEIKG7UuwTlVx7BI7eVHWI=,iv:Fd1wA+4uB7Ypix8n4aTGKJo6V+uiCkr4yZ3wkGZ5rQA=,tag:c0qpH/v7lgmU5yXZo7n+RA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBbnY1VndkeE9hYnByK3RU\nRDFUYU5DcWhCRXYrdStoallzU0ZnWkJJeEY4CnBUYUlIK2ZhcUV2aWhqSDFSYnhD\nb1hkOGdkSGFyajRXNVg5a1FZcE9mQzQKLS0tIEVKZThiV1RMdkhhaWlvM05vY1g4\nYTNYUkFaYUJzbVUvMi83eHg1NXhXcGMKShBIdPKho4WmWiykqFXdmQ/NRAKoUpUw\ntUQKcGlrhHRhpgaFTyeKj+UVD1y52cXWZX9xhFdJ7TFxEO1opNc9LQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n -sops_lastmodified=2026-03-27T15:40:55Z -sops_mac=ENC[AES256_GCM,data:pkYMdGTyjc6/ig/hdBD4GGeyiOkJBp/z9+w3vlT/bfHeje4FBZxjdmgykfCmNFJqov1MoysEsP0z8VZpdcSa+cjcJXVIhXcSpl4Uo/NXZuYRIuPkQztSGna+tStrpus4Zxk2SLe8IF5AFgPtuCwZvS7R7JE4KKbkKG/kfcTMrR8=,iv:SXzctdK5QEV6RZuSSxDor7MYPgEJGvSih4l3G2+lwYI=,tag:umAgJ94DM7DKVPPniCdRvw==,type:str] +sops_lastmodified=2026-03-27T19:07:05Z +sops_mac=ENC[AES256_GCM,data:hKi5AD4+FFL5/bDHnBfNvWipsUA0GOLVF0/l5o2I6bh5aD4l/ZJbL1hOdxq/2MO7eJPjej5AFqREEDbMKHwY7s9nvKJhNAjtg8ejRK/y1x8r8Skm790cjcT04hIbpIgc2yaM131eyOuz39q9HODAmCoLqrVBpkStEQYhId5gQcY=,iv:fFSYntpfurMfD5vzRgaVONMZe3NyxuJPw0w2lqqCyQs=,tag:8gPfB9sJpwUaG8cc43kesQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.9.4 From e866a1ad40480f36037ecaa9446f8f401fad884d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:16:54 +0100 Subject: [PATCH 013/764] Add Business Metrics Grafana dashboard Panels: - Stat cards: total users, routes, activities, active planner sessions - Time series: cumulative user growth, route growth - Bar charts: signups/day, routes/day, planner sessions/day - Table: top 20 routes by distance All queries use the grafana_reader PostgreSQL datasource. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../grafana/dashboards/business.json | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 infrastructure/grafana/dashboards/business.json diff --git a/infrastructure/grafana/dashboards/business.json b/infrastructure/grafana/dashboards/business.json new file mode 100644 index 0000000..8d29c53 --- /dev/null +++ b/infrastructure/grafana/dashboards/business.json @@ -0,0 +1,125 @@ +{ + "title": "Business Metrics", + "uid": "trails-business", + "timezone": "browser", + "refresh": "5m", + "panels": [ + { + "title": "Total Users", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 0 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { "rawSql": "SELECT count(*) AS \"Users\" FROM journal.users", "format": "table" } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" } } } + }, + { + "title": "Total Routes", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 0 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { "rawSql": "SELECT count(*) AS \"Routes\" FROM journal.routes", "format": "table" } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "green" } } } + }, + { + "title": "Total Activities", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 12, "y": 0 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { "rawSql": "SELECT count(*) AS \"Activities\" FROM journal.activities", "format": "table" } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" } } } + }, + { + "title": "Planner Sessions (active)", + "type": "stat", + "gridPos": { "h": 6, "w": 6, "x": 18, "y": 0 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { "rawSql": "SELECT count(*) AS \"Active Sessions\" FROM planner.sessions WHERE closed = false", "format": "table" } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "purple" } } } + }, + { + "title": "User Growth", + "type": "timeseries", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 6 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT created_at AS time, count(*) OVER (ORDER BY created_at) AS \"Total Users\" FROM journal.users ORDER BY created_at", + "format": "time_series" + } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" } } } + }, + { + "title": "Route Growth", + "type": "timeseries", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 6 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT created_at AS time, count(*) OVER (ORDER BY created_at) AS \"Total Routes\" FROM journal.routes ORDER BY created_at", + "format": "time_series" + } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "green" } } } + }, + { + "title": "Signups per Day", + "type": "barchart", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 16 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT date_trunc('day', created_at) AS time, count(*) AS \"Signups\" FROM journal.users GROUP BY 1 ORDER BY 1", + "format": "time_series" + } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" } } } + }, + { + "title": "Routes Created per Day", + "type": "barchart", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 16 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT date_trunc('day', created_at) AS time, count(*) AS \"Routes\" FROM journal.routes GROUP BY 1 ORDER BY 1", + "format": "time_series" + } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "green" } } } + }, + { + "title": "Planner Sessions per Day", + "type": "barchart", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 26 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT date_trunc('day', created_at) AS time, count(*) AS \"Sessions\" FROM planner.sessions GROUP BY 1 ORDER BY 1", + "format": "time_series" + } + ], + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "purple" } } } + }, + { + "title": "Top Routes by Distance", + "type": "table", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 26 }, + "datasource": { "uid": "postgres" }, + "targets": [ + { + "rawSql": "SELECT r.name, u.username AS owner, round(r.distance::numeric / 1000, 1) AS \"km\", round(r.elevation_gain::numeric) AS \"ascent (m)\", r.created_at FROM journal.routes r JOIN journal.users u ON r.owner_id = u.id ORDER BY r.distance DESC NULLS LAST LIMIT 20", + "format": "table" + } + ] + } + ] +} From 3914da7a57ea9bca3e86639d61c6d6a34d1ed860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:23:46 +0100 Subject: [PATCH 014/764] Fix Grafana alert rules: proper threshold expressions The threshold expressions were missing the 'expression' field referencing the query refId, causing 'no variable specified for refId C' errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../grafana/provisioning/alerting/alerts.yml | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml index b98bbd3..92e0e90 100644 --- a/infrastructure/grafana/provisioning/alerting/alerts.yml +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -8,22 +8,22 @@ groups: rules: - uid: disk-usage-high title: Disk usage > 80% - condition: C + condition: B data: - refId: A relativeTimeRange: { from: 600, to: 0 } datasourceUid: prometheus model: expr: (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 - intervalMs: 60000 - maxDataPoints: 43200 - - refId: C - relativeTimeRange: { from: 600, to: 0 } + instant: true + - refId: B datasourceUid: __expr__ model: type: threshold + expression: A conditions: - evaluator: { params: [80], type: gt } + operator: { type: and } reducer: { type: last } for: 15m annotations: @@ -31,22 +31,22 @@ groups: - uid: app-health-failing title: App health check failing - condition: C + condition: B data: - refId: A relativeTimeRange: { from: 300, to: 0 } datasourceUid: prometheus model: - expr: up{job=~"journal|planner"} == 0 - intervalMs: 15000 - maxDataPoints: 43200 - - refId: C - relativeTimeRange: { from: 300, to: 0 } + expr: up{job=~"journal|planner"} + instant: true + - refId: B datasourceUid: __expr__ model: type: threshold + expression: A conditions: - - evaluator: { params: [0], type: gt } + - evaluator: { params: [1], type: lt } + operator: { type: and } reducer: { type: last } for: 2m annotations: @@ -54,22 +54,22 @@ groups: - uid: error-rate-high title: Error rate > 5% - condition: C + condition: B data: - refId: A relativeTimeRange: { from: 600, to: 0 } datasourceUid: prometheus model: expr: sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) * 100 - intervalMs: 60000 - maxDataPoints: 43200 - - refId: C - relativeTimeRange: { from: 600, to: 0 } + instant: true + - refId: B datasourceUid: __expr__ model: type: threshold + expression: A conditions: - evaluator: { params: [5], type: gt } + operator: { type: and } reducer: { type: last } for: 5m annotations: From 296f9e57dd28b17dd2726918e875e13fbfe55084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:27:45 +0100 Subject: [PATCH 015/764] Add SMTP configuration to Grafana for alert emails Co-Authored-By: Claude Opus 4.6 (1M context) --- infrastructure/docker-compose.yml | 6 ++++++ infrastructure/secrets.infra.env | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index e537029..c97b378 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -155,6 +155,12 @@ services: GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool GF_AUTH_GITHUB_SCOPES: user:email,read:org GF_AUTH_DISABLE_LOGIN_FORM: "true" + GF_SMTP_ENABLED: "true" + GF_SMTP_HOST: ${GF_SMTP_HOST:-} + GF_SMTP_USER: ${GF_SMTP_USER:-} + GF_SMTP_PASSWORD: ${GF_SMTP_PASSWORD:-} + GF_SMTP_FROM_ADDRESS: noreply@trails.cool + GF_SMTP_FROM_NAME: trails.cool Grafana GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD:-} volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro diff --git a/infrastructure/secrets.infra.env b/infrastructure/secrets.infra.env index bda2951..3f259ac 100644 --- a/infrastructure/secrets.infra.env +++ b/infrastructure/secrets.infra.env @@ -1,11 +1,14 @@ -#ENC[AES256_GCM,data:0mZOnEJyXdCzF0HYzuVFkOmGOpfsTzfHbrwmCwU7NyeaDKFpoTvH1fQW/XCcflGaWPRrKRyCEYOa4sdPnag=,iv:iiEYiIqvmvOFQTEDPFeu7fkluWf0z/v+tvDmY7L0cFE=,tag:Ds/EHF6XSGfnv8ZCa5OU6w==,type:comment] -#ENC[AES256_GCM,data:+au0EOdh5q7IY7qcP4Jko5dJz4AdDCF8ilBqKUCHb9hB8xlM2FHiyLrcf3Q0eYbAyg28xaTm+wewKDivvuoOv4grGohqezJ4Ypffm1Yg6g==,iv:zXrneKQUu+bL5oRFmz5nVdUcUolH0HDFuOSK460rmpc=,tag:WDybda6Q9WBEp+SuzfKQqw==,type:comment] -GF_AUTH_GITHUB_CLIENT_ID=ENC[AES256_GCM,data:mik8yBpOAkJXHQ8vEeMZV2Y+KY4=,iv:DNZLDWxnichM9E4fitJSQdf0lxNsneYJxleIs6CPJgs=,tag:aXCt7Ff5PggBWaXvRYv9tw==,type:str] -GF_AUTH_GITHUB_CLIENT_SECRET=ENC[AES256_GCM,data:jcGhMeBwOLZotvfExZWqyMtql1C7AC0hlus7Vl/RLvEjshPmNtO9yg==,iv:20HLImbrFMkWLQ+chInq0Ig9j2Q4NSLXFN+72jXxM/M=,tag:INKt4Icg9QI1PTD+KEfCHg==,type:str] -GRAFANA_DB_PASSWORD=ENC[AES256_GCM,data:o1G5rDo1G4RiOGBbd7KDcqEIKG7UuwTlVx7BI7eVHWI=,iv:Fd1wA+4uB7Ypix8n4aTGKJo6V+uiCkr4yZ3wkGZ5rQA=,tag:c0qpH/v7lgmU5yXZo7n+RA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBbnY1VndkeE9hYnByK3RU\nRDFUYU5DcWhCRXYrdStoallzU0ZnWkJJeEY4CnBUYUlIK2ZhcUV2aWhqSDFSYnhD\nb1hkOGdkSGFyajRXNVg5a1FZcE9mQzQKLS0tIEVKZThiV1RMdkhhaWlvM05vY1g4\nYTNYUkFaYUJzbVUvMi83eHg1NXhXcGMKShBIdPKho4WmWiykqFXdmQ/NRAKoUpUw\ntUQKcGlrhHRhpgaFTyeKj+UVD1y52cXWZX9xhFdJ7TFxEO1opNc9LQ==\n-----END AGE ENCRYPTED FILE-----\n +#ENC[AES256_GCM,data:JvvZXUiIkpPVV/11ClLHHjghySNGUl7Y6LYgetLwZem2Y4nLRT8O/puqPaoUErBtFadZvv3jHYg0GYNGdjs=,iv:qEqWxBu2lIj6eF1QdHwzbtilEKAWvkPX+/ej1Mz33tA=,tag:RKhE8NJ0CCP6VSInIm43MA==,type:comment] +#ENC[AES256_GCM,data:IYPyc8spVmPQu+QmC8xHM2B250LuaVe/Co8jGqqu/dYsUGrN4MMUT9S3xsFrzu+qbmZFoiWuFRwvKnpkv83JP+Oiab7TNEJkU16lRmTAvw==,iv:tbsQLbSMbJCCOqlZZCTxsC9+0FJWXIoX7VuxXGhcV/8=,tag:SSBO6GlROMsUS/fIJELZ8g==,type:comment] +GF_AUTH_GITHUB_CLIENT_ID=ENC[AES256_GCM,data:htq48rFOLrBxXoHeQMDXS0cM3Hs=,iv:8w5H6sMr8zXqFGFSaCeiSEfZhR4ETN4ypUzHNxcvNN4=,tag:qkdBnizdOxOQ87JzGxwy1g==,type:str] +GF_AUTH_GITHUB_CLIENT_SECRET=ENC[AES256_GCM,data:tTkVsYmNgLKxbv8QVyHR52RAJNmX9L//8W/hfXK2jSvkkODNIQ7Y8A==,iv:QN7AhtS0iv4WQtwmetp8KYXbiOJpHC4lhKVxgpljng4=,tag:0c+zp4h7X/ibQHY/vebp8g==,type:str] +GRAFANA_DB_PASSWORD=ENC[AES256_GCM,data:aPA1BvrPUsUOwWZClrQfbdlUqOS5OpmSME7h9/m1DC0=,iv:fTpP7czGHZGUdbGE8lDVN61iQ/XyWQ9yt0B8jArjTs4=,tag:GGHgXehE6H1SSQq/P23nzw==,type:str] +GF_SMTP_HOST=ENC[AES256_GCM,data:rfGovWBsyxZsQ6sSv20/Sui0Pcww,iv:t4dlQXafGVYcE62udWGSwVcl/puz4yr52xLhczytsGw=,tag:TZvQpsdxJI4F9YPbiwOAhA==,type:str] +GF_SMTP_USER=ENC[AES256_GCM,data:R80opLVhdfTz9eK+jw5Enni5,iv:4lS+qDLIYBb3Pm6e70lOOLQQzWNGasI8xU8IMjSE8Tc=,tag:VJTSK+r7rMgFZvCtFLFruw==,type:str] +GF_SMTP_PASSWORD=ENC[AES256_GCM,data:GUREzRWoUpRKicl0hyRsGQ==,iv:Yv64LhLcJ/aDW+0zMcrlgFDMTxuIfSS8BZmp3TYAcvU=,tag:OEReZeEgxqbblGgUp2E6iw==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwOFVLYi9VYlRQdWwzK0g3\nNmRwemxjeU5zYWhPRzIxbzJWc0E2cXNYN2xRClVYUlkxWnIreUhtZ044dkhBeFBk\ncW9YZi9RN1E0Qjg4cVp4T2ZPaGR4WEUKLS0tIGFxQTV2blhzSmUvT2ljeFN2blVj\na3l1NGc5dEtZYkNJZG1GYlo2YXM3OVEKd1UVotkdkOqaA23UeEJckItDV7HU72uO\nH7Nza9clHgQwXqwYFFxBBAPRiVo/aX+J+jXELNp2fA/Wt/66SjrBig==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n -sops_lastmodified=2026-03-27T19:07:05Z -sops_mac=ENC[AES256_GCM,data:hKi5AD4+FFL5/bDHnBfNvWipsUA0GOLVF0/l5o2I6bh5aD4l/ZJbL1hOdxq/2MO7eJPjej5AFqREEDbMKHwY7s9nvKJhNAjtg8ejRK/y1x8r8Skm790cjcT04hIbpIgc2yaM131eyOuz39q9HODAmCoLqrVBpkStEQYhId5gQcY=,iv:fFSYntpfurMfD5vzRgaVONMZe3NyxuJPw0w2lqqCyQs=,tag:8gPfB9sJpwUaG8cc43kesQ==,type:str] +sops_lastmodified=2026-03-27T19:27:27Z +sops_mac=ENC[AES256_GCM,data:j+vdVbcJ1xPcD2+MY6lwLy2e2oPzP5Obk+sJL/2G3jttaPBe82io+E9MhrXJYRJPbKwVgwl8GY3FX4TG57jRknLolAk966fe8eQYnc+4waMBM+UQclfsBRa4M7H7DjWGB+fArBaDLKvotSjFais8BnVf/O9l/K9rVOlkWPlvnvo=,iv:wZJD5s0+llOmXEA6VRmjw9KJBTyx58EeaaHBdzkjhaY=,tag:ITh7F9Ul7pBCE7qX7qXUsg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.9.4 From 3c5fb517dccb2591f6de39ad218825a7065ee4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:42:10 +0100 Subject: [PATCH 016/764] Add health and metrics routes to Journal route config The route files existed but weren't registered in the explicit routes.ts, so React Router never compiled them into the build. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 1e9bb96..5195009 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -20,4 +20,6 @@ export default [ route("activities/:id", "routes/activities.$id.tsx"), route("users/:username", "routes/users.$username.tsx"), route("privacy", "routes/privacy.tsx"), + route("api/health", "routes/api.health.ts"), + route("api/metrics", "routes/api.metrics.ts"), ] satisfies RouteConfig; From 98df1501c706e961db83ee0c6ec94c0a698a7ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:46:16 +0100 Subject: [PATCH 017/764] Document explicit route registration requirement in CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index f50f611..c525e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ pnpm db:studio # Open Drizzle Studio (DB browser) ## 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 From 0ff9052ae83745d46b76f5549eeec1e1d6a32459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:48:27 +0100 Subject: [PATCH 018/764] Add version (git SHA) to health endpoints Returns SENTRY_RELEASE (set at build time) as the version field, making it easy to verify which commit is deployed. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/api.health.ts | 10 +++++----- apps/planner/server.ts | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/journal/app/routes/api.health.ts b/apps/journal/app/routes/api.health.ts index cc1a396..6603a65 100644 --- a/apps/journal/app/routes/api.health.ts +++ b/apps/journal/app/routes/api.health.ts @@ -1,13 +1,13 @@ import { data } from "react-router"; import { withDb } from "@trails-cool/db"; +const version = process.env.SENTRY_RELEASE ?? "dev"; + export async function loader() { try { - await withDb(async () => { - // withDb creates a connection — if it succeeds, DB is reachable - }); - return data({ status: "ok", db: "connected" }); + await withDb(async () => {}); + return data({ status: "ok", version, db: "connected" }); } catch { - return data({ status: "degraded", db: "unreachable" }, { status: 503 }); + return data({ status: "degraded", version, db: "unreachable" }, { status: 503 }); } } diff --git a/apps/planner/server.ts b/apps/planner/server.ts index ac4e6c3..6fd8c6d 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -71,16 +71,18 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis res.end(metrics); } +const version = process.env.SENTRY_RELEASE ?? "dev"; + async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { try { const { withDb, db } = await import("@trails-cool/db"); const { sql } = await import("drizzle-orm"); await withDb(async () => { await db.execute(sql`SELECT 1`); }); res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", db: "connected" })); + res.end(JSON.stringify({ status: "ok", version, db: "connected" })); } catch { res.writeHead(503, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "degraded", db: "unreachable" })); + res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" })); } } From 9187c3ce2b46d8ca68fff7817d3e9b4eceaea68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:52:10 +0100 Subject: [PATCH 019/764] Disable Sentry session replays Removes replayIntegration and replay sample rates from both apps. Quota was already exhausted. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/entry.client.tsx | 3 --- apps/planner/app/entry.client.tsx | 3 --- 2 files changed, 6 deletions(-) diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 1efd2c4..f77e5e9 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -18,12 +18,9 @@ Sentry.init({ createRoutesFromChildren, matchRoutes, }), - Sentry.replayIntegration(), ], environment: sentryEnvironment, tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysOnErrorSampleRate: 1.0, enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index b425aa0..a9813eb 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -18,12 +18,9 @@ Sentry.init({ createRoutesFromChildren, matchRoutes, }), - Sentry.replayIntegration(), ], environment: sentryEnvironment, tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysOnErrorSampleRate: 1.0, enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); From 7fdbd4cddf861ae32c496fe45d60bf50ae9a1ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 20:58:17 +0100 Subject: [PATCH 020/764] Fix Planner health check: use createDb instead of non-existent db export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @trails-cool/db doesn't export a db instance — only createDb and withDb. The health check was importing a non-existent export, always failing. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 6fd8c6d..cc984c8 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -75,9 +75,10 @@ const version = process.env.SENTRY_RELEASE ?? "dev"; async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { try { - const { withDb, db } = await import("@trails-cool/db"); + const { createDb } = await import("@trails-cool/db"); const { sql } = await import("drizzle-orm"); - await withDb(async () => { await db.execute(sql`SELECT 1`); }); + const db = createDb(); + await db.execute(sql`SELECT 1`); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", version, db: "connected" })); } catch { From d95d14212210211f03cac5d32570955c5dd3f473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 21:18:14 +0100 Subject: [PATCH 021/764] Fix passkey credential ID encoding and improve error handling Bug: @simplewebauthn v13 changed credential.id from Uint8Array to base64url string. Buffer.from(string) without encoding stored the ASCII text instead of decoded bytes. Authentication then failed to match because it correctly decoded with "base64url". Fix: Buffer.from(credential.id, "base64url") in both registration and add-passkey flows. Also: catch WebAuthn "not allowed" errors and show a friendly message instead of the raw browser error. Existing passkeys must be re-registered after deploy. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/auth.server.ts | 4 ++-- apps/journal/app/routes/auth.login.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 8905332..a85a55e 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -81,7 +81,7 @@ export async function finishRegistration( await db.insert(credentials).values({ id: randomUUID(), userId, - credentialId: Buffer.from(credential.id), + credentialId: Buffer.from(credential.id, "base64url"), publicKey: Buffer.from(credential.publicKey), counter: credential.counter, transports: response.response.transports, @@ -137,7 +137,7 @@ export async function addPasskeyFinish( await db.insert(credentials).values({ id: randomUUID(), userId, - credentialId: Buffer.from(credential.id), + credentialId: Buffer.from(credential.id, "base64url"), publicKey: Buffer.from(credential.publicKey), counter: credential.counter, transports: response.response.transports, diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index 9698f19..eaa0ed1 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -47,7 +47,12 @@ export default function LoginPage() { window.location.href = "/"; } } catch (err) { - setError((err as Error).message); + const message = (err as Error).message; + if (message.includes("timed out") || message.includes("not allowed")) { + setError("No passkey found for this site. Register a new account or use a magic link instead."); + } else { + setError(message); + } } finally { setLoading(false); } From 39b57b73b7fc9be4c342250b6d9b7aaaedfd2965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 21:33:34 +0100 Subject: [PATCH 022/764] Add passkey authentication E2E tests Uses Playwright's virtual WebAuthn authenticator (CDP) to test: - Register with passkey + sign in (full round-trip) - Login fails with no credential (friendly error message) - Duplicate email rejection - Duplicate username rejection Would have caught the credential encoding bug. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/auth.test.ts | 112 +++++++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 8 ++++ 2 files changed, 120 insertions(+) create mode 100644 e2e/auth.test.ts diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts new file mode 100644 index 0000000..ce19e79 --- /dev/null +++ b/e2e/auth.test.ts @@ -0,0 +1,112 @@ +import { test, expect, type CDPSession, type Page } from "@playwright/test"; + +// Virtual authenticator helpers +async function setupVirtualAuthenticator(cdp: CDPSession) { + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return authenticatorId; +} + +async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) { + await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); + await cdp.send("WebAuthn.disable"); +} + +async function registerUser(page: Page, email: string, username: string) { + await page.goto("/auth/register"); + await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); + await page.getByLabel("Email").click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Username").click(); + await page.getByLabel("Username").fill(username); + // Verify both fields retained values before submitting + await expect(page.getByLabel("Email")).toHaveValue(email); + await expect(page.getByLabel("Username")).toHaveValue(username); + await page.getByRole("button", { name: /Register with Passkey/ }).click(); +} + +async function logout(page: Page) { + await page.getByRole("navigation").getByRole("button", { name: "Log Out" }).click(); + await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 }); +} + +test.describe("Passkey Authentication", () => { + test("register with passkey and sign in", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + + const email = `test-${Date.now()}@example.com`; + const username = `testuser${Date.now()}`; + + // Register + await registerUser(page, email, username); + await expect(page).toHaveURL("/", { timeout: 10000 }); + await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 }); + + // Log out + await logout(page); + + // Sign in with passkey + await page.goto("/auth/login"); + await page.getByRole("button", { name: /Sign in with Passkey/ }).click(); + await expect(page).toHaveURL("/", { timeout: 10000 }); + await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("passkey login fails with no registered credential", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + + await page.goto("/auth/login"); + await page.getByRole("button", { name: /Sign in with Passkey/ }).click(); + await expect(page.getByText(/No passkey found/i)).toBeVisible({ timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("register rejects duplicate email", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + + const email = `dup-${Date.now()}@example.com`; + + // Register first user + await registerUser(page, email, `first${Date.now()}`); + await expect(page).toHaveURL("/", { timeout: 10000 }); + await logout(page); + + // Try to register with same email + await registerUser(page, email, `second${Date.now()}`); + await expect(page.getByText(/already in use/i)).toBeVisible({ timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("register rejects duplicate username", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + + const username = `uniq${Date.now()}`; + + // Register first user + await registerUser(page, `first-${Date.now()}@example.com`, username); + await expect(page).toHaveURL("/", { timeout: 10000 }); + await logout(page); + + // Try to register with same username + await registerUser(page, `second-${Date.now()}@example.com`, username); + await expect(page.getByText(/already taken/i)).toBeVisible({ timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index b8a79dc..ff32d8b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -31,6 +31,14 @@ export default defineConfig({ baseURL: "http://localhost:3001", }, }, + { + name: "auth", + testMatch: "auth.test.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:3000", + }, + }, { name: "integration", testMatch: "integration.test.ts", From f344c27478d673097b7d8108fd2ee2635c9a6c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 21:34:41 +0100 Subject: [PATCH 023/764] Allow Planner to connect to Journal in CSP The Planner's save-to-journal callback makes a cross-origin request to trails.cool from planner.trails.cool. Add the Journal's origin to the Planner's connect-src CSP directive. Co-Authored-By: Claude Opus 4.6 (1M context) --- infrastructure/Caddyfile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index 84d4eaf..f2a61b6 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -40,7 +40,14 @@ grafana.internal.{$DOMAIN:trails.cool} { } planner.{$DOMAIN:trails.cool} { - import security_headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self' wss: https://{$DOMAIN:trails.cool} https://*.sentry.io https://*.ingest.de.sentry.io; font-src 'self';" + } import block_scanners log { output stdout From 81d0feffdd14a40ce78a5c9f2616afc5bc8cd54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 21:44:07 +0100 Subject: [PATCH 024/764] Archive observability change, sync specs to main Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-27-observability}/.openspec.yaml | 0 .../2026-03-27-observability}/design.md | 0 .../2026-03-27-observability}/proposal.md | 0 .../specs/infrastructure/spec.md | 0 .../specs/observability/spec.md | 0 .../2026-03-27-observability}/tasks.md | 0 openspec/specs/infrastructure/spec.md | 14 ++++ openspec/specs/observability/spec.md | 67 +++++++++++++++++++ 8 files changed, 81 insertions(+) rename openspec/changes/{observability => archive/2026-03-27-observability}/.openspec.yaml (100%) rename openspec/changes/{observability => archive/2026-03-27-observability}/design.md (100%) rename openspec/changes/{observability => archive/2026-03-27-observability}/proposal.md (100%) rename openspec/changes/{observability => archive/2026-03-27-observability}/specs/infrastructure/spec.md (100%) rename openspec/changes/{observability => archive/2026-03-27-observability}/specs/observability/spec.md (100%) rename openspec/changes/{observability => archive/2026-03-27-observability}/tasks.md (100%) create mode 100644 openspec/specs/observability/spec.md diff --git a/openspec/changes/observability/.openspec.yaml b/openspec/changes/archive/2026-03-27-observability/.openspec.yaml similarity index 100% rename from openspec/changes/observability/.openspec.yaml rename to openspec/changes/archive/2026-03-27-observability/.openspec.yaml diff --git a/openspec/changes/observability/design.md b/openspec/changes/archive/2026-03-27-observability/design.md similarity index 100% rename from openspec/changes/observability/design.md rename to openspec/changes/archive/2026-03-27-observability/design.md diff --git a/openspec/changes/observability/proposal.md b/openspec/changes/archive/2026-03-27-observability/proposal.md similarity index 100% rename from openspec/changes/observability/proposal.md rename to openspec/changes/archive/2026-03-27-observability/proposal.md diff --git a/openspec/changes/observability/specs/infrastructure/spec.md b/openspec/changes/archive/2026-03-27-observability/specs/infrastructure/spec.md similarity index 100% rename from openspec/changes/observability/specs/infrastructure/spec.md rename to openspec/changes/archive/2026-03-27-observability/specs/infrastructure/spec.md diff --git a/openspec/changes/observability/specs/observability/spec.md b/openspec/changes/archive/2026-03-27-observability/specs/observability/spec.md similarity index 100% rename from openspec/changes/observability/specs/observability/spec.md rename to openspec/changes/archive/2026-03-27-observability/specs/observability/spec.md diff --git a/openspec/changes/observability/tasks.md b/openspec/changes/archive/2026-03-27-observability/tasks.md similarity index 100% rename from openspec/changes/observability/tasks.md rename to openspec/changes/archive/2026-03-27-observability/tasks.md diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 78a90cf..61fb6c5 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -121,3 +121,17 @@ Grafana SHALL authenticate users via GitHub OAuth, restricted to the trails-cool #### Scenario: No password-based login - **WHEN** Grafana is deployed - **THEN** the login form is disabled and only GitHub OAuth is available + +### Requirement: Docker Compose deployment +All services SHALL be deployed via Docker Compose, including Grafana, Prometheus, and Loki for the flagship instance. + +#### Scenario: Monitoring stack starts +- **WHEN** `docker compose up -d` is run +- **THEN** Grafana, Prometheus, and Loki containers start alongside the application containers + +### Requirement: Caddy access logging +Caddy SHALL emit structured JSON access logs for all requests. + +#### Scenario: Access log emitted +- **WHEN** any HTTP request passes through Caddy +- **THEN** a JSON log line with remote IP, method, path, status, and duration is written to stdout diff --git a/openspec/specs/observability/spec.md b/openspec/specs/observability/spec.md new file mode 100644 index 0000000..850e803 --- /dev/null +++ b/openspec/specs/observability/spec.md @@ -0,0 +1,67 @@ +## Requirements + +### Requirement: Health endpoints +Both apps SHALL expose a `/health` endpoint returning service and database status. + +#### Scenario: Healthy service +- **WHEN** the app is running and the database is reachable +- **THEN** `GET /health` returns `{ "status": "ok", "db": "connected" }` with HTTP 200 + +#### Scenario: Degraded service +- **WHEN** the app is running but the database is unreachable +- **THEN** `GET /health` returns `{ "status": "degraded", "db": "unreachable" }` with HTTP 503 + +### Requirement: Prometheus metrics +Both apps SHALL expose a `/metrics` endpoint with Prometheus-formatted metrics. + +#### Scenario: Default Node.js metrics +- **WHEN** Prometheus scrapes `/metrics` +- **THEN** it receives event loop lag, heap usage, and GC metrics + +#### Scenario: HTTP request metrics +- **WHEN** requests are served +- **THEN** `http_request_duration_seconds` histogram is updated with route, method, and status labels + +#### Scenario: Planner-specific metrics +- **WHEN** the Planner is running +- **THEN** `planner_active_sessions` and `planner_connected_clients` gauges reflect current state + +### Requirement: Structured logging +Both apps SHALL output structured JSON logs in production. + +#### Scenario: Request logging +- **WHEN** an HTTP request is served in production +- **THEN** a JSON log line is emitted with method, path, status, duration, and timestamp + +#### Scenario: Dev mode pretty printing +- **WHEN** running in development +- **THEN** logs are human-readable (pretty-printed) + +### Requirement: Grafana dashboards +The flagship instance SHALL have pre-configured Grafana dashboards. + +#### Scenario: Overview dashboard +- **WHEN** an operator opens Grafana +- **THEN** they see request rate, error rate, and latency percentiles + +#### Scenario: Infrastructure dashboard +- **WHEN** an operator checks infrastructure health +- **THEN** they see CPU, memory, disk usage, and DB connection pool stats + +### Requirement: Alerting +Grafana SHALL alert on critical conditions. + +#### Scenario: Disk usage alert +- **WHEN** disk usage exceeds 80% for 15 minutes +- **THEN** an alert fires + +#### Scenario: App down alert +- **WHEN** an app returns zero healthy responses for 2 minutes +- **THEN** an alert fires + +### Requirement: Log aggregation +Loki SHALL collect and index logs from all Docker containers. + +#### Scenario: Search logs +- **WHEN** an operator searches logs in Grafana +- **THEN** they can filter by container name, log level, and time range From 55afc4cbf3bf8fe42267204eb81b17352f2d42d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 28 Mar 2026 07:49:37 +0100 Subject: [PATCH 025/764] Fix Dependabot alerts: brace-expansion and path-to-regexp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Override brace-expansion >=4.0.0 <5.0.5 → 5.0.5 (process hang via zero-step sequences) - Override path-to-regexp <0.1.13 → 0.1.13 (ReDoS via Express transitive dep) - Dismissed esbuild alert #1 (already on 0.25.12+, alert for <=0.24.2) Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 4 +++- pnpm-lock.yaml | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 0624e3e..b2956db 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ }, "pnpm": { "overrides": { - "picomatch@>=4.0.0 <4.0.4": "4.0.4" + "picomatch@>=4.0.0 <4.0.4": "4.0.4", + "brace-expansion@>=4.0.0 <5.0.5": "5.0.5", + "path-to-regexp@<0.1.13": "0.1.13" }, "onlyBuiltDependencies": ["@sentry/cli", "esbuild"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d19e0cb..7497ab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,8 @@ catalogs: overrides: picomatch@>=4.0.0 <4.0.4: 4.0.4 + brace-expansion@>=4.0.0 <5.0.5: 5.0.5 + path-to-regexp@<0.1.13: 0.1.13 importers: @@ -2171,8 +2173,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} browserslist@4.28.1: @@ -3203,8 +3205,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -5723,7 +5725,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -6129,7 +6131,7 @@ snapshots: methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 0.1.12 + path-to-regexp: 0.1.13 proxy-addr: 2.0.7 qs: 6.14.2 range-parser: 1.2.1 @@ -6496,7 +6498,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimist@1.2.8: {} @@ -6592,7 +6594,7 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 - path-to-regexp@0.1.12: {} + path-to-regexp@0.1.13: {} pathe@1.1.2: {} From 2b0aba205f63c4a2f8aa1083d2045cf6c205bfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 28 Mar 2026 07:54:49 +0100 Subject: [PATCH 026/764] Fix error rate alert: avoid NaN when no traffic The error rate formula (errors/total*100) produces NaN when there's zero traffic (0/0). Grafana treats NaN as a firing condition. Use clamp_min on the denominator to return 0% instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- infrastructure/grafana/provisioning/alerting/alerts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml index 92e0e90..42594e5 100644 --- a/infrastructure/grafana/provisioning/alerting/alerts.yml +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -60,7 +60,7 @@ groups: relativeTimeRange: { from: 600, to: 0 } datasourceUid: prometheus model: - expr: sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_request_duration_seconds_count[5m])) * 100 + expr: sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) / clamp_min(sum(rate(http_request_duration_seconds_count[5m])), 0.001) * 100 instant: true - refId: B datasourceUid: __expr__ From 36b9b6b3827518ac5f0014d0fb19e6821a2ec1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 28 Mar 2026 19:28:08 +0100 Subject: [PATCH 027/764] Add visual redesign OpenSpec change with mockups and logo concepts Design direction: D1 warmth + D3 lightness (warm off-whites, sage green, Outfit + Geist Mono). Includes desktop + mobile HTML mockup, 5 logo concepts, and 33 implementation tasks across 7 phases. Logo decision: Waypoint Dot mark (3 connected dots) + wordmark combo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../changes/visual-redesign/.openspec.yaml | 2 + openspec/changes/visual-redesign/design.md | 179 ++ .../visual-redesign/logo-concepts.html | 231 +++ openspec/changes/visual-redesign/mockup.html | 1550 +++++++++++++++++ openspec/changes/visual-redesign/proposal.md | 49 + openspec/changes/visual-redesign/tasks.md | 61 + 6 files changed, 2072 insertions(+) create mode 100644 openspec/changes/visual-redesign/.openspec.yaml create mode 100644 openspec/changes/visual-redesign/design.md create mode 100644 openspec/changes/visual-redesign/logo-concepts.html create mode 100644 openspec/changes/visual-redesign/mockup.html create mode 100644 openspec/changes/visual-redesign/proposal.md create mode 100644 openspec/changes/visual-redesign/tasks.md diff --git a/openspec/changes/visual-redesign/.openspec.yaml b/openspec/changes/visual-redesign/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/visual-redesign/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/visual-redesign/design.md b/openspec/changes/visual-redesign/design.md new file mode 100644 index 0000000..5254af6 --- /dev/null +++ b/openspec/changes/visual-redesign/design.md @@ -0,0 +1,179 @@ +## Context + +The Planner was built function-first with default Tailwind styling. A design +exploration ("D1 warmth + D3 lightness") produced desktop and mobile mockups. +The reference HTML mockup is at `mockup.html`. + +## Goals / Non-Goals + +**Goals:** +- Implement the visual design from the mockup across all Planner components +- Establish a design token system (CSS vars + Tailwind) for consistency +- Make the Planner mobile-responsive with a bottom sheet sidebar +- Style aspirational features (day breakdown, waypoint notes) as muted/coming-soon + +**Non-Goals:** +- Redesigning the Journal app (separate change) +- Implementing multi-day splitting logic (just the visual containers) +- Implementing waypoint notes logic (just the UI placeholders) +- Dark mode (later) +- Landing page redesign (separate) + +## Decisions + +### D1: Design tokens as CSS custom properties + +```css +:root { + /* Surface */ + --bg: #F5F2EB; /* warm off-white, main background */ + --bg-subtle: #EDEAE1; /* card backgrounds */ + --bg-raised: #FAF8F4; /* topbar, sidebar, elevated surfaces */ + --map-tint: #E4DFD2; /* map background tone */ + + /* Text */ + --text-hi: #1A1916; /* primary */ + --text-md: #5C5847; /* secondary labels */ + --text-lo: #9A9484; /* tertiary, placeholders */ + --text-inv: #FAF8F4; /* on dark surfaces */ + + /* Accent: muted sage-forest green */ + --accent: #4A6B40; + --accent-dim: #6A8B5E; + --accent-bg: rgba(74,107,64,.08); + --accent-border: rgba(74,107,64,.2); + + /* Overnight stop: warm amber-brown */ + --stop: #8B6D3A; + --stop-bg: rgba(139,109,58,.1); + --stop-border:rgba(139,109,58,.25); + + /* Danger / no-go */ + --nogo: rgba(160,60,60,.12); + --nogo-border:rgba(160,60,60,.3); + + /* UI chrome */ + --border: #DDD9D0; + --border-md: #CAC6BC; + --shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); + --shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.05); + + /* Elevation gradient (route + chart) */ + --eg-lo: #5A8F46; /* green, low elevation */ + --eg-mid: #C4A840; /* amber, mid elevation */ + --eg-hi: #C46040; /* terracotta, high elevation */ + + /* Typography */ + --font-body: 'Outfit', sans-serif; + --font-mono: 'Geist Mono', monospace; +} +``` + +Extended into Tailwind via `tailwind.config.ts` theme extension so utility +classes work: `text-text-hi`, `bg-bg-raised`, `border-border`, etc. + +### D2: Typography & Logo + +- **Body**: Outfit (clean, geometric, friendly) — 14px base +- **Stats/distances**: Geist Mono — used for km, elevation, coordinates +- **Logo**: Two-part system: + - **Mark**: "Waypoint Dot" — three connected dots with a curved route line + (sage green, SVG). Used as favicon, app icon, and topbar mark. + - **Wordmark**: "trails" (Outfit 700) + ".cool" (Outfit 300, muted). Used + alongside the mark in the topbar, standalone for large displays. + - See `logo-concepts.html` for all sizes (24px, 48px, 128px). +- Loaded via Google Fonts or self-hosted for performance + +### D3: Topbar layout + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [∧ trails.cool] │ [🚴 Cycling (safe) ▾] │ [Y] You │ [A][S][M] +3 │ [+ Invite] ││ [Plain │ Elevation │ Surface] │ [↓ Export GPX] │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Waypoint Dot mark + "trails .cool" wordmark, separated by vertical divider +- Profile selector with bike icon +- Participant avatars with "You" label and Host badge +- "+ Invite" button (copies session link) +- Segmented toggle for color mode (replaces dropdown) +- Export GPX right-aligned + +### D4: Sidebar layout + +``` +┌─────────────────────────┐ +│ [WAYPOINTS] [NOTES] │ +├─────────────────────────┤ +│ ACTIVE ROUTE │ +│ Berlin → Erfurt │ +│ via Dessau │ +│ 343 km ↑868m 3 days │ +├─────────────────────────┤ +│ DAY BREAKDOWN [SOON] │ +│ ┌─────────────────────┐ │ +│ │ 01 Berlin→Dessau │ │ +│ │ ↑340m 120 km │ │ +│ │ ● Berlin Alexplatz │ │ +│ │ ○ Zossen │ │ +│ │ ○ Jüterbog │ │ +│ │ ● Dessau OVERNIGHT │ │ +│ │ "Elbe crossing" │ │ +│ └─────────────────────┘ │ +│ ▸ 02 Dessau→Halle 130km│ +│ ▸ 03 Halle→Erfurt 93km│ +└─────────────────────────┘ +``` + +- Route summary always visible at top +- Day breakdown collapsible, with waypoints nested inside +- Overnight stops marked with amber badge +- Waypoint notes as italic text under waypoint name +- Day 01 expanded by default, others collapsed + +### D5: Map marker styling + +- **Waypoint markers**: Dark olive circles (#4A6B40) with white number, not blue +- **Overnight stops**: Amber-brown (#8B6D3A) circle with "NIGHT N" badge above +- **Waypoint notes**: Small note icon on marker, hover/tap to read +- **Day labels**: White pill on route line ("Day 1 · 120 km") +- **No-go areas**: Use `--nogo` / `--nogo-border` tokens (muted red) +- **Ghost marker**: Sage green circle matching accent + +### D6: Elevation chart redesign + +- Day dividers as dashed vertical lines with "Day 1", "Day 2" labels +- Elevation gradient uses `--eg-lo` → `--eg-mid` → `--eg-hi` +- Min/max elevation labels on Y axis +- Km marker tooltip on hover (not just crosshair) +- Stats right-aligned: "343.3 km distance ↑ 868 m ascent" + +### D7: Mobile bottom sheet + +Replace hidden sidebar with a draggable bottom sheet: +- **Collapsed**: Route summary + elevation mini-chart visible +- **Half-expanded**: Tabs (Days/Waypoints/Notes) + scrollable content +- **Full-expanded**: Full sidebar content +- Swipe up/down to toggle states +- Map takes full viewport, sheet overlays from bottom + +### D8: Implementation phases + +1. **Design tokens + fonts**: CSS vars, Tailwind config, font loading +2. **Topbar**: New layout, segmented toggle, invite button, avatar styling +3. **Sidebar**: Route summary, day breakdown placeholder, waypoint styling +4. **Map markers**: New waypoint icons, overnight badges, ghost marker color +5. **Elevation chart**: New gradient, day dividers, hover label +6. **Mobile**: Bottom sheet component, responsive header +7. **Polish**: Transitions, hover states, loading states, empty states + +## Risks / Trade-offs + +- **Font loading**: Two Google Fonts add ~40KB. Mitigate with `display=swap` + and preconnect. Can self-host later. +- **Aspirational features shown as muted**: Users might expect them to work. + Mitigate with clear "SOON" badges. +- **Custom CSS vars + Tailwind**: Slight complexity. But ensures consistency + and makes future dark mode straightforward. +- **Bottom sheet on mobile**: Complex to build well. Can use a library like + `vaul` or build minimal version first. diff --git a/openspec/changes/visual-redesign/logo-concepts.html b/openspec/changes/visual-redesign/logo-concepts.html new file mode 100644 index 0000000..1a5e039 --- /dev/null +++ b/openspec/changes/visual-redesign/logo-concepts.html @@ -0,0 +1,231 @@ + + + + + +trails.cool — Logo Concepts + + + + + +

trails.cool — Logo Concepts

+

5 directions. Sage green (#4A6B40) on warm off-white. Avoiding mountain silhouettes (AllTrails territory).

+ +
+ + +
+

1. Route Trace

+

An organic curved line — the shape of a real route. Suggests movement, planning, flow.

+
+
+ + + + + +
128px
+
+
+ + + + + +
48px
+
+
+ + + + + +
24px
+
+
+
+ + + + + + trails .cool +
+
+ + +
+

2. Contour Ring

+

Topographic contour lines seen from above. Says "terrain" without being a mountain.

+
+
+ + + + + +
128px
+
+
+ + + + + +
48px
+
+
+ + + + +
24px
+
+
+
+ + + + + + trails .cool +
+
+ + +
+

3. Waypoint Dot

+

Three connected dots — the core gesture of the app. Place waypoints, get a route. Most ownable.

+
+
+ + + + + + 1 + 2 + 3 + +
128px
+
+
+ + + + + + +
48px
+
+
+ + + + + + +
24px
+
+
+
+ + + + + + + trails .cool +
+
+ + +
+

4. Elevation Mark

+

A mini elevation profile — the signature feature of the app. Not a mountain, it's data.

+
+
+ + + + + +
128px
+
+
+ + + + +
48px
+
+
+ + + +
24px
+
+
+
+ + + + + trails .cool +
+
+ + +
+

5. Wordmark Only

+

No mark. The typography IS the brand. The dot in ".cool" is the visual anchor.

+
+
+ + trails .cool + +
large
+
+
+ + trails.cool + +
medium
+
+
+ + t.c + +
favicon
+
+
+
+ + trails.cool + +
+
on dark
+
+ +
+ + + diff --git a/openspec/changes/visual-redesign/mockup.html b/openspec/changes/visual-redesign/mockup.html new file mode 100644 index 0000000..da2e4ff --- /dev/null +++ b/openspec/changes/visual-redesign/mockup.html @@ -0,0 +1,1550 @@ + + + + + +trails.cool — Revised Direction + + + + + + + + + + + + +
+
+
Revised direction — merging D1 warmth + D3 lightness
+
trails.cool — Desktop
+ +
+ + +
+ + + + + +
+
+ + Cycling (safe) + +
+
+ + +
+ + +
+
Y
+
+
You
+
Host
+
+
+ + +
+
A
+
S
+
M
+
+3
+
+ + + + + + + +
+ + +
+
+ + + +
+ +
+
+ Connected · cb6ada8e +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+
1
+
Berlin Alexanderplatz
+
+ + +
+
Night 1
+
+ 2 +
+
+
Dessau
+
+ + +
+
3
+
Erfurt Altstadt
+
+ + +
Day 1 · 120 km
+
Day 2 · 130 km
+ + +
+
+
Alex
+
+
+
+
Sara
+
+
+
+
Tom
+
+ +
© OpenStreetMap
+
+ + + +
+ + +
+
+ Elevation profile +
+ 343.3 km distance + ↑ 868 m ascent +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + Day 1 + Day 2 + Day 3 + + + 286m + 31m + 0 + 342 km + + + + + + + km 171 + +
+
+ +
+
+
+ + + + +
+
+
Revised direction
+
trails.cool — Mobile
+ +
+ + +
+ + +
+
+ 🚲 Cycling (safe) + +
+
+
+
Y
+
A
+
S
+
+3
+
+
+
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + +
+
1
+
Berlin
+
+
+
Night 1
+
2
+
Dessau
+
+
+
3
+
Erfurt
+
+ + +
Day 1 · 120 km
+ + +
+
+
Alex
+
+ +
© OpenStreetMap
+
+ + +
+
+ +
+
+
Berlin → Erfurt
+
3 days · via Dessau · Elevation gradient
+
+
+ 343 km + ↑ 868 m +
+
+ + +
+ + + + + + + + + + + + + Day 1 + Day 2 + Day 3 + +
+ + +
+ + + +
+ + +
+
+ 01 +
+
Berlin → Dessau
+
↑ 340 m ascent
+
+ 120 km +
+
+ 02 +
+
Dessau → Halle
+
↑ 310 m ascent
+
+ 130 km +
+
+ 03 +
+
Halle → Erfurt
+
↑ 218 m ascent
+
+ 93 km +
+
+
+ +
+
+
+ + + + diff --git a/openspec/changes/visual-redesign/proposal.md b/openspec/changes/visual-redesign/proposal.md new file mode 100644 index 0000000..6e713a9 --- /dev/null +++ b/openspec/changes/visual-redesign/proposal.md @@ -0,0 +1,49 @@ +## Why + +The current UI is functional but unstyled — default Tailwind colors, no visual +identity, no typography system. As features mature (route coloring, no-go areas, +multiplayer), the app needs a design language that matches the quality of the +interactions. Early testers find the tool powerful but visually generic. + +A design exploration produced a "Trail Worn warmth + Nordic Precision lightness" +direction: warm off-whites, sage green accent, earthy overnight markers, Outfit +font, Geist Mono for stats. The reference mockup lives at +`mockup.html` (desktop + mobile views). + +## What Changes + +- **Design system**: CSS custom properties for colors, typography, spacing. + Tailwind config extended with project tokens. Outfit (body) + Geist Mono + (stats) fonts. +- **Topbar redesign**: Logo with mountain mark, segmented color mode toggle, + participant avatars with names, "+ Invite" button, Export GPX right-aligned +- **Sidebar redesign**: Route summary header (name, stats, duration), collapsible + day breakdown (aspirational, "SOON" badge), waypoints nested inside days, + overnight badges on stop waypoints, waypoint notes +- **Map marker styling**: Olive/dark numbered circles instead of blue, "NIGHT" + badges on overnight stops, day segment labels on route +- **Elevation chart**: Day dividers as dashed vertical lines, elevation gradient + matching route colors, km hover label, min/max elevation labels +- **Mobile responsive**: Bottom sheet pattern replacing hidden sidebar, simplified + header, swipeable tabs (Days/Waypoints/Notes) + +## Capabilities + +### New Capabilities + +- `design-system`: Shared design tokens, typography, color palette, component + styling patterns for the Planner app + +### Modified Capabilities + +- `map-display`: New waypoint marker styling, overnight badges, day labels +- `planner-session`: Sidebar layout with route summary and day breakdown + +## Impact + +- **Tailwind config**: Extended with project color tokens and font families +- **All Planner components**: Updated styling (colors, typography, spacing) +- **New fonts**: Outfit + Geist Mono loaded via Google Fonts or self-hosted +- **No functional changes**: All existing interactions preserved, only visual +- **ElevationChart**: Canvas drawing updated for new color gradient + day dividers +- **Mobile**: New bottom sheet component for sidebar content diff --git a/openspec/changes/visual-redesign/tasks.md b/openspec/changes/visual-redesign/tasks.md new file mode 100644 index 0000000..c6ee3c7 --- /dev/null +++ b/openspec/changes/visual-redesign/tasks.md @@ -0,0 +1,61 @@ +## 1. Design Tokens & Typography + +- [ ] 1.1 Add CSS custom properties (`:root` vars) for colors, shadows, borders +- [ ] 1.2 Extend Tailwind config with project color tokens and font families +- [ ] 1.3 Add Outfit + Geist Mono fonts (Google Fonts or self-hosted) +- [ ] 1.4 Update base styles: body background, default text color, font-family +- [ ] 1.5 Update elevation gradient colors in ColoredRoute + ElevationChart to use tokens + +## 2. Topbar Redesign + +- [ ] 2.1 New logo: Waypoint Dot mark SVG (3 dots + route curve) + "trails .cool" wordmark (Outfit 700/300) +- [ ] 2.1b Generate favicon from Waypoint Dot mark (16px, 32px, 180px apple-touch) +- [ ] 2.2 Replace color mode dropdown with segmented toggle (Plain/Elevation/Surface) +- [ ] 2.3 Restyle participant avatars with name labels and Host badge +- [ ] 2.4 Add "+ Invite" button (copies session link to clipboard) +- [ ] 2.5 Restyle profile selector with bike/hike icon +- [ ] 2.6 Move Export GPX to right-aligned position +- [ ] 2.7 Apply token colors to topbar (--bg-raised, --border, etc.) + +## 3. Sidebar Redesign + +- [ ] 3.1 Add route summary header (route name, distance, ascent, duration) +- [ ] 3.2 Add day breakdown section with "SOON" badge (collapsible, placeholder) +- [ ] 3.3 Nest waypoints inside day sections (expandable/collapsible) +- [ ] 3.4 Style overnight stop waypoints with amber badge +- [ ] 3.5 Add waypoint note display (italic text under waypoint name, placeholder) +- [ ] 3.6 Apply token colors to sidebar (--bg-raised, --text-md, --accent, etc.) + +## 4. Map Marker Styling + +- [ ] 4.1 Replace blue waypoint markers with olive/dark circles (#4A6B40 accent) +- [ ] 4.2 Add overnight stop marker variant (amber-brown with "NIGHT N" badge) +- [ ] 4.3 Update ghost marker color to match accent +- [ ] 4.4 Update no-go area colors to use --nogo tokens +- [ ] 4.5 Add day segment labels on route ("Day 1 · 120 km") — placeholder/aspirational + +## 5. Elevation Chart Redesign + +- [ ] 5.1 Update chart gradient to use --eg-lo / --eg-mid / --eg-hi tokens +- [ ] 5.2 Add day divider vertical dashed lines with labels (aspirational) +- [ ] 5.3 Add min/max elevation labels on Y axis +- [ ] 5.4 Update hover tooltip to show km marker +- [ ] 5.5 Right-align stats display: "343 km distance ↑ 868 m ascent" + +## 6. Mobile Responsive + +- [ ] 6.1 Create bottom sheet component (collapsed/half/full states) +- [ ] 6.2 Move sidebar content into bottom sheet on mobile +- [ ] 6.3 Show route summary + mini elevation in collapsed state +- [ ] 6.4 Add swipeable tabs in bottom sheet (Days/Waypoints/Notes) +- [ ] 6.5 Simplify topbar on mobile (hide text labels, compact avatars) +- [ ] 6.6 Test touch interactions (map drag, waypoint tap, sheet swipe) + +## 7. Polish + +- [ ] 7.1 Add hover/focus states matching design tokens +- [ ] 7.2 Add transitions for sidebar collapse, bottom sheet, tab switches +- [ ] 7.3 Style loading states (connecting, computing route) +- [ ] 7.4 Style empty states (no waypoints, no route) +- [ ] 7.5 Add i18n keys for any new UI text (en + de) +- [ ] 7.6 Visual verification in cmux browser (desktop + mobile viewport) From 0a330e446616b7c2fc310dd70ef8fb7a03745945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 09:53:41 +0200 Subject: [PATCH 028/764] Break up route-features into focused specs, add new changes Archive the monolithic route-features spec and replace with 9 focused OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping), undo-redo, local-dev-stack, route-sharing, route-discovery, activity-photos, osm-overlays, plus the existing changelog and komoot-import. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../changes/activity-photos/.openspec.yaml | 2 + openspec/changes/activity-photos/design.md | 187 +++++++++++++ openspec/changes/activity-photos/proposal.md | 62 +++++ openspec/changes/activity-photos/tasks.md | 61 +++++ .../2026-03-28-route-features/.openspec.yaml | 2 + .../2026-03-28-route-features/design.md | 71 +++++ .../2026-03-28-route-features/proposal.md | 43 +++ .../specs/activity-feed/spec.md | 12 + .../specs/activity-photos/spec.md | 16 ++ .../specs/multi-day-routes/spec.md | 16 ++ .../specs/route-management/spec.md | 12 + .../specs/route-sharing/spec.md | 30 +++ .../specs/spatial-search/spec.md | 12 + .../2026-03-28-route-features/tasks.md | 68 +++++ .../changes/local-dev-stack/.openspec.yaml | 2 + openspec/changes/local-dev-stack/design.md | 151 +++++++++++ openspec/changes/local-dev-stack/proposal.md | 46 ++++ openspec/changes/local-dev-stack/tasks.md | 34 +++ .../changes/multi-day-routes/.openspec.yaml | 2 + openspec/changes/multi-day-routes/design.md | 211 +++++++++++++++ openspec/changes/multi-day-routes/proposal.md | 72 +++++ openspec/changes/multi-day-routes/tasks.md | 40 +++ openspec/changes/osm-overlays/.openspec.yaml | 2 + openspec/changes/osm-overlays/design.md | 186 +++++++++++++ openspec/changes/osm-overlays/proposal.md | 28 ++ .../osm-overlays/specs/map-display/spec.md | 20 ++ .../specs/osm-poi-overlays/spec.md | 83 ++++++ .../specs/osm-tile-overlays/spec.md | 68 +++++ .../specs/planner-session/spec.md | 24 ++ openspec/changes/osm-overlays/tasks.md | 64 +++++ .../changes/route-discovery/.openspec.yaml | 2 + openspec/changes/route-discovery/design.md | 177 ++++++++++++ openspec/changes/route-discovery/proposal.md | 62 +++++ openspec/changes/route-discovery/tasks.md | 43 +++ openspec/changes/route-sharing/.openspec.yaml | 2 + openspec/changes/route-sharing/design.md | 255 ++++++++++++++++++ openspec/changes/route-sharing/proposal.md | 77 ++++++ openspec/changes/route-sharing/tasks.md | 45 ++++ openspec/changes/undo-redo/.openspec.yaml | 2 + openspec/changes/undo-redo/design.md | 189 +++++++++++++ openspec/changes/undo-redo/proposal.md | 61 +++++ openspec/changes/undo-redo/tasks.md | 34 +++ .../changes/waypoint-notes/.openspec.yaml | 2 + openspec/changes/waypoint-notes/design.md | 250 +++++++++++++++++ openspec/changes/waypoint-notes/proposal.md | 76 ++++++ openspec/changes/waypoint-notes/tasks.md | 64 +++++ 46 files changed, 2968 insertions(+) create mode 100644 openspec/changes/activity-photos/.openspec.yaml create mode 100644 openspec/changes/activity-photos/design.md create mode 100644 openspec/changes/activity-photos/proposal.md create mode 100644 openspec/changes/activity-photos/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-route-features/design.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md create mode 100644 openspec/changes/archive/2026-03-28-route-features/tasks.md create mode 100644 openspec/changes/local-dev-stack/.openspec.yaml create mode 100644 openspec/changes/local-dev-stack/design.md create mode 100644 openspec/changes/local-dev-stack/proposal.md create mode 100644 openspec/changes/local-dev-stack/tasks.md create mode 100644 openspec/changes/multi-day-routes/.openspec.yaml create mode 100644 openspec/changes/multi-day-routes/design.md create mode 100644 openspec/changes/multi-day-routes/proposal.md create mode 100644 openspec/changes/multi-day-routes/tasks.md create mode 100644 openspec/changes/osm-overlays/.openspec.yaml create mode 100644 openspec/changes/osm-overlays/design.md create mode 100644 openspec/changes/osm-overlays/proposal.md create mode 100644 openspec/changes/osm-overlays/specs/map-display/spec.md create mode 100644 openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md create mode 100644 openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md create mode 100644 openspec/changes/osm-overlays/specs/planner-session/spec.md create mode 100644 openspec/changes/osm-overlays/tasks.md create mode 100644 openspec/changes/route-discovery/.openspec.yaml create mode 100644 openspec/changes/route-discovery/design.md create mode 100644 openspec/changes/route-discovery/proposal.md create mode 100644 openspec/changes/route-discovery/tasks.md create mode 100644 openspec/changes/route-sharing/.openspec.yaml create mode 100644 openspec/changes/route-sharing/design.md create mode 100644 openspec/changes/route-sharing/proposal.md create mode 100644 openspec/changes/route-sharing/tasks.md create mode 100644 openspec/changes/undo-redo/.openspec.yaml create mode 100644 openspec/changes/undo-redo/design.md create mode 100644 openspec/changes/undo-redo/proposal.md create mode 100644 openspec/changes/undo-redo/tasks.md create mode 100644 openspec/changes/waypoint-notes/.openspec.yaml create mode 100644 openspec/changes/waypoint-notes/design.md create mode 100644 openspec/changes/waypoint-notes/proposal.md create mode 100644 openspec/changes/waypoint-notes/tasks.md diff --git a/openspec/changes/activity-photos/.openspec.yaml b/openspec/changes/activity-photos/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/activity-photos/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/activity-photos/design.md b/openspec/changes/activity-photos/design.md new file mode 100644 index 0000000..c59061e --- /dev/null +++ b/openspec/changes/activity-photos/design.md @@ -0,0 +1,187 @@ +## Context + +The Journal stores activities in `journal.activities` with text fields (name, +description, stats) and an optional GPX track. The existing `photos` jsonb +column on activities was a placeholder -- it stores an array of strings but has +no upload pipeline, no storage backend, and no UI. + +The architecture plan calls for Garage (S3-compatible, self-hosted) as the +object store. The Garage container is already defined in docker-compose.yml but +commented out. The Journal app has no S3 client code. + +Activity detail page (`activities.$id.tsx`) currently shows stats, description, +and route linking -- no photo display. + +## Goals / Non-Goals + +**Goals:** +- Photo upload on activity pages, stored in S3-compatible storage (Garage) +- Photo gallery display on activity detail page +- Client-side image processing (resize, EXIF strip) before upload +- Presigned URL upload flow (no server proxying) +- Soft delete with S3 cleanup +- Privacy manifest update + +**Non-Goals:** +- Video uploads +- GPS-tagged photo placement on route maps +- Photo editing, cropping, or filters +- CDN or edge caching +- Cross-activity photo albums + +## Decisions + +### D1: Storage -- Garage (S3-compatible, self-hosted) + +Enable the existing Garage container in docker-compose.yml. Single bucket +(`activity-photos`) for all photo objects. Keys follow the pattern: + +``` +{activityId}/{photoId}.webp +``` + +Garage runs as an internal service -- not exposed to the public internet. The +Journal app generates presigned URLs for both upload (PUT) and download (GET). +This keeps the storage layer behind the application. + +Environment variables added to the Journal service: +- `S3_ENDPOINT` -- internal Garage URL (e.g. `http://garage:3900`) +- `S3_ACCESS_KEY` / `S3_SECRET_KEY` -- Garage API credentials +- `S3_BUCKET` -- bucket name (`activity-photos`) +- `S3_PUBLIC_ENDPOINT` -- optional public URL for serving photos (falls back + to presigned GET URLs if not set) + +### D2: Upload flow -- presigned PUT URLs + +The client never sends photo bytes to the Journal app server. Flow: + +1. Client selects photo(s) and processes them (resize, EXIF strip, convert to + WebP) +2. Client requests a presigned PUT URL from `POST /api/activities/:id/photos` + with metadata (filename, content type, dimensions) +3. Server validates the user owns the activity, generates a presigned PUT URL + (5 min expiry), creates a pending `activity_photos` row, returns the URL + and photo ID +4. Client uploads directly to Garage using the presigned PUT URL +5. Client confirms upload via `PATCH /api/activities/:id/photos/:photoId` with + `status: 'uploaded'` +6. Server verifies the object exists in S3 and marks the photo as active + +This avoids proxying large files through the Node.js server and keeps the app +server lightweight. Pending photos that are never confirmed are cleaned up by +a periodic check (or on next page load). + +### D3: Schema -- `journal.activity_photos` table + +```sql +journal.activity_photos ( + id text PRIMARY KEY, + activity_id text NOT NULL REFERENCES journal.activities(id) ON DELETE CASCADE, + s3_key text NOT NULL, + alt_text text, + width integer, + height integer, + size_bytes integer, + status text NOT NULL DEFAULT 'pending', -- 'pending', 'active', 'deleted' + created_at timestamptz NOT NULL DEFAULT now() +) +``` + +The existing `photos` jsonb column on `journal.activities` is superseded by +this table. It can be dropped in a future migration once this feature is stable. + +Photos are ordered by `created_at` (upload order). No explicit `position` +column initially -- reordering can be added later if users request it. + +### D4: Image processing -- client-side resize and EXIF strip + +All image processing happens in the browser before upload: + +- **Resize**: Max 2048px on the long edge. Maintains aspect ratio. Uses + canvas `drawImage` for the resize. +- **Format**: Convert to WebP (broad browser support, good compression). Fall + back to JPEG for browsers without WebP encode support. +- **EXIF stripping**: Read the image via canvas `drawImage` -- this + inherently drops EXIF data since canvas does not preserve metadata. This + removes GPS coordinates, camera info, timestamps, and other metadata that + users may not want to share. +- **Size limit**: Max 5 MB per photo after processing. The presigned URL has a + content-length condition enforcing this. + +No server-side image processing. This keeps the server simple and avoids +adding Sharp or similar native dependencies. + +### D5: Gallery -- grid display with lightbox + +Activity detail page shows photos in a responsive grid: + +- 1 column on mobile, 2 on tablet, 3 on desktop +- Aspect ratio preserved via `object-cover` in fixed-height cells +- Lazy loading via `loading="lazy"` on `` tags +- Click opens a lightbox overlay with full-size image, left/right navigation, + close on escape/click-outside +- Alt text shown below image in lightbox (if provided) +- Owner sees an "Add photos" button and delete icons on each photo + +The gallery uses presigned GET URLs with 1-hour expiry, generated at page load +time in the loader. For the lightbox, the same URLs are reused (they are valid +long enough for a viewing session). + +### D6: Deletion -- soft delete with background cleanup + +Deleting a photo sets `status = 'deleted'` in the database. The photo +immediately disappears from the UI. The actual S3 object is cleaned up: + +- On the next page load of the activity (loader checks for deleted photos and + removes them from S3) +- Or by a periodic cleanup that runs on app startup and every 24 hours + +This avoids the complexity of a separate job queue while ensuring S3 objects +are eventually cleaned up. The cleanup is idempotent -- deleting a +non-existent S3 key is a no-op. + +### D7: Privacy -- manifest and EXIF documentation + +Update the privacy page to document: + +- **Photo storage**: Photos uploaded to activities are stored in S3-compatible + object storage (Garage) on the same server as the Journal. Self-hosters + control their own storage. +- **EXIF stripping**: All photo metadata (GPS coordinates, camera info, + timestamps) is stripped in the browser before upload. The server never sees + original EXIF data. +- **Deletion**: Deleted photos are removed from storage. No backups are kept + beyond what the storage system provides. +- **Data export**: Photos are included in data exports (future). + +### D8: Limits -- per-activity and per-photo constraints + +- Max **20 photos** per activity. Enforced server-side when generating + presigned URLs. Client shows remaining count. +- Max **5 MB** per photo (after client-side processing). Enforced via + presigned URL content-length condition. +- Max **2048px** long edge (client-side resize). Not enforced server-side -- + the 5 MB limit is the hard constraint. +- Supported input formats: JPEG, PNG, WebP, HEIC (converted to WebP on + client). + +## Risks / Trade-offs + +- **Garage operational complexity**: Adding another container increases the + operational surface. Garage is lightweight but needs monitoring. Mitigate by + adding a Garage health check to the existing Prometheus setup. +- **Client-side processing reliability**: Canvas-based resize may behave + differently across browsers, especially for HEIC on non-Safari browsers. + Mitigate by testing on Chrome, Firefox, and Safari, and falling back to + JPEG if WebP encoding fails. +- **Presigned URL expiry**: If a user's upload takes longer than 5 minutes + (slow connection, large batch), the presigned URL expires. Mitigate by + generating URLs one at a time as each upload starts, not all upfront. +- **No server-side validation of image content**: The server trusts that the + client uploaded a valid image. A malicious user could upload non-image data. + Mitigate by checking Content-Type on the S3 object during confirmation step + and adding server-side validation later if abuse occurs. +- **EXIF stripping depends on canvas**: The canvas approach strips EXIF + reliably but loses all metadata including orientation. The resize step + handles orientation via `createImageBitmap` with `imageOrientation: 'from-image'` + before drawing to canvas. diff --git a/openspec/changes/activity-photos/proposal.md b/openspec/changes/activity-photos/proposal.md new file mode 100644 index 0000000..5e9d482 --- /dev/null +++ b/openspec/changes/activity-photos/proposal.md @@ -0,0 +1,62 @@ +## Why + +Activities are text-only. Users complete a hike or bike tour and want to share +photos from the trip alongside their route and stats, but there is no way to +attach images. This is the most basic social feature missing from the Journal -- +without it, activities feel like spreadsheet rows rather than trip reports. + +The infrastructure for photo storage (Garage, an S3-compatible self-hosted +object store) is already planned and commented out in docker-compose.yml. This +change enables it and builds the upload + display pipeline. + +## What Changes + +- **Infrastructure**: Enable the Garage container in docker-compose, create a + bucket for activity photos, add S3 client utility to the Journal app. +- **Schema**: New `journal.activity_photos` table linking photos to activities + with metadata (S3 key, alt text, dimensions). +- **Upload flow**: Server generates presigned PUT URLs, client uploads directly + to Garage. Photos are resized and EXIF-stripped on the client before upload. +- **Display**: Photo gallery grid on the activity detail page with lightbox + view and lazy loading. +- **Deletion**: Soft delete with background S3 cleanup. +- **Privacy**: Photo storage documented in privacy manifest, EXIF stripping + explained. + +## Capabilities + +### New Capabilities + +- `activity-photos`: Photo upload, storage, gallery display, and deletion on + Journal activities + +### Modified Capabilities + +- `infrastructure`: Garage S3 container enabled, bucket provisioned +- `activity-management`: Activity detail page gains photo gallery and upload UI +- `privacy-manifest`: Documents photo storage, EXIF handling, S3 retention + +## Non-Goals + +- **Video uploads**: Photos only. Video adds transcoding complexity. +- **GPS-tagged photo mapping**: Placing photos on the route map by GPS + coordinates is a future enhancement, not part of this change. +- **Photo editing or filters**: Upload as-is (after resize and EXIF strip). +- **CDN**: Serve directly from Garage via presigned GET URLs. CDN layer is a + future optimization. +- **Photo albums or collections**: Photos belong to a single activity. No + cross-activity albums. +- **Bulk import from external services**: Manual upload only. + +## Impact + +- **Infrastructure**: Garage container added to docker-compose, new volume for + object storage, Caddy config for S3 endpoint (internal only) +- **Database**: New `journal.activity_photos` table +- **Dependencies**: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` + for presigned URL generation; client-side image resize library +- **Files**: Activity detail page, new upload component, new gallery component, + storage server utility, privacy page update +- **Environment variables**: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, + `S3_BUCKET` added to Journal service +- **Privacy**: Photo storage and EXIF handling documented in manifest diff --git a/openspec/changes/activity-photos/tasks.md b/openspec/changes/activity-photos/tasks.md new file mode 100644 index 0000000..c1f14e0 --- /dev/null +++ b/openspec/changes/activity-photos/tasks.md @@ -0,0 +1,61 @@ +## 1. Infrastructure + +- [ ] 1.1 Enable Garage container in `infrastructure/docker-compose.yml`: uncomment the existing service, add `garage_data` volume, add health check, add `garage.toml` config file with single-node setup +- [ ] 1.2 Create `activity-photos` bucket in Garage: add an init script or document the `garage bucket create` command, generate API key with read/write access to the bucket +- [ ] 1.3 Add S3 environment variables to Journal service in docker-compose: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `S3_PUBLIC_ENDPOINT` + +## 2. S3 Client & Server Utilities + +- [ ] 2.1 Add `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` dependencies to the Journal app +- [ ] 2.2 Create `apps/journal/app/lib/storage.server.ts`: S3 client singleton, `generateUploadUrl(key, contentType, maxBytes)`, `generateDownloadUrl(key, expiresIn)`, `deleteObject(key)`, `headObject(key)` functions +- [ ] 2.3 Add env validation for S3 variables in Journal app startup (fail loudly if missing when photo feature is used) + +## 3. Database Schema + +- [ ] 3.1 Add `activityPhotos` table to `packages/db/src/schema/journal.ts`: id, activityId (FK to activities, cascade delete), s3Key, altText, width, height, sizeBytes, status (pending/active/deleted), createdAt +- [ ] 3.2 Push schema with `pnpm db:push` and verify table exists in local PostgreSQL + +## 4. Upload API + +- [ ] 4.1 Create `apps/journal/app/routes/api.activities.$id.photos.ts`: POST handler that validates ownership, checks photo count limit (max 20), generates presigned PUT URL, creates pending `activity_photos` row, returns URL + photo ID +- [ ] 4.2 Create `apps/journal/app/routes/api.activities.$id.photos.$photoId.ts`: PATCH handler to confirm upload (verify S3 object exists via HEAD, set status to active), DELETE handler to soft-delete (set status to deleted) +- [ ] 4.3 Register both API routes in `apps/journal/app/routes.ts` + +## 5. Client-Side Image Processing + +- [ ] 5.1 Create `apps/journal/app/lib/image-processing.ts`: `resizeImage(file, maxDimension)` using canvas, outputs WebP blob (JPEG fallback), strips EXIF by virtue of canvas redraw, handles orientation via `createImageBitmap` +- [ ] 5.2 Create `apps/journal/app/components/PhotoUploader.tsx`: file input (accept image types), processes each file through `resizeImage`, requests presigned URL from API, uploads to S3 via fetch PUT, confirms via PATCH, shows progress per photo +- [ ] 5.3 Add size and count validation in the uploader: max 5 MB per photo after processing, max 20 photos per activity, show clear error messages + +## 6. Photo Display + +- [ ] 6.1 Update activity detail loader (`activities.$id.tsx`) to fetch active photos for the activity and generate presigned GET URLs (1 hour expiry) +- [ ] 6.2 Create `apps/journal/app/components/PhotoGallery.tsx`: responsive grid (1/2/3 columns), `object-cover` thumbnails, `loading="lazy"`, click to open lightbox +- [ ] 6.3 Create `apps/journal/app/components/PhotoLightbox.tsx`: full-screen overlay, left/right navigation, close on escape/backdrop click, alt text display +- [ ] 6.4 Integrate PhotoGallery into activity detail page, show "Add photos" button for owner + +## 7. Photo Management + +- [ ] 7.1 Add delete button (trash icon) on each photo in gallery view (owner only), calls DELETE endpoint, optimistically removes from UI +- [ ] 7.2 Add alt text editing: inline text input below each photo in edit mode, saves via PATCH endpoint + +## 8. Cleanup + +- [ ] 8.1 Add cleanup logic in `storage.server.ts`: function to find photos with status `deleted` or `pending` older than 1 hour, delete S3 objects, remove database rows +- [ ] 8.2 Call cleanup on activity detail page load (non-blocking) and on app startup + +## 9. Privacy Manifest + +- [ ] 9.1 Update `apps/journal/app/routes/privacy.tsx`: add "Photos" section documenting S3 storage, EXIF stripping, deletion behavior, data export plans +- [ ] 9.2 Add note about EXIF stripping in the upload UI (tooltip or help text explaining GPS/metadata removal) + +## 10. i18n + +- [ ] 10.1 Add translation keys (en + de) for: "Add photos", "Delete photo", "Photo uploaded", upload errors, photo count limit, alt text placeholder, EXIF stripping explanation, privacy manifest photo section + +## 11. Testing + +- [ ] 11.1 Unit tests for `image-processing.ts`: verify resize output dimensions, WebP output, size within limits, EXIF data absent from output +- [ ] 11.2 Unit tests for `storage.server.ts`: presigned URL generation, delete, head object (mock S3 client) +- [ ] 11.3 Unit tests for photo API routes: ownership validation, count limits, status transitions (pending -> active -> deleted) +- [ ] 11.4 E2E test: upload a photo on an activity, verify it appears in the gallery, delete it, verify it disappears diff --git a/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml b/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml new file mode 100644 index 0000000..40c5540 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-25 diff --git a/openspec/changes/archive/2026-03-28-route-features/design.md b/openspec/changes/archive/2026-03-28-route-features/design.md new file mode 100644 index 0000000..9d8adaa --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/design.md @@ -0,0 +1,71 @@ +## Context + +The architecture doc defines a rich route model with permissions, multi-day +support, spatial queries, and photos. Currently routes are owner-only, flat +(no days), and have no photos or spatial search. This change adds the route +and activity features needed before federation makes them visible to others. + +## Goals / Non-Goals + +**Goals:** +- Route visibility (private/public) and sharing with specific users +- Fork public routes +- Multi-day route support with day-break waypoints +- Map-based route discovery via PostGIS +- Photo attachments on activities +- Contributor tracking on route versions + +**Non-Goals:** +- Federation of routes (separate change) +- Real-time collaborative permission changes +- Photo editing or filters +- Route recommendations based on preferences +- Activity collections (multi-day trip grouping — future) + +## Decisions + +### D1: Visibility as enum column on routes and activities + +Add `visibility` column (`public`, `private`, default `private`) to both +routes and activities. `followers_only` added later when following exists. +Public routes appear in spatial search. Private routes are owner-only. + +### D2: Route shares table for explicit sharing + +``` +journal.route_shares (routeId, userId, permission: 'view' | 'edit') +``` + +Owner can share with specific users. Shared users see the route in their +"Shared with me" section. Edit permission allows starting Planner sessions. + +### D3: Day breaks as waypoint property + +The Planner's Yjs waypoint Y.Map gets an optional `isDayBreak: true` field. +The sidebar and elevation chart show day boundaries. GPX export uses one +track segment per day. The Journal stores day-break indices in route metadata. + +### D4: PostGIS spatial search + +Routes already store geometry as PostGIS LineString. Add a `/routes/explore` +page with a map — as the user pans/zooms, query public routes within the +bounding box using `ST_Intersects`. Show route previews on the map. + +### D5: Photo storage via S3 (Garage) + +Use the Garage S3-compatible storage already in the architecture. Upload flow: +server generates presigned PUT URL → client uploads directly to S3 → server +stores the S3 key in a `journal.activity_photos` table. Serve via presigned +GET URLs or a CDN path. + +### D6: Contributor tracking via array column + +Add `contributors` text array to `journal.route_versions`. When a Planner +session saves back via callback, the JWT identifies the actor. The version +records the contributor's user ID (or ActivityPub URI for future federation). + +## Risks / Trade-offs + +- **S3 setup required** → Garage needs to be enabled in docker-compose (currently commented out). Adds operational complexity. +- **Spatial search performance** → PostGIS bounding box queries are fast with spatial indexes. Already have the geometry column. +- **Multi-day in Planner requires Yjs changes** → Adding `isDayBreak` to waypoints is backward-compatible (optional field). Existing sessions unaffected. diff --git a/openspec/changes/archive/2026-03-28-route-features/proposal.md b/openspec/changes/archive/2026-03-28-route-features/proposal.md new file mode 100644 index 0000000..b8b74df --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/proposal.md @@ -0,0 +1,43 @@ +## Why + +Routes are currently flat — no permissions beyond owner, no way to share with +specific people, no forking, no multi-day support, no spatial discovery, and +no contributor tracking. The architecture doc specifies all of these for Phase +2. Without them, routes are just personal GPX storage with no social or +collaborative dimension. + +## What Changes + +- **Route sharing permissions**: Private/public/shared visibility levels with + a view/edit permission matrix +- **Route forking**: Copy someone else's public route to your own collection +- **Multi-day routes**: Day-break waypoints that split a route into stages with + per-day stats and GPX segments +- **Spatial search**: "Routes near me" or "routes in this area" using PostGIS +- **Contributor tracking**: Record who contributed to each route version +- **Activity visibility levels**: Public/followers-only/private on activities +- **Photo attachments**: Upload photos to activities (requires S3/Garage setup) + +## Capabilities + +### New Capabilities + +- `route-sharing`: Permission model (private/public/shared), view/edit access, route forking +- `spatial-search`: Map-based route discovery using PostGIS bounding box and proximity queries +- `multi-day-routes`: Day-break markers on waypoints, per-day stats, multi-segment GPX export +- `activity-photos`: Photo upload and display on activities via S3-compatible storage + +### Modified Capabilities + +- `route-management`: Add visibility, contributor tracking, forking +- `activity-feed`: Add visibility levels (public/followers-only/private) +- `planner-session`: Support day-break waypoint markers +- `infrastructure`: S3/Garage setup for photo storage + +## Impact + +- **Database**: New columns (visibility, contributors) on routes and activities, new route_shares table, photo storage metadata +- **Storage**: S3-compatible object storage (Garage) for photos +- **Files**: Route detail page, activity pages, search page, Planner waypoint UI, sharing UI +- **Dependencies**: S3 client library for photo uploads +- **Privacy**: Photo storage, route visibility controls documented in manifest diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md new file mode 100644 index 0000000..55ea432 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Activity visibility +Activities SHALL have visibility levels controlling who can see them. + +#### Scenario: Private activity +- **WHEN** an activity's visibility is "private" +- **THEN** only the owner can view it + +#### Scenario: Public activity +- **WHEN** an activity's visibility is "public" +- **THEN** anyone can view it on the owner's profile diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md new file mode 100644 index 0000000..6ba4a24 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Photo attachments on activities +Users SHALL be able to upload photos to their activities. + +#### Scenario: Upload photo +- **WHEN** a user adds a photo to an activity +- **THEN** the photo is uploaded to S3 storage and linked to the activity + +#### Scenario: View photos +- **WHEN** a user views an activity with photos +- **THEN** the photos are displayed in a gallery on the activity page + +#### Scenario: Delete photo +- **WHEN** a user deletes a photo from their activity +- **THEN** the photo is removed from storage and unlinked diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md new file mode 100644 index 0000000..40f5f57 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Day-break waypoints +Waypoints in the Planner SHALL support a day-break marker that splits the route into stages. + +#### Scenario: Mark day break +- **WHEN** a user marks a waypoint as a day break in the Planner +- **THEN** the route is visually split into days at that point + +#### Scenario: Per-day statistics +- **WHEN** a route has day-break markers +- **THEN** the sidebar shows distance and elevation per day/stage + +#### Scenario: GPX export with day segments +- **WHEN** a multi-day route is exported as GPX +- **THEN** each day is a separate track segment in the GPX file diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md new file mode 100644 index 0000000..a54b15b --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Route metadata +Routes SHALL track visibility, contributors, and support forking. + +#### Scenario: Visibility on route creation +- **WHEN** a user creates a route +- **THEN** the route defaults to "private" visibility + +#### Scenario: Contributor recorded on version +- **WHEN** a Planner session saves a new route version via callback +- **THEN** the version records the contributor who made the edit diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md new file mode 100644 index 0000000..bfb69f8 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Route visibility levels +Routes SHALL have a visibility setting controlling who can see them. + +#### Scenario: Private route +- **WHEN** a route's visibility is "private" +- **THEN** only the owner can view it + +#### Scenario: Public route +- **WHEN** a route's visibility is "public" +- **THEN** anyone can view it and export its GPX + +### Requirement: Share routes with specific users +Route owners SHALL be able to share routes with specific users at view or edit permission levels. + +#### Scenario: Share with view access +- **WHEN** owner shares a route with another user as "view" +- **THEN** that user can see the route and export GPX but cannot edit + +#### Scenario: Share with edit access +- **WHEN** owner shares a route with another user as "edit" +- **THEN** that user can start Planner sessions and create new versions + +### Requirement: Fork routes +Users SHALL be able to fork (copy) public routes to their own collection. + +#### Scenario: Fork a public route +- **WHEN** a user clicks "Fork" on a public route +- **THEN** a copy is created in their collection with the original credited diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md new file mode 100644 index 0000000..7c35e6a --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Map-based route discovery +Users SHALL be able to discover public routes by browsing a map. + +#### Scenario: Browse routes on map +- **WHEN** a user visits the route explore page and pans/zooms the map +- **THEN** public routes within the visible area are shown as polylines on the map + +#### Scenario: Route preview +- **WHEN** a user clicks a route on the explore map +- **THEN** a popup or sidebar shows the route name, distance, elevation, and a link to the detail page diff --git a/openspec/changes/archive/2026-03-28-route-features/tasks.md b/openspec/changes/archive/2026-03-28-route-features/tasks.md new file mode 100644 index 0000000..44950b0 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/tasks.md @@ -0,0 +1,68 @@ +## 1. Route Visibility & Sharing Schema + +- [ ] 1.1 Add `visibility` enum column (private, public) to `journal.routes`, default private +- [ ] 1.2 Add `visibility` enum column (private, public) to `journal.activities`, default private +- [ ] 1.3 Create `journal.route_shares` table (routeId, userId, permission: view/edit) +- [ ] 1.4 Add `contributors` text array column to `journal.route_versions` +- [ ] 1.5 Add `forkedFromId` nullable column to `journal.routes` +- [ ] 1.6 Push schema and verify locally + +## 2. Route Permissions Logic + +- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with canView(routeId, userId), canEdit(routeId, userId) functions +- [ ] 2.2 Update route detail loader to check visibility/permissions (show 404 for unauthorized) +- [ ] 2.3 Update route list to only show own + shared routes +- [ ] 2.4 Add visibility toggle (private/public) to route edit page +- [ ] 2.5 Add share dialog — search users, set view/edit permission + +## 3. Route Forking + +- [ ] 3.1 Add "Fork" button on public route detail pages (visible to logged-in users who aren't the owner) +- [ ] 3.2 Create fork API route — copies route + latest GPX to user's collection, sets forkedFromId +- [ ] 3.3 Show "Forked from [original]" link on forked routes + +## 4. Spatial Search + +- [ ] 4.1 Ensure PostGIS spatial index exists on routes.geometry column +- [ ] 4.2 Create `/routes/explore` route with a full-page map +- [ ] 4.3 Add API route to query public routes within bounding box (ST_Intersects) +- [ ] 4.4 Render matching routes as polylines on the explore map +- [ ] 4.5 Add route popup/sidebar with name, stats, link to detail + +## 5. Multi-Day Routes + +- [ ] 5.1 Add `isDayBreak` optional boolean support to Planner waypoint Y.Map +- [ ] 5.2 Add day-break toggle in Planner waypoint sidebar (click to mark/unmark) +- [ ] 5.3 Show day boundaries in elevation chart +- [ ] 5.4 Compute per-day distance and elevation in sidebar +- [ ] 5.5 Update GPX export to use one track segment per day +- [ ] 5.6 Store dayBreaks array in route metadata on save + +## 6. Activity Photos + +- [ ] 6.1 Enable Garage in docker-compose.yml, create bucket +- [ ] 6.2 Create S3 client utility (`apps/journal/app/lib/storage.server.ts`) with presigned URL generation +- [ ] 6.3 Create `journal.activity_photos` table (id, activityId, s3Key, altText, createdAt) +- [ ] 6.4 Add photo upload UI on activity detail/edit page +- [ ] 6.5 Display photo gallery on activity detail page +- [ ] 6.6 Add delete photo functionality + +## 7. Contributor Tracking + +- [ ] 7.1 Update Planner→Journal callback to include contributor info from JWT +- [ ] 7.2 Store contributor in route_versions on save +- [ ] 7.3 Display contributors on route detail page + +## 8. Privacy & i18n + +- [ ] 8.1 Update /privacy page: document photo storage, route visibility, sharing +- [ ] 8.2 Add i18n keys for all new UI strings (en + de) + +## 9. Verify + +- [ ] 9.1 Test route visibility: create private and public routes, verify access control +- [ ] 9.2 Test sharing: share route with another user, verify view/edit access +- [ ] 9.3 Test forking: fork a public route, verify copy created +- [ ] 9.4 Test spatial search: create public routes, verify they appear on explore map +- [ ] 9.5 Test multi-day: mark day breaks, verify per-day stats and GPX export +- [ ] 9.6 Test photos: upload, view, delete photos on activities diff --git a/openspec/changes/local-dev-stack/.openspec.yaml b/openspec/changes/local-dev-stack/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/local-dev-stack/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/local-dev-stack/design.md b/openspec/changes/local-dev-stack/design.md new file mode 100644 index 0000000..4a6e61d --- /dev/null +++ b/openspec/changes/local-dev-stack/design.md @@ -0,0 +1,151 @@ +## Context + +trails.cool runs `docker-compose.dev.yml` for local dev (PostgreSQL + BRouter) +and `infrastructure/docker-compose.yml` for production (full stack with Caddy, +Prometheus, Grafana, Loki, exporters). There is no staging environment. CI runs +E2E tests with a manually started PostgreSQL container and a separately +downloaded BRouter, not reusing the dev compose file. The gap between local dev +and production causes issues: + +1. Monitoring changes (alert rules, dashboards) are untestable before deploy +2. CI's PostgreSQL setup diverges from both dev and prod (no PostGIS extensions + preloaded, no pg_stat_statements, no init scripts) +3. The dev PostgreSQL lacks `pg_stat_statements` which means queries that depend + on it (like Grafana datasource queries) fail locally + +## Goals / Non-Goals + +**Goals:** +- Local dev PostgreSQL matches production config (pg_stat_statements, init + scripts) +- Optional monitoring stack available locally via a compose profile +- CI E2E tests use the same compose file as local dev +- One-command reset for a clean dev environment +- Seed data available for both local dev and CI + +**Non-Goals:** +- Replicating the production Caddy reverse proxy locally (apps run natively + with Vite, no TLS needed for local dev) +- Running S3/Garage locally (media storage is a future concern) +- Federation testing (ActivityPub requires publicly reachable endpoints) +- Matching exact production image versions (dev uses source builds, prod uses + GHCR images) + +## Decisions + +### D1: Extend docker-compose.dev.yml with profiles, don't create a new file + +Add monitoring services to the existing `docker-compose.dev.yml` using Docker +Compose profiles. The core services (postgres, brouter) have no profile +assigned and always start. Monitoring services get the `monitoring` profile +and only start when explicitly requested. + +```bash +pnpm dev:services # postgres + brouter (default) +docker compose -f docker-compose.dev.yml --profile monitoring up -d # + monitoring +``` + +**Alternative**: Separate `docker-compose.monitoring.yml` with `extends`. +Rejected — profiles are the standard Docker Compose mechanism for this, and a +single file is simpler to maintain. + +### D2: Service profiles — core always runs, monitoring is opt-in + +Two logical groups: + +| Profile | Services | When | +|---------|----------|------| +| *(none)* | postgres, brouter | Always — required for app development | +| `monitoring` | prometheus, grafana, loki | Opt-in — for testing observability changes | + +Production-only services NOT included locally: Caddy (apps run natively), +node-exporter (host metrics not useful in Docker Desktop), cadvisor (container +metrics not useful locally), postgres-exporter (can add later if needed). + +Grafana runs with anonymous auth locally (no GitHub OAuth), connecting to +the local Prometheus and Loki instances. + +### D3: Database initialization — auto-push schema, seed script for test data + +On `pnpm dev:full`, the `scripts/dev.sh` script already runs `pnpm db:push`. +Add a seed script (`scripts/seed.ts`) that inserts test data: + +- A test user account in Journal +- A sample route with waypoints (Berlin area, matching the BRouter segment) +- A sample activity linked to the route + +The seed script is idempotent (uses `ON CONFLICT DO NOTHING`). It runs +automatically in `dev:full` but can be run standalone with `pnpm db:seed`. + +For CI, the seed script runs after `db:push` and before E2E tests, ensuring +tests have consistent data to work with. + +### D4: CI uses compose file for services + +Replace the manual `docker run` and BRouter download steps in `.github/ +workflows/ci.yml` with: + +```yaml +- name: Start services + run: docker compose -f docker-compose.dev.yml up -d --wait +``` + +The `--wait` flag blocks until health checks pass, replacing the manual +`pg_isready` loops. BRouter still builds from the local Dockerfile in +`docker/brouter/` and uses the same segment download mechanism. + +Benefits: +- CI and local dev use identical service configuration +- Health check logic is defined once (in compose) not twice (compose + CI) +- Simpler CI workflow with fewer steps + +Trade-off: Docker Compose in CI adds ~5s overhead for compose parsing. The +PostGIS image is already cached. BRouter segment download is already cached. +Net time should be similar or faster due to parallel health checks. + +### D5: dev.sh improvements — health checks, error messages, monitoring flag + +Improve `scripts/dev.sh`: + +1. **Health check with timeout**: Use `docker compose up -d --wait` instead + of manual `pg_isready` loop. This respects the healthcheck config in the + compose file and has a built-in timeout. +2. **Error messages**: If Docker is not running, print a clear message instead + of a cryptic error. Check for Docker before anything else. +3. **Monitoring flag**: `pnpm dev:full -- --monitoring` starts the monitoring + profile alongside core services. +4. **Seed data**: Run seed script after schema push. + +### D6: Environment — .env.development template with sensible defaults + +Create `.env.development` (gitignored) from `.env.development.example` +(committed). All values have working defaults so local dev works with zero +configuration: + +```env +DATABASE_URL=postgres://trails:trails@localhost:5432/trails +BROUTER_URL=http://localhost:17777 +JWT_SECRET=dev-secret-not-for-production +SESSION_SECRET=dev-secret-not-for-production +``` + +The apps already read `DATABASE_URL` from environment. The `.env.development` +file is for documentation and convenience — `scripts/dev.sh` sets these +values if not already present. + +## Risks / Trade-offs + +**[Monitoring profile adds image pulls]** → First `--profile monitoring` run +downloads Prometheus, Grafana, Loki images (~500MB). Mitigation: one-time +cost, cached by Docker. + +**[Compose in CI needs Docker Compose v2]** → GitHub Actions ubuntu-latest +includes Docker Compose v2. No action needed. + +**[Seed data can drift from schema]** → If schema changes, seed script may +break. Mitigation: seed script uses Drizzle ORM (not raw SQL), so TypeScript +catches drift at compile time. + +**[BRouter segment download in CI]** → The compose file builds BRouter from +Dockerfile but doesn't include segments. CI still needs the segment download +step (cached). The compose file mounts a local directory for segments. diff --git a/openspec/changes/local-dev-stack/proposal.md b/openspec/changes/local-dev-stack/proposal.md new file mode 100644 index 0000000..14a66a0 --- /dev/null +++ b/openspec/changes/local-dev-stack/proposal.md @@ -0,0 +1,46 @@ +## Why + +There is no staging environment — production is the only deployed instance. Infra +changes (Prometheus alerts, Caddy config, Grafana dashboards) go straight to prod +with no way to validate them locally first. Meanwhile, CI E2E tests skip ~15 of +20 tests because there is no PostgreSQL service in the workflow, and BRouter was +only recently added to CI with manual setup instead of reusing the dev compose +file. The existing `docker-compose.dev.yml` covers PostgreSQL and BRouter but +nothing else from the production stack. + +## What Changes + +- Extend `docker-compose.dev.yml` with an optional monitoring profile + (Prometheus, Grafana, Loki) so developers can test observability changes + locally before deploying to production +- Add `pg_stat_statements` and initialization scripts to the dev PostgreSQL + to match production config +- Improve `scripts/dev.sh` with proper health checks and better error output +- Simplify CI by using the same compose file instead of ad-hoc `docker run` + commands +- Add a database seed script for consistent test data +- Add a `pnpm dev:reset` command to tear down and recreate the local stack + +## Capabilities + +### New Capabilities + +- `local-monitoring`: Optional local Prometheus + Grafana + Loki stack via + `--profile monitoring`, matching production monitoring configuration +- `dev-reset`: One command to wipe and recreate the local dev environment + +### Modified Capabilities + +- `local-dev-environment`: PostgreSQL config aligned with production + (pg_stat_statements, init scripts), improved health checks, seed data + +## Impact + +- **docker-compose.dev.yml**: Add monitoring services behind a profile, update + postgres config to match production +- **.github/workflows/ci.yml**: Replace manual `docker run` with compose-based + service startup +- **scripts/dev.sh**: Better health checks, error messages, optional monitoring +- **scripts/reset-dev.sh**: New script to wipe volumes and restart +- **scripts/seed.ts**: Test data for local development and E2E tests +- **Dependencies**: None new (all Docker images already used in production) diff --git a/openspec/changes/local-dev-stack/tasks.md b/openspec/changes/local-dev-stack/tasks.md new file mode 100644 index 0000000..c2a0973 --- /dev/null +++ b/openspec/changes/local-dev-stack/tasks.md @@ -0,0 +1,34 @@ +## 1. Docker Compose + +- [ ] 1.1 Update dev PostgreSQL to match production: add `shared_preload_libraries=pg_stat_statements` command and mount `infrastructure/postgres/init-grafana-user.sql` as init script +- [ ] 1.2 Add Prometheus service with `monitoring` profile, mounting `infrastructure/prometheus/prometheus.yml` +- [ ] 1.3 Add Grafana service with `monitoring` profile, anonymous auth enabled, provisioned with local Prometheus and Loki datasources +- [ ] 1.4 Add Loki service with `monitoring` profile, mounting `infrastructure/loki/loki-config.yml` + +## 2. Database + +- [ ] 2.1 Create `scripts/seed.ts` with idempotent test data: user account, sample route (Berlin area), sample activity. Use Drizzle ORM, `ON CONFLICT DO NOTHING` +- [ ] 2.2 Add `pnpm db:seed` script to root package.json that runs `scripts/seed.ts` with `--experimental-strip-types` + +## 3. CI Integration + +- [ ] 3.1 Replace manual `docker run` PostgreSQL setup in `ci.yml` e2e job with `docker compose -f docker-compose.dev.yml up -d --wait` +- [ ] 3.2 Replace manual BRouter download/start in `ci.yml` with the compose BRouter service plus cached segment mount +- [ ] 3.3 Add `pnpm db:seed` step after `pnpm db:push` in CI e2e job +- [ ] 3.4 Remove any E2E test skip workarounds that check for DB availability (the `withDb()` 503 skip pattern) + +## 4. Scripts + +- [ ] 4.1 Improve `scripts/dev.sh`: check Docker is running first, use `docker compose up -d --wait`, add `--monitoring` flag to start monitoring profile, run seed after schema push +- [ ] 4.2 Create `scripts/reset-dev.sh`: stop containers, remove volumes, restart — add as `pnpm dev:reset` in package.json + +## 5. Environment + +- [ ] 5.1 Create `.env.development.example` with documented defaults (DATABASE_URL, BROUTER_URL, JWT_SECRET, SESSION_SECRET) and add `.env.development` to `.gitignore` + +## 6. Verify + +- [ ] 6.1 Test full local dev flow: `pnpm dev:full` starts services, pushes schema, seeds data, launches apps — create session, compute route, verify seeded data visible +- [ ] 6.2 Test monitoring profile: `pnpm dev:full -- --monitoring` starts Prometheus + Grafana + Loki, verify Grafana dashboards load at localhost:3002 +- [ ] 6.3 Test CI flow: push branch, verify e2e job uses compose, all ~20 E2E tests run (none skipped) +- [ ] 6.4 Test reset: `pnpm dev:reset` wipes volumes and restarts cleanly diff --git a/openspec/changes/multi-day-routes/.openspec.yaml b/openspec/changes/multi-day-routes/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/multi-day-routes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/multi-day-routes/design.md b/openspec/changes/multi-day-routes/design.md new file mode 100644 index 0000000..2ad3e7b --- /dev/null +++ b/openspec/changes/multi-day-routes/design.md @@ -0,0 +1,211 @@ +## Context + +The Planner stores waypoints as a Yjs `Y.Array>` where each +Y.Map holds `lat`, `lon`, and optionally `name`. Routes are computed +segment-by-segment between consecutive waypoints via BRouter, producing an +`EnrichedRoute` with `coordinates`, `segmentBoundaries`, `totalLength`, +`totalAscend`, and `totalTime`. The visual-redesign change already defines the +UI treatment for multi-day routes (sidebar day breakdown, elevation chart +dividers, map day labels) but explicitly defers the data model and logic. + +This design covers the data model, computation, and integration decisions. + +## Decisions + +### D1: Day-break waypoints via `overnight` flag + +A waypoint becomes a day boundary by setting `overnight: true` on its Y.Map. +The first waypoint of the route is the implicit start of Day 1, the last +waypoint is the implicit end of the final day, and every waypoint with +`overnight: true` marks the end of one day and start of the next. + +``` +Waypoints: [Berlin, Zossen, Dessau(overnight), Halle, Erfurt] + |------- Day 1 -------||------- Day 2 ------| +``` + +This is the simplest possible model: a single boolean on existing data. No new +Yjs types, no separate array, no ordering concerns. It composes naturally with +waypoint reordering, insertion, and deletion -- if an overnight waypoint is +removed, the two days merge automatically. + +### D2: Day computation as a pure utility + +A `computeDays()` function takes the waypoints array and the `EnrichedRoute` +and returns an array of day objects: + +```typescript +interface DayStage { + dayNumber: number; + startWaypointIndex: number; + endWaypointIndex: number; + startName: string; + endName: string; + distance: number; // meters + ascent: number; // meters + descent: number; // meters + estimatedTime: number; // seconds + coordStartIndex: number; + coordEndIndex: number; +} + +function computeDays( + waypoints: Array<{ lat: number; lon: number; name?: string; overnight?: boolean }>, + route: EnrichedRoute, +): DayStage[]; +``` + +The function walks `segmentBoundaries` to map waypoint indices to coordinate +ranges, then accumulates distance and elevation per day by iterating +coordinates within each range. If no waypoints have `overnight: true`, it +returns a single day covering the entire route. + +This is a pure function with no Yjs dependency -- it takes plain data and +returns plain data. This makes it easy to test and reuse. + +### D3: Yjs state -- minimal addition + +The only change to Yjs state is adding an `overnight` key to waypoint Y.Maps: + +```typescript +// Setting overnight on a waypoint +const waypointMap = yjs.waypoints.get(index); +waypointMap.set("overnight", true); + +// Clearing overnight +waypointMap.delete("overnight"); +``` + +No new Y.Array or Y.Map types are introduced. The `overnight` key is optional +-- existing waypoints without it are treated as regular (non-overnight) +waypoints. This is fully backwards-compatible: sessions created before this +feature work identically, and clients that don't understand `overnight` simply +ignore it. + +The existing crash-recovery logic (periodic localStorage save of Y.Doc state) +preserves overnight flags automatically since they are part of the Y.Doc. + +### D4: Sidebar day breakdown + +The `WaypointSidebar` component gains a day-grouped view when any waypoint has +`overnight: true`. The layout follows visual-redesign D4: + +``` +ACTIVE ROUTE +Berlin -> Erfurt 343 km ^868m 2 days + +DAY 1 - Berlin -> Dessau [v] + ^340m 120 km ~4h 30m + 1. Berlin Alexanderplatz + 2. Zossen + 3. Juterbog + 4. Dessau [OVERNIGHT] + +> DAY 2 - Dessau -> Erfurt 223 km [>] + +(collapsed days show summary only) +``` + +**Behavior:** +- Day 1 is expanded by default; other days are collapsed +- Clicking a day header toggles expand/collapse +- Per-day stats (distance, ascent, estimated time) shown in day header +- Overnight waypoints display an amber badge +- Waypoints within each day are numbered sequentially (1, 2, 3... restarting + per day would be confusing -- use global numbering) +- When no waypoints have `overnight: true`, the sidebar shows the flat list + as it does today (no "Day 1" wrapper for single-day routes) + +### D5: Elevation chart day dividers + +The `ElevationChart` canvas drawing is extended to render day boundaries as +vertical dashed lines. For each day boundary (overnight waypoint), the +corresponding distance along the route is computed from `segmentBoundaries` +and `coordinates`, and a dashed vertical line is drawn at that x-position. + +``` + Day 1 Day 2 Day 3 + ___/\__/\___ : __/\_____ : __/\___ + / \ : / \ : / \ +/_______________\:/___________\:/________\ +0 km 120 km 120 km 250 km 250 km 343 km +``` + +Each divider has a "Day N" label at the top of the chart area. The dashed line +uses a muted color (`--text-lo` / `#9A9484`) to avoid competing with the +elevation profile. This follows visual-redesign D6. + +### D6: Map day labels + +White pill-shaped labels are placed on the route at each day boundary, showing +"Day 1 . 120 km". These use Leaflet DivIcon markers positioned at the +coordinate of the overnight waypoint. + +Styling follows visual-redesign D5: +- White background with subtle shadow (`--shadow-sm`) +- Text in `--text-hi` with distance in `--font-mono` +- Positioned slightly offset from the route line to avoid overlap with the + route itself + +Day labels are only shown when there are 2+ days. They update reactively when +overnight flags change (same Yjs observe pattern as existing markers). + +### D7: GPX export with day-break metadata + +Day-break waypoints are exported with a `overnight` element inside +the `` tag. This is valid GPX 1.1 (the `` element is a standard +child of ``). + +```xml + + Dessau + overnight + +``` + +Additionally, the track can optionally be split into multiple `` elements +(one per day), each with a `` like "Day 1: Berlin - Dessau". This gives +GPS devices and other tools a natural per-day breakdown. The single-track export +remains the default; multi-track is an option in the export dialog. + +On GPX import (future), the parser should recognize `overnight` +waypoints and restore the overnight flags. This is not in scope for this change +but the format is designed to support it. + +### D8: Overnight toggle UX + +Two interaction paths to toggle a waypoint as overnight: + +1. **Sidebar**: Each waypoint row in the sidebar gains an overnight toggle + button (crescent moon icon). Clicking it sets/clears `overnight` on the + waypoint's Y.Map. The button uses amber styling (`--stop`, `--stop-bg`) + when active. + +2. **Map context menu**: Right-clicking (long-press on mobile) a waypoint + marker on the map shows a context menu with "Mark as overnight stop" / + "Remove overnight stop". This reuses the same Y.Map mutation. + +Visual feedback follows visual-redesign tokens: +- Overnight waypoint markers on the map use amber-brown (`--stop`: `#8B6D3A`) + instead of the default olive (`--accent`: `#4A6B40`) +- Sidebar overnight waypoints have a subtle amber background (`--stop-bg`) +- The "OVERNIGHT" badge uses `--stop` text on `--stop-bg` background with + `--stop-border` border + +## Risks / Trade-offs + +- **Segment boundary alignment**: The day computation relies on + `segmentBoundaries` from `EnrichedRoute` to map waypoint indices to + coordinate ranges. If the segment merge logic changes, day computation + breaks. Mitigate with thorough unit tests on `computeDays`. +- **Large routes**: A route with 50+ waypoints and many overnight stops could + make the sidebar unwieldy. Collapsible sections mitigate this. We can add + virtual scrolling later if needed. +- **Yjs backwards compatibility**: Adding `overnight` to Y.Maps is safe, but + older clients that don't understand it will silently ignore overnight flags. + In a collaborative session, one user could see day breakdown while another + does not. This is acceptable for now since all clients will be updated + together. +- **GPX round-trip**: The `overnight` convention is not a standard + GPX extension namespace. Other tools will ignore it, which is fine. The data + is not lost, just not interpreted. diff --git a/openspec/changes/multi-day-routes/proposal.md b/openspec/changes/multi-day-routes/proposal.md new file mode 100644 index 0000000..bb8cbbe --- /dev/null +++ b/openspec/changes/multi-day-routes/proposal.md @@ -0,0 +1,72 @@ +## Why + +Long routes -- multi-day bike tours, thru-hikes, extended backpacking trips -- +are flat lists of waypoints with no structure. Users planning a 5-day ride from +Berlin to Prague have no way to mark where each day ends, see per-day distance +and climbing, or reason about daily effort. They resort to external spreadsheets +or mental arithmetic to divide the route into manageable stages. + +The Planner already computes total distance and elevation across the full route. +Adding day structure is a matter of marking overnight stops on existing waypoints +and deriving per-day stats from the segment data we already have. + +## What Changes + +- **Day-break waypoints**: Any waypoint can be toggled as an overnight stop. + This adds an `overnight: true` flag to the waypoint's Y.Map in the existing + Yjs waypoints array. First and last waypoints are implicit day boundaries. +- **Per-day stats**: Distance, total ascent, and estimated duration computed per + day by splitting the route at overnight waypoints. Derived from the existing + `segmentBoundaries` and `coordinates` in the enriched route data. +- **Sidebar day breakdown**: Waypoints grouped by day with collapsible sections, + per-day stats, and overnight toggle. Day 1 expanded by default. +- **Elevation chart day dividers**: Dashed vertical lines at day boundaries with + "Day N" labels. +- **Map day labels**: White pill markers on the route at day boundaries showing + "Day 1 . 120 km". +- **GPX export**: Day-break waypoints exported with a `overnight` + element so the structure survives round-trips. + +All state lives in Yjs. No database changes are needed -- the Planner remains +stateless. The visual design is already specified in the `visual-redesign` +change (D4 sidebar, D5 map markers, D6 elevation chart); this spec covers +the data model, computation logic, and integration wiring. + +## Capabilities + +### New Capabilities + +- `multi-day-routes`: Overnight waypoint markers, per-day stats computation, + day-aware sidebar/chart/map display, multi-day GPX export + +### Modified Capabilities + +- `planner-session`: Waypoints gain an `overnight` property in Yjs state +- `map-display`: Day boundary labels on route, overnight marker styling +- `gpx-export`: Day-break metadata in exported GPX waypoints + +## Non-Goals + +- **Automatic day splitting**: No algorithm to suggest where to stop. Users + decide manually. This avoids opinionated defaults and keeps the logic simple. +- **Accommodation search**: No POI lookup for campsites or hotels. Out of scope. +- **Per-day routing profiles**: All days use the same BRouter profile. Supporting + different profiles per day would require rearchitecting the routing pipeline. +- **Journal integration**: Saving multi-day routes to the Journal is a separate + concern (route-features change). This spec is Planner-only. + +## Impact + +- **Yjs state**: `overnight` boolean added to waypoint Y.Map entries (additive, + backwards-compatible -- existing sessions without it behave as single-day) +- **Shared types**: `Waypoint.isDayBreak` already exists in `@trails-cool/types` + but is unused. This change activates it. +- **New utility**: `compute-days.ts` -- pure function that splits route data at + overnight waypoints and returns per-day stats +- **Sidebar**: `WaypointSidebar.tsx` gains day-grouped view with collapsible + sections and overnight toggle buttons +- **ElevationChart**: Canvas drawing extended with vertical day dividers +- **Map**: New day-label layer and overnight marker variant +- **GPX**: `generateGpx` extended to emit `overnight` on day-break + waypoints +- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de) diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md new file mode 100644 index 0000000..d6f984d --- /dev/null +++ b/openspec/changes/multi-day-routes/tasks.md @@ -0,0 +1,40 @@ +## 1. Data Model & Computation + +- [ ] 1.1 Add `overnight` property support to Yjs waypoint Y.Maps: helper functions `setOvernight(yjs, index, value)` and `isOvernight(yMap)` in a new `apps/planner/app/lib/overnight.ts` +- [ ] 1.2 Create `apps/planner/app/lib/compute-days.ts` with `computeDays()` pure function: takes waypoints array + `EnrichedRoute`, returns `DayStage[]` (day number, waypoint range, coord range, distance, ascent, descent, estimated time) +- [ ] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: observes Yjs waypoints + routeData, calls `computeDays()`, returns reactive `DayStage[]` + +## 2. Sidebar Day Breakdown + +- [ ] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default +- [ ] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()` +- [ ] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise +- [ ] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days") + +## 3. Map Integration + +- [ ] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker +- [ ] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates +- [ ] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option + +## 4. Elevation Chart + +- [ ] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top +- [ ] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary) + +## 5. GPX Export + +- [ ] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `overnight` for waypoints with `isDayBreak: true` +- [ ] 5.2 Add multi-track export option: split track into one `` per day, each named "Day N: Start - End" + +## 6. i18n + +- [ ] 6.1 Add translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Ubernachtung markieren"), per-day stats, route summary + +## 7. Testing + +- [ ] 7.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases +- [ ] 7.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map +- [ ] 7.3 Unit tests for GPX export with overnight waypoints: verify `overnight` output, multi-track splitting +- [ ] 7.4 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats +- [ ] 7.5 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata diff --git a/openspec/changes/osm-overlays/.openspec.yaml b/openspec/changes/osm-overlays/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/osm-overlays/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/osm-overlays/design.md b/openspec/changes/osm-overlays/design.md new file mode 100644 index 0000000..86e02a9 --- /dev/null +++ b/openspec/changes/osm-overlays/design.md @@ -0,0 +1,186 @@ +## Context + +The Planner currently has three base tile layers (OSM, OpenTopoMap, CyclOSM) in +`packages/map/src/layers.ts`, rendered via Leaflet's `LayersControl`. There are +no overlay layers and no POI display. brouter-web offers hillshading, Waymarked +Trails networks, and ~60 Overpass-powered POI categories — a model worth +adopting selectively. + +The Planner is stateless (Yjs CRDT), collaborative, and uses BRouter for +routing. Overlay state should sync across participants. + +## Goals / Non-Goals + +**Goals:** +- Add tile-based overlays (hillshading, waymarked trails) to the layer switcher +- Add viewport-scoped POI overlays from Overpass API with per-category toggling +- Auto-suggest relevant overlays based on routing profile +- Build a reusable Overpass client shared with waypoint-notes POI snap + +**Non-Goals:** +- Custom tile server or self-hosted overlays (use public tile services) +- Full brouter-web layer catalog (50+ layers, most country-specific) +- Offline/cached tile data +- Vector tile overlays (MVT) — stick with raster for now +- POI editing or contributing back to OSM + +## Decisions + +### D1: Tile overlay definitions + +Add an `overlayLayers` export to `packages/map/src/layers.ts` alongside +existing `baseLayers`. Each overlay is a transparent tile layer rendered on top +of the base layer. + +Initial overlays: + +| Id | Name | URL | Attribution | +|----|------|-----|-------------| +| `hillshading` | Hillshading | `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png` via [Leaflet.TileLayer.Terrarium](https://github.com/pka/leaflet-terrarium-hillshading) or pre-rendered from `https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png` | SRTM/Mapzen | +| `waymarked-cycling` | Cycling Routes | `https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) | +| `waymarked-hiking` | Hiking Routes | `https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) | +| `waymarked-mtb` | MTB Routes | `https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) | + +These are all free, public tile endpoints used by brouter-web and other OSM +tools. No API keys needed. + +**Alternative considered**: Thunderforest Outdoors or OpenCycleMap — requires +API key, limited free tier. Not worth the complexity. + +### D2: Overlay toggle in LayersControl + +Leaflet's `LayersControl` already supports overlays natively via +`LayersControl.Overlay` (react-leaflet). Add overlay tile layers as checkboxes +alongside the existing base layer radio buttons. No custom UI needed for Phase 1. + +### D3: POI category system + +Define POI categories as a typed configuration mapping OSM tags to display +properties. Inspired by brouter-web's `layers/overpass/` structure but +simplified to the categories most relevant for route planning: + +```typescript +interface PoiCategory { + id: string; + name: string; // i18n key + icon: string; // emoji or SVG icon id + color: string; // marker color + query: string; // Overpass QL fragment, e.g. "nwr[amenity=drinking_water]" + profiles?: string[]; // routing profiles where this is auto-enabled +} +``` + +**Initial categories** (curated from brouter-web's full list): + +| Category | POI types (OSM tags) | Icon | Auto-enable for | +|----------|---------------------|------|-----------------| +| Drinking water | `amenity=drinking_water`, `amenity=water_point` | 💧 | all | +| Shelter | `amenity=shelter`, `tourism=wilderness_hut` | 🛖 | hiking | +| Camping | `tourism=camp_site`, `tourism=caravan_site`, `tourism=picnic_site` | ⛺ | all | +| Food & drink | `amenity=restaurant`, `amenity=cafe`, `amenity=fast_food`, `amenity=pub`, `amenity=biergarten` | 🍽️ | — | +| Groceries | `shop=supermarket`, `shop=convenience`, `shop=bakery` | 🛒 | — | +| Bike infrastructure | `amenity=bicycle_parking`, `amenity=bicycle_repair_station`, `amenity=bicycle_rental` | 🔧 | cycling | +| Accommodation | `tourism=hotel`, `tourism=hostel`, `tourism=guest_house` | 🏨 | — | +| Viewpoints | `tourism=viewpoint` | 👁️ | hiking | +| Toilets | `amenity=toilets` | 🚻 | — | + +**Not included** (from brouter-web but too niche): ATMs, banks, benches, +telephones, kneipp water cures, car parking, railway stations, art galleries, +museums, ice cream shops, BBQs. Can be added later by extending the config. + +### D4: Overpass client + +Create `apps/planner/app/lib/overpass.ts` with: + +- `queryPois(bbox, categories): Promise` — builds Overpass QL query + combining all enabled categories into one request (union query), returns + parsed GeoJSON features +- **Bbox query**: `[bbox:south,west,north,east]` in Overpass QL, scoped to + current Leaflet viewport +- **Endpoint**: `https://overpass-api.de/api/interpreter` (public, no key) +- **Response format**: Request `[out:json]` for easier parsing than XML +- **Deduplication**: Overpass may return same node via multiple tags — dedup by + OSM id + +This client is also used by the waypoint-notes POI snap feature (smaller radius +query around a single waypoint). + +### D5: POI caching and rate limiting + +Overpass API is public and rate-limited. Must be respectful: + +- **Debounce**: 500ms after map `moveend` before querying +- **Abort**: Cancel in-flight requests when viewport changes (AbortController) +- **Tile-based caching**: Quantize viewport to grid tiles (e.g., 0.1° cells), + cache results per tile. Reuse cached tiles that overlap new viewport. +- **TTL**: 10 minutes for cached tiles (POI data changes slowly) +- **Max concurrent**: 1 request at a time +- **429 handling**: Exponential backoff, disable auto-refresh temporarily, show + "POI data unavailable" message +- **Zoom threshold**: Only query POIs at zoom >= 12 (avoids massive result sets + at country-level zoom) + +### D6: POI overlay panel + +A collapsible panel (not the LayersControl — too many items) for toggling POI +categories. Positioned below the layer switcher on the right side of the map. + +- Toggle button with POI icon to open/close +- Checkbox per category with icon and name +- "Loading..." indicator while Overpass query is in flight +- Category count badge showing number of visible POIs +- Panel state (which categories are enabled) synced via Yjs so all participants + see the same POIs + +### D7: POI marker rendering + +- Use Leaflet `L.Marker` with `L.DivIcon` for each POI (not CircleMarker — + need icons) +- Icon shows the category emoji/icon at 20×20px +- Popup on click showing: name, category, opening hours (if available), website + link (if available), OSM link +- **Clustering**: Use `leaflet.markercluster` at low zoom levels to avoid + thousands of markers. Cluster by category color. +- **Z-index**: POI markers below route and waypoint markers + +### D8: Profile-aware overlay defaults + +When the routing profile changes (via Yjs `routeOptions.profile`), suggest +relevant overlays: + +- **Cycling profiles** (cycling-safe, cycling-fast, etc.): Auto-enable + Waymarked Cycling overlay + Bike infrastructure POIs +- **Hiking profiles**: Auto-enable Waymarked Hiking + Shelter + Viewpoints +- **MTB profiles**: Auto-enable Waymarked MTB + Bike infrastructure + +"Auto-enable" means toggling on when profile changes, not forcing — users can +still disable. Only auto-enable on profile change, not on page load (respect +user's previous choice stored in Yjs). + +### D9: Yjs overlay state + +Store enabled overlays in Yjs `routeOptions` Y.Map: + +``` +routeOptions.overlays = ["hillshading", "waymarked-cycling"] +routeOptions.poiCategories = ["drinking_water", "camping", "bike_infra"] +``` + +Array of string IDs. Changes sync to all participants. Persisted in crash +recovery localStorage snapshot. + +## Risks / Trade-offs + +- **Overpass API availability**: Public endpoint, no SLA. If down, POI overlays + fail gracefully (show message, tile overlays still work). → Could add + fallback endpoint (`overpass.kumi.systems`) later. +- **Tile service availability**: Waymarked Trails and hillshading tiles are + community-run. → Degrade gracefully if tiles 404. Consider self-hosting tiles + if usage grows. +- **Performance with many POIs**: Dense areas (cities) may return hundreds of + POIs. → Marker clustering + zoom threshold mitigate this. Limit Overpass + response to 200 elements per category. +- **leaflet.markercluster dependency**: Adds ~40KB. → Only load when POI + overlays are enabled (dynamic import). +- **Overpass query cost**: Combining many categories into one query is efficient + but returns large payloads. → Only query enabled categories, not all. diff --git a/openspec/changes/osm-overlays/proposal.md b/openspec/changes/osm-overlays/proposal.md new file mode 100644 index 0000000..04574ec --- /dev/null +++ b/openspec/changes/osm-overlays/proposal.md @@ -0,0 +1,28 @@ +## Why + +The Planner shows three base tile layers (OSM, OpenTopoMap, CyclOSM) but no overlays. Route planners like brouter-web offer hillshading, waymarked trail networks, and toggleable POI layers from OpenStreetMap — information that is essential for planning multi-day bike tours and hikes. Without overlays, users must cross-reference other tools to find water sources, campsites, bike repair stations, or see official trail routes. + +## What Changes + +- **Tile overlays**: Add hillshading and Waymarked Trails (cycling, hiking, MTB) as toggle-able overlay layers in the Leaflet LayersControl +- **POI overlay panel**: Add a collapsible panel to toggle categories of OSM points of interest (water, shelter, camping, food, bike infrastructure, accommodation) queried from the Overpass API within the current viewport +- **POI markers**: Render POI results as categorized markers with icons, name labels, and popups showing OSM tags (opening hours, website, etc.) +- **Profile-aware defaults**: Auto-enable relevant overlays based on the active routing profile (cycling → Waymarked Cycling + bike POIs; hiking → Waymarked Hiking + water/shelter POIs) +- **Overpass client**: Shared utility for querying the Overpass API with caching, debouncing, and rate limit handling — reused by the waypoint-notes POI snap feature + +## Capabilities + +### New Capabilities +- `osm-tile-overlays`: Hillshading and Waymarked Trails tile overlays in the map layer switcher +- `osm-poi-overlays`: Toggleable POI categories from Overpass API rendered as map markers within the viewport + +### Modified Capabilities +- `map-display`: Add overlay layers to the layer switcher alongside existing base layers +- `planner-session`: Persist enabled overlay state in Yjs so all participants see the same overlays + +## Impact + +- **Files**: `packages/map/src/layers.ts` (overlay tile definitions), new POI overlay components in `apps/planner/`, new Overpass client in `packages/map/` or `apps/planner/app/lib/` +- **APIs**: Overpass API (external, rate-limited — public endpoint at `overpass-api.de`) +- **Dependencies**: None new — Leaflet handles tile overlays natively, Overpass is a REST API +- **Performance**: Tile overlays are lightweight (transparent PNGs). POI overlays need viewport-scoped queries with caching to avoid excessive Overpass load. diff --git a/openspec/changes/osm-overlays/specs/map-display/spec.md b/openspec/changes/osm-overlays/specs/map-display/spec.md new file mode 100644 index 0000000..50d474f --- /dev/null +++ b/openspec/changes/osm-overlays/specs/map-display/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Base layer switching +The map SHALL support switching between multiple base tile layers, and SHALL support toggling overlay tile layers independently. + +#### Scenario: Switch to OpenTopoMap +- **WHEN** a user selects "OpenTopoMap" from the layer switcher +- **THEN** the map tiles change to topographic tiles from OpenTopoMap + +#### Scenario: Available base layers +- **WHEN** a user opens the layer switcher +- **THEN** the options include OpenStreetMap, OpenTopoMap, and CyclOSM + +#### Scenario: Available overlay layers +- **WHEN** a user opens the layer switcher +- **THEN** overlay checkboxes are shown for Hillshading, Cycling Routes, Hiking Routes, and MTB Routes + +#### Scenario: Toggle overlay +- **WHEN** a user checks an overlay checkbox in the layer switcher +- **THEN** the overlay tiles are rendered on top of the current base layer diff --git a/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md b/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md new file mode 100644 index 0000000..f05a44b --- /dev/null +++ b/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: POI overlay panel +The Planner SHALL provide a collapsible panel for toggling POI categories on the map. + +#### Scenario: Open POI panel +- **WHEN** a user clicks the POI toggle button on the map +- **THEN** a panel opens showing checkboxes for each POI category with icons and names + +#### Scenario: Close POI panel +- **WHEN** the POI panel is open and the user clicks the toggle button again +- **THEN** the panel collapses and POI markers remain visible on the map + +### Requirement: POI categories +The Planner SHALL support the following POI categories queried from OpenStreetMap via Overpass API: drinking water, shelter, camping, food & drink, groceries, bike infrastructure, accommodation, viewpoints, and toilets. + +#### Scenario: Enable a POI category +- **WHEN** a user enables the "Drinking water" category in the POI panel +- **THEN** drinking water POIs within the current map viewport are fetched from Overpass and rendered as markers + +#### Scenario: Disable a POI category +- **WHEN** a user disables a previously enabled POI category +- **THEN** markers for that category are removed from the map + +#### Scenario: Multiple categories enabled +- **WHEN** a user enables "Camping" and "Drinking water" simultaneously +- **THEN** both categories of markers are visible, each with distinct icons + +### Requirement: POI markers +Each POI SHALL be rendered as a map marker with a category-specific icon. + +#### Scenario: POI marker display +- **WHEN** POIs are loaded for an enabled category +- **THEN** each POI appears as a small icon marker at its coordinates on the map + +#### Scenario: POI marker popup +- **WHEN** a user clicks a POI marker +- **THEN** a popup shows the POI name, category, and available details (opening hours, website, OSM link) + +#### Scenario: POI marker clustering +- **WHEN** many POIs are visible in a small area +- **THEN** markers are clustered with a count badge, and expand when the user zooms in + +### Requirement: Viewport-scoped POI loading +The Planner SHALL load POIs only within the current map viewport, refreshing when the viewport changes. + +#### Scenario: Load POIs on viewport +- **WHEN** POI categories are enabled and the user pans or zooms the map +- **THEN** POIs are fetched for the new viewport after a 500ms debounce + +#### Scenario: Zoom threshold +- **WHEN** the map zoom level is below 12 +- **THEN** POI queries are not sent and a message indicates the user should zoom in to see POIs + +#### Scenario: Cached results +- **WHEN** the user pans back to a previously viewed area within 10 minutes +- **THEN** cached POI results are displayed without a new Overpass query + +### Requirement: Overpass rate limit handling +The Planner SHALL handle Overpass API rate limits gracefully. + +#### Scenario: Rate limited response +- **WHEN** the Overpass API returns a 429 status +- **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and retries with exponential backoff + +#### Scenario: Overpass unavailable +- **WHEN** the Overpass API is unreachable +- **THEN** the Planner shows a message and tile overlays continue to function normally + +### Requirement: Profile-aware POI defaults +The Planner SHALL auto-enable relevant POI categories when the routing profile changes. + +#### Scenario: Cycling profile POI defaults +- **WHEN** the routing profile is changed to a cycling variant +- **THEN** the "Bike infrastructure" POI category is automatically enabled + +#### Scenario: Hiking profile POI defaults +- **WHEN** the routing profile is changed to a hiking variant +- **THEN** "Shelter" and "Viewpoints" POI categories are automatically enabled + +#### Scenario: User override persists +- **WHEN** a user manually disables an auto-enabled POI category +- **THEN** it remains disabled until the next profile change diff --git a/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md b/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md new file mode 100644 index 0000000..396fee5 --- /dev/null +++ b/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Hillshading overlay +The Planner map SHALL offer a hillshading tile overlay that visualizes terrain relief. + +#### Scenario: Enable hillshading +- **WHEN** a user toggles "Hillshading" in the layer switcher +- **THEN** semi-transparent terrain shading tiles are rendered on top of the base layer + +#### Scenario: Hillshading with any base layer +- **WHEN** hillshading is enabled and the user switches base layers +- **THEN** hillshading remains visible on top of the new base layer + +### Requirement: Waymarked Trails cycling overlay +The Planner map SHALL offer a Waymarked Trails cycling overlay showing official cycle route networks. + +#### Scenario: Enable cycling routes overlay +- **WHEN** a user toggles "Cycling Routes" in the layer switcher +- **THEN** official cycling routes (EuroVelo, national networks) are rendered as colored lines on the map from Waymarked Trails tiles + +#### Scenario: Cycling overlay at different zoom levels +- **WHEN** cycling routes overlay is enabled +- **THEN** international routes are visible at low zoom and local routes appear at higher zoom levels + +### Requirement: Waymarked Trails hiking overlay +The Planner map SHALL offer a Waymarked Trails hiking overlay showing official hiking trail networks. + +#### Scenario: Enable hiking routes overlay +- **WHEN** a user toggles "Hiking Routes" in the layer switcher +- **THEN** official hiking trails (GR routes, national trails) are rendered as colored lines on the map + +### Requirement: Waymarked Trails MTB overlay +The Planner map SHALL offer a Waymarked Trails MTB overlay showing official mountain bike trail networks. + +#### Scenario: Enable MTB routes overlay +- **WHEN** a user toggles "MTB Routes" in the layer switcher +- **THEN** official MTB trails are rendered as colored lines on the map + +### Requirement: Multiple simultaneous overlays +The Planner map SHALL support enabling multiple tile overlays at the same time. + +#### Scenario: Hillshading plus cycling routes +- **WHEN** a user enables both "Hillshading" and "Cycling Routes" +- **THEN** both overlays are visible simultaneously, with cycling routes rendered above hillshading + +### Requirement: Overlay tile attribution +Each tile overlay SHALL display proper attribution when enabled. + +#### Scenario: Attribution updates +- **WHEN** an overlay is toggled on +- **THEN** its attribution text is added to the map attribution control +- **WHEN** the overlay is toggled off +- **THEN** its attribution text is removed + +### Requirement: Profile-aware overlay suggestions +The Planner SHALL auto-enable relevant tile overlays when the routing profile changes. + +#### Scenario: Switch to cycling profile +- **WHEN** the routing profile is changed to a cycling variant +- **THEN** the Waymarked Trails cycling overlay is automatically enabled + +#### Scenario: Switch to hiking profile +- **WHEN** the routing profile is changed to a hiking variant +- **THEN** the Waymarked Trails hiking overlay is automatically enabled + +#### Scenario: User can disable auto-enabled overlays +- **WHEN** an overlay was auto-enabled by a profile change +- **THEN** the user can manually disable it and it stays disabled until the next profile change diff --git a/openspec/changes/osm-overlays/specs/planner-session/spec.md b/openspec/changes/osm-overlays/specs/planner-session/spec.md new file mode 100644 index 0000000..7233678 --- /dev/null +++ b/openspec/changes/osm-overlays/specs/planner-session/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Real-time collaborative editing +The Planner SHALL synchronize waypoint edits, route options, and overlay preferences across all connected participants in real-time using Yjs CRDTs. + +#### Scenario: Add waypoint +- **WHEN** participant A adds a waypoint to the map +- **THEN** participant B sees the waypoint appear within 500ms + +#### Scenario: Reorder waypoints +- **WHEN** participant A drags a waypoint to reorder it +- **THEN** participant B sees the updated waypoint order within 500ms + +#### Scenario: Concurrent edits +- **WHEN** participant A and B both add waypoints simultaneously +- **THEN** both waypoints appear for both participants without conflict + +#### Scenario: Overlay sync +- **WHEN** participant A enables the "Hillshading" tile overlay +- **THEN** participant B sees hillshading appear on their map within 500ms + +#### Scenario: POI category sync +- **WHEN** participant A enables the "Drinking water" POI category +- **THEN** participant B sees drinking water markers appear on their map diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md new file mode 100644 index 0000000..28a5d99 --- /dev/null +++ b/openspec/changes/osm-overlays/tasks.md @@ -0,0 +1,64 @@ +## 1. Tile Overlay Definitions + +- [ ] 1.1 Add `overlayLayers` export to `packages/map/src/layers.ts` with hillshading, Waymarked Cycling, Waymarked Hiking, Waymarked MTB tile configs +- [ ] 1.2 Add `LayersControl.Overlay` entries in `MapView.tsx` and `PlannerMap.tsx` for each overlay +- [ ] 1.3 Verify overlay attribution updates correctly when toggling overlays on/off + +## 2. Overlay State Sync + +- [ ] 2.1 Add `overlays` string array to Yjs `routeOptions` Y.Map for enabled tile overlay IDs +- [ ] 2.2 Add `poiCategories` string array to Yjs `routeOptions` Y.Map for enabled POI category IDs +- [ ] 2.3 Sync LayersControl state with Yjs — toggling overlay updates Yjs, Yjs changes toggle layers +- [ ] 2.4 Include overlay state in crash recovery localStorage snapshot + +## 3. Overpass Client + +- [ ] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function +- [ ] 3.2 Build Overpass QL union queries from enabled POI category configs +- [ ] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags) +- [ ] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries) + +## 4. POI Caching & Rate Limiting + +- [ ] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL +- [ ] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query +- [ ] 4.3 Use AbortController to cancel in-flight requests when viewport changes +- [ ] 4.4 Handle 429 responses with exponential backoff and user-visible message +- [ ] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below + +## 5. POI Category Configuration + +- [ ] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets) +- [ ] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles + +## 6. POI Overlay Panel + +- [ ] 6.1 Create collapsible POI panel component with toggle button (map right side, below layer switcher) +- [ ] 6.2 Render checkbox per POI category with icon, name, and visible count badge +- [ ] 6.3 Show loading indicator while Overpass query is in flight +- [ ] 6.4 Show empty/error states (no results, Overpass unavailable, zoom too low) + +## 7. POI Marker Rendering + +- [ ] 7.1 Render POI markers using `L.Marker` with `L.DivIcon` showing category icon +- [ ] 7.2 Add click popup with POI name, category, opening hours, website, and OSM link +- [ ] 7.3 Add `leaflet.markercluster` for clustering dense POI areas (dynamic import to avoid bundle bloat) +- [ ] 7.4 Set z-index so POI markers render below route polyline and waypoint markers + +## 8. Profile-Aware Defaults + +- [ ] 8.1 Define profile-to-overlay mapping (cycling → waymarked-cycling + bike POIs, hiking → waymarked-hiking + shelter + viewpoints, MTB → waymarked-mtb + bike POIs) +- [ ] 8.2 Auto-enable mapped overlays on routing profile change (update Yjs arrays) +- [ ] 8.3 Only auto-enable on explicit profile change, not on initial page load (respect existing Yjs state) + +## 9. i18n + +- [ ] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de) + +## 10. Testing + +- [ ] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication +- [ ] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss +- [ ] 10.3 Unit tests for profile-to-overlay mapping +- [ ] 10.4 E2E test: enable hillshading overlay, verify tile requests +- [ ] 10.5 E2E test: enable POI category, verify markers appear (mock Overpass response) diff --git a/openspec/changes/route-discovery/.openspec.yaml b/openspec/changes/route-discovery/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/route-discovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/route-discovery/design.md b/openspec/changes/route-discovery/design.md new file mode 100644 index 0000000..392ced8 --- /dev/null +++ b/openspec/changes/route-discovery/design.md @@ -0,0 +1,177 @@ +## Context + +Routes in the Journal store geometry as PostGIS `LineString(4326)` in the +`journal.routes.geom` column (see `packages/db/src/schema/journal.ts`). The +`@trails-cool/map` package provides `MapView` (Leaflet map with layer controls) +and `RouteLayer` (GeoJSON polyline rendering). The route-features change adds a +`visibility` column to routes, enabling public/private distinction. This change +builds on all of that to let users discover public routes by browsing a map. + +The existing route-features tasks.md (section 4) sketches spatial search in five +bullet points. This change breaks it out into a standalone, fully specified +implementation plan. + +## Goals / Non-Goals + +**Goals:** +- Full-page map at `/routes/explore` for browsing public routes +- Efficient PostGIS bounding box queries with spatial indexing +- Clickable route polylines with preview popups +- Debounced viewport-based fetching with result caching + +**Non-Goals:** +- Text search, filtering, sorting, recommendations +- Sidebar with route list (map-only for v1) +- Federated route discovery across instances +- Route clustering for dense areas + +## Decisions + +### D1: Explore page -- full-page Leaflet map + +The explore page at `/routes/explore` renders a full-page `MapView` from +`@trails-cool/map` with no sidebar or list panel. The map fills the viewport +below the navigation bar. This is the simplest useful interface and avoids +premature layout decisions. + +The page is accessible to all users (including unauthenticated visitors) since +it only shows public routes. The initial map center and zoom come from the +user's last position (stored in localStorage) or fall back to the default +center (Europe overview, `[50.1, 10.0]` zoom 6). + +Route: `route("routes/explore", "routes/routes.explore.tsx")` added to +`apps/journal/app/routes.ts`. + +### D2: Bounding box query API + +A new API route at `GET /api/routes/explore` accepts the map viewport as query +parameters and returns public routes within the bounds: + +``` +GET /api/routes/explore?south=47.2&west=5.8&north=55.1&east=15.0 +``` + +The server query: + +```sql +SELECT id, name, distance, elevation_gain, owner_id, + ST_AsGeoJSON(ST_Simplify(geom, 0.001)) AS geom_json +FROM journal.routes +WHERE visibility = 'public' + AND geom IS NOT NULL + AND ST_Intersects( + geom, + ST_MakeEnvelope(:west, :south, :east, :north, 4326) + ) +ORDER BY distance DESC NULLS LAST +LIMIT 50; +``` + +Key decisions: +- **`ST_Intersects`** over `ST_Within`: routes that cross the viewport boundary + should still appear, not just routes fully contained. +- **`ST_Simplify(geom, 0.001)`**: Simplify geometries for transfer (~100m + tolerance at European latitudes). The explore map doesn't need full-resolution + tracks -- users click through to the detail page for that. +- **Limit 50**: Prevents overwhelming the map and keeps response times fast. + Ordered by distance descending so longer (likely more interesting) routes + appear first when the limit is hit. +- **Owner join**: Include owner username and display name for the popup author + attribution. + +Response format: + +```json +{ + "routes": [ + { + "id": "abc123", + "name": "Berlin to Prague", + "distance": 343000, + "elevationGain": 1240, + "author": { "username": "ullrich", "displayName": "Ullrich" }, + "geometry": { "type": "LineString", "coordinates": [...] } + } + ] +} +``` + +### D3: Route rendering with interactive popups + +Public routes are rendered as polylines on the explore map. Each route is a +clickable Leaflet polyline. Clicking opens a Leaflet popup showing: + +- Route name (linked to `/routes/:id`) +- Distance (formatted: "343 km") +- Elevation gain (formatted: "+1,240 m") +- Author name (linked to `/users/:username`) + +This requires a new component in `@trails-cool/map` -- an `ExploreRouteLayer` +that takes an array of route objects and renders them as interactive polylines. +Unlike the existing `RouteLayer` (which renders a single GeoJSON object), this +component manages multiple routes with distinct click handlers. + +Styling: +- Default: blue polyline (`#2563eb`, weight 3, opacity 0.6) +- Hover: increase opacity to 0.9 and weight to 5 +- Active (popup open): keep highlighted styling + +### D4: Spatial index verification + +The `journal.routes.geom` column uses `geometry(LineString, 4326)`. PostGIS +does not automatically create a spatial index. A GiST index is required for +`ST_Intersects` to perform well: + +```sql +CREATE INDEX IF NOT EXISTS idx_routes_geom ON journal.routes USING GIST (geom); +``` + +This should be added as a Drizzle migration or verified to already exist. If +using Drizzle's `db:push`, the index needs to be added via a custom SQL +migration since Drizzle ORM does not natively support GiST index declarations +on custom types. + +### D5: Debounced viewport fetching + +The explore page fetches routes when the map viewport changes (Leaflet +`moveend` event). To avoid excessive API calls during panning and zooming: + +- **Debounce 300ms**: Wait 300ms after the last `moveend` before fetching. +- **Abort previous**: Cancel in-flight requests when a new fetch starts + (AbortController). +- **Cache by bounds**: Store the last response keyed by rounded bounds. If the + user pans back to a previously viewed area, serve from cache. Simple + Map-based cache with a max of 20 entries (LRU eviction). +- **Loading state**: Show a subtle loading indicator (spinner in map corner) + during fetches. Don't clear existing routes while loading -- overlay new + results when they arrive. + +This logic lives in a custom hook: `useExploreRoutes(map)` that returns +`{ routes, isLoading }`. + +### D6: Dependency on route-sharing + +This change cannot function without the `visibility` column on +`journal.routes`. The bounding box query filters on `visibility = 'public'`. +If the column doesn't exist, the query fails. + +Implementation order: route-sharing schema changes (route-features section 1) +must be completed first. The explore page can be built in parallel but only +tested after visibility exists and at least one route is set to public. + +## Risks / Trade-offs + +- **50-result limit may frustrate users**: In dense areas (Alps, popular hiking + regions) many routes could exist. The limit means some routes are invisible. + Mitigate by ordering by distance (longer routes first) and noting this is v1. + Clustering or pagination can come later. +- **Geometry simplification may look rough**: `ST_Simplify(geom, 0.001)` is + aggressive. At the explore zoom level this is fine, but if a user zooms in + close, simplified routes look jagged. Acceptable because clicking opens the + detail page with full geometry. +- **No spatial index in Drizzle**: Drizzle doesn't support GiST indexes on + custom types declaratively. The index must be managed via raw SQL migration. + This is a minor operational concern, not a technical risk. +- **Unauthenticated access**: The explore endpoint is public. This is + intentional (discovery should not require login) but means rate limiting + should be considered to prevent abuse. diff --git a/openspec/changes/route-discovery/proposal.md b/openspec/changes/route-discovery/proposal.md new file mode 100644 index 0000000..387ccf9 --- /dev/null +++ b/openspec/changes/route-discovery/proposal.md @@ -0,0 +1,62 @@ +## Why + +The Journal currently has no way to discover routes other users have published. +Each user's route list is isolated -- you can only see your own routes. Once +route-sharing adds public visibility, there needs to be a way to actually find +those public routes. Without discovery, making a route public has no effect. + +Outdoor platforms live and die by discovery. A hiker planning a trip to the +Harz Mountains should be able to pan the map there and see what routes exist. +This is the most natural interface for spatial data: a map. + +## What Changes + +- **Explore page**: A new `/routes/explore` page in the Journal with a full-page + Leaflet map. Users pan and zoom to browse public routes in any area. +- **Bounding box API**: A server endpoint that takes the current map viewport + bounds and returns public routes whose geometry intersects the bounding box, + using PostGIS `ST_Intersects`. Results are limited to 50 and geometries are + simplified for transfer performance. +- **Route polylines**: Public routes rendered as clickable polylines on the + explore map. Clicking a route shows a popup with name, distance, elevation + gain, author, and a link to the route detail page. +- **Spatial index**: Verify (or create) a GiST index on `journal.routes.geom` + to keep bounding box queries fast. + +## Capabilities + +### New Capabilities + +- `route-discovery`: Map-based exploration of public routes via spatial queries + +### Modified Capabilities + +- `journal-navigation`: Add explore link to main navigation +- `map-display`: Route polylines with interactive popups on explore map + +## Non-Goals + +- **Full-text search**: No searching routes by name or description. Map browsing + is the only discovery mechanism for now. +- **Filtering**: No filtering by distance, elevation, activity type, difficulty, + or tags. These are useful but add complexity -- defer until real users ask. +- **Recommendations**: No "routes you might like" or personalized suggestions. +- **Clustering**: No marker clustering for dense areas. The 50-result limit and + geometry simplification keep the map readable. Clustering can come later. +- **Federated discovery**: Routes from other Journal instances are out of scope. + This only covers routes on the local instance. + +## Dependencies + +- **route-sharing** (route-features change, section 1): The `visibility` column + on `journal.routes` must exist before this change can query for public routes. + Without it, there are no public routes to discover. + +## Impact + +- **Database**: GiST spatial index on `journal.routes.geom` (may already exist) +- **New route**: `/routes/explore` page + API endpoint +- **Navigation**: Explore link added to Journal nav +- **Packages**: Uses `@trails-cool/map` (MapView, RouteLayer) -- may need a new + component for interactive route polylines with popups +- **i18n**: New keys for explore page UI (en + de) diff --git a/openspec/changes/route-discovery/tasks.md b/openspec/changes/route-discovery/tasks.md new file mode 100644 index 0000000..c060899 --- /dev/null +++ b/openspec/changes/route-discovery/tasks.md @@ -0,0 +1,43 @@ +## 1. Spatial Index & Database + +- [ ] 1.1 Verify whether a GiST index exists on `journal.routes.geom` -- if not, create one via raw SQL migration: `CREATE INDEX IF NOT EXISTS idx_routes_geom ON journal.routes USING GIST (geom);` +- [ ] 1.2 Verify the `visibility` column exists on `journal.routes` (dependency on route-sharing) -- if not, note this as a blocker + +## 2. Bounding Box API + +- [ ] 2.1 Create `apps/journal/app/routes/api.routes.explore.ts` with a GET loader that accepts `south`, `west`, `north`, `east` query params, validates bounds, and returns public routes within the bounding box using `ST_Intersects` + `ST_Simplify(geom, 0.001)`, limited to 50 results +- [ ] 2.2 Join route owner to include `username` and `displayName` in response +- [ ] 2.3 Register the API route in `apps/journal/app/routes.ts`: `route("api/routes/explore", "routes/api.routes.explore.ts")` + +## 3. Explore Page + +- [ ] 3.1 Create `apps/journal/app/routes/routes.explore.tsx` with a full-page `MapView` from `@trails-cool/map`, filling the viewport below the nav bar +- [ ] 3.2 Register the page route in `apps/journal/app/routes.ts`: `route("routes/explore", "routes/routes.explore.tsx")` (before the `routes/:id` route to avoid param conflict) +- [ ] 3.3 Create `useExploreRoutes` hook: listens to Leaflet `moveend`, debounces 300ms, fetches `/api/routes/explore` with current viewport bounds, returns `{ routes, isLoading }`. Use AbortController to cancel in-flight requests. +- [ ] 3.4 Add simple bounds-based cache (Map with max 20 entries) to `useExploreRoutes` to avoid re-fetching previously viewed areas +- [ ] 3.5 Store and restore last map center/zoom in localStorage so the explore page remembers the user's last viewport + +## 4. Route Rendering & Interaction + +- [ ] 4.1 Create `ExploreRouteLayer` component in `@trails-cool/map`: renders an array of route objects as Leaflet polylines with hover highlighting (opacity 0.6 -> 0.9, weight 3 -> 5) +- [ ] 4.2 Add click handler to each polyline that opens a Leaflet popup with route name (linked to `/routes/:id`), formatted distance, elevation gain, and author name (linked to `/users/:username`) +- [ ] 4.3 Export `ExploreRouteLayer` from `@trails-cool/map` package index + +## 5. Navigation + +- [ ] 5.1 Add "Explore" link to Journal navigation bar, pointing to `/routes/explore` + +## 6. Performance + +- [ ] 6.1 Add loading indicator (small spinner in map corner) shown during API fetches, without clearing existing routes from the map +- [ ] 6.2 Verify query performance with `EXPLAIN ANALYZE` on the bounding box query with the GiST index -- should use index scan, not sequential scan + +## 7. i18n + +- [ ] 7.1 Add translation keys (en + de) for: explore page title ("Explore Routes" / "Routen entdecken"), loading indicator, popup labels (distance, elevation, author), empty state ("No public routes in this area" / "Keine offentlichen Routen in diesem Bereich"), nav link + +## 8. Testing + +- [ ] 8.1 Unit test for bounding box API loader: mock database, verify correct SQL parameters, response format, 50-result limit, handling of missing/invalid bounds +- [ ] 8.2 Unit test for `useExploreRoutes` hook: verify debouncing, abort behavior, cache hit/miss +- [ ] 8.3 E2E test: navigate to `/routes/explore`, verify map renders, pan the map, verify routes appear as polylines (requires seeded public routes with geometry in test database) diff --git a/openspec/changes/route-sharing/.openspec.yaml b/openspec/changes/route-sharing/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/route-sharing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/route-sharing/design.md b/openspec/changes/route-sharing/design.md new file mode 100644 index 0000000..9a7becc --- /dev/null +++ b/openspec/changes/route-sharing/design.md @@ -0,0 +1,255 @@ +## Context + +The Journal stores routes in `journal.routes` with an `ownerId` foreign key to +`journal.users`. Route versions live in `journal.route_versions` with a +`routeId` reference. Activities live in `journal.activities` with an `ownerId`. +Currently, all route access is implicitly owner-only: the route detail loader +returns the route regardless of who requests it, but the route list only shows +the current user's routes. There is no visibility column, no sharing table, and +no permission checks beyond ownership. + +The architecture doc defines a permission matrix with private/public/shared +visibility, view/edit permission levels, and forking. The original +`route-features` spec designed these alongside spatial search, multi-day routes, +and photos. This design covers only the sharing, permissions, and forking scope. + +## Decisions + +### D1: Visibility enum -- private and public + +Add a `visibility` column to `journal.routes` and `journal.activities` using a +PostgreSQL enum type `journal.visibility_enum` with values `private` and +`public`. Default is `private`. + +```sql +CREATE TYPE journal.visibility_enum AS ENUM ('private', 'public'); +ALTER TABLE journal.routes ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private'; +ALTER TABLE journal.activities ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private'; +``` + +In Drizzle: + +```typescript +import { pgEnum } from "drizzle-orm/pg-core"; + +export const visibilityEnum = journalSchema.enum("visibility_enum", ["private", "public"]); + +// On routes table: +visibility: visibilityEnum("visibility").notNull().default("private"), + +// On activities table: +visibility: visibilityEnum("visibility").notNull().default("private"), +``` + +Only two values for now. A `followers_only` value can be appended to the enum +later when the follow system exists. Starting minimal avoids unused code paths +and UI states. + +### D2: Route shares table + +Create `journal.route_shares` to store per-user sharing permissions: + +```typescript +export const routeSharePermission = journalSchema.enum( + "route_share_permission", + ["view", "edit"], +); + +export const routeShares = journalSchema.table("route_shares", { + id: text("id").primaryKey(), + routeId: text("route_id") + .notNull() + .references(() => routes.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + permission: routeSharePermission("permission").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +A unique constraint on `(routeId, userId)` prevents duplicate shares. If the +owner updates a share, the existing row is replaced (upsert). Deleting a share +revokes access immediately. + +The table is intentionally simple: one row per user per route. No expiry, no +"pending" state. The owner shares, the recipient sees it. This matches the +architecture doc's "simple permissions" decision. + +### D3: Permission functions + +Create `apps/journal/app/lib/permissions.server.ts` with two core functions: + +```typescript +async function canView(routeId: string, userId: string | null): Promise; +async function canEdit(routeId: string, userId: string | null): Promise; +``` + +Logic for `canView`: +1. If user is the route owner: **yes** +2. If route visibility is `public`: **yes** (even if userId is null) +3. If user has a route_share with `view` or `edit` permission: **yes** +4. Otherwise: **no** + +Logic for `canEdit`: +1. If user is the route owner: **yes** +2. If user has a route_share with `edit` permission: **yes** +3. Otherwise: **no** + +Note: public visibility grants view access but never edit access. Edit access +requires either ownership or an explicit `edit` share. + +These functions are used in: +- **Route detail loader**: Replace the current unconditional fetch with a + `canView` check. Return 404 (not 403) for unauthorized access to avoid + revealing route existence. +- **Route list loader**: Query own routes + routes shared with the user. + Public routes are not shown in the list (they are discoverable via the + explore page in a future change). +- **Route edit action**: Check `canEdit` before allowing updates. +- **Edit-in-Planner API**: Check `canEdit` before generating a Planner session. +- **GPX download API**: Check `canView` before serving GPX. + +### D4: Forking + +Forking copies a route and its latest GPX into the current user's collection. +Only public routes can be forked (shared routes cannot -- the owner explicitly +chose to share, not to allow copying). + +Schema addition on `journal.routes`: + +```typescript +forkedFromId: text("forked_from_id").references(() => routes.id, { onDelete: "set null" }), +``` + +`onDelete: "set null"` means if the original route is deleted, the fork remains +but loses the provenance link. This is correct -- the fork is an independent +copy. + +Fork API route (`api.routes.$id.fork.ts`): +1. Verify user is logged in +2. Load the source route, check visibility is `public` +3. Create a new route with: + - `ownerId`: current user + - `name`: same as original (user can rename later) + - `description`: same as original + - `gpx`: copy of current GPX + - `geom`: copy of current geometry + - `routingProfile`, `distance`, `elevationGain`, `elevationLoss`: copied + - `forkedFromId`: source route ID + - `visibility`: `private` (forks start private) +4. Create a v1 route version with the copied GPX +5. Redirect to the new route's detail page + +UI: A "Fork" button appears on the route detail page when: +- The route is public +- The viewer is logged in +- The viewer is not the owner + +The route detail page shows "Forked from [Original Route Name]" with a link +when `forkedFromId` is set and the original route still exists and is viewable. + +### D5: Contributor tracking + +Add a `contributors` text array column to `journal.route_versions`: + +```typescript +contributors: jsonb("contributors").$type(), +``` + +This stores user IDs (not usernames) of people who contributed to this version. +For now this is populated from the JWT callback: the Planner session callback +includes the JWT which identifies the route and the user who initiated the +session. When the callback saves a new version, the contributor is the user +associated with the JWT token. + +In the callback handler (`api.routes.$id.callback.ts`): +1. Decode the JWT to get the user identity (already done for auth) +2. Pass the contributor user ID to the version creation function +3. Store it in the `contributors` array on the new version row + +For self-saves (owner editing via Planner), the contributor is the owner. For +cross-instance edits (future federation), the contributor would be the remote +user's ActivityPub URI. + +Display on the route detail page: each version in the version history shows +contributor names/usernames. The route header shows a combined list of all +unique contributors across versions. + +### D6: Share dialog + +The share dialog is a modal accessible from the route detail page (owner only). +It provides: + +1. **User search**: A text input that searches users by username or display name. + Uses an API endpoint (`api.users.search.ts`) that returns matching users + (excluding the route owner). Search is debounced (300ms) and returns at most + 10 results. + +2. **Permission selector**: For each user in the share list, a dropdown with + `view` or `edit`. Defaults to `view`. + +3. **Current shares**: Shows users the route is already shared with, their + permission level, and a remove button. + +4. **Actions**: "Share" button adds the selected user with the chosen permission. + "Remove" button revokes a share. + +The dialog uses form submissions (React Router actions) for mutations, keeping +it server-rendered and progressive. The user search uses a client-side fetch to +the search API for responsiveness. + +### D7: Visibility toggle + +A simple toggle on the route detail page (owner only) that switches between +private and public. Implemented as a form with a hidden intent field: + +```html +
+ + +
+``` + +The toggle shows the current state clearly: "This route is private" with a +"Make public" button, or "This route is public" with a "Make private" button. +Making a route private does not revoke existing shares -- shared users retain +their access. This is intentional: visibility controls anonymous access, shares +control named-user access. + +The same pattern applies to activities: a visibility toggle on the activity +detail page. + +### D8: Privacy manifest update + +The privacy page (`apps/journal/app/routes/privacy.tsx`) must document: + +- **Route visibility**: Routes and activities have a visibility setting + (private or public). Public routes are viewable by anyone with the link. +- **Route sharing**: Owners can share routes with specific users. Shared users + can see the route name, description, and GPX data according to their + permission level. +- **Forking**: Public routes can be forked (copied) by other users. The fork + is an independent copy -- changes to the fork do not affect the original and + vice versa. +- **Contributor tracking**: When you edit someone's route via the Planner, your + user ID is recorded as a contributor on that version. + +## Risks / Trade-offs + +- **No followers-only visibility**: Starting with just private/public is + simpler but means there is no middle ground. Users who want to share with + followers but not the world must use per-user shares. This is acceptable + until the follow system exists. +- **Fork divergence**: Forks are fully independent copies. There is no mechanism + to sync upstream changes or notify fork owners of updates. This is by design + -- forks are for adaptation, not tracking. +- **Share revocation is immediate**: Removing a share immediately revokes access. + If a shared user has the route open in a Planner session, they can finish + their current session (the JWT is still valid) but cannot start a new one. +- **User search exposes usernames**: The user search API returns usernames and + display names. This is necessary for sharing to work. Rate limiting on the + search endpoint mitigates enumeration concerns. +- **404 vs 403**: Returning 404 for unauthorized access prevents route existence + leaks but makes debugging harder for shared users who lost access. This is the + standard privacy-first approach. diff --git a/openspec/changes/route-sharing/proposal.md b/openspec/changes/route-sharing/proposal.md new file mode 100644 index 0000000..09c57b9 --- /dev/null +++ b/openspec/changes/route-sharing/proposal.md @@ -0,0 +1,77 @@ +## Why + +Routes in the Journal are currently invisible to everyone except the owner. There +is no way to make a route public, share it with a specific person, or fork +someone else's route into your own collection. The architecture doc specifies +visibility levels, a permission matrix, and forking for Phase 2, and the original +`route-features` spec bundled these with spatial search, multi-day routes, and +photos. This change breaks out the sharing and permissions scope into a focused, +independent unit of work. + +Without visibility and sharing, routes are just private GPX storage. Users cannot +show a planned bikepacking route to a friend, let a co-planner view the latest +version in the Journal, or build on someone else's published route. This is the +minimum social layer needed before federation makes routes visible across +instances. + +## What Changes + +- **Route visibility**: `private` (default) and `public` on routes and + activities. Public routes are viewable by anyone with the link. Private routes + are owner-only. +- **Route sharing**: Owners can share a route with specific users at `view` or + `edit` permission level. Shared users see the route in their collection and can + act according to their permission. +- **Route forking**: Logged-in users can fork any public route, copying the route + metadata and latest GPX into their own collection with a `forkedFromId` link + back to the original. +- **Contributor tracking**: When the Planner saves back to the Journal via JWT + callback, the contributor's identity is recorded on the route version. The + route detail page shows who contributed to each version. + +These are all Journal-side changes (database schema, server logic, and UI). No +Planner changes are needed. + +## Capabilities + +### New Capabilities + +- `route-sharing`: Visibility levels (private/public), per-user sharing with + view/edit permissions, share dialog with user search +- `route-forking`: Copy public routes into own collection with provenance link + +### Modified Capabilities + +- `route-management`: Visibility toggle on routes and activities, permission + checks on all route access, contributor display +- `planner-callback`: JWT callback records contributor identity on route versions + +## Non-Goals + +- **Granular permissions beyond view/edit**: No "edit waypoints but not + description" or "view but not export GPX". Two levels are enough. +- **Team or organization sharing**: No group-level permissions. Share with + individual users only. +- **ActivityPub federation of shares**: Sharing is local to the instance for now. + Cross-instance sharing comes with the federation change. +- **Followers-only visibility**: Only private and public for now. A + followers-only level can be added when the follow system exists. +- **Spatial search / explore page**: Broken out as a separate change. Public + routes are a prerequisite; discovery is not. + +## Impact + +- **Database**: New `visibility` enum column on `journal.routes` and + `journal.activities`. New `journal.route_shares` table. New `contributors` + text array on `journal.route_versions`. New `forkedFromId` column on + `journal.routes`. +- **Server logic**: New `permissions.server.ts` module with `canView` and + `canEdit` functions. Route loaders and list queries updated to respect + permissions. +- **UI**: Visibility toggle on route detail/edit page. Share dialog modal with + user search. Fork button on public routes. "Forked from" link. Contributor + list on route detail. +- **Privacy**: Privacy manifest updated to document visibility and sharing + behavior. +- **i18n**: New translation keys for visibility, sharing, forking, contributors + (en + de). diff --git a/openspec/changes/route-sharing/tasks.md b/openspec/changes/route-sharing/tasks.md new file mode 100644 index 0000000..09b3425 --- /dev/null +++ b/openspec/changes/route-sharing/tasks.md @@ -0,0 +1,45 @@ +## 1. Schema + +- [ ] 1.1 Add `visibility_enum` PostgreSQL enum (`private`, `public`) to `packages/db/src/schema/journal.ts` using `journalSchema.enum()` +- [ ] 1.2 Add `visibility` column to `journal.routes` table, type `visibility_enum`, default `private` +- [ ] 1.3 Add `visibility` column to `journal.activities` table, type `visibility_enum`, default `private` +- [ ] 1.4 Create `journal.route_shares` table with columns: `id` (text PK), `routeId` (FK to routes, cascade delete), `userId` (FK to users, cascade delete), `permission` (enum: view/edit), `createdAt`. Add unique constraint on `(routeId, userId)` +- [ ] 1.5 Add `forkedFromId` nullable text column to `journal.routes`, FK to `routes.id` with `onDelete: "set null"` +- [ ] 1.6 Add `contributors` jsonb text array column to `journal.route_versions` +- [ ] 1.7 Run `pnpm db:push` and verify schema locally + +## 2. Permissions Logic + +- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with `canView(routeId, userId)` and `canEdit(routeId, userId)` — owner always has full access, public routes viewable by anyone, shared routes respect permission level +- [ ] 2.2 Update route detail loader (`routes.$id.tsx`) to check `canView` — return 404 for unauthorized. Pass `canEdit` result to the component for conditional UI (edit button, share button) +- [ ] 2.3 Update route list loader (`routes._index.tsx`) to include routes shared with the current user alongside owned routes + +## 3. Sharing UI + +- [ ] 3.1 Add visibility toggle to route detail page (owner only): form with `intent=toggleVisibility`, shows current state and toggle button +- [ ] 3.2 Create user search API route (`api.users.search.ts`): accepts `q` query param, returns matching users by username/display name (max 10, excludes requesting user), debounce-friendly +- [ ] 3.3 Create share dialog component and integrate on route detail page (owner only): user search input, permission dropdown (view/edit), current shares list with remove button. Uses form actions for add/remove share + +## 4. Forking + +- [ ] 4.1 Create fork API route (`api.routes.$id.fork.ts`): verify user is logged in, source route is public, copy route + latest GPX + metadata to user's collection with `forkedFromId` set, create v1 version, redirect to new route +- [ ] 4.2 Add "Fork" button on route detail page: visible when route is public, viewer is logged in, and viewer is not the owner +- [ ] 4.3 Show "Forked from [route name]" link on route detail page when `forkedFromId` is set and original route is viewable + +## 5. Contributor Tracking + +- [ ] 5.1 Update Planner callback handler (`api.routes.$id.callback.ts`): extract contributor identity from JWT, pass to version creation +- [ ] 5.2 Update `updateRoute` in `routes.server.ts` to accept and store `contributors` array on new route version rows +- [ ] 5.3 Display contributors on route detail page: per-version contributor names in version history, combined unique contributors in route header + +## 6. Privacy & i18n + +- [ ] 6.1 Update privacy manifest page (`privacy.tsx`): document route visibility settings, sharing behavior, forking, and contributor tracking +- [ ] 6.2 Add i18n keys (en + de) for: visibility labels (private/public), share dialog strings, fork button/label, contributor display, "forked from" text + +## 7. Testing + +- [ ] 7.1 Unit tests for `permissions.server.ts`: owner access, public access, shared view/edit access, no access, null user on public route +- [ ] 7.2 Unit tests for fork logic: successful fork of public route, reject fork of private route, forkedFromId set correctly, fork starts as private +- [ ] 7.3 E2E test: create route, toggle visibility to public, verify accessible when logged out +- [ ] 7.4 E2E test: share route with another user at view level, verify they can see it but not edit; share at edit level, verify edit access diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/undo-redo/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/undo-redo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/undo-redo/design.md new file mode 100644 index 0000000..95fd812 --- /dev/null +++ b/openspec/changes/undo-redo/design.md @@ -0,0 +1,189 @@ +## Context + +The Planner uses Yjs for collaborative CRDT state with four shared types: +`waypoints` (Y.Array), `noGoAreas` (Y.Array), `routeData` (Y.Map), and +`notes` (Y.Text). All mutations happen through Yjs transactions. The editor +supports multiple simultaneous users via y-websocket. + +Yjs provides a built-in `UndoManager` class that tracks changes to specified +shared types, scoped by transaction origin, with configurable capture +timeouts for grouping related changes. + +## Goals / Non-Goals + +**Goals:** +- Undo/redo for waypoint operations (add, delete, reorder, drag-move) +- Undo/redo for no-go area operations (add, delete) +- Undo/redo for notes text edits +- Keyboard shortcuts that work naturally (Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y) +- Topbar buttons with visual disabled state +- Collaborative-safe: only undo your own changes, never other users' +- Drag operations grouped as a single undo step + +**Non-Goals:** +- Undo for routeData (derived data, not user input) +- Undo for route options / color mode changes +- Persistent undo history across page reloads +- Customizable undo stack depth + +## Decisions + +### D1: Single UndoManager tracking waypoints, noGoAreas, and notes + +Create one `Y.UndoManager` instance in `use-yjs.ts`, tracking all three +user-editable shared types: `waypoints`, `noGoAreas`, and `notes`. + +```ts +const undoManager = new Y.UndoManager( + [waypoints, noGoAreas, notes], + { + captureTimeout: 500, + trackedOrigins: new Set(["local"]), + } +); +``` + +A single UndoManager means undo walks back through all user actions in order, +regardless of which shared type was modified. This matches user expectations: +"undo my last thing" not "undo my last waypoint thing." + +The UndoManager is created inside the `useYjs` hook and exposed on the +`YjsState` interface so all components can access it. + +### D2: Transaction origins for local vs. remote scoping + +All local mutations must use the origin `"local"` so UndoManager can +distinguish them from remote changes arriving via y-websocket: + +```ts +doc.transact(() => { + waypoints.push([yMap]); +}, "local"); +``` + +Current code has some `doc.transact()` calls without an origin and some +direct mutations (e.g., `yjs.waypoints.push([yMap])`) outside a transaction. +All mutation sites need to be wrapped in `doc.transact(() => { ... }, "local")`. + +Mutation sites to update: +- `PlannerMap.tsx`: `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, + `deleteWaypoint` +- `WaypointSidebar.tsx`: `deleteWaypoint`, `moveWaypoint` +- `NoGoAreaLayer.tsx`: no-go area creation (`pm:create` handler), no-go area + deletion (contextmenu handler) +- `NotesPanel.tsx`: `handleInput` (already uses `doc.transact`, needs origin) +- `use-yjs.ts`: initial waypoint seeding (use `"init"` origin, not `"local"`, + so it's not undoable) + +### D3: Keyboard shortcuts + +Register a global `keydown` listener (in a `useUndoShortcuts` hook or +similar) that handles: +- **Ctrl+Z** (or Cmd+Z on macOS): `undoManager.undo()` +- **Ctrl+Shift+Z** or **Ctrl+Y** (Cmd+Shift+Z on macOS): `undoManager.redo()` + +The listener must suppress when the active element is an ``, +`