Commit graph

292 commits

Author SHA1 Message Date
Ullrich Schäfer
ff09775091 Fix e2e fallout from default-private profile_visibility
Three places assumed the old default-public model:

1. e2e/public-content.test.ts: "profile 404 when user has no public
   content" → updated to assert the new locked-account stub renders
   200 with "This profile is private" and the private route doesn't
   appear. The two follow-on tests (public-route profile, unlisted
   route) now flip the owner to public via a setProfileVisibilityPublic
   helper before the anon-visit assertions, since new users default to
   private.

2. e2e/demo-bot.test.ts: bruno is seeded with profile_visibility =
   'public' on insert (timestamped seed) and the existing-bruno path
   gets a follow-up UPDATE so the test's anon-visitor assertion
   (profile renders, public route is listed) holds regardless of which
   default he was first created under.

3. apps/journal/app/lib/demo-bot.server.ts ensureDemoUser: also pins
   profile_visibility = 'public' on insert. The demo persona is
   discoverable by design — that's the whole point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:46:56 +02:00
Ullrich Schäfer
5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00
Ullrich Schäfer
811d5f62f5 Implement social-feed: local follows + /feed + profile visibility
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.

Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
  federation forward-compat. Local IRIs look like
  `${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
  Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
  'public'). Existing users land 'public' via the default; current
  effective behavior is unchanged.

Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
  truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
  countFollowers / countFollowing / listFollowers / listFollowing.
  Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
  follows → activities WHERE visibility='public', reverse-chrono.

Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
  for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.

UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
  "New Activity".

Privacy manifest updated to document the new follows relation and
profile_visibility setting.

Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.

Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:51:18 +02:00
Ullrich Schäfer
19f25f8e26 Personal activity dashboard for signed-in users
Previously a signed-in user landing on `/` saw the same page as an
anonymous visitor: the visitor hero, the flagship marketing cards, and
the instance-wide public feed. "Home" should mean "your stuff."

The home route now branches on session: signed-out visitors keep the
marketing + public-feed layout from #303; signed-in users get a
personal dashboard showing their own activities reverse-chronologically
(all visibilities), a welcome line linking to their profile, and a
"New Activity" CTA. The public instance feed and marketing blurbs are
suppressed for signed-in users — they'd be redundant on a personal
landing surface.

Loader picks `listActivities(user.id)` vs `listRecentPublicActivities`
based on session, so only one feed query runs per request.

Updates the `journal-landing` spec with the new session-based split and
a "Personal dashboard for signed-in users" requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:16:56 +02:00
Ullrich Schäfer
f0ef989ac3 Rebuild the Journal home around a public feed and flagship marketing
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.

The new home is one layout that serves both audiences:

- Hero: a product-describing h1 ("Federated outdoor journal") +
  tagline. The site name already lives in the top banner and nav brand,
  so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
  Planner demotes to a small "Or try the Planner without an account →"
  link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
  for Planner / Journal / Federation / Ownership, matching the Planner
  home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
  a "Powered by trails.cool — about the project" link back to the
  flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
  activities on the instance, with owner + distance + date. Empty state
  for fresh installs.

Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
  users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
  cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
  so self-hosters (who deploy without these workflows) default to off.

Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
  so the visibility contract is pinned (public in feed, unlisted +
  private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
  feed layout contract and the `IS_FLAGSHIP` toggle, so future
  instance-branding or empty-state changes have a stable anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:00:23 +02:00
Ullrich Schäfer
2e5606458d Fix Planner/Journal healthchecks; auto-clean compose orphans
The compose healthcheck for both apps was `curl -sf http://localhost:.../health`,
but node:25-slim (Debian trixie-slim) ships neither curl nor wget, so
the check always reported the containers as `unhealthy` even while they
were happily serving 200s. Install curl in both Dockerfiles.

Separately, today's flagship BRouter cleanup (PR #297) left an orphan
`brouter` container behind because cd-infra's `docker compose up -d <list>`
doesn't pass `--remove-orphans`. I had to remove it by hand. Pass the
flag in both cd-infra and cd-apps so future service deletions clean up
on the next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:28:04 +02:00
Ullrich Schäfer
c0c3ce98d0 Cache BRouter segments client-side, fetch only the diff
Moving one waypoint previously re-fetched all N-1 segments of the route
through /api/route every time, because the server-side computeRoute
recomputes the whole route from scratch. Under rapid edits this piles up
on BRouter's thread pool and wastes BRouter+Planner cycles on segments
the caller already has.

This change keeps a host-local LRU segment cache in the Planner tab,
keyed by (from, to, profile, noGoHash). On each computeRoute:

  - build the pair list from the current waypoints
  - split into cached vs. missing by looking up each pair key
  - if any missing, POST { pairs: missing, … } to the new
    /api/route-segments endpoint and populate the cache with its ordered
    raw BRouter responses
  - merge cache-ordered segments into an EnrichedRoute client-side via
    mergeGeoJsonSegments (moved to a pure, isomorphic route-merge.ts)
  - write to yjs.routeData exactly as before

Typical drag of one waypoint in a 10-waypoint route drops from 9 BRouter
fetches to 2. A session re-entering a cached arrangement (undo/redo)
short-circuits the server entirely. Profile/no-go changes invalidate all
keys and trigger a full refetch, matching prior behavior.

Yjs stays out of the cache: segments are bulky and CRDT sync of large
blobs hurts more than it helps. The elected host owns the cache; on host
handover the new host pays one full recompute.

Backwards-compat: /api/route is unchanged for external callers (e2e
integration tests, the Journal demo-bot's format=gpx path). Server-side
it now delegates to the shared fetchSegments helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:57:54 +02:00
Ullrich Schäfer
f283401076 Stop BRouter contention kills under rapid editing
When a user rapidly moves waypoints in the Planner, BRouter's
thread-priority watchdog kills older in-flight requests to free a thread
for newer ones, surfacing to the user as "operation killed by
thread-priority-watchdog" errors.

Two fixes:

1. Bump the BRouter thread pool on the dedicated host from 4 to 16.
   `BROUTER_THREADS` is now a Dockerfile ENV (default 4 for
   flagship/CI/dev) overridden to 16 in `infrastructure/brouter-host/`.
   The host has 32 GB of RAM and idle cores; 4 threads was a flagship-era
   constraint that no longer applies.

2. Cancel the previous in-flight `/api/route` request when a new one
   starts. `use-routing.ts` now keeps an `AbortController` in a ref,
   aborts it at the top of each `computeRoute`, and bails quietly on
   `AbortError` so the newer call drives state.

Together these eliminate both the "real" contention (at the BRouter
level) and the wasted work (BRouter computing a route the client no
longer cares about).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:43:15 +02:00
Ullrich Schäfer
c49047fd33
BRouter host compose + Planner auth + cd-brouter rewrite
Lands sections 3-5 of the relocate-brouter-to-dedicated-host change:
everything needed to run BRouter on the dedicated Hetzner Robot host
and have the Planner talk to it with the shared-secret header. Does
NOT flip the cutover — the flagship BRouter stays warm during soak.

## BRouter host compose (section 3)

New `infrastructure/brouter-host/` — a standalone compose project that
runs as the `trails` user on `ullrich.is`:

- `docker-compose.yml` — brouter + caddy sidecar. BRouter has no host
  port; caddy binds only to `10.0.1.10:17777` (vSwitch IP). Every
  service explicitly overrides the host's default Loki logging driver
  to `json-file` so logs don't leak to the operator's personal Loki.
- `Caddyfile` — single-purpose reverse proxy that requires
  `X-BRouter-Auth: ${BROUTER_AUTH_TOKEN}` on every request. `auto_https
  off` (vSwitch-only); default access log format omits request
  headers, so the token is never written to disk.
- `download-segments.sh` — crawls brouter.de, pulls planet-wide RD5
  tiles via `wget -N` (incremental). Idempotent, safe to cron.
- `README.md` — one-shot provisioning + token rotation + rollback
  notes.

`docker/brouter/Dockerfile` is patched to honor `JAVA_OPTS` (was
hardcoded `-Xmx1024M` in CMD). Default keeps the flagship's current
heap; compose on the dedicated host overrides to `-Xmx8g` for planet
scale on a 32 GB box.

## Planner shared-secret header (section 4)

`apps/planner/app/lib/brouter.ts`:

- Module-level guard: throws at startup in production if
  `BROUTER_AUTH_TOKEN` is unset.
- `authHeaders()` helper (reads env at call time, so tests can
  `vi.stubEnv` without module reset).
- Header attached on both `computeRoute` (per-segment) and
  `computeSegmentGpx`.

3 new unit tests cover header attachment + the no-token path.

`infrastructure/docker-compose.yml` passes `BROUTER_AUTH_TOKEN` to
the Planner service, and makes `BROUTER_URL` overridable via SOPS so
the cutover is a one-variable flip.

## cd-brouter workflow (section 5)

Rewritten to deploy to the dedicated host:

- SSH as `trails@${BROUTER_DEPLOY_HOST}` on port
  `${BROUTER_DEPLOY_SSH_PORT}` (2232) using
  `${BROUTER_DEPLOY_SSH_KEY}`.
- Decrypts SOPS, extracts ONLY `BROUTER_AUTH_TOKEN` into a `.env`
  file, scp'd alongside the compose project.
- `paths:` trigger now includes `infrastructure/brouter-host/**`.
- Segment download is NOT run here — first-time seed is a manual
  operator step (multi-hour). Routine re-runs are cron-able on the
  dedicated host.
- Grafana annotation step preserved (reaches flagship Grafana as
  before).

## What's NOT here

- `brouter:` service on the flagship is intentionally left in place
  (removed in section 7.5 after the 48 h soak window post-cutover).
- Observability (section 6) — Prometheus scrape + Loki shipping from
  the dedicated host — comes in a follow-up PR.
- Cutover itself (section 7) — flip `BROUTER_URL`, verify, remove the
  flagship brouter — is an operator action gated on first-time
  provisioning + smoke testing.

## Verification

`pnpm typecheck && pnpm lint && pnpm test` all clean; planner build
passes (the CI regression from #286 was fixed in #290).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:52:48 +02:00
Ullrich Schäfer
458b0a8a4c
Fix planner build: move Overpass upstream fetch to overpass.server.ts
Route files in React Router only get server-code stripped from a
specific set of exports (loader/action/middleware/headers). The
overpass failover work added a `fetchWithFailover` named export to
`app/routes/api.overpass.ts` for unit tests, but that export pulls
in `metrics.server` (prom-client registry, etc.) — which then
leaks into the client bundle and fails the production build with:

  [plugin react-router:dot-server]
  Error: Server-only module referenced by client
  'app/lib/metrics.server' imported by route 'app/routes/api.overpass.ts'

Move the upstream fetch logic and its config constants into
`app/lib/overpass.server.ts`. The `.server.ts` naming makes the
server-only boundary explicit, and tests now import from the lib
instead of from the route. The route keeps only its `action` export
(plus the per-instance LRU cache, which is internal to the module
and not exported).

Tests: 85 passing (no behavior changes). Build: planner + journal
both succeed. Typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:40:15 +02:00
Ullrich Schäfer
0553cf4a5e
Fix planner_active_sessions gauge drifting negative
The gauge was maintained by conditional inc on "this sessionId isn't
in the in-memory `docs` Map yet" and dec on "last client for the
sessionId left." Because `docs` caches entries indefinitely — even
after every client disconnects — a reconnect found the doc still
cached, skipped the inc, then decremented again on the next
disconnect. Every reconnect-after-last-disconnect leaked one
decrement. After a day of normal reload / wifi-blip / tab-hibernate
patterns the gauge hit -182.

Replace the transitions with a `set()` derived from the live `conns`
map: count distinct session ids with at least one open WebSocket.
Self-correcting, no drift possible.

Extract `countActiveSessions()` as a pure helper + unit tests
including a regression case that simulates repeated reconnect/
disconnect cycles on one session and asserts the count never goes
negative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:37:58 +02:00
Ullrich Schäfer
ed7f6ce153
Session-bind /api/route and /api/overpass
Today both proxies are effectively open to anyone who can set an
Origin header for trails.cool — a third party can use us as a free
BRouter/Overpass relay. Require a live planner session on every call
so abuse traffic costs the scraper a session row (observable,
revocable) before they can issue a single query.

Server:
- New `requireSession(id)` helper — returns the session row or a 401
  Response. Reused by both route handlers.
- `/api/route`: `sessionId` in body is now required and verified;
  rate-limit key always falls back to the session id.
- `/api/overpass`: new `X-Trails-Session` header, verified. Header
  keeps the session out of the request body so the body-keyed cache
  is unaffected.

Client plumbing:
- `useRouting(yjs, sessionId)` — sessionId goes into the /api/route
  body.
- `usePois(sessionId)` → `queryPois(..., sessionId)` → `X-Trails-Session`
  on the proxy call.
- `PlannerMap` + `YjsDebugPanel` gain a `sessionId` prop from
  `SessionView`.

Journal server-to-server:
- Demo-bot and `/api/v1/routes/compute` now POST `/api/sessions` to
  mint a throwaway planner session, then cite it on the forwarded
  `/api/route` call. Planner's `expire-sessions` cron cleans these up
  (7d window) so nothing needs explicit teardown.

Tests:
- 5 unit tests for `requireSession` covering missing / empty /
  non-string / unknown-session / valid-session cases.
- Two integration E2E tests document the 401 for missing session on
  each proxy.
- Pre-existing `/api/route` integration tests updated to mint a
  session first.

Caveat: existing browser tabs lose their /api/route ability until
reload (the old JS doesn't know to send sessionId). Acceptable for
an anonymous planner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:18:45 +02:00
Ullrich Schäfer
963902514b
Failover across multiple Overpass upstreams
Public Overpass instances go bad without warning — we just lived
through a day where overpass.private.coffee (our single upstream)
returned 504s after 60s while lz4.overpass-api.de served the same
query in 0.6s. Survive that by trying a list of upstreams in order
until one responds usefully.

Server changes:
- OVERPASS_URLS env — comma-separated list, tried in order. Falls
  back to OVERPASS_URL for backward compat, then to a built-in
  default pair (lz4.overpass-api.de, overpass-api.de).
- Per-upstream timeout of 10s via AbortSignal.timeout, so a saturated
  instance fails over in seconds, not minutes.
- Client abort propagation via AbortSignal.any — if the browser
  cancels (e.g. user panned the map), the in-flight upstream fetch is
  aborted too. Stops wasting upstream capacity on requests nobody
  wants.
- Rate-limited-body detection (Overpass returns 200 with a
  `rate_limited` marker when throttling) triggers failover.
- Per-upstream labels on overpass_upstream_requests_total and
  overpass_upstream_duration_seconds so the dashboard can break out
  health + latency by instance. Histogram buckets extended to 30s.

Compose: OVERPASS_URLS passthrough with the default pair hard-wired,
overridable via SOPS.

Tests: 8 cases covering the new fetchWithFailover helper — happy
path, 5xx failover, rate_limited failover, network error failover,
timeout tagging, all-upstreams-fail, client abort stops the loop,
per-attempt latency observation.

Follow-ups left out of scope:
- Client-side debounce (UI concern).
- Moving `.observe()` after body read for accuracy in measurement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 07:36:10 +02:00
Ullrich Schäfer
c2e8461f9c
Upgrade pg-boss from 10 to 12
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.

## Breaking changes in our usage surface

### v12: default export removed → named `PgBoss` export

Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
  re-exports `Job` at the package root in v12)

### v11: queue names restricted to `[A-Za-z0-9_.-]`

Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune`    → `demo-bot-prune`

The planner's `expire-sessions` was already valid.

### v12: minimum Node 22.12

Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.

## Regression guard

Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.

## Prod rollout note

Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.

## Verified

- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:01:11 +02:00
dependabot[bot]
fef9b5f3f6
Bump the production group with 10 updates
Bumps the production group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` |
| [i18next](https://github.com/i18next/i18next) | `26.0.4` | `26.0.6` |
| [prettier](https://github.com/prettier/prettier) | `3.8.2` | `3.8.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.3` | `17.0.4` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.38` | `5.1.39` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.6.2` | `5.7.0` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.23.0` | `4.24.0` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.41.0` | `6.41.1` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.48.0` | `10.49.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.37.0` | `10.49.0` |

Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `i18next` from 26.0.4 to 26.0.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.4...v26.0.6)

Updates `prettier` from 3.8.2 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

Updates `react-i18next` from 17.0.3 to 17.0.4
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.3...v17.0.4)

Updates `isbot` from 5.1.38 to 5.1.39
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.38...v5.1.39)

Updates `react-native-safe-area-context` from 5.6.2 to 5.7.0
- [Release notes](https://github.com/AppAndFlow/react-native-safe-area-context/releases)
- [Commits](https://github.com/AppAndFlow/react-native-safe-area-context/compare/v5.6.2...v5.7.0)

Updates `react-native-screens` from 4.23.0 to 4.24.0
- [Release notes](https://github.com/software-mansion/react-native-screens/releases)
- [Commits](https://github.com/software-mansion/react-native-screens/compare/4.23.0...4.24.0)

Updates `@codemirror/view` from 6.41.0 to 6.41.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@sentry/node` from 10.48.0 to 10.49.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.48.0...10.49.0)

Updates `@sentry/react` from 10.37.0 to 10.49.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.37.0...10.49.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.41.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 21:40:33 +02:00
dependabot[bot]
e94069232b
Bump @sentry/react-native from 7.11.0 to 8.8.0
Bumps [@sentry/react-native](https://github.com/getsentry/sentry-react-native) from 7.11.0 to 8.8.0.
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/7.11.0...8.8.0)

---
updated-dependencies:
- dependency-name: "@sentry/react-native"
  dependency-version: 8.8.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 11:34:23 +00:00
Ullrich Schäfer
7e302b1fbe
Upgrade @maplibre/maplibre-react-native from 10 to 11
v11 is a breaking API rework (renamed components, restructured props,
event payloads on nativeEvent) and requires React Native's new
architecture. Doing it as a manual migration instead of taking
dependabot's #268 because the renames need code changes.

Changes in apps/mobile:

- RouteMap.tsx — switch to named imports; rename MapView→Map,
  ShapeSource→GeoJSONSource (with shape→data), LineLayer→Layer
  (with type="line" and paint instead of style), MarkerView→
  ViewAnnotation (with coordinate→lngLat); Camera uses
  initialViewState with bounds as [w,s,e,n] and a separate padding
  object; onLongPress reads lngLat from event.nativeEvent.

- app.config.ts — set `newArchEnabled: true`. MapLibre RN v11
  supports only the new architecture, so Fabric/TurboModules must
  be on for the Expo prebuild. Cast the config type since Expo SDK
  55's ExpoConfig typings don't yet include the field (the runtime
  does).

- package.json — `^10.4.2` → `^11.0.0`.

Supersedes dependabot #268; it'll auto-close once this merges.

Runtime verification requires an EAS dev build (native deps
changed + new arch flipped). JS-only surface typechecks + lints +
unit tests clean locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:09:06 +02:00
Ullrich Schäfer
edd2cabf12
DRY up ZodError → FieldError mapping
Five journal API route handlers had the same inline lambda:

    parsed.error.issues.map((i) => ({
      field: i.path.join("."),
      message: i.message,
    }))

Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.

Public error-response shape is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:09 +02:00
Ullrich Schäfer
079c3de90c
Modernise zod idioms to v4
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:

- z.string().url()      → z.url()      (3 sites)
- z.string().uuid()     → z.uuid()     (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)

Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.

All 147 tests still pass.

The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:41:03 +02:00
Ullrich Schäfer
0eea6f2047
Upgrade zod from 3 to 4
Mechanical bump across the three packages that pull zod directly:
- packages/api (8 schema files, ~300 lines)
- apps/journal (persona schema in demo-bot.server.ts, 5 API route
  handlers that introspect parsed.error.issues)
- apps/mobile (declared dep; no active imports yet)

No code changes required. The legacy methods we use (.url(), .uuid(),
.datetime() on strings) still parse identically in v4 via the compat
shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue
introspection (parsed.error.issues.map(i => ...)) keeps the same shape
at the fields we read (path, message, code).

All 147 tests across journal + api + packages pass against zod 4.3.6.

Follow-ups out of scope for this PR:
- Modernise to top-level validator helpers (z.url(), z.iso.datetime()).
  The legacy string methods are deprecated but not removed.
- Consider migrating error introspection to the richer v4 shape (.path
  is now branded, .input is new) if we ever want structured error
  surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:35:39 +02:00
Ullrich Schäfer
df6074b0b5
Fix demo-bot cron + cadence math
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:25:06 +02:00
Ullrich Schäfer
42f4c98014
Route journal BRouter calls through the planner
The planner is the BRouter client in this architecture — route
compute, rate limiting, and the eventual move of BRouter off-box all
live there. The journal had two places that called BRouter directly
(demo-bot and /api/v1/routes/compute) and both were broken on prod
because the journal service has no BROUTER_URL.

Fix:
- Extend the planner's /api/route with an optional `format: "gpx"`
  that returns BRouter's raw GPX (for server-to-server callers that
  don't need the way-tag enriched GeoJSON).
- Point the journal demo-bot at `PLANNER_URL/api/route` with
  `format: "gpx"` instead of calling BRouter directly.
- Point /api/v1/routes/compute at `PLANNER_URL/api/route` instead of
  BRouter directly.

Journal no longer needs BROUTER_URL at all. When BRouter moves to a
dedicated host later, only the planner changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:11:10 +02:00
Ullrich Schäfer
e16bd6de99
Ship app/jobs dir in the journal image
server.ts imports from ./app/jobs/demo-bot-generate.ts and ./app/jobs/
demo-bot-prune.ts, but the Dockerfile only COPYs app/lib. On prod the
worker crashes on boot with ERR_MODULE_NOT_FOUND and neither the
generate nor the prune cron gets scheduled. Add the jobs dir to the
runtime layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:53:22 +02:00
Ullrich Schäfer
100187cb80
Fix prom-client double-registration on prod
/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.

Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:44:57 +02:00
Ullrich Schäfer
fc4485f6ef
Apply configurable-demo-persona: per-instance demo identity + voice
Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.

- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
  persona username is already a real user; server declines to
  schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
  boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:30:32 +02:00
Ullrich Schäfer
ba4c4e1b4f
Apply demo-activity-bot: Bruno, the synthetic public-walk generator
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:14:58 +02:00
Ullrich Schäfer
32661a0163
Add visibility disclosure to the binding German privacy text
The previous commit only added a bullet to the English-only Privacy
Manifest appendix. The formal, binding German sections (which the
policy explicitly declares authoritative) didn't mention visibility
at all — and the paired English summary under each section likewise
didn't.

Add a sentence under §2 "Erhobene Daten und Zwecke → Nutzerinhalte"
explaining the visibility setting, defaults, and world-visibility
consequences. Mirror in the section's English summary paragraph. Keep
the Privacy Manifest bullet as an additional plain-language mention.

Re-rendered docs/legal-archive/privacy-2026-04-20.md; both languages
present (verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:14:15 +02:00
Ullrich Schäfer
5905cdadea
Apply public-content-visibility: visibility flag + public profile
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.

Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.

Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
  centralises the rule: public → anyone; unlisted → anyone on direct
  link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
  canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
  content only.

Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
  action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.

Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
  for cross-user queries. Existing owner-scoped listRoutes/listActivities
  stay — owners see their own content regardless of visibility.

Public profile:
- /users/:username is now truly public. Renders the user's public
  routes + activities, 404s when no public content exists AND viewer
  isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.

Privacy manifest:
- Added a bullet noting public content is world-visible on profile
  and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
  snapshot.

Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
  - Private route → 404 for logged-out visitor
  - Public route → reachable + OG tags present (og:title, og:type,
    og:site_name)
  - Owner still sees own private content
  - Profile 404 when no public content
  - Profile renders when at least one public route exists
  - Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
  WebAuthn virtual-authenticator races under Playwright's default
  parallel workers.
- New public-content Playwright project added to config.

Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:11:39 +02:00
Ullrich Schäfer
5b40bd9b00
Show empty-state CTA on Route detail page when no waypoints
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.

Changes:
- Detect "empty" as no geometry AND no distance (the state every new
  route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
  / Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
  viewer-only copy), primary "Open in Planner" button (same action as
  the existing top-row button, just visually prominent), and a
  secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.

No non-empty route loses any UI — those paths stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:13:24 +02:00
Ullrich Schäfer
f16e80a2eb
Prompt users with stale terms_version to re-accept
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.

Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
  writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
  throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
  request is already on an allow-listed path
  (/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
  and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
  acceptance and bounces to a sanitised returnTo). Same-origin check
  on returnTo to avoid open-redirect abuse. Logout button is provided
  as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
  successful re-accept, missing consent, returnTo sanitisation).

No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:06:16 +02:00
Ullrich Schäfer
9c4c3d6444
Store Terms version alongside acceptance timestamp
Reviewer's follow-up: it's not enough to record when a user accepted
the Terms; we also need to record which version of the text they saw.

Changes:
- journal.users gains a nullable `terms_version` text column (nullable
  so the three pre-existing users without a version are kept as-is).
- New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single
  source of truth, reused by the legal pages' "Last updated" header
  and by the registration flow as the value to send/store.
- Registration form posts `termsVersion` alongside `termsAccepted` on
  all three relevant steps (start, finish, register-magic-link).
- API route validates that `termsVersion` is a non-empty string on
  any step that requires terms, and forwards it to the auth server.
- auth.server finishRegistration and registerWithMagicLink now take
  `termsVersion` and persist it on the users row.
- journal-auth spec gets a new scenario for version storage and a
  rejection scenario for missing version.

PRIVACY_LAST_UPDATED is also exported from the same module and used
by the Privacy page header, keeping both pages on a single legal.ts
source of truth for "last updated" labels. Privacy is not per-user
stored — it's informational, not contract.

Existing users have NULL terms_version; if we ever prompt them to
re-accept updated Terms, we can backfill with the version they
re-accept at that point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:46:34 +02:00
Ullrich Schäfer
bc311ab7bc
Apply minimal legal-review follow-ups
Privacy:
- Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry
  is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen
  werden nicht aktiv gespeichert" per reviewer's preferred wording.
  Drop the unverified "Frankfurt" hosting claim; keep
  Auftragsverarbeiter framing.
- SMTP → "externer SMTP-Dienst" per reviewer's phrasing.
- Storage-durations section: add explicit alpha-reset caveat so it
  aligns with Terms §4 (database resets).

Terms:
- § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser
  Dienst" (leaves pricing model open without promising free-forever).
- § 7 acceptable use: scraping clause softened to "kein massenhaftes
  automatisiertes Auslesen von Daten" (drops the specific "to build
  competing services" qualifier).

Imprint:
- Normalise address formatting: multi-line across both DE blocks and
  the EN § 5 TMG block (§ 18 MStV and EN block previously used a
  comma-separated single line). One style only, everywhere.
- Add whitespace-nowrap to the mailto link so the email address
  never breaks mid-word on narrow viewports.

No changes to legal bases, server-logs section, storage durations
themselves, or any third-party other than Sentry + SMTP wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:27:56 +02:00
Ullrich Schäfer
1462c54df7
Rewrite legal pages per legal review feedback
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
  required statement that we don't participate in consumer dispute
  resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
  repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.

Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
  purposes → Legal bases → Server logs → Storage durations → Third
  parties → Rights → Complaint authority → (plain-language) Privacy
  Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
  to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
  contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
  / status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
  magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
  browser sends IP+UA directly to OSM tile servers, with OSMF privacy
  policy link), Overpass (via our proxy, upstream only sees our
  server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
  the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
  aligned with current reality (no cookies/localStorage/sessionStorage
  in Planner; no replays; no PII via Sentry).

Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
  has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
  modify/limit/interrupt/discontinue the service or individual
  accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
  operator a limited, non-transferable licence to store/process/
  display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
  (standard German DACH tiering: unlimited for intent/gross neg./life-
  body-health; limited to foreseeable damages on cardinal-duty breach
  for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:57:29 +02:00
Ullrich Schäfer
248849318f
Align privacy manifest with Sentry + Overpass hardening
Three sections drifted from the actual behaviour of the deployed apps:

Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
  removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.

Sentry section:
- Drops the "session replays on error" claim — replay integration is
  not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
  are both 0.
- Documents the actual scope: Journal server-side always; Journal
  client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
  and that only the user ID is attached to logged-in Journal errors.

Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
  through our own /api/overpass so the upstream host sees our server,
  not end users.

Also bumps "Last updated" to today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:32:48 +02:00
Ullrich Schäfer
2f5ce0e80e
Merge branch 'main' into fix/overpass-proxy-forwarded-origin 2026-04-18 02:22:30 +02:00
Ullrich Schäfer
e11c9ab58b
Fix /api/overpass 403 behind Caddy reverse proxy
The same-origin check compared the browser's Origin header against
new URL(request.url), but inside the Planner container request.url
is http://planner:3001/... while the browser sends Origin
https://planner.trails.cool — so production always 403'd.

Trust Caddy's X-Forwarded-Host and X-Forwarded-Proto headers when
present (both are set by Caddy's reverse_proxy directive by default)
to reconstruct the external origin the browser actually connected to.
Falls back to request.url for dev and any direct (non-proxied) access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:21:31 +02:00
Ullrich Schäfer
6c1148d5b7
Quantize Overpass query bbox to align cache keys across clients
Near-identical viewports from different collaborators (slightly
different pan/zoom because Yjs awareness doesn't enforce pixel-exact
alignment) previously produced byte-different Overpass queries and
therefore different server-side cache keys. Each client missed cache
and triggered its own upstream fetch.

buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding
outward (south/west floor, north/east ceil) so the viewport is always
covered, and formats coords with 3 decimals for a stable string.

Trade-offs:
- Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on
  each side. For a zoom-12 viewport (~11 km wide) that's <10% extra
  data; bounded by the existing `[maxsize:1048576]` + `out 100` limits.
- Coordinates in the query are now 3-decimal strings regardless of the
  caller's precision — existing buildQuery test updated to match.

Two new tests cover the cache-alignment property and the outward-
expansion invariant. Hit ratio impact will be visible on the Grafana
"Overpass Cache Hit Ratio" stat added in the prior PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:18:46 +02:00
Ullrich Schäfer
62e208d850
Add Prometheus metrics + Grafana panels for Overpass proxy
Adds four metrics surfaced via the existing /metrics endpoint:
- overpass_cache_events_total{result=hit|miss|coalesced}
- overpass_cache_size (gauge)
- overpass_upstream_duration_seconds (histogram)
- overpass_upstream_requests_total{status} — covers 2xx/4xx/5xx and
  an explicit "error" label for network-level failures

Grafana dashboards/planner.json gets a new row:
- Upstream health (5m 2xx success ratio, red <90%, green >99%)
- Cache hit ratio
- Cache size
- Upstream p95 latency
Plus timeseries panels for cache event breakdown, upstream status
over time, and p50/p95/p99 upstream latency.

The success-rate formula uses clamp_min(..., 1) on the denominator so
it doesn't NaN when traffic is zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:12:19 +02:00
Ullrich Schäfer
a4df5a43f6
Route Overpass through server-side proxy with cache + coalescing
Adds apps/planner/app/routes/api.overpass.ts — a same-origin, rate-limited
server-side proxy that forwards Overpass QL to the upstream endpoint
configured via OVERPASS_URL (default: overpass.private.coffee).

Motivation:
- private.coffee's best-practices require a meaningful User-Agent
  identifying the project. Browsers cannot set User-Agent on fetch()
  (forbidden header), so the request has to originate server-side.
- Collaborative sessions commonly have N clients pan/zoom the same map,
  so the same bbox query arrives multiple times within seconds.
- Setting up the proxy now lets us swap OVERPASS_URL to a self-hosted
  Overpass later without client changes.

What the proxy does:
- Sets User-Agent "trails.cool Planner (https://trails.cool; legal@trails.cool)"
- Enforces same-origin via the Origin header
- Rate-limits per client IP (120 req/min)
- In-memory LRU cache of upstream responses keyed on the form-encoded body
  (TTL 10 min, max 200 entries)
- Coalesces concurrent misses for the same key so N simultaneous clients
  in one session incur exactly one upstream call

Client change: apps/planner/app/lib/overpass.ts POSTs to /api/overpass
instead of iterating over public Overpass endpoints. Fallback list
removed; resilience is now the proxy's responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:04:50 +02:00
Ullrich Schäfer
f7c2604121
Use overpass.private.coffee as primary Overpass endpoint
Switch the Planner's primary Overpass endpoint from
overpass.kumi.systems to overpass.private.coffee. private.coffee is a
privacy-focused public Overpass instance that doesn't log queries,
which fits the Planner's privacy-first principle better than the
previous default. Keep overpass-api.de as the silent fallback for
resilience while the self-host-overpass change is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:48:09 +02:00
Ullrich Schäfer
01f002cc46
Stop Sentry on logout; fix E2E + Dockerfile for merged consolidation
- sentry.client.ts: add stopSentryClient() that awaits Sentry.close() so
  the SDK tears down when a user logs out. Hub methods become no-ops
  until initSentryClient() is called again on next login.
- root.tsx: call stopSentryClient() from the user-effect when user === null.
- Add sentry-config to journal + planner Dockerfiles (was missing after
  #237 introduced the new workspace package, breaking the Dockerfile
  package check).
- Footer: replace inner <nav> with <div> — nested nav landmarks broke
  the journal "nav bar shows on all pages" e2e test (two navigation
  roles on the page).
- e2e auth: tick the required ToS checkbox in the shared registerUser
  helper so passkey registration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:40:36 +02:00
Ullrich Schäfer
8e576ac578
Consolidate Sentry config into shared package; fix inconsistencies
Introduces @trails-cool/sentry-config with three helpers that encode
the common Sentry.init options:
- nodeSentryConfig(appContext) for the two HTTP servers
- browserSentryConfig(appContext, env) for the Journal client
- mobileSentryConfig(__DEV__) for the React Native app
- drop404s beforeSend for servers

Also fixes three inconsistencies found in the audit:
- Planner server was missing sendDefaultPii: false (IPs could leak)
- Planner entry.server.tsx didn't call Sentry.captureException on SSR
  render errors; Journal's SSR did
- Sentry.init location now matches across apps (both in server.ts;
  Journal previously had it in app/entry.server.tsx, which meant init
  ran lazily on first request rather than at server startup)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:30:09 +02:00
Ullrich Schäfer
4092f21475
Log Sentry dev-inert status; explicit replay=0
When Sentry init runs but is disabled (local dev / CI), log a debug
message so developers can see Sentry *would* send in production.

Also explicitly set replaysSessionSampleRate/replaysOnErrorSampleRate
to 0 in the client and mobile configs. Replay integration isn't
installed, so this is documentation — it prevents future accidental
enablement if an integration is ever added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:15:31 +02:00
Ullrich Schäfer
cc0f44258a
Add legal pages, ToS acceptance, and alpha banner
- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:08:48 +02:00
Ullrich Schäfer
a056ec286b
Merge branch 'main' into feat/login-code 2026-04-17 22:38:57 +02:00
Ullrich Schäfer
258e1bb5e5
Fix CI on main: RouteMap typecheck + metro.config lint
- RouteMap.tsx referenced undefined MapLibreRN namespace, causing
  typecheck to fail on main and in every downstream PR
- metro.config.js is CommonJS, exclude from ESLint flat config

Changes that were pushed directly to main earlier bypassed CI and
broke both typecheck and lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 22:26:38 +02:00
Ullrich Schäfer
3bcb1fce7c
Add 6-digit login code for mobile authentication
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:56:42 +02:00
Ullrich Schäfer
2a94af5655
Add Metro config for monorepo module resolution
Metro couldn't resolve hoisted packages (@maplibre, @gorhom, etc.)
because it only looked in apps/mobile/node_modules. Configure
watchFolders and nodeModulesPaths to include the monorepo root.

Also revert MapLibre fallback — direct import is correct, the issue
was Metro resolution, not a missing native module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:39:04 +02:00
Ullrich Schäfer
58bff065ff
Gracefully handle missing MapLibre native module
Use try/catch require() for MapLibre so the app works without it
(shows fallback UI). Needed when running on builds that don't include
MapLibre native module (old EAS builds, Expo Go).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:28:25 +02:00
Ullrich Schäfer
5c4c0ae49d
Fix route compute 401: use authenticated API client
The route editor called fetch() directly for /api/v1/routes/compute
without the bearer token. Now uses the API client which injects auth
headers and handles 401 auto-refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:12:13 +02:00