Commit graph

148 commits

Author SHA1 Message Date
Ullrich Schäfer
acb92b32fa Spec drift catch-up: URLs, settings split, navbar shape
Outcome of today's `/spec-drift-review`. Concentrated on the
high-and-medium-severity items; low-severity wording left for the
next per-feature change to pick up naturally.

High-severity URL fixes (specs were actively misleading):

- account-management — email-change verification URL was specced as
  `/auth/verify-email-change?token=...`; the actual route is
  `/auth/verify?email-change=1&token=...` (see auth.verify.tsx:8).
  A reader implementing against the old wording would build a link
  that 404s.
- observability — "Both apps SHALL expose a /metrics endpoint" was
  half right: the planner is at /metrics, but the journal exposes
  /api/metrics. The infrastructure spec already had the right URLs;
  the observability spec disagreed with itself. Now both correct,
  with a one-line note explaining the per-app split.

Medium-severity wording drift (Stream E aftermath):

- profile-settings, account-management, connected-services — all
  three said "the settings page SHALL include a [...] section",
  which described the old single-scrollable settings page. Stream E
  (PR #323) split /settings into four sub-pages
  (/settings/{profile,account,security,connections}); rewording each
  spec to point at its specific sub-page. The API endpoints
  (/api/settings/*) and behavior are unchanged.
- authentication-methods — passkey add/delete previously said "via
  the settings page"; now specifically /settings/security.

Code drift fixed inline:

- apps/journal/app/routes/auth.verify.tsx — after a successful
  email change, the redirect was going to `/settings#account` (an
  anchor on the OLD single-scrollable settings page). Stream E
  retired that page; the right destination now is
  `/settings/account`. Without this the user would land on
  /settings/profile (which is what /settings redirects to) instead
  of the page that just changed.

sse-broker spec wording:

- The "no buffering tweaks in the Caddy reverse_proxy block"
  scenario asserted the entry was "a plain `reverse_proxy
  journal:3000`". After PR #329 the journal block has
  `lb_try_duration 30s` / `lb_try_interval 250ms` — neither affects
  streaming, so SSE still works, but the spec's "plain" language
  was no longer literally true. Reworded to forbid only
  buffering-related directives; explicitly call out that
  retry-on-restart directives like lb_try_duration are fine.

Navbar consolidation (journal-landing):

- The shipped navbar's full shape was scattered: notifications said
  "navbar has a bell," explore said "navbar has an Explore entry,"
  but no spec described the avatar dropdown, the primary-nav
  cluster (Feed/Routes/Activities), or the mobile drawer (Stream
  C / PR #324). Added a "Top navbar shape" requirement to
  journal-landing covering all of it — anonymous vs signed-in,
  desktop vs mobile, dropdown contents, drawer behavior. The
  per-feature specs (notifications, explore) still own their own
  badges/entries; this requirement just says what the whole
  cluster looks like.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:19:02 +02:00
Ullrich Schäfer
8d7c48d8c1 Include the demo persona on /explore so users can follow it
The directory was filtering out the demo persona on the rationale
that "the demo bot is not a real user and should not appear in real
discovery." That's exactly backwards — the whole point of having a
demo persona is to give new users a follow target so the platform
doesn't feel empty when they arrive. Hiding the bot from the
discovery surface defeats its purpose.

Concretely on flagship: only one local user (ullrich) was visible
on /explore today, even though Bruno (the demo persona) is
public-by-default and posting public activities. After this change
both appear; Bruno carries a small "🐕 Demo account" badge next to
his display name so viewers know what they're following.

- apps/journal/app/lib/explore.server.ts — drop the
  ne(users.username, persona.username) clause from exclusionFilters.
  The demo persona is now treated like any other public user. Banned/
  suspended scaffolding stays for forward-compat.
- apps/journal/app/routes/explore.tsx — loader computes isDemoUser
  per row (cheap, just username comparison against
  loadPersona().username). DirectoryRow renders the demo badge inline
  with the display name, matching the existing pattern on
  /users/:username.
- openspec/specs/explore/spec.md — updated the "Excluded users"
  requirement to remove the demo persona, replaced the "demo
  excluded" scenario with "demo appears with badge", and updated
  the "Active recently" requirement + scenarios accordingly.
- apps/journal/app/lib/explore.integration.test.ts — flipped the
  demo-persona test from "is excluded" to "is included".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:43:24 +02:00
Ullrich Schäfer
a3b0c6ad56 Archive add-explore-page change and promote specs
Promotes the deltas from openspec/changes/add-explore-page/ into
top-level specs after the implementation landed in #321.

- New top-level spec: openspec/specs/explore/spec.md (paginated local
  user directory at /explore, with the "Active recently" sub-section,
  exclusion rules for private profiles and the demo persona, offset
  pagination, and the navbar entry rule).
- Updated openspec/specs/journal-landing/spec.md with the new
  "Visitor home links to /explore" requirement.
- Updated openspec/CAPABILITIES.md with an entry under Social.
- Moved openspec/changes/add-explore-page/ to
  openspec/changes/archive/2026-04-26-add-explore-page/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:42 +02:00
Ullrich Schäfer
83df9e3312 Propose /explore page: local user discovery directory
Stream F from docs/information-architecture.md. Closes the gap between
"I want to follow someone on this instance" and "I have a username
from outside the app."

This change is the OpenSpec proposal only — the four artifacts
(proposal.md, design.md, specs/, tasks.md) plus a new top-level spec
target at openspec/specs/explore/. Implementation lands in a follow-up
PR via /opsx:apply.

Key decisions captured in design.md:

- Anonymous access is allowed (the data shown is already public, and
  it's a stronger first-visit experience).
- Directory order: MAX(activities.created_at) DESC NULLS LAST,
  tiebreaker users.id DESC. Recency-of-activity is the simplest signal
  that meaningfully reflects "who's active here right now."
- "Active recently" sub-section: same query, sliced — top 5 users
  with a public activity in the last 30 days, hidden when empty.
- Privacy filter: profile_visibility = 'public' AND not the demo
  persona AND (forward-compat) not banned/suspended.
- No search in v1 — deferred until the user count makes it actually
  useful. /users/<username> already partially solves it.
- Offset pagination (?page=, ?perPage=, capped 100). Cursor
  pagination is the right answer at ~10K users; not now.
- Navbar gets an "Explore" entry for signed-in users; anonymous
  visitors reach /explore via a link on the visitor home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:16:31 +02:00
Ullrich Schäfer
b6d8c621f8 Add Followed/Public toggle to /feed for signed-in users
Stream A from docs/information-architecture.md: signed-in users now
have a single /feed destination with two views — Followed (default,
people they accepted-follow) and Public (instance-wide). Logging in no
longer hides the public instance feed; switching is a query-param flip
that's bookmarkable and SSR-rendered.

- apps/journal/app/routes/feed.tsx — loader reads ?view=, branches
  fetch (listSocialFeed vs listRecentPublicActivities, both already
  exist), passes view to the component. Component renders a tab strip
  at the top using plain <Link>s so the toggle works without JS. Per-
  view <meta> title and empty state. The "see public feed" escape
  from the empty Followed view now points at ?view=public instead of
  /, keeping the user on /feed.
- packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ },
  social.feed.public.{heading,empty}, and social.feed.seePublic
  keys; old social.feed.publicFeedLink renamed to seePublic.
- openspec/specs/social-follows/spec.md — Social activity feed
  requirement extended with the two-view structure, including the
  Public view, the toggle, and the unrecognized-value fallback.
- openspec/specs/activity-feed/spec.md — Instance-wide public
  activity feed requirement notes the Public view of /feed is now a
  consumer alongside the visitor home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:03:38 +02:00
Ullrich Schäfer
11635eaead Drop redundant "Feed" button on the logged-in home page
The navbar already has a "Feed" entry that signed-in users see on every
page, including /. The page-header button next to "New Activity"
duplicated that path without adding anything — they pointed at the same
URL, with the navbar entry being the more discoverable of the two.

Stream D from docs/information-architecture.md.

- apps/journal/app/routes/home.tsx — remove the Feed anchor; "New
  Activity" stays as the only header CTA on the personal dashboard.
- openspec/specs/journal-landing/spec.md — retire the "Social feed
  link for signed-in users" requirement that prescribed the now-deleted
  button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:44:08 +02:00
Ullrich Schäfer
0306d90de8 Merge /follows/requests into /notifications as a tabbed inbox
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.

Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:24:59 +02:00
Ullrich Schäfer
37073eafd7 Spec catchup: drift fixes, account-settings split, notifications archive
Drift (specs aligned to shipped code):
- social-follows: locked-account access rule for /users/:u/followers and
  /users/:u/following (owner + accepted-follower see; non-followers of
  private get 404). Adds the follow→notification lifecycle requirement.
  Fills the placeholder Purpose.
- public-profiles: counts degrade to plain text (not anchors) for viewers
  who can't see the lists. Cross-references social-follows. Fills the
  placeholder Purpose.
- journal-auth slimmed to cookie session + Terms gate. Auth methods moved
  out (see authentication-methods).

Splits:
- account-settings (14-line stub) deleted, content split into:
  - profile-settings (display name, bio, profile_visibility)
  - account-management (email change with verification, account deletion)
  - connected-services (Wahoo + future external integrations)
- authentication-methods split out of journal-auth: passkeys
  (register/login/add/delete), magic links, 6-digit codes
  (login + register), method toggle on register/login forms,
  dev-console fallback.

New specs:
- sse-broker: /api/events, in-process broker, useUnreadNotifications
  hook, Caddy passthrough, multi-process forward-compat contract.

Archived: notifications change → openspec/changes/archive/2026-04-26-notifications.
Promoted the four delta spec files into top-level specs:
- specs/notifications/ (new capability)
- specs/activity-feed/ (added: public activity fan-out)
- specs/journal-landing/ (added: Notifications navbar entry)
- specs/social-follows/ (added: follow→notification lifecycle)

Added openspec/CAPABILITIES.md grouped index covering all 40 specs with
a Conventions section explaining cross-references, naming, and the
catch-up-vs-change rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:02:43 +02:00
Ullrich Schäfer
b20c8cca39 Cursor-based pagination for /notifications
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.

Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.

Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:42:32 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
dcbd703f47
Propose social MVP: public-content-visibility + demo-activity-bot
Two stacked OpenSpec change proposals for a demoable social layer,
with scope deliberately minimal ("enough to send a URL and have it
open to real-looking content without signup"):

1. public-content-visibility
   - Adds visibility enum {private, unlisted, public} on routes +
     activities, defaulting to private for every existing row.
   - Detail pages (/routes/:id, /activities/:id) become accessible to
     logged-out visitors when content is public or unlisted; private
     → 404 (not 403) to avoid existence leaks.
   - Broadens /users/:username into a public profile listing only
     public routes + activities; 404s when there's no public content
     to prevent account enumeration.
   - Open Graph / Twitter Card meta on public detail + profile pages.
   - Visibility selector in the owner's edit flow.
   - Out of scope: follow/follower, cross-user feed, reactions,
     federation.

2. demo-activity-bot  (depends on #1)
   - A single bot user, Bruno the trail dog, seeded on worker startup
     when DEMO_BOT_ENABLED=true. Reserved username, sentinel email,
     no credentials.
   - pg-boss recurring job fires every 90 min, decides-to-walk with
     p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at
     organic times.
   - Each walk: random start + end within inner-Berlin bbox, trekking
     only (dogs don't ride bikes), 2-12 km crow. BRouter plans the
     route; the route GPX is also attached as the activity's trace.
   - Everything inserted with visibility=public, synthetic=true.
   - Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old
     (default 14). Hard cap of 40 items/14d protects against runaway
     growth.
   - Small "🐕 demo account" badge on /users/bruno for honesty.

Both changes are artifacts only — no code lands with this PR. Apply
in order after merge: public-content-visibility first, then
demo-activity-bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:49:59 +02:00
Ullrich Schäfer
621d91e14f
Park mobile-activity-recording + mobile-nearby-sync under docs/ideas
Both are blocked on the mobile-app foundation and on a user base that
actually records activities or needs offline peer sync. With 3 users
and 0 activities on prod today, these are engineering for a
hypothetical population.

Move the artifact sets out of openspec/changes/ (so openspec list
stays clean) and under docs/ideas/ where self-host-overpass already
lives. Each gets a short README documenting:

- current parked status
- revive triggers (when should we look at this again?)
- key constraints not to rediscover (background-GPS privacy review;
  BLE dev-build requirement; QR-only subset as a cheaper path)

Reviving is `git mv docs/ideas/<name> openspec/changes/<name>` and
running /opsx:apply, same pattern as self-host-overpass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:23:44 +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
2c512ce75e
Realign legal-disclaimers spec with post-review implementation
Fixes one contradiction and closes five gaps where the code (after the
legal-review rounds in #235, #246, #247) now exceeds what the spec
required.

Contradiction fixed:
- Alpha banner was specified as dismissible via sessionStorage, with
  "reappears in a new session". In reality the dismiss control and
  sessionStorage were removed during privacy hardening — the banner
  is now always visible. Updated the requirement to match.

New scenarios documenting what's already shipped:
- Impressum: EU ODR link + non-participation notice; English
  translation noted as informational.
- Terms of Service: service availability, minimum age 16 for Journal
  accounts, content usage-rights licence, acceptable-use rules
  (including no mass automated data extraction), German-law-tiered
  liability, bilingual structure.
- Datenschutzerklärung: explicit data-categories-and-purposes list;
  Terms acceptance classified under Art. 6(1)(b) and explicitly NOT
  (1)(a); dedicated server-logs section (≤14 days, Art. 6(1)(f));
  storage-durations list; alpha-reset caveat; per-third-party
  disclosures with transfer details (Sentry, OSM tile servers,
  Overpass proxy, BRouter, SMTP); Art. 44 ff. DSGVO third-country
  transfer note for Sentry with SCC basis; Berlin supervisory
  authority; Privacy Manifest appendix.
- Footer: now includes a link to the source repository on both apps.

No functional changes — only spec text catching up with what is live
in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:33:27 +02:00
Ullrich Schäfer
21a00afa85
Archive legal-disclaimers change and sync specs
Move completed legal-disclaimers change to archive, apply deltas to
main specs: append "Terms acknowledgement at signup" requirement to
journal-auth and create the legal-disclaimers capability spec
covering Impressum, Terms, Datenschutzerklärung, alpha banner, and
footer links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:17:48 +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
b58d899132
Merge branch 'main' into spec/legal-disclaimers 2026-04-17 22:29:44 +02:00
Ullrich Schäfer
680815e1f6 Add OpenSpec proposal for legal disclaimers
Proposal, design, specs, and tasks for German legal compliance (Impressum,
Datenschutzerklärung, ToS) and alpha-stage disclaimers at signup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 22:12:23 +02:00
Ullrich Schäfer
bd311cd339
Implement mobile route editor with MapLibre and BRouter routing
Route editing (Phase 3.3):
- useRouteEditor hook: waypoint state, add/move/delete, BRouter
  routing via Journal API proxy, GPX generation, save to API
- RouteMap component: MapLibre Native with OSM raster tiles,
  route polyline (ShapeSource + LineLayer), waypoint markers
  (MarkerView), long-press to add waypoints, computing indicator
- WaypointSheet: bottom sheet for waypoint actions — delete with
  confirmation, overnight stop toggle, coordinates display
- Route detail screen: view mode (map preview + stats) and edit
  mode (full-screen map with save button)
- Unsaved changes guard on navigation (beforeRemove listener)
- Smart waypoint insertion at nearest route segment
- Waypoint extraction from GPX via regex (sync, no DOM needed)

Adds DOM.Iterable to mobile tsconfig for gpx package compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:10 +02:00
Ullrich Schäfer
448fa8b9b6
Merge branch 'main' into spec/staging-environments 2026-04-15 01:01:05 +02:00
Ullrich Schäfer
39ebaa9841 Add OpenSpec proposal for staging environments
Proposal, design, specs, and tasks for persistent staging instance and
ephemeral PR preview environments on the existing Hetzner server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:02:23 +02:00
Ullrich Schäfer
32c5fbde8f Add pg-boss background job queue with session expiry
Add @trails-cool/jobs package wrapping pg-boss for durable background job
execution using the existing PostgreSQL database. Wire up planner session
expiry as the first scheduled job (hourly, 7-day TTL) — this was previously
dead code that never ran. Add placeholder worker to journal for future
Komoot import and federation jobs.

Infrastructure: grant grafana_reader access to pgboss schema, add job queue
health panels to Service Health dashboard, add failed job alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:17:24 +02:00
Ullrich Schäfer
204fd7084b
Configure EAS Build and fix expo-doctor issues
- Add eas.json with development, preview, and production profiles
- Link project to EAS (projectId: 93c75cae-fecf-4ce5-8cd8-c823760b12e2)
- Add expo-dev-client, expo-constants, expo-linking dependencies
- Remove eas-cli from project deps (use globally)
- Add ITSAppUsesNonExemptEncryption to iOS infoPlist
- Use catalog react with expo.install.exclude to skip version check
- Fix slug to match EAS project registration
- Run pnpm dedupe to resolve duplicate react across workspaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:35:15 +02:00
Ullrich Schäfer
2f99ce926a
Add route detail screen with metadata and action buttons
Route detail at /routes/:id shows:
- Header with back navigation and route name
- Map placeholder (MapLibre requires EAS dev build)
- Stats row: distance, elevation gain/loss, day count
- Description text
- Version count
- "Edit in Planner" opens Journal web UI
- "Download Offline" button (handler pending Phase 5)

Safe area insets applied to header and scroll content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:15:41 +02:00
Ullrich Schäfer
77e3c1b1ee
Build Routes tab with paginated list and Zod-validated API responses
Routes tab (Phase 3.1):
- Paginated FlatList fetching from Journal REST API
- Route cards with name, distance (km), elevation gain, day count
- Pull-to-refresh, loading spinner, error state with retry, empty state
- Safe area insets for notch and tab bar

API client:
- Import types from @trails-cool/api instead of hand-written interfaces
- Parse list/detail responses through Zod schemas at runtime
- Add zod as mobile app dependency

i18n:
- Add mobile namespace (en + de) for routes, activities, profile, login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:15:40 +02:00
Ullrich Schäfer
18c3c37eaf
Implement Journal REST API v1 endpoints
Add all REST API endpoints for mobile app consumption:

Routes:
- GET /api/v1/routes — paginated list with cursor, Zod validation
- GET /api/v1/routes/:id — full detail with GPX, versions
- POST /api/v1/routes — create with Zod-validated body
- PUT /api/v1/routes/:id — update, creates new version
- DELETE /api/v1/routes/:id — returns 204

Activities:
- GET /api/v1/activities — paginated list with cursor
- GET /api/v1/activities/:id — full detail
- POST /api/v1/activities — create with GPX stat extraction
- DELETE /api/v1/activities/:id — returns 204

Supporting:
- POST /api/v1/routes/compute — BRouter proxy
- POST /api/v1/uploads — presigned upload URL generation

Device management:
- GET /api/v1/auth/devices — list connected devices with isCurrent
- DELETE /api/v1/auth/devices/:id — revoke device token
- Store device_name on token exchange

Infrastructure:
- requireApiUser() guard — returns 401 with structured error
- apiError() helper for consistent error responses
- All endpoints use Zod schemas from @trails-cool/api for validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:57:27 +02:00
Ullrich Schäfer
1ae406a8aa
Implement OAuth2 PKCE auth, discovery, and mobile API client
Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:41:40 +02:00
Ullrich Schäfer
2ac4014521
Standardize monorepo pipeline: test, lint, typecheck across all workspaces
Previously only 2-3 workspaces participated in each turbo task. This
expands test, lint, and typecheck to cover all 11 workspaces with
parallel execution and caching.

Test pipeline:
- Move from single root vitest to per-workspace test scripts via turbo
- Shared vitest config (vitest.shared.ts) with passWithNoTests
- Per-workspace configs re-export the shared base
- Mobile uses Jest (jest-expo + React Native Testing Library)
- Add node-environment GPX tests verifying linkedom fallback
- Add i18n mobile init tests
- Exclude apps/mobile from root vitest (uses Jest separately)

Lint pipeline:
- Add eslint lint script to all 9 previously unlinted workspaces
- Standardize all scripts to "eslint ." with shared root config
- Add .expo/ to global ESLint ignores
- Fix lint errors: unused imports in api/types, export parseGpx

Typecheck pipeline:
- Add "typecheck": "tsc" to all 8 packages
- Add @types/node (catalog) to gpx and db packages
- Fix mobile app.config.ts: remove deprecated experiments.monorepo
  and newArchEnabled (both default in Expo SDK 55)
- Add allowImportingTsExtensions to mobile tsconfig

Shared package compatibility (mobile-app Phase 1.3):
- Add linkedom as explicit dependency to @trails-cool/gpx
- Add initI18nMobile() export to @trails-cool/i18n
- Confirm @trails-cool/types is pure interfaces (no DOM deps)

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