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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Today both proxies are effectively open to anyone who can set an
Origin header for trails.cool — a third party can use us as a free
BRouter/Overpass relay. Require a live planner session on every call
so abuse traffic costs the scraper a session row (observable,
revocable) before they can issue a single query.
Server:
- New `requireSession(id)` helper — returns the session row or a 401
Response. Reused by both route handlers.
- `/api/route`: `sessionId` in body is now required and verified;
rate-limit key always falls back to the session id.
- `/api/overpass`: new `X-Trails-Session` header, verified. Header
keeps the session out of the request body so the body-keyed cache
is unaffected.
Client plumbing:
- `useRouting(yjs, sessionId)` — sessionId goes into the /api/route
body.
- `usePois(sessionId)` → `queryPois(..., sessionId)` → `X-Trails-Session`
on the proxy call.
- `PlannerMap` + `YjsDebugPanel` gain a `sessionId` prop from
`SessionView`.
Journal server-to-server:
- Demo-bot and `/api/v1/routes/compute` now POST `/api/sessions` to
mint a throwaway planner session, then cite it on the forwarded
`/api/route` call. Planner's `expire-sessions` cron cleans these up
(7d window) so nothing needs explicit teardown.
Tests:
- 5 unit tests for `requireSession` covering missing / empty /
non-string / unknown-session / valid-session cases.
- Two integration E2E tests document the 401 for missing session on
each proxy.
- Pre-existing `/api/route` integration tests updated to mint a
session first.
Caveat: existing browser tabs lose their /api/route ability until
reload (the old JS doesn't know to send sessionId). Acceptable for
an anonymous planner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.
## Breaking changes in our usage surface
### v12: default export removed → named `PgBoss` export
Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
re-exports `Job` at the package root in v12)
### v11: queue names restricted to `[A-Za-z0-9_.-]`
Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune` → `demo-bot-prune`
The planner's `expire-sessions` was already valid.
### v12: minimum Node 22.12
Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.
## Regression guard
Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.
## Prod rollout note
Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.
## Verified
- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five journal API route handlers had the same inline lambda:
parsed.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}))
Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.
Public error-response shape is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:
- z.string().url() → z.url() (3 sites)
- z.string().uuid() → z.uuid() (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)
Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.
All 147 tests still pass.
The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planner is the BRouter client in this architecture — route
compute, rate limiting, and the eventual move of BRouter off-box all
live there. The journal had two places that called BRouter directly
(demo-bot and /api/v1/routes/compute) and both were broken on prod
because the journal service has no BROUTER_URL.
Fix:
- Extend the planner's /api/route with an optional `format: "gpx"`
that returns BRouter's raw GPX (for server-to-server callers that
don't need the way-tag enriched GeoJSON).
- Point the journal demo-bot at `PLANNER_URL/api/route` with
`format: "gpx"` instead of calling BRouter directly.
- Point /api/v1/routes/compute at `PLANNER_URL/api/route` instead of
BRouter directly.
Journal no longer needs BROUTER_URL at all. When BRouter moves to a
dedicated host later, only the planner changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.
Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The previous commit only added a bullet to the English-only Privacy
Manifest appendix. The formal, binding German sections (which the
policy explicitly declares authoritative) didn't mention visibility
at all — and the paired English summary under each section likewise
didn't.
Add a sentence under §2 "Erhobene Daten und Zwecke → Nutzerinhalte"
explaining the visibility setting, defaults, and world-visibility
consequences. Mirror in the section's English summary paragraph. Keep
the Privacy Manifest bullet as an additional plain-language mention.
Re-rendered docs/legal-archive/privacy-2026-04-20.md; both languages
present (verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.
Changes:
- Detect "empty" as no geometry AND no distance (the state every new
route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
/ Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
viewer-only copy), primary "Open in Planner" button (same action as
the existing top-row button, just visually prominent), and a
secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.
No non-empty route loses any UI — those paths stay identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Privacy:
- Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry
is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen
werden nicht aktiv gespeichert" per reviewer's preferred wording.
Drop the unverified "Frankfurt" hosting claim; keep
Auftragsverarbeiter framing.
- SMTP → "externer SMTP-Dienst" per reviewer's phrasing.
- Storage-durations section: add explicit alpha-reset caveat so it
aligns with Terms §4 (database resets).
Terms:
- § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser
Dienst" (leaves pricing model open without promising free-forever).
- § 7 acceptable use: scraping clause softened to "kein massenhaftes
automatisiertes Auslesen von Daten" (drops the specific "to build
competing services" qualifier).
Imprint:
- Normalise address formatting: multi-line across both DE blocks and
the EN § 5 TMG block (§ 18 MStV and EN block previously used a
comma-separated single line). One style only, everywhere.
- Add whitespace-nowrap to the mailto link so the email address
never breaks mid-word on narrow viewports.
No changes to legal bases, server-logs section, storage durations
themselves, or any third-party other than Sentry + SMTP wording.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
required statement that we don't participate in consumer dispute
resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.
Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
purposes → Legal bases → Server logs → Storage durations → Third
parties → Rights → Complaint authority → (plain-language) Privacy
Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
/ status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
browser sends IP+UA directly to OSM tile servers, with OSMF privacy
policy link), Overpass (via our proxy, upstream only sees our
server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
aligned with current reality (no cookies/localStorage/sessionStorage
in Planner; no replays; no PII via Sentry).
Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
modify/limit/interrupt/discontinue the service or individual
accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
operator a limited, non-transferable licence to store/process/
display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
(standard German DACH tiering: unlimited for intent/gross neg./life-
body-health; limited to foreseeable damages on cardinal-duty breach
for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three sections drifted from the actual behaviour of the deployed apps:
Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.
Sentry section:
- Drops the "session replays on error" claim — replay integration is
not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
are both 0.
- Documents the actual scope: Journal server-side always; Journal
client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
and that only the user ID is attached to logged-in Journal errors.
Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
through our own /api/overpass so the upstream host sees our server,
not end users.
Also bumps "Last updated" to today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sentry.client.ts: add stopSentryClient() that awaits Sentry.close() so
the SDK tears down when a user logs out. Hub methods become no-ops
until initSentryClient() is called again on next login.
- root.tsx: call stopSentryClient() from the user-effect when user === null.
- Add sentry-config to journal + planner Dockerfiles (was missing after
#237 introduced the new workspace package, breaking the Dockerfile
package check).
- Footer: replace inner <nav> with <div> — nested nav landmarks broke
the journal "nav bar shows on all pages" e2e test (two navigation
roles on the page).
- e2e auth: tick the required ToS checkbox in the shared registerUser
helper so passkey registration tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces @trails-cool/sentry-config with three helpers that encode
the common Sentry.init options:
- nodeSentryConfig(appContext) for the two HTTP servers
- browserSentryConfig(appContext, env) for the Journal client
- mobileSentryConfig(__DEV__) for the React Native app
- drop404s beforeSend for servers
Also fixes three inconsistencies found in the audit:
- Planner server was missing sendDefaultPii: false (IPs could leak)
- Planner entry.server.tsx didn't call Sentry.captureException on SSR
render errors; Journal's SSR did
- Sentry.init location now matches across apps (both in server.ts;
Journal previously had it in app/entry.server.tsx, which meant init
ran lazily on first request rather than at server startup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Sentry init runs but is disabled (local dev / CI), log a debug
message so developers can see Sentry *would* send in production.
Also explicitly set replaysSessionSampleRate/replaysOnErrorSampleRate
to 0 in the client and mobile configs. Replay integration isn't
installed, so this is documentation — it prevents future accidental
enablement if an integration is ever added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.
- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
oauth.server.ts used a TypeScript parameter property (`public code: string`
in constructor) which is not supported by Node.js v25's strip-only TypeScript
mode. This caused the journal to crash-loop on startup after seeding OAuth
clients.
Also enable `erasableSyntaxOnly` in tsconfig.base.json so `pnpm typecheck`
catches any future use of non-erasable TypeScript syntax.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests the full handler flow with mocked DB: auth guard (401), Zod
validation (400), successful responses, and 404s for missing resources.
Also adds ~ alias resolution to Journal vitest config.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests Zod schema validation for all API endpoints (routes, activities,
compute, uploads), error codes, and cursor pagination logic. 21 new
tests covering the validation layer without requiring a running DB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Full notes lifecycle:
- GPX: description field in GpxData, <metadata><desc> in generate/parse
- Export: Plan GPX and Save to Journal include notes as description
- Journal: updateRoute extracts description from GPX, stores on route
- Reimport: Edit in Planner passes notes via URL params → Yjs Y.Text
- Drop import: GPX with <desc> restores notes in session
Spec updated: session-notes gains GPX export, Journal sync, and
reimport requirements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When hovering a day row in the route detail breakdown, the map smoothly
flies to fit that segment's bounds (0.5s animation). On mouse leave it
flies back to the full route bounds. Refactored geometry splitting into
shared helpers used by both DayColoredRoute and FlyToSegment.
Rotation not implemented — Leaflet doesn't support native bearing
rotation. Would require leaflet-rotate plugin or MapLibre GL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hovering a day row dims other segments (opacity 0.3) and thickens
the hovered day's segment (weight 5) for clear visual focus
- Each day row has a GPX download button that exports just that day's
track segment via /api/routes/:id/gpx?day=N
- GPX endpoint uses computeDays to extract the correct track slice
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- RouteMapThumbnail: Color route segments per day with alternating colors
when dayBreaks are present, pass through from route detail page
- overnight.test.ts: 6 tests for isOvernight/setOvernight with Y.Doc
- daybreaks-extraction.test.ts: 3 tests for GPX → dayBreaks index extraction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- routes.server.ts: Extract dayBreaks from parsed GPX waypoints on save
- routes.$id.tsx: Compute per-day stats in loader, render day breakdown
section with distance/ascent/descent per day
- ElevationChart: Dashed vertical lines at day boundaries
- i18n: Add Journal day breakdown keys (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
.client.tsx modules return undefined on the server, but lazy() still
tries to resolve them during SSR causing "Element type is invalid".
Replace lazy()+Suspense with a ClientMap component that dynamically
imports the map only in useEffect (client-side only).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Leaflet accesses `window` on import, crashing during SSR. Renamed
RouteMapThumbnail to .client.tsx so React Router excludes it from
the server bundle entirely. Updated all lazy() imports to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
api.health.ts was never registered in routes.ts — the server.ts
handler intercepts /api/health before React Router sees it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ActivityInput now accepts optional distance, duration, and startedAt.
GPX-derived stats take precedence when available, but provider stats
are used as fallback for workouts without GPS data.
The import form and Import All now pass these fields from the Wahoo
workout data so activities show correct metadata even without a FIT file.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bugs fixed:
- FIT parser returns timestamps as Date objects, not strings — convert
to ISO 8601 before passing to generateGpx (caused str.replace crash)
- FIT parser already converts semicircles to degrees — remove redundant
conversion that produced near-zero coordinates
- Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header
from download requests (caused 400 "Unsupported Authorization Type")
- Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo
does not share their data via the API
UX improvements:
- Individual imports use fetcher (no page refresh), button shows
"Importing..." inline
- "Import all" button imports all unimported workouts on the page
sequentially with progress indicator
- Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1)
Also adds unit test for FIT-to-GPX conversion with real fixture file,
and updates wahoo-import spec to reflect all behaviors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>