Commit graph

961 commits

Author SHA1 Message Date
Ullrich Schäfer
abb754e6a5 Replace waitForLoadState("networkidle") in e2e helpers
The SSE connection to /api/events (added with notifications) keeps the
network in-flight forever, so `networkidle` never resolves. Each save
in the affected helpers timed out at 30s × 3 retries, which both broke
the run and made it suspiciously long.

Switched to explicit waits:
- "Profile saved." text after settings save (notifications + public-content + social)
- "No pending follow requests." after Approve (social.test.ts)
- toBeHidden poll for the Mark all read button (notifications)
- toBeVisible poll for the empty-state copy after Approve (notifications)

These are all the affected `networkidle` call sites in e2e. The
fetcher.Form pattern stays — once the action commits, the page
revalidates and the post-state element appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:37:37 +02:00
Ullrich Schäfer
e61179ab27 Implement notifications + supporting fixes
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.

Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
  paste-friendly), with `[Register Magic Link]` console line in dev so
  the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
  had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
  singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
  synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
  profile route (owner + accepted followers see the list; others 404).
  Profile page renders the count chips as plain spans for viewers who
  can't see the lists, so private profiles don't surface dead links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:55 +02:00
Ullrich Schäfer
10c03643a5
Merge pull request #312 from trails-cool/chore/archive-social-feed
Archive social-feed
2026-04-26 00:19:55 +02:00
Ullrich Schäfer
4b414eccd0 Archive social-feed
The social layer (local follows, /feed, profile_visibility, locked
accounts) is fully shipped via the social-feed implementation work
plus the locked-account follow-up. Closing out the change.

Pre-archive ticks:
- 1.4: schema migrated on prod via cd-apps drizzle-kit push; column
  + table verified on the running DB.
- 3.3: follower/following counts on /users/:username shipped in #310.
- 7.1: cd-apps drizzle-kit push --force ran; verified post-deploy.
- 7.2: smoke inputs verified (bruno is public on prod, has 17 public
  activities, /users/bruno returns 200). Live click-through is
  operator-discretion; the listSocialFeed query correctness is proven
  by integration tests.
- 7.3 / 7.4: forward-pointers, not deliverables for this change.

One task explicitly deferred:
- 6.2: full activity-creation E2E for the /feed assertion. Equivalent
  coverage at the integration level + the e2e Follow-button +
  visibility tests; not worth wiring an e2e activity-creation helper
  just for this one path.

Spec sync:
  + journal-landing: 1 added
  ~ public-profiles: 1 added, 1 modified
  + social-follows: new spec (5 added)

Move to openspec/changes/archive/2026-04-25-social-feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:19:15 +02:00
Ullrich Schäfer
406d2d3a61
Merge pull request #311 from trails-cool/proposal/notifications
Propose: notifications (closes the locked-account UX loop)
2026-04-26 00:14:31 +02:00
Ullrich Schäfer
95ac79b093 Propose: notifications
Closes the social loop opened by social-feed: a Pending follower has no
way to know their request was approved, and a follower has no signal at
all that someone they follow just posted. Adds a notifications surface
for three v1 event types — follow_request_approved, follow_received,
activity_published — plus a /notifications page, navbar unread count,
and mark-as-read controls.

Capabilities:
- New: notifications (table, page, badge, generation hooks)
- Modified: social-follows (approve + auto-accept emit)
- Modified: activity-feed (public create fans out)
- Modified: journal-landing (nav entry alongside follow-requests)

Design picks:
- Fan-out-on-write for activity_published (1:N) so /notifications is a
  flat single-table query and "mark read" composes trivially. 1:1
  events insert directly. Cost ceiling documented at 10k followers ×
  50 activities/day = 500k/day, still trivial; revisit only if hot.
- Single notifications table with loose subject_id (no per-type FK);
  renderer dereferences by type. Mastodon-style.
- Two distinct nav entries (Follow requests + Notifications). Pending
  is "act on this", notifications is "this happened" — different
  semantics, kept separate.
- Loader-driven unread count (no real-time channel). Real-time is
  deferred.
- 90-day retention for read rows; unread kept indefinitely.

Out of scope: per-type mute preferences, email/push, real-time,
notifications about routes/replies/mentions, federated notifications.
Each tracked as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:12:35 +02:00
Ullrich Schäfer
937bde3b0b
Merge pull request #310 from trails-cool/feat/locked-profiles
Locked-account profiles: private = stub + Pending follow flow
2026-04-25 23:54:16 +02:00
Ullrich Schäfer
1219b46ca3 Fix profile-visibility radio selector in e2e (strict-mode collision)
`getByLabel('Public')` matched both radios because the Private radio's
help-text label contains the substring "public" ("…followers see your
public content."). Switch to targeting the input directly by name+value,
which is unambiguous regardless of help-text wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:51:19 +02:00
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
ede68712a3
Merge pull request #308 from trails-cool/feat/social-follows-and-feed
Implement social-feed: local follows + /feed + profile_visibility
2026-04-25 22:54:13 +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
6440631be4
Merge pull request #307 from trails-cool/proposal/social-federation
Propose: social-federation (ActivityPub via Fedify)
2026-04-25 22:33:54 +02:00
Ullrich Schäfer
aab9c35d7f
Merge pull request #306 from trails-cool/proposal/social-feed
Propose: social-feed (follows + /feed)
2026-04-25 22:33:26 +02:00
Ullrich Schäfer
bc6059ac77 Propose: social-federation (ActivityPub via Fedify)
Follow-up to social-feed. Adds real ActivityPub: per-user actor
objects, WebFinger, signed inbox/outbox, push delivery on local
activity create, signed outbox-poll for remote trails actors.

Asymmetric scope keeps v1 small:
- Inbound: anyone (Mastodon, Pleroma, …) can follow a trails user
  and receive their public activities via push delivery.
- Outbound: trails users can follow other *trails* instances only.
  Following Mastodon/Pleroma/Misskey is refused at the API layer
  with a clear "v1 limitation" message.

This avoids the engineering tar pit of robustly parsing arbitrary AP
vocabulary (Notes with attachments, polls, mentions, content warnings,
threads) while still delivering the core federation pitch.

Capabilities:
- New: social-federation
- Modified: social-follows (remote follows + Pending lifecycle)
- Modified: public-profiles (Pending button state, actor object gate)
- Modified: infrastructure (Fedify dep, key management runbook)
- Modified: security-hardening (HTTP Signatures, signed-fetch policy)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:31:28 +02:00
Ullrich Schäfer
94c4f4389e Propose: social-feed (follows + /feed)
Proposal + design + specs + tasks for the social layer. Adds a
`follows` relation (local + federated via ActivityPub), Follow buttons
on public profiles, and a `/feed` route showing public activities from
followed users.

Capabilities:
- New: social-follows (the graph + /feed)
- Modified: public-profiles (Follow button + counts)
- Modified: journal-landing (Feed link on signed-in dashboard)

Design picks:
- Pull-based feed (no fan-out-on-write) — fine at trails.cool scale
- follows keyed by actor IRI, local denorm FK for join perf
- Fedify wires Follow/Accept/Undo; we handle the DB side
- Remote activity ingestion via polling (not push) — tolerates our
  downtime without losing activities

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:26:20 +02:00
Ullrich Schäfer
172d980a24
Merge pull request #305 from trails-cool/chore/archive-public-content-visibility
Archive public-content-visibility
2026-04-24 22:00:25 +02:00
Ullrich Schäfer
ba6f09171d Archive public-content-visibility
Final tasks ticked post-merge:
- 10.2: verified on prod that journal.routes + journal.activities still
  default to 'private' NOT NULL, with the only public rows being the
  15-each demo-bot seeded content
- 10.3: demo-activity-bot already inserts with visibility='public'
  directly in demo-bot.server.ts

Syncs the three delta specs into main:
  + activity-feed: 2 added, 1 modified
  + public-profiles: new spec (1 added)
  + route-management: 2 added, 1 modified

Moves change to openspec/changes/archive/2026-04-24-public-content-visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:57:21 +02:00
Ullrich Schäfer
9709a0c150
Merge pull request #304 from trails-cool/feat/personal-dashboard
Personal activity dashboard for signed-in users
2026-04-24 20:20:33 +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
cabfff6b22
Merge pull request #303 from trails-cool/feat/journal-home-feed
Rebuild Journal home: flagship marketing + public activity feed
2026-04-24 20:03:28 +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
a9847c5cab
Merge pull request #302 from trails-cool/fix/grafana-pushover-contact-point
Provision Grafana Pushover contact point
2026-04-24 19:10:17 +02:00
Ullrich Schäfer
98341e3603 Provision Grafana Pushover contact point
The notification policy and contact points live in
`infrastructure/grafana/provisioning/alerting/alerts.yml`, so
UI-created contact points can't be attached to provisioned alerts.
Provision the Pushover integration instead.

Rather than add a second contact point and do the fan-out in the
notification tree (Grafana's tree only fires one receiver per match
without duplication workarounds), put both email + pushover under a
single `default` contact point. Every alert now fans out to both.

Secrets go to SOPS `secrets.infra.env`. The Grafana container reads
`PUSHOVER_API_TOKEN` and `PUSHOVER_USER_KEY_ULLRICH` from env; the
provisioning YAML references them via `$VAR` and Grafana substitutes
at startup, so no secret lands in git.

Priority 1 on firing (bypass quiet hours), 0 on resolve. User can
tweak in the YAML later.

Post-deploy: delete the UI-created "Pushover Ullrich" contact point
in Grafana to avoid a duplicate. It's unused (no policy references it)
but shows up in the contact-point list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:07:04 +02:00
Ullrich Schäfer
79c4e1e457
Merge pull request #301 from trails-cool/fix/cd-infra-copy-init-sql
Copy init-grafana-user.sql on every cd-infra deploy
2026-04-24 18:50:23 +02:00
Ullrich Schäfer
bddc3d7620 Copy init-grafana-user.sql on every cd-infra deploy
The postgres init script grants read permissions to `grafana_reader` on
`public`, `planner`, `journal`, and `pgboss` schemas. It's mounted into
the postgres container via bind mount from
`/opt/trails-cool/postgres/init-grafana-user.sql`, and cd-infra re-runs
it with `psql -f` after postgres comes up.

But `cd-infra.yml` only scp's `infrastructure/postgres/queries.yml` to
the flagship — not `init-grafana-user.sql`. The file on the server was
the pre-pg-boss-upgrade March version with no pgboss grants. After
pg-boss v12 landed and (re)created the `pgboss` schema, the
grafana_reader lost schema-level USAGE, the "Background job failures"
alert query started erroring with `permission denied for schema pgboss`,
and the alert fired as "Alerting (Error)" for hours.

Fix: add the init SQL to the scp source list so changes to it actually
reach the flagship. I also manually re-granted on the live postgres to
clear the firing alert; the next cd-infra deploy will now pick up future
edits to the init file too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:47:18 +02:00
Ullrich Schäfer
d93e40bda5
Merge pull request #300 from trails-cool/chore/archive-brouter-relocation
Archive relocate-brouter-to-dedicated-host
2026-04-24 18:38:58 +02:00
Ullrich Schäfer
66fb5d3044 Archive relocate-brouter-to-dedicated-host
Final step (task 9.4) of the BRouter relocation. The change is moved to
openspec/changes/archive/2026-04-24-relocate-brouter-to-dedicated-host/
and the four delta specs (brouter-integration, infrastructure,
observability, security-hardening) are merged into the main specs:

  + 6 added requirements
  ~ 4 modified requirements

The two intentionally-unchecked tasks are 2.2 (obsoleted, see crossed-out
note) and 9.4 itself (this archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:35:37 +02:00
Ullrich Schäfer
3d0371cab6
Merge pull request #299 from trails-cool/infra/healthcheck-and-orphans
Fix app healthchecks; auto-clean compose orphans
2026-04-24 18:32:02 +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
c0b42e4743
Merge pull request #298 from trails-cool/infra/grafana-editor-default
Default Grafana auto-assign role to Editor
2026-04-24 18:08:47 +02:00
Ullrich Schäfer
a421f8ad33
Merge pull request #297 from trails-cool/cleanup/flagship-brouter
Drop the flagship BRouter service after cutover
2026-04-24 18:07:16 +02:00
Ullrich Schäfer
360603a213 Default Grafana auto-assign role to Editor
Users land via GitHub OAuth gated by the trails-cool org allowlist, so
Viewer-only is more restrictive than we actually want — no Explore
access, no ad-hoc Loki/Prometheus queries. Promote the default role to
Editor so new sign-ins get it automatically.

Note: GF_USERS_AUTO_ASSIGN_ORG_ROLE only applies to new users on first
sign-in. Existing Viewers (including the single current user) need a
one-off promotion via the Grafana admin UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:11 +02:00
Ullrich Schäfer
c16c140223 Drop the flagship BRouter service after cutover
After PR #293 flipped BROUTER_URL to the dedicated host and Grafana +
manual smoke tests confirmed clean routing (US route worked, request
rate/latency/memory/logs all green), there's no reason to keep the
in-tree flagship brouter container warm any longer.

- Remove the `brouter:` service and its `./segments` bind mount from
  `infrastructure/docker-compose.yml`.
- Remove `depends_on: brouter` from the planner service.
- Tighten the BROUTER_URL and BROUTER_AUTH_TOKEN env wiring from
  `${…:-default}` to `${…:?message}` so a missing SOPS value fails the
  compose up loudly instead of silently pointing at the removed service
  (or missing auth).
- Tick tasks 5.5, 7.5, 8.4, 9.3 in the OpenSpec change; 9.4 (archive)
  is the last remaining step once this merges.

Post-merge operator step: `docker image prune -f` on the flagship to
reclaim the brouter image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:04:17 +02:00
Ullrich Schäfer
50349b0363
Merge pull request #296 from trails-cool/feat/segment-cache
Cache BRouter segments client-side, fetch only the diff
2026-04-24 18:01:33 +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
2d3c45e929
Merge pull request #295 from trails-cool/fix/brouter-contention
Stop BRouter contention kills under rapid editing
2026-04-24 17:46:53 +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
05b72e9114
Merge pull request #293 from trails-cool/cutover/brouter-url
Cut Planner over to dedicated BRouter host
2026-04-24 17:21:36 +02:00
Ullrich Schäfer
a6bc5106bc Cut Planner over to dedicated BRouter host
Adds BROUTER_URL=http://10.0.1.10:17777 (vSwitch) to SOPS so the Planner
starts routing through the dedicated host's Caddy sidecar instead of the
in-tree flagship BRouter. Flagship BRouter stays warm for the 48h
soak/rollback window.

Pre-flight from the flagship confirmed the new host answers 200 with the
auth header and 403 without.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:17:18 +02:00
Ullrich Schäfer
6bae26de6e
Update brouter deploy scp to single ssh 2026-04-24 14:41:30 +02:00
Ullrich Schäfer
b763d9350c
Merge pull request #292 from trails-cool/feat/brouter-observability
BRouter host observability + docs
2026-04-24 08:03:40 +02:00
Ullrich Schäfer
6d089275eb
Mark cutover tasks 7.1-7.3 complete
End-to-end verified on the dedicated host:
- All 4 containers up (brouter 1.7.9, caddy, cadvisor, promtail)
- Planet segments seeded (~10 GB in 1 min, not hours)
- Auth enforced: 403 without header, 200 with header from flagship
- Planner live with BROUTER_AUTH_TOKEN wired (module-level guard passed)

Remaining:
- 7.4 Flip BROUTER_URL in SOPS (the actual cutover)
- 7.5 Remove flagship brouter after 48h soak
- 7.6 Document rollback in final PR description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:26:14 +02:00
Ullrich Schäfer
3c4c1eebd8
Fix BRouter first-deploy issues discovered during cutover
Three fixes after the first cd-brouter run on the dedicated host:

1. **BRouter 1.7.8 → 1.7.9** (`docker/brouter/Dockerfile`). Planet RD5
   segments on brouter.de are now version 11; 1.7.8's `lookups.dat`
   is v10, causing `lookup version mismatch (old rd5?)
   lookups.dat=10 E10_N45.rd5=11` on every route request.

2. **cd-brouter.yml: docker login to ghcr.io before pull**.
   ghcr.io/trails-cool/brouter is private, and the dedicated host's
   Docker daemon isn't logged in by default. Extract DEPLOY_GHCR_TOKEN
   from SOPS at runner side, pass to the SSH step via envs, and
   `docker login` before `docker compose pull`. Credential is
   `::add-mask::`-ed so it doesn't show in logs.

3. **Drop custom healthcheck** on the brouter service. The image
   strips wget/curl post-build, and /bin/sh in the base doesn't
   support /dev/tcp, so there's no in-image way to do an HTTP probe.
   Real health is observed via Caddy's upstream 502 behavior on
   outage and the Planner-side `brouter_request_duration_seconds`
   metric. caddy's `depends_on` drops from service_healthy to
   service_started.

End-to-end verified on the dedicated host after applying the compose
fix manually:
- Caddy enforces auth: 403 without header, proxies with.
- BRouter 1.7.9 will resolve the segment-version error once the image
  is rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:21:46 +02:00
Ullrich Schäfer
9e598fb6a1
BRouter host observability + docs
Lands sections 6 and 8 of relocate-brouter-to-dedicated-host on top
of the host compose in #291.

## Observability (section 6)

- **Prometheus**: new `brouter-cadvisor` job scraping
  `10.0.1.10:8080` over the vSwitch, labeled `host="brouter"`.
- **cAdvisor sidecar** on the dedicated host's compose
  (`--docker_only --whitelisted_container_labels=trails.cool.service`)
  so metrics only cover trails containers, never the operator's
  unrelated workloads on the shared box.
- **Promtail sidecar** on the dedicated host, Docker SD with relabel-
  drop on missing `trails.cool.service` label, pushing to flagship
  Loki at `http://10.0.0.2:3100/loki/api/v1/push`.
- **Flagship compose**: Loki now publishes port 3100 on the vSwitch
  IP only (10.0.0.2:3100) — Hetzner Cloud firewall blocks it from
  the public internet.
- **Grafana dashboard**: `brouter.json` — scrape up/down, request
  rate (from Planner-side `brouter_request_duration_seconds`),
  p50/p95/p99, container memory/CPU, Loki logs panel.
- **Alert**: `brouter-scrape-down` fires on
  `up{job="brouter-cadvisor"} < 1 for 2m`; `noDataState: Alerting`
  so a total scrape failure still pages.

Operator needs one UFW rule on the dedicated host for the cAdvisor
port — documented in `infrastructure/brouter-host/README.md`.

## Documentation (section 8)

- `CLAUDE.md` — hosts table + updated deployment table with SSH
  targets per workflow; BRouter host SSH is `-p 2232 trails@...`,
  different key.
- `docs/architecture.md` — Hosting section rewritten to cover both
  hosts, vSwitch boundary, and the observability-scoping rationale
  for the shared dedicated host.
- `docs/deployment.md` (new) — full operator runbook: host layout,
  first-time BRouter provisioning, SOPS rotation (including the
  macOS `SOPS_AGE_KEY_FILE` gotcha), cutover procedure with
  rollback, manual workflow triggers.

Task 8.4 (infrastructure/README.md) skipped: that file doesn't
exist and the ground is covered by brouter-host/README.md +
docs/deployment.md.

## Validation

- `docker compose config` on both the flagship and brouter-host
  compose files — both validate.
- `pnpm typecheck`, `pnpm lint`, `pnpm test` — all clean (full
  turbo cache hits; no code changes in this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:10:45 +02:00
Ullrich Schäfer
34b2e44ed1
Merge pull request #291 from trails-cool/feat/brouter-host-compose
BRouter host compose + Planner auth + cd-brouter rewrite
2026-04-23 23:09:23 +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
12010b48c2
Add BROUTER_AUTH_TOKEN to app secrets (SOPS)
Adds the shared-secret header value consumed by the Planner
(as X-BRouter-Auth outbound) and by the BRouter host's Caddy
sidecar (enforced inbound). Lives in secrets.app.env only;
cd-brouter.yml will also read from this file, making it the
single source of truth. secrets.infra.env is intentionally NOT
updated — cd-infra no longer touches BRouter after the move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:47:54 +02:00
Ullrich Schäfer
f0fe6abfa4
Merge pull request #289 from trails-cool/feat/brouter-vswitch-network
Add Hetzner vSwitch network for BRouter relocation
2026-04-23 22:47:07 +02:00
Ullrich Schäfer
102a744e67
Add Hetzner vSwitch network for BRouter relocation
Adds an OpenSpec change scoping the relocation of BRouter from the
co-located flagship (cx23, 4 GB RAM, 40 GB SSD, Europe segments only) to
a dedicated Hetzner Robot host in the same datacenter, with private
connectivity over Hetzner vSwitch #80672 (VLAN 4000).

This first PR only lays the network prerequisite:

- Terraform: a Hetzner Cloud Network (10.0.0.0/16) with a cloud subnet
  (10.0.0.0/24) hosting the flagship at 10.0.0.2, and a vSwitch subnet
  (10.0.1.0/24) bridged to Robot VLAN 4000. The dedicated host's VLAN
  sub-interface (10.0.1.10 on enp4s0.4000) is configured out-of-band via
  netplan and is not Terraform-managed.
- lifecycle { ignore_changes = [user_data] } on the flagship server to
  prevent the Hetzner provider's post-1.45 user_data hash drift from
  triggering a spurious full-server replacement on unrelated applies.
- OpenSpec change with proposal, design, specs (delta for
  brouter-integration / infrastructure / observability /
  security-hardening), and tasks; Section 1 (pre-flight) is checked off
  with operator notes.

Verification: ping both directions across the vSwitch is 0% loss,
sub-ms latency; dedicated host's VLAN config persists across reboot
(verified ~60 s to restore private reachability).

Follow-up PRs will land the BRouter host compose project, Planner
shared-secret header, CD workflow retarget, observability wiring, and
the cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:43:57 +02:00