Compare commits

..

732 commits

Author SHA1 Message Date
Ullrich Schäfer
76cb64f4ef
Merge pull request #616 from trails-cool/chore/archive-fit-parsing-hardening
chore(openspec): archive fit-parsing-hardening
2026-07-17 00:14:43 +02:00
Ullrich Schäfer
0e04c48225
chore(openspec): archive fit-parsing-hardening
Sync the wahoo-import "FIT to GPX conversion" requirement into the main
spec and move the completed change to
openspec/changes/archive/2026-07-16-fit-parsing-hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:09:27 +02:00
Ullrich Schäfer
e262b671e2
Merge pull request #615 from trails-cool/feat/fit-parsing-hardening
feat(journal): harden FIT→GPX conversion (fit-parsing-hardening)
2026-07-17 00:08:11 +02:00
Ullrich Schäfer
6eea6af673
feat(journal): harden FIT→GPX conversion (pause/session segmentation, validation, sport)
Implements the fit-parsing-hardening change. The shared converter now:
- splits output into <trkseg>s on timer stop/start events, falling back to
  record gaps > 5 min, so downstream moving-time never bridges a pause;
- slices records into per-session windows (multisport → one activity, one
  segment per session), single-session behavior unchanged;
- validates records: finite/in-range coordinates required, timestamp
  required, non-finite altitude dropped (point kept), prefers
  enhanced_altitude;
- returns { gpx, sport }, mapping FIT session sport/sub-sport to a Journal
  SportType (first session wins), consumed by the Wahoo importer + webhook
  and Garmin importer as a fallback when the provider sends no type.

Tests drive the converter via mocked fit-file-parser output (segmentation,
validation, sport mapping, no-GPS) per the chosen fixtures approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:03:25 +02:00
Ullrich Schäfer
1473e2f291
Merge pull request #614 from trails-cool/fix/planner-clear-route-under-two
fix(planner): clear the route when waypoints drop below two
2026-07-16 23:50:21 +02:00
Ullrich Schäfer
0f57e9b9ac
fix(planner): clear the route when waypoints drop below two
Deleting waypoints down to <2 left the last computed route rendered —
nothing cleared the stale geometry (recompute only runs for >=2). Add a
waypoints observer that drops the computed geometry once fewer than two
waypoints remain, via a new clearComputedRoute (keeps the routing
profile, unlike clearRouteData). Covers all delete paths (sidebar, map,
undo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:46:00 +02:00
Ullrich Schäfer
104424d531
Merge pull request #613 from trails-cool/fix/planner-nearby-pois-session
fix(planner): nearby-POI lookup sends the real session (was 401 Unauthorized)
2026-07-16 23:41:58 +02:00
Ullrich Schäfer
267c2fb8b7
fix(planner): nearby-POI lookup sends the real session (was 401)
fetchNearbyPois defaulted sessionId to the placeholder "nearby", which
/api/pois's requireSession rejects (401 Unauthorized) — so the nearby-POI
lookup for a selected waypoint always failed on real deployments. Thread
the live planner sessionId through useNearbyPois → fetchNearbyPois →
queryPois (same session the main POI markers already use), and make
sessionId a required arg so the placeholder can't silently return.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:37:31 +02:00
Ullrich Schäfer
0f371d868f
Merge pull request #612 from trails-cool/feat/planner-route-segment-perf
perf(planner): group colored route into color runs (not one polyline per coordinate)
2026-07-16 08:37:03 +02:00
Ullrich Schäfer
413c46623e
perf(planner): group colored route into color runs, not 1 polyline/coord
ColoredRoute rendered one interactive-registered <Polyline> per
coordinate pair — ~14.6k on a 485 km route — flooding Leaflet's SVG
layer and event-target map. Run-length group consecutive same-color
segments into a single polyline each (new pure buildColorRuns helper,
unit-tested). Discrete modes (surface/highway/…) collapse to a handful;
the elevation gradient is quantized into 24 buckets so its runs merge
too. Rendering is otherwise identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:32:36 +02:00
Ullrich Schäfer
75b28919c4
Merge pull request #611 from trails-cool/fix/planner-waypoint-click-fallthrough
fix(planner): waypoint click + drag (event fell through to map; drag snapped back)
2026-07-16 08:16:33 +02:00
Ullrich Schäfer
c6ae08676b
fix(planner): waypoint drag no longer snaps back on release
Extract the waypoint marker into a React.memo'd component. The marker's
position/icon were new references every render, so any unrelated
PlannerMap re-render during a drag (e.g. the route-hover chart-sync
state update) re-applied the stale saved position mid-drag — orphaning
the drag so it never committed and the pin jumped back on release.
Memoizing means unrelated re-renders no longer touch the marker, so the
drag commits and the moved position persists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:12:06 +02:00
Ullrich Schäfer
6871b154db
fix(planner): clicking a waypoint no longer adds a duplicate
Waypoint markers had no click listener, so Leaflet routed the click to
the map — MapClickHandler then added a new waypoint on top of the one
you clicked (and made markers feel unresponsive). Add a click handler
that consumes the event, so Leaflet treats the marker as the target and
suppresses the map click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:59:48 +02:00
Ullrich Schäfer
23882dae68
Merge pull request #610 from trails-cool/feat/planner-map-markers
feat(planner): restyle map markers + route on tokens
2026-07-16 07:24:39 +02:00
Ullrich Schäfer
7b15ba609f
feat(planner): restyle map markers + route on tokens
Bring the map surface on-brand (visual-redesign group 4):

- Waypoint markers: sage accent numbered circles (was blue #2563eb);
  overnight stops keep the warm stop tone; note indicator uses the
  eg-mid gold token.
- Plain route line + ghost (insert) marker: sage accent (was blue).
- Map highlight dot (synced with the chart hover): sage accent (was
  red), so map and chart hovers match.
- Day labels: bg-raised surface + text-hi.
- No-go areas: danger token (#a03c3c) instead of bright red.
- GPX drag-over overlay: accent tokens.

Per-mode route coloring (surface/grade/…) is unchanged (data-viz);
coordinating the elevation-mode gradient with the route line is task 1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:20:17 +02:00
Ullrich Schäfer
92821e6e8b
Merge pull request #609 from trails-cool/feat/planner-elevation-collapsible
feat(planner): collapsible elevation chart with summary bar
2026-07-16 07:11:16 +02:00
Ullrich Schäfer
a520668dc0
Merge pull request #607 from trails-cool/feat/planner-elevation-canvas
feat(planner): restyle elevation chart canvas on tokens
2026-07-16 01:44:50 +02:00
Ullrich Schäfer
a5162fcc90
Merge branch 'main' into feat/planner-elevation-canvas 2026-07-16 01:40:27 +02:00
Ullrich Schäfer
8cbaceb356
feat(planner): collapsible elevation chart with summary bar
Docked-but-collapsible placement (the chosen direction):

- A collapse toggle (chevron) in the chart header; state persists to
  localStorage.
- Collapsed = a thin summary bar: a mini sage sparkline + distance and
  ascent/descent + an expand toggle. Reclaims map space on demand.
- Summary figures come from the same authoritative routeStats the
  sidebar uses (distance, elevationGain, elevationLoss), so the numbers
  match the sidebar exactly — not recomputed from the raw elevation
  points (which over-counted ascent ~2x from noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:39:01 +02:00
Ullrich Schäfer
87c06ead2d
Merge pull request #608 from trails-cool/fix/planner-elevation-interaction
fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom button
2026-07-16 01:32:24 +02:00
Ullrich Schäfer
97ddf36621
fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom
- Drag-to-zoom on the chart now tracks via window mousemove/mouseup, so
  it keeps going when the pointer leaves the chart and still completes if
  released outside it (previously mouseleave aborted the drag and an
  outside release was lost). Listeners are detached on mouseup/unmount.
- Move the "reset zoom" button from top-right (which overlapped the
  color-mode dropdown) to the bottom-right of the chart, and restyle it
  on tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:27:45 +02:00
stigi
0b157ce96e chore: update visual snapshots [skip ci] 2026-07-15 23:22:05 +00:00
Ullrich Schäfer
c38e054088
feat(planner): restyle elevation chart canvas on tokens
Bring the chart's chrome onto the design system (spec task 5.1):

- Plain mode: sage accent line + soft accent→transparent gradient fill
  (was off-palette blue).
- Hover: calm near-black crosshair + accent dot with a light ring, mono
  label (was red).
- Axis + day-divider labels: token colors, Geist Mono; dividers use the
  border-md token.
- Drag-select: accent tint (was blue).

Per-mode data-viz palettes (grade, surface, …) are unchanged. Darwin
visual-regression baselines regenerated; linux baselines to follow via
the update-snapshots workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:20:36 +02:00
Ullrich Schäfer
6d905c152f
Merge pull request #606 from trails-cool/feat/planner-computing-overlay
feat(planner): move "computing route" indicator from topbar to a map overlay
2026-07-16 01:16:09 +02:00
Ullrich Schäfer
d2d2d057cd
Merge pull request #604 from trails-cool/feat/planner-colormode-select
feat(planner): restyle color-mode selector + chart header on tokens
2026-07-16 01:07:11 +02:00
Ullrich Schäfer
24913b9f3e
Merge branch 'main' into feat/planner-colormode-select 2026-07-16 01:03:09 +02:00
Ullrich Schäfer
46018c9f89
feat(planner): move "computing route" from topbar to a map overlay
The topbar's "Route wird berechnet…" text appeared/disappeared with
routing, shifting the topbar layout. Move it onto the map as a floating
status pill (spinner + label, top-center, pointer-events-none) that
appears temporarily without affecting the topbar. Restyle the ambient
top-edge progress bar to accent tokens. Drop the now-unused `computing`
prop from Topbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:01:08 +02:00
Ullrich Schäfer
d17d4bc3a4
Merge pull request #602 from trails-cool/feat/topbar-button-restyle
feat(planner): restyle Export + SaveToJournal buttons on tokens
2026-07-16 00:49:09 +02:00
Ullrich Schäfer
079ce5aba2
Merge branch 'main' into feat/planner-colormode-select 2026-07-16 00:44:42 +02:00
Ullrich Schäfer
c504d6e34b
Merge branch 'main' into feat/topbar-button-restyle 2026-07-16 00:44:25 +02:00
Ullrich Schäfer
8eae92b8a2
Merge pull request #605 from trails-cool/feat/planner-sidebar
feat(planner): restyle sidebar on tokens (waypoints, notes, days)
2026-07-16 00:43:34 +02:00
Ullrich Schäfer
3603624b70
fix(planner): distinct aria-label for export dropdown toggle
The restyled Export split-button's dropdown toggle got aria-label
"Export GPX", colliding with the main button's text and breaking the
E2E selector that clicked the old "▾" glyph. Give it a dedicated
exportOptions label (en + de) and target it in the E2E test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:43:30 +02:00
Ullrich Schäfer
1d3ba956cb
feat(planner): restyle Export + SaveToJournal buttons on tokens
Bring the two slotted topbar actions onto the design system so the bar
reads as one cohesive surface:

- SaveToJournalButton: primary Button primitive; "saved" uses the
  accent, error uses a new --color-danger token, and the return link
  gets secondary token styling.
- ExportButton: token split-button (secondary look, chevron icon,
  single divider) and a token dropdown menu (raised surface, soft
  shadow, muted descriptions).
- New --color-danger token (#a03c3c, the no-go hue at full strength)
  for error text.

Behavior and i18n unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:42:37 +02:00
Ullrich Schäfer
b09d810301
feat(planner): restyle sidebar on tokens (waypoints, notes, days)
Migrate the whole sidebar surface onto the design system:

- SidebarTabs: token tab bar, sage active indicator.
- WaypointSidebar: token surfaces/text; sage number badges; overnight
  marker uses the Badge (stop tone) primitive; route summary + stats
  footer in Geist Mono; destructive delete signalled via bg-nogo.
- NotesPanel + DayBreakdown headers/rows on tokens.
- i18n the previously-hardcoded empty-state string (en + de).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:39:12 +02:00
Ullrich Schäfer
28f92d0d8f
feat(planner): restyle color-mode selector + chart header on tokens
The elevation chart's 10-mode color selector now uses the Select
primitive (token-styled, consistent with ProfileSelector). Chart-header
chrome (top border, title link, legend text) moved onto tokens too.

Kept as a dropdown rather than a segmented control — 10 modes don't fit
a segmented toggle. Canvas/legend data-viz colors are unchanged (task
1.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:32:05 +02:00
Ullrich Schäfer
2bc6afcf4f
Merge pull request #603 from trails-cool/feat/planner-profile-selector
feat(ui): Select primitive; restyle ProfileSelector on tokens
2026-07-16 00:16:40 +02:00
Ullrich Schäfer
a56445daf3
feat(ui): Select primitive; restyle ProfileSelector on tokens
- New Select primitive in @trails-cool/ui: token-styled native <select>
  (appearance-none + overlaid chevron for a consistent closed control),
  sm/md sizes, sibling of Input. Unit-tested + added to /dev/ui.
- ProfileSelector uses Select (size sm) with token label; the profile
  label now hides on small screens. Completes the topbar's migration
  onto the design system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:12:23 +02:00
Ullrich Schäfer
bf73feb75a
Merge pull request #601 from trails-cool/fix/dev-ui-scroll
fix(planner): make the /dev/ui gallery scrollable
2026-07-16 00:01:47 +02:00
Ullrich Schäfer
723cd8069b
fix(planner): make the /dev/ui gallery scrollable
The app body is h-screen overflow-hidden (for the full-screen map
editor), which clipped the taller gallery so it couldn't scroll. Give
the gallery route its own full-height scroll container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:57:44 +02:00
Ullrich Schäfer
5e75894372
Merge pull request #600 from trails-cool/feat/planner-topbar
feat(planner): redesign topbar with tokens + primitives
2026-07-15 23:55:59 +02:00
Ullrich Schäfer
4f0d4a34b3
feat(planner): redesign topbar with tokens + primitives, presentational
Extract a presentational Topbar driven by plain props, so SessionView
is the container (reads Yjs/awareness) and the topbar can render in the
/dev/ui gallery without a live session.

- New useParticipants(yjs) hook: awareness -> sorted Participant[] +
  renameLocal. Replaces ParticipantList's inline logic.
- New presentational ParticipantAvatars (Avatar + Host Badge + inline
  rename) and Topbar (token-styled shell; undo/redo via IconButton;
  connection status with a live dot). ProfileSelector/Export/Save are
  passed in as slots (their own restyle is separate).
- SessionView composes <Topbar> from real data; delete ParticipantList.
- /dev/ui gains a "Topbar - configurations" section (solo, multiplayer
  + computing, guest/connecting). Surfaced a narrow-width overflow, now
  fixed by making the participant strip the shrink/clip element.

Connection status text ("Connected"/"Verbunden") and behavior are
unchanged, so existing planner E2E selectors still match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:51:27 +02:00
Ullrich Schäfer
9463c5b1e5
Merge pull request #599 from trails-cool/feat/ui-primitives-2
feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
2026-07-15 23:47:48 +02:00
Ullrich Schäfer
1bd5f18a33
feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
Adds the shared primitives the planner topbar redesign needs, on the
design-system tokens:

- IconButton — icon-only button (undo/redo), ghost/secondary variants,
  requires an accessible label.
- SegmentedControl — generic single-select toggle (color mode
  Plain/Elevation/Surface), radiogroup semantics.
- Avatar — colored initial circle for participants, per-user color with
  an accent fallback.

Co-located jsdom unit tests for each (roles, aria-checked, onChange,
disabled, color fallback). Extends the dev-only /dev/ui gallery with
sections for all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:43:14 +02:00
Ullrich Schäfer
c9ad475e59
Merge pull request #598 from trails-cool/feat/ui-primitives
feat(ui): shared primitives (Button, Badge, Card, Input) on the tokens
2026-07-15 23:37:09 +02:00
Ullrich Schäfer
6344a513ce
feat(ui): shared primitives (Button, Badge, Card, Input) on the tokens
Second step of the visual-redesign (tokens -> primitives -> surfaces):
a small set of shared, token-driven primitives in @trails-cool/ui so
both apps compose from one styled vocabulary instead of hand-rolling
bg-white/text-gray-* per screen.

- @trails-cool/ui now ships React components (Button variants+sizes,
  Badge tones, Card raised/subtle, Input) plus a tiny cn() helper.
  React is a peer dep; package builds with no bundling step.
- Co-located jsdom unit tests (@testing-library/react) assert roles,
  token classes, variant switching, and behavior — run in the existing
  Unit Tests gate, cross-platform, no snapshot baseline needed.
- Tailwind @source directive in both apps' styles.css so the package's
  token utility classes get generated.
- Dev-only /dev/ui gallery route in the planner renders every primitive
  in all states — a zero-dependency stand-in for Storybook. Excluded
  from production builds (NODE_ENV guard in routes.ts).

No app surfaces are refactored yet; adopting the primitives in the
topbar/sidebar/etc. is the surface task groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:32:28 +02:00
Ullrich Schäfer
546d1d61d6
Merge pull request #597 from trails-cool/fix/dockerfile-ui-package
fix(docker): copy packages/ui/package.json in both Dockerfiles
2026-07-15 23:21:30 +02:00
Ullrich Schäfer
91fb764556
Merge branch 'main' into fix/dockerfile-ui-package 2026-07-15 23:17:29 +02:00
Ullrich Schäfer
df6df023e0
fix(docker): copy packages/ui/package.json in both Dockerfiles
New @trails-cool/ui workspace package needs its package.json in the
deps stage so pnpm install --frozen-lockfile can resolve the
workspace:* dependency. Fixes the Dockerfile Package Check and the
journal image build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:11:50 +02:00
Ullrich Schäfer
834536308d
Merge pull request #596 from trails-cool/feat/design-system-foundation
feat(ui): design-system foundation — shared token layer + fonts
2026-07-15 23:05:04 +02:00
Ullrich Schäfer
34c3a31e73
feat(ui): design-system foundation — shared token layer + fonts
Establish the visual-redesign foundation (task group 1): a single
shared source of design tokens both apps consume, so Planner and
Journal read from one palette/typography layer instead of drifting
per-app.

- New @trails-cool/ui package exporting theme.css: Tailwind v4 @theme
  tokens (warm off-white surfaces, one sage accent, earthy overnight
  tones, elevation-gradient colors, shadows) extracted from the
  visual-redesign mockup.
- Self-hosted Outfit (body) + Geist Mono (stats) via @fontsource-variable
  — privacy-first, no Google Fonts CDN.
- Both apps import the theme and set the base canvas to the warm
  off-white token + Outfit as the default font.

Foundation only: tokens are now available as Tailwind utilities
(bg-bg-raised, text-accent, font-mono, …) and raw CSS vars. Wiring
them into specific surfaces (topbar, sidebar, markers, elevation
chart) is later task groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:00:28 +02:00
Ullrich Schäfer
ad9a633ae0
Merge pull request #595 from trails-cool/fix/planner-note-tooltip-adaptive-width
fix(planner): note tooltip sizes to content (max-content, cap 280)
2026-07-15 21:48:22 +02:00
Ullrich Schäfer
eb1ab7d9e4
fix(planner): note tooltip sizes to content (width max-content, cap 280)
Refines #594: a fixed width made short notes an oversized box and wrapped
long notes early. Use `width: max-content` capped at `max-width: 280` so
the tooltip hugs short notes on one line and grows to 280px before wrapping
long ones (instead of collapsing to the longest word, the pre-#593 bug).
Keeps the centered text.

Verified in local dev with both a short ("Zug zurück?") and a long note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:44:05 +02:00
Ullrich Schäfer
f751f5057d
Merge pull request #594 from trails-cool/fix/planner-note-tooltip-width-align
fix(planner): note tooltip fixed width + centered text
2026-07-15 21:38:11 +02:00
Ullrich Schäfer
64d770ebea
fix(planner): give the note tooltip a fixed width + centered text
Follow-up to #593. With a long note the tooltip still squeezed to ~1 word
wide: an inline-block with only max-width, inside Leaflet's auto-width
tooltip, shrinks to its longest word rather than filling max-width.

Give it an explicit `width: 200` (a consistent, comfortably wide box that
wraps long notes cleanly) and `text-align: center`. Verified live in the
browser against a long repeated note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:33:43 +02:00
Ullrich Schäfer
b660b1582c
Merge pull request #593 from trails-cool/fix/planner-note-tooltip-width
fix(planner): waypoint-note tooltip collapses to one char per line
2026-07-15 17:52:18 +02:00
Ullrich Schäfer
8cf483a4ee
fix(planner): waypoint-note tooltip no longer collapses to one char per line
The note tooltip's inner span was `display: block` with
`word-break: break-word` and no width, so the Leaflet tooltip sized itself
to the block's *min-content* — which under break-word is ~1 character —
and every character wrapped onto its own line (a tall vertical strip).

Switch to `display: inline-block` (sizes to content up to maxWidth, so it
stays one line when short and wraps at 220px when long) and the standard
`overflow-wrap: break-word`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:48:33 +02:00
Ullrich Schäfer
9ecce6a219
Merge pull request #592 from trails-cool/chore/archive-planner-route-encoding
chore(openspec): archive planner-route-encoding
2026-07-15 17:17:37 +02:00
Ullrich Schäfer
6fcd1e21cd
chore(openspec): archive planner-route-encoding
All 12 tasks complete (shipped in #590 codec + #591 wiring). Archive via
`openspec archive`:
- Creates openspec/specs/planner-route-encoding/spec.md — new capability
  (5 requirements: compact single-source geometry, RLE road metadata,
  backward-compatible reads, preserved compute-once model, bounded doc
  size). Purpose filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-15-planner-route-encoding/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:13:19 +02:00
Ullrich Schäfer
86fc596e50
Merge pull request #591 from trails-cool/feat/planner-route-encoding-wire
feat(planner): store computed route compact-encoded
2026-07-15 17:12:00 +02:00
Ullrich Schäfer
c33a413395
feat(planner): store computed route compact-encoded (groups 2–4)
Wire the map-core codec into route-data.ts so the Yjs session doc stores
the computed route compactly, without changing the routing-host
compute-once-and-share model.

- Codec: add encodeElevations/decodeElevations (delta+varint) so geometry
  = encoded [lon,lat] polyline + elevation channel; precision bumped to
  1e6 (~0.11 m) to preserve the router's 6-decimal output.
- writeComputedRoute: geometry stored once (encoded polyline + elevations,
  no redundant geojson), road metadata run-length encoded.
- Dual-format reads: getCoordinates / readRoadMetadata / new readGeojson
  transparently handle the new encoding AND legacy JSON docs (+ oldest
  geojson-only) — no migration, no flag day; legacy docs re-encode on the
  next recompute. use-elevation-data reads readGeojson (assembled for new
  docs).
- Spec correction: a lossy compact codec can't be byte-identical to legacy
  (generateGpx emits raw precision), so "GPX byte-compatible" is corrected
  to "coordinates preserved within the router's ~0.1 m precision".

Tests: legacy-format read, size assertion (>4x smaller / round-trips),
elevation round-trip. map-core + planner typecheck/lint clean; full
pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:07:28 +02:00
Ullrich Schäfer
84744567bc
Merge pull request #590 from trails-cool/feat/planner-route-encoding-codec
feat(map-core): compact route codec (polyline + RLE)
2026-07-15 17:00:00 +02:00
Ullrich Schäfer
b81108b10b
feat(map-core): compact route codec (polyline + RLE) for planner doc
Task group 1 of planner-route-encoding. Framework-free `route-codec.ts`:
- encodePolyline/decodePolyline — fixed-precision (1e5) delta + zig-zag
  varint + base64; lossless to ~1 m, version-tagged ("p1:").
- encodeRuns/decodeRuns — run-length codec for the per-coordinate
  road-metadata channels, tagged ("r1:").
- isEncodedPolyline/isEncodedRuns — so the read path (group 3) tells the
  new encoding apart from legacy JSON.

Not yet wired into route-data.ts (groups 2–3). Verified: map-core
typecheck + lint clean, 52/52 tests (round-trip within 1 m, empty/single,
RLE collapse, >3x smaller than JSON).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:55:34 +02:00
Ullrich Schäfer
e92591b3ff
Merge pull request #589 from trails-cool/chore/propose-planner-route-encoding
docs(openspec): propose planner-route-encoding
2026-07-14 15:27:20 +02:00
Ullrich Schäfer
b55f4020cf
Merge branch 'main' into chore/propose-planner-route-encoding 2026-07-14 15:22:55 +02:00
Ullrich Schäfer
5856baac0d
Merge pull request #588 from trails-cool/ci/tolerate-prune-collision
ci: don't fail deploys on a transient prune collision
2026-07-14 09:32:22 +02:00
Ullrich Schäfer
d933cca451
docs(openspec): propose planner-route-encoding change
Follow-up to the planner WS reconnect-loop incident. Proposes compactly
encoding the computed route in the Yjs session doc (single-source geometry
+ delta/varint polyline + run-length-encoded road metadata, dual-format
backward-compatible reads) to cut a ~305 KB / 77 km route to ~40–60 KB —
WITHOUT changing the routing-host "compute once, share via Yjs" model or
GPX export output.

Artifacts: proposal, design, specs (new planner-route-encoding
capability), tasks. Validates --strict. Ready for /opsx:apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:58 +02:00
Ullrich Schäfer
a75eb1965c
ci: don't fail deploys on a transient "prune already running" collision
cd-apps's post-deploy `docker image prune` runs under `set -euo pipefail`
after the containers are already swapped, so when it collides with a
concurrent deploy/disk-maintenance prune ("Error response from daemon: a
prune operation is already running") it fails an otherwise-successful
deploy (observed on the #587 planner deploy). The cleanup is best-effort;
disk-maintenance.yml is the real image-prune safety net.

- cd-apps: `docker image prune -af || true`.
- disk-maintenance: tolerate the same collision on its prune; the disk-%
  threshold check afterward stays the real failure gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:27:50 +02:00
Ullrich Schäfer
5debe5643e
Merge pull request #586 from trails-cool/ci/opt-in-pr-previews
ci(staging): make PR previews opt-in (label or body marker)
2026-07-14 09:22:39 +02:00
Ullrich Schäfer
2405ff288b
Merge branch 'main' into ci/opt-in-pr-previews 2026-07-14 09:18:47 +02:00
Ullrich Schäfer
2421b59cf8
Merge pull request #587 from trails-cool/fix/planner-yjs-frame-cap
fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop)
2026-07-14 09:03:30 +02:00
Ullrich Schäfer
e34a06ff5a
fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop)
The Yjs sync WebSocket had MAX_MESSAGE_BYTES = 256 KB (per frame) but
MAX_DOC_BYTES = 5 MB (per session doc). A full-state sync frame carries
the entire doc, so any session doc between 256 KB and 5 MB was allowed to
exist yet could never sync: the client's sync frame tripped the 256 KB
per-frame cap, the server closed it (1008), the client reconnected, resent
the same oversized frame, and looped forever — the UI froze, the connection
flapped Verbunden/Verbinde, and the network tab filled with 101 reconnects.

Real routes hit this fast because the BRouter geometry is stored in the doc:
a 4-waypoint, 77 km cycling route was already 305 KB.

Fix: the per-frame cap must be >= the doc cap (a full-doc sync must fit in
one frame). Set MAX_MESSAGE_BYTES = MAX_DOC_BYTES + 256 KB overhead; the
5 MB doc cap stays the real size/abuse guard. Updated the test that had
codified the inverted ordering.

Verified: planner typecheck + lint clean, yjs-server 9/9, planner suite
183/183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:58:24 +02:00
Ullrich Schäfer
696ffb7306
Merge pull request #585 from trails-cool/fix/flagship-log-rotation
fix(infra): cap container log sizes to prevent disk-full outages
2026-07-14 08:38:03 +02:00
Ullrich Schäfer
3db78a4a48
ci(staging): make PR previews opt-in (label or body marker)
PR previews were built + deployed for every apps/packages PR — each one a
journal container + its own database on the flagship, which is a standing
contributor to the disk pressure behind recurring deploy failures and the
2026-07-14 disk-full outage.

Make them opt-in: a preview is built/deployed only when the PR carries the
`preview` label OR a `<!-- preview -->` marker in its description. Gate the
(costly) build-images job, deploy-preview, and — via new labeled/unlabeled
triggers — tear the preview down when the label is pulled, so a de-flagged
PR can't orphan its stack. Main-push / dispatch deploys are unchanged.

Note: the pull_request `paths` filter still applies, so labeling a PR that
touches no apps/packages files won't spin up a preview (nothing to preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:37:50 +02:00
Ullrich Schäfer
010edae636
fix(infra): cap container log sizes to prevent disk-full outages
Every compose service used docker's default json-file driver with no
rotation, so a service logging in a loop grew unbounded — a 2.6 GB
container log contributed to a 2026-07-14 disk-full outage (/ at 100%,
Postgres down, apps 500ing, deploys failing at the SCP step).

Add an `x-logging` anchor (10 MB x 3 files = 30 MB max per container) and
apply it to all 11 services. Complements disk-maintenance.yml (which prunes
images but can't touch container logs). Takes effect when cd-infra recreates
the containers. Documented the disk-pressure runbook in docs/deployment.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:34:17 +02:00
Ullrich Schäfer
0328fb7f33
Merge pull request #584 from trails-cool/chore/archive-elevation-profile-hardening
chore(openspec): archive elevation-profile-hardening
2026-07-14 01:13:37 +02:00
Ullrich Schäfer
a57441fce8
chore(openspec): archive elevation-profile-hardening
All 11 tasks complete (shipped in #581/#582/#583). Archive via
`openspec archive` (CLI 1.6.0):
- Creates openspec/specs/elevation-computation/spec.md — new capability
  (4 requirements: spike removal, threshold-filtered ascent/descent,
  cleaned profile series, per-day totals from cleaned data). Purpose
  filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-13-elevation-profile-hardening/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 01:09:53 +02:00
Ullrich Schäfer
3e711a624a
Merge pull request #583 from trails-cool/feat/elevation-hardening-days
feat(gpx): per-day ascent/descent from cleaned elevation
2026-07-14 01:08:58 +02:00
Ullrich Schäfer
3db59d99e3
feat(gpx): per-day ascent/descent from cleaned elevation
Task group 2 (part b) + verification of elevation-profile-hardening.

compute-days.ts no longer sums every raw point-to-point delta for
cumulative ascent/descent (which overstated day totals the same 20–50%).
It now despikes each segment, runs cumulativeFilteredTotals once over the
despiked track, and maps the running filtered ascent/descent back onto the
flat allPoints indices (carrying forward across ele-less points). Day
totals = cumulative[end] − cumulative[start] therefore match the filtered
route total by construction. DayStage shape + rounding unchanged.

Test: a two-day split over the shared noisy/spiky track reports filtered
(not inflated) per-day ascent, and the day ascents sum exactly to the
whole-route filtered ascent.

Completes the change (11/11). Verified: gpx 112/112; full pnpm typecheck
13/13, lint 13/13, test 11/11 (journal + planner compile unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 01:04:12 +02:00
Ullrich Schäfer
84db039648
Merge pull request #582 from trails-cool/feat/elevation-hardening-wire
feat(gpx): apply elevation cleaning to totals + profile chart
2026-07-14 01:01:07 +02:00
Ullrich Schäfer
29be9cbe5f
feat(gpx): apply elevation cleaning to totals + profile chart
Task group 2 (part a) of elevation-profile-hardening — wire the group-1
primitives into the headline stat and the chart.

- computeElevation (parse.ts): despike each segment's elevation sequence,
  then take filteredTotals; gain/loss are now noise-filtered (no more
  20–50% inflation from per-point jitter), the profile is built from the
  despiked data, and additive gainRaw/lossRaw expose the unfiltered sums.
- elevationSeries: despike per segment (no cross-trkseg interpolation)
  before downsampling, so the chart and the headline totals derive from
  the same cleaned data.
- types.ts: GpxData.elevation gains gainRaw/lossRaw (additive).

Well-formed monotonic climbs are unchanged (steps exceed the 5 m
threshold → filtered == raw); a shared noisy-track test proves jitter is
filtered and a 300 m single-point spike is interpolated out of both the
totals and the chart.

Verified: gpx 111/111; full pnpm typecheck 13/13, lint 13/13, test 11/11
(journal + planner compile with the additive fields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:56:36 +02:00
Ullrich Schäfer
79124620d3
Merge pull request #581 from trails-cool/feat/elevation-hardening-clean
feat(gpx): elevation cleaning primitives (despike + hysteresis totals)
2026-07-14 00:50:48 +02:00
Ullrich Schäfer
990bbaa99e
feat(gpx): elevation cleaning primitives (despike + hysteresis totals)
Task group 1 of elevation-profile-hardening. New pure module
elevation-clean.ts (not yet wired — group 2 does that):

- despike(points): interpolates out isolated peak/pit spikes (opposite-sign
  slope outliers both exceeding MAX_SLOPE_PERCENT=100); steep monotonic
  terrain (same-sign) is never touched; per-segment, no cross-gap interp.
- filteredTotals(points): hysteresis ascent/descent (NOISE_THRESHOLD_M=5)
  that suppresses sub-threshold jitter, returning {gain,loss,gainRaw,lossRaw}.
- cumulativeFilteredTotals(points): running filtered arrays for per-day
  splits (day total = cumulative[end] − cumulative[start]).

DESIGN NOTE for review: the spec's task 1.2 says the zero-filtered
fallback should "report raw", but the raw sum-of-deltas re-inflates the
very jitter we remove (a ±2 m wobble sums to several metres) — directly
contradicting task 1.4's "±2 m jitter → 0 totals". Resolved toward the
stated goal: fall back to NET change (end−start), so flat wobble reads ~0
while a genuine small net climb still surfaces. gainRaw/lossRaw still
expose the raw sums.

Verified: gpx typecheck+lint clean, 108/108; full pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:46:13 +02:00
Ullrich Schäfer
bc91edb988
Merge pull request #580 from trails-cool/chore/archive-gpx-parser-robustness
chore(openspec): archive gpx-parser-robustness
2026-07-14 00:40:23 +02:00
Ullrich Schäfer
5fca8d8f18
chore(openspec): archive gpx-parser-robustness
All 16 tasks complete (shipped in #576/#578/#579). Archive via
`openspec archive` (CLI 1.6.0):
- Creates openspec/specs/gpx-parsing/spec.md — the new capability (5
  requirements: invalid-point skipping, route support, timestamp repair,
  metadata fallback, fixture corpus). Purpose filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-13-gpx-parser-robustness/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:36:45 +02:00
Ullrich Schäfer
5dd8c6e796
Merge pull request #579 from trails-cool/feat/gpx-parser-robustness-fixtures
test(gpx): fixture corpus + verification (groups 5–6)
2026-07-14 00:34:57 +02:00
Ullrich Schäfer
36c5a8beaa
test(gpx): fixture corpus + verification (groups 5–6)
Task groups 5 and 6 of gpx-parser-robustness.

Group 5 — packages/gpx/fixtures/*.gpx (synthetic, no personal data):
route-only, missing-coords, garbage-ele, timestamps-partial,
timestamps-mostly-invalid, multi-track, unicode-name,
namespaced-extensions, plus trimmed Komoot/Wahoo-style exports.
parse-node.test.ts gains a fixture table test that auto-discovers every
*.gpx from disk, asserts finite stats + per-fixture shape, and fails
loudly if a file has no EXPECTATIONS entry (the corpus is the regression
mechanism).

Group 6 — verification: GpxData shape unchanged; journal + planner
compile with no changes; gpx suite 98/98; full pnpm typecheck 13/13,
lint 13/13, test 11/11. Route-only + broken-file import behaviour is
covered by the fixtures + consumer compile; the browser leg rides e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:30:46 +02:00
Ullrich Schäfer
ca3e4232c3
Merge pull request #578 from trails-cool/feat/gpx-parser-robustness-metadata
feat(gpx): metadata name/desc fallback + <cmt> merge
2026-07-14 00:27:34 +02:00
Ullrich Schäfer
b7b8906ce9
feat(gpx): metadata name/desc fallback + <cmt> merge
Task group 4 of gpx-parser-robustness. Many apps put the only
human-readable title on the <trk>/<rte>, not in <metadata>.

- name/description now fall back to the first <trk>/<rte>'s <name>/<desc>
  when <metadata> lacks them (<metadata> still wins when present).
- that track/route's <cmt> is appended to the description (blank-line
  separated) when present and not already identical (OM BuildDescription
  dedup); used as the description outright when there's no <desc>.

`directChildText` reads direct-child text without relying on `:scope`
(portable across the jsdom + linkedom paths). No GpxData shape change;
well-formed files with metadata are unaffected.

Verified: gpx typecheck+lint clean, 87/87; full `pnpm test` 11/11;
journal + planner typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:23:53 +02:00
Ullrich Schäfer
7c0fa60ae2
Merge pull request #576 from trails-cool/feat/gpx-parser-robustness-lenience
feat(gpx): lenient point parsing + route (<rte>) support
2026-07-14 00:21:29 +02:00
Ullrich Schäfer
ee87a55fdd
test(fit): adapt single-point GPX case to the parser's <2-point drop
CI "Unit Tests" (full monorepo) caught that @trails-cool/fit's round-trip
fixture loop included single-point.gpx, which now parses to zero track
points (the parser drops <2-point segments), so gpxToFitCourse correctly
throws its zero-points guard. Drop single-point.gpx from the round-trip
loop and cover the degenerate case explicitly: a lone point → zero points
→ refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:17:49 +02:00
Ullrich Schäfer
82d726bc14
Merge pull request #577 from trails-cool/feat/gpx-parser-robustness-timestamps
feat(gpx): post-parse timestamp repair
2026-07-14 00:11:14 +02:00
Ullrich Schäfer
4536470eb9
feat(gpx): post-parse timestamp repair
Task group 3 of gpx-parser-robustness. Timestamps were passed through raw;
a partially-broken time channel from a flaky recorder silently shrank
moving time and skewed start-time derivation.

New pure `timestamp-repair.ts` applied per segment after parsing:
- validity = Date.parse yields a finite epoch;
- a segment with no valid timestamps is left untouched (untimed track);
- >50% invalid → drop all the segment's timestamps (noise, not signal);
- otherwise → linearly interpolate invalid runs between valid neighbours
  (by point index), leading/trailing runs clamp to the nearest valid
  timestamp; only repaired points are rewritten as ISO 8601, valid points
  keep their original string.

Monotonicity intentionally not enforced (movingTime already skips
non-positive intervals; reordering would be fabrication). No GpxData shape
change. Wired into parseTracks output in parse.ts.

Verified: gpx typecheck + lint clean, 81/81 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:10:57 +02:00
Ullrich Schäfer
99278fd305
feat(gpx): lenient point parsing + route (<rte>) support
Task groups 1–2 of gpx-parser-robustness. The parser trusted its input:
`parseFloat(attr ?? "0")` turned a missing lat/lon into a 0,0 Null Island
point (which passes range validation) and garbage into NaN that poisoned
distance and gain/loss totals; `<rte>`/`rtept` files (Garmin courses, many
exporters) parsed to zero track points and were rejected.

- `parsePoint`: skip a trkpt/rtept whose lat/lon is missing or non-finite
  (no more 0,0 default); a non-finite `<ele>` becomes `undefined` so it
  never leaks NaN into totals. Parsing stays parseFloat-lenient (trailing
  junk like `471.0m` still accepted), gated by Number.isFinite.
- Drop segments left with fewer than 2 points (render nothing / break
  distance math).
- Parse `<rte>` as track segments appended after `<trk>` segments, rtept
  handled identically — route-only files now import.

No GpxData shape change; well-formed files parse identically. Updated the
geom single-point test to the new drop-invariant.

Verified: gpx typecheck + lint clean, 76/76 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:06:35 +02:00
Ullrich Schäfer
e2f25e1d27
Merge pull request #575 from trails-cool/chore/archive-federation-hardening
chore(openspec): archive federation-hardening
2026-07-13 23:59:00 +02:00
Ullrich Schäfer
42149d8e02
chore(openspec): archive federation-hardening
All 13 tasks complete (implementation shipped in #570–573; staging
verification in #574). Archive via `openspec archive` (CLI 1.6.0):

- Creates openspec/specs/federation-operations/spec.md — the new
  capability: durable federation queue, inbound replay defense, instance
  blocklist, published protocol doc, delivery observability (Purpose
  filled in; CLI leaves a TBD placeholder).
- Applies the MODIFIED requirement to openspec/specs/social-federation:
  "Push delivery on local activity create" now guarantees persistent
  queueing + a "Fan-out survives a deploy" scenario.
- Moves the change to openspec/changes/archive/2026-07-13-federation-hardening/.

Both specs pass `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:54:56 +02:00
Ullrich Schäfer
55da03132a
Merge pull request #574 from trails-cool/chore/federation-hardening-5.1-verified
chore(federation-hardening): 5.1 verified on staging
2026-07-13 23:51:41 +02:00
Ullrich Schäfer
cca1428f26
chore(federation-hardening): 5.1 verified on staging
Ran the post-deploy staging check on staging.trails.cool:
- Durability: enqueued a real delivery, restarted the journal mid-flight;
  the job survived the restart (still `created` in Postgres afterward)
  and completed after — "Successfully sent activity … to
  social.ullrich.is/users/ullrich/inbox", federation_delivery_total
  {outcome="delivered"}=1, queue drained to 0. The old in-process queue
  would have dropped it.
- Blocklist outbound: a poll-remote-actor for a blocked domain returned
  {skipped: "blocked instance"} with no network fetch.
- Operator block/unblock procedure works against the live staging DB.

Inbound-drop leg is covered by the group-2/3 real-Postgres integration
tests + the two-instance e2e harness (firing it live needs a
peer-initiated signed request). All 13 tasks now complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:47:32 +02:00
Ullrich Schäfer
b1f5f853c3
Merge pull request #573 from trails-cool/feat/federation-hardening-docs-observability
feat(journal): federation protocol doc + delivery observability
2026-07-13 23:16:59 +02:00
Ullrich Schäfer
711065586e
chore(federation-hardening): record group 5 verification status
5.2 typecheck/lint/test pass locally; test:e2e is enforced by the
required "E2E Tests" CI check. 5.1 is a post-deploy staging check
(fan-out survives a restart; a blocked domain is inert both ways) —
left open with the runbook commands in the PR body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:13:04 +02:00
Ullrich Schäfer
881991ca18
feat(journal): federation protocol doc + delivery observability
Task group 4 of federation-hardening.

4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.

4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
  (delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
  drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
  /api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
  regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.

Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:43 +02:00
Ullrich Schäfer
8f7fd15685
Merge pull request #572 from trails-cool/feat/federation-hardening-blocklist
feat(journal): federation instance blocklist
2026-07-13 23:03:40 +02:00
Ullrich Schäfer
57696286e4
feat(journal): federation instance blocklist
Task group 3 of federation-hardening. There was no blocklist of any kind;
the only lever against a hostile instance was an IP/host block in Caddy.

- New `federation_blocked_instances` table (domain PK, reason,
  created_at). Additive → created by drizzle-kit push.
- `federation-blocklist.server.ts`: exact-host matching —
  `isBlockedDomain`, `isBlockedIri` (unparseable IRI ⇒ treated as
  blocked), and `filterBlockedDomains` for batch recipient filtering.
- Enforced at all three boundaries (spec: federation-operations
  "Instance blocklist"):
  - inbox — each of the 4 listeners silently drops a blocked actor's
    activity (202, no error oracle) before dedup/side effects;
  - delivery enqueue — `enqueueActivityDeliveries` filters blocked
    recipients in one batch query;
  - outbox poll / actor fetch — `pollRemoteActor` refuses a blocked host
    up front (`skipped: "blocked instance"`), before any network.
- Operator procedure (SQL insert/list/delete) documented in the
  deployment runbook's federation section.
- Tests: unit (hostOfIri) + integration against real Postgres covering
  the helper and the delivery + outbox boundaries; inbox uses the same
  tested isBlockedIri primitive.

Note: the inbox-drop *counter* (federation_inbox_dropped_total{reason})
lands with the other metrics in task 4.2; this commit is the enforcement.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; blocklist integration tests green against real Postgres;
journal unit suite 357 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:59:11 +02:00
Ullrich Schäfer
3bd1f87e63
Merge pull request #571 from trails-cool/feat/federation-hardening-replay-blocklist
feat(journal): inbound federation replay defense
2026-07-13 22:55:08 +02:00
Ullrich Schäfer
105659df7c
feat(journal): inbound federation replay defense
Task group 2 of federation-hardening. The narrow inbox
(Follow/Undo/Accept/Reject) had no replay protection — only Create(Note)
did, via the activities.remote_origin_iri unique constraint. A remote
redelivering a signed follow-graph activity would re-run its side
effects.

- New `federation_processed_activities` table (activity IRI PK,
  received_at + index). Additive, so drizzle-kit push creates it; no
  hand-written migration needed.
- `federation-replay.server.ts`: `markInboundActivityProcessed` does an
  insert-or-drop (ON CONFLICT DO NOTHING RETURNING) and reports whether
  the IRI is fresh; `sweepProcessedActivities` deletes rows > 30 days old
  (signature date-freshness already rejects older replays).
- Each inbox listener drops a duplicate before side effects. The
  follow-graph handlers are idempotent, so a handler failure whose retry
  is later dropped as a duplicate can't corrupt state.
- `federation-dedup-sweep` job (daily 04:30 UTC) runs the TTL sweep.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; replay integration test (fresh-vs-duplicate + 30-day sweep)
green against real Postgres; journal unit suite 355 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:07:30 +02:00
Ullrich Schäfer
71d92306db
Merge pull request #570 from trails-cool/fix/federation-durable-queue
fix(journal): durable Fedify message queue over pg-boss
2026-07-13 21:54:51 +02:00
Ullrich Schäfer
b8fc8fadff
feat(journal): durable Fedify message queue over pg-boss
Task group 1 of federation-hardening. Fedify was configured with
`InProcessMessageQueue`, so every queued outbound delivery, its pending
retry state, and inbox processing task was lost on a container restart
(routine here: deploys, OOM history) — directly contradicting the
social-federation spec's promise that fan-out survives a deploy.

- `federation-queue.server.ts`: `PgBossMessageQueue` implementing Fedify's
  `MessageQueue` over the pg-boss instance the journal already runs,
  mirroring the `PostgresKvStore` adapter. `nativeRetrial = false` keeps
  Fedify the retry-policy owner; pg-boss supplies durability + delayed
  jobs (delay → whole-second `startAfter`, `retryLimit: 0`).
- Swap it in for `InProcessMessageQueue` in `federation.server.ts`;
  document the two intentional queueing layers (our fan-out jobs feed
  Fedify; Fedify's sends now durable underneath).
- `server.ts`: create the durable queue at startup when federation is on.
- Broaden the structural `BossLike` in `boss.server.ts` with the
  work/offWork/createQueue/getQueue methods the adapter needs.
- Tests: enqueue maps retry/delay correctly, consume roundtrip, restart
  durability (fresh listener drains a prior instance's backlog), abort
  stops the worker, depth reports ready vs delayed.

Verified: journal typecheck + lint clean, 7/7 new unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:46:46 +02:00
Ullrich Schäfer
1ea2559246
Merge pull request #569 from trails-cool/chore/archive-route-surface-breakdown
chore(openspec): archive route-surface-breakdown
2026-07-13 21:25:48 +02:00
Ullrich Schäfer
fc5e361a11
chore(openspec): archive route-surface-breakdown
The route-surface-breakdown change is fully implemented (18/18 tasks,
all artifacts done). Archive it via `openspec archive` (CLI 1.6.0):

- Moves the change to openspec/changes/archive/2026-07-13-route-surface-breakdown/
- Promotes its 5 delta requirements into a new capability spec at
  openspec/specs/route-surface-breakdown/spec.md (surface/waytype
  breakdown from BRouter waytags, async Overpass backfill, live SSE
  update, proportion bars, localized labels)

Purpose paragraph filled in (the CLI leaves a TBD placeholder). Both the
spec and archived change pass `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:21:58 +02:00
Ullrich Schäfer
ea10e9dd19
Merge pull request #568 from trails-cool/ci/dependabot-openspec-autofix
ci(dependabot): regenerate OpenSpec tool files on bump
2026-07-13 21:17:26 +02:00
Ullrich Schäfer
52aada6242
Merge remote-tracking branch 'origin/ci/dependabot-openspec-autofix' into ci/dependabot-openspec-autofix 2026-07-13 21:12:04 +02:00
Ullrich Schäfer
fd80815119
ci: rename workflow file to match its name (dependabot-auto-fix.yml)
The workflow was renamed "Dependabot dedupe" -> "Dependabot auto-fix" in
the previous commit; rename the file to match and fix the self-reference
in its error message. Safe — this workflow is not a required status check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:10:37 +02:00
Ullrich Schäfer
47be98bd51
Merge branch 'main' into ci/dependabot-openspec-autofix 2026-07-13 21:09:10 +02:00
Ullrich Schäfer
905c2d83b6
ci(dependabot): regenerate OpenSpec tool files on bump
Extends the dependabot auto-fix workflow (formerly "Dependabot dedupe")
to also run `openspec update --force`, so a @fission-ai/openspec bump
regenerates the generated agent skills (.agents/skills/openspec-*) and
opsx slash commands (.claude/commands/opsx/*) and pushes them back to the
PR branch — the manual step that PR #567 had to do by hand for 1.2.0 ->
1.6.0.

Kept as a single workflow (one checkout/commit/push) so the dedupe and
openspec fixups don't race to push the same branch. The commit message
names only the parts that actually changed; no-op when neither applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:06:57 +02:00
Ullrich Schäfer
8b7420be19
Merge pull request #567 from trails-cool/chore/openspec-1.6-tooling
chore(openspec): regenerate skills + commands for CLI 1.6.0
2026-07-13 21:03:31 +02:00
Ullrich Schäfer
c0bb992d6b
chore(openspec): regenerate skills + commands for CLI 1.6.0
Regenerated the OpenSpec agent skills (.agents/skills/openspec-*) and
opsx slash commands (.claude/commands/opsx/*) from openspec CLI 1.6.0
(was 1.2.0). Adds `allowed-tools` frontmatter, multi-store selection
support (`--store <id>`), and new status fields (planningHome,
changeRoot, artifactPaths, actionContext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:59:01 +02:00
Ullrich Schäfer
614e577e26
Merge pull request #566 from trails-cool/fix/dependabot-security-overrides
fix(deps): resolve 12 Dependabot alerts via pnpm overrides
2026-07-13 20:12:04 +02:00
Ullrich Schäfer
b4f290ceed
fix(deps): resolve 12 Dependabot alerts via pnpm overrides
All 12 open Dependabot alerts are transitive dependencies, so resolve
them with pnpm.overrides (the pattern already used in this repo for
picomatch, brace-expansion, path-to-regexp, lodash).

- shell-quote <1.8.4 -> 1.8.4         (critical, #22; react-devtools)
- undici 7.x <7.28.0 -> 7.28.0        (high/med/low ×6, #30-36; jsdom/vitest)
- form-data <4.0.6 -> 4.0.6           (high, #28; jest-expo)
- js-yaml <3.15.0 -> 3.15.0           (medium, #37; jest istanbul)
- esbuild 0.27.x -> 0.28.1            (low, #23; vite/tsx)
- @opentelemetry/core <2.8.0 -> 2.9.0 (medium, #25; fedify runtime)
- uuid 7.0.3 -> 11.1.1                (medium, #19; xcode/expo prebuild)

Notes:
- undici override is scoped to 7.x so fedify's undici@6.27.0 (not in any
  vulnerable range) is left untouched.
- @opentelemetry/core pinned to 2.9.0 (not the minimum 2.8.0) to dedupe
  with the copy Sentry already pulls in, avoiding an otel version skew.
- uuid pinned narrowly to the vulnerable 7.0.3; xcode calls
  require('uuid').v4(), which still works under v11 (verified).

Verified: pnpm install / build / typecheck / test all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:07:26 +02:00
Ullrich Schäfer
004ec4914f
Merge pull request #561 from trails-cool/dependabot/npm_and_yarn/production-ba42795ad1
build(deps): bump the production group across 1 directory with 35 updates
2026-07-13 09:35:19 +02:00
Ullrich Schäfer
d9eee37e39
build(deps): bump the production group across 1 directory with 35 updates
Bumps 35 dependencies in the production group; lockfile regenerated and deduped against latest main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:29:56 +02:00
Ullrich Schäfer
8af5ea47e0
Merge pull request #559 from trails-cool/dependabot/npm_and_yarn/vite-8.1.0
build(deps): bump vite from 7.3.5 to 8.1.0
2026-07-13 08:53:42 +02:00
Ullrich Schäfer
d98dd3c8f4
build(deps): bump vite from 7.3.5 to 8.1.0
Bumps vite catalog to ^8.1.0 and regenerates the lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:49:22 +02:00
Ullrich Schäfer
79addc8f5a
Merge pull request #557 from trails-cool/dependabot/github_actions/actions/cache-6
build(deps): bump actions/cache from 5 to 6
2026-07-13 08:48:56 +02:00
Ullrich Schäfer
ae9c686050
build(deps): bump actions/cache from 5 to 6
Bumps actions/cache from 5 to 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:44:37 +02:00
Ullrich Schäfer
aa515e5fa0
Merge pull request #565 from trails-cool/archive-poi-index
Archive poi-index + sync specs
2026-07-13 01:51:07 +02:00
Ullrich Schäfer
8396041c26
openspec: archive poi-index; sync deltas into canonical specs
poi-index shipped and is live in production (self-hosted planet POI index,
8.4M rows serving /api/pois; Overpass removed from the Planner). Archive the
change and apply its spec deltas:

- new capability: poi-index
- osm-poi-overlays: POIs from the instance index, not Overpass
- rate-limiting: /api/pois limit replaces the Overpass proxy limit
- infrastructure: POI extract (BRouter host) + import (flagship) components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:46:13 +02:00
Ullrich Schäfer
5539b34613
poi-index: mark operational tasks done (planet extract + import verified in prod)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:43:36 +02:00
Ullrich Schäfer
b7c3297d4a
Merge pull request #564 from trails-cool/fix-poi-import-dedup
Fix poi-import: dedupe elements matching a category via multiple selectors
2026-07-13 00:33:46 +02:00
Ullrich Schäfer
55796e7d15
poi-index: dedupe classify INSERT (element matching a category via 2 selectors)
The import hit a PK violation on (osm_type, osm_id, category): an OSM element
that matches one category through two selectors on different keys (e.g. shelter
= amenity=shelter OR tourism=wilderness_hut, both present) produced two rows for
that category. Add DISTINCT ON (osm_type, osm_id, category) so classification is
one row per (element, category), matching matchingCategoryIds semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:33:17 +02:00
Ullrich Schäfer
7d6a6147a0
Merge pull request #563 from trails-cool/fix-poi-import-selectors
Fix poi-import: stream selectors SQL into psql stdin
2026-07-13 00:29:41 +02:00
Ullrich Schäfer
a6f8595c4b
poi-index: stream selectors SQL into psql stdin (fix \i container path)
poi-import.sh ran the classifier selectors via psql \i, but psql executes
inside the postgres container (docker compose exec), so it couldn't find the
host path /opt/trails-cool/scripts/poi-selectors.sql. Concatenate BEGIN + the
selectors file contents + the build statements on the host and pipe them into
psql stdin instead, keeping the ON COMMIT DROP temp table in one transaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:27:48 +02:00
Ullrich Schäfer
552899e2b7
Merge pull request #562 from trails-cool/poi-index
poi-index: replace Overpass with a self-hosted POI index
2026-07-13 00:07:32 +02:00
Ullrich Schäfer
115c76a69a
poi-index: drop vSwitch mentions from poi-extract README (flagship detail)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:30:19 +02:00
Ullrich Schäfer
4935e5beae
poi-index: generalize poi-extract docs/units (drop host/username specifics)
Remove ullrich.is / 1.8 TB / hardcoded 'trails' username assumptions from the
poi-extract README and service unit; refer to 'the pipeline user' and $USER /
%h instead so the docs serve self-hosters too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:29:45 +02:00
Ullrich Schäfer
9dec68175b
poi-index: drop specific host disk figure from README (drifts; check df at runtime)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:18:34 +02:00
Ullrich Schäfer
1b5f48f118
poi-index: run osmium via Docker (host has no osmium + no sudo)
The BRouter host's trails user is non-root without sudo, so osmium-tool can't
be apt-installed. Ship osmium.Dockerfile (built on first use) and run osmium in
a container with the work dir bind-mounted (native I/O, negligible overhead on
Linux). python3/curl/gzip/sha256sum remain on the host. Correct the README
disk figure to the /home free space (~595 GB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:16:57 +02:00
Ullrich Schäfer
157a4fad64
poi-index: ship poi-extract/ to the BRouter host via cd-brouter tarball
cd-brouter.yml bundles an explicit file list; the new poi-extract dir was
missing, so the extract scripts would never land on the host. Add it (+ a
defensive chmod).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:12:55 +02:00
Ullrich Schäfer
2a59ad67d9
poi-index: mark 9.2 — planner e2e green (POI tests pass on warm server)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:05:50 +02:00
Ullrich Schäfer
bc4bb7988d
poi-index: mark 2.2 done — verified index-driven query plans locally
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:02:23 +02:00
Ullrich Schäfer
b1aa687c51
poi-index: name pois PK explicitly (pois_pkey) for drift-free swap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:55:49 +02:00
Ullrich Schäfer
96de8831cf
poi-index: Grafana dashboard/alert, docs, privacy manifest
- planner dashboard: replace Overpass panels with POI index freshness +
  serving panels (age, rows/category, import status, API request rate/errors)
- alerts: replace overpass-upstream-unhealthy with poi-index-stale (>6 weeks)
- architecture.md: POI data flow now the self-hosted index
- privacy manifest (DE+EN): Overpass is Journal-surface-backfill-only;
  Planner POIs served same-origin from /api/pois
- self-host-overpass README + roadmap: superseded for POIs by poi-index
- poi-extract README: self-hoster story (optional pipeline, regional extract,
  graceful empty index)
- map-core tsconfig: exclude *.sync.test.ts from tsc to keep it zero-dep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:54:51 +02:00
Ullrich Schäfer
e703843b9b
poi-index: extract pipeline (BRouter host) + import job (flagship) + e2e
- poi-extract.sh: PBF download -> osmium tags-filter -> centroid NDJSON +
  manifest, published via the Caddy sidecar at vSwitch-only /poi/*
- osmium-filters.txt + poi-selectors.sql generated from map-core selectors,
  guarded by sync tests so poiCategories stays the single source of truth
- poi-import.sh: checksum verify -> classify into category rows ->
  70% guard (--bootstrap override) -> atomic rename swap; node_exporter
  textfile metric for import outcome
- systemd service+timer units for both halves (monthly, offset)
- cd-infra.yml: ship infrastructure/scripts to the flagship
- e2e: mock /api/pois instead of /api/overpass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:48:32 +02:00
Ullrich Schäfer
b45e69885d
poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics
Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:38:03 +02:00
Ullrich Schäfer
7dd9ccd2b0 Merge branch 'inspirations-research-proposals'
Docs + OpenSpec proposals only (no app/infra code); merged directly to
main via admin override — no CI-relevant surface, no deploy triggers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:24:00 +02:00
Ullrich Schäfer
5dd4968626 docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
Add docs/inspirations.md as the durable record of the 2026-07-05/06
prior-art research — per-project learnings with source paths, canonical
credit lines, and the changes each spawned — and extend the
acknowledgment lists in philosophy.md/architecture.md (Organic Maps,
Endurain, wanderer).

New OpenSpec changes (proposal/design/specs/tasks each):
- Organic Maps: elevation-profile-hardening, gpx-parser-robustness,
  hiking-time-estimate, poi-index, hiking-foot-profile
- Endurain: account-export, activity-duplicate-review,
  fit-parsing-hardening, activity-locations, self-hosting-guide,
  activity-privacy-controls
- wanderer: federation-hardening, link-share-tokens
- credits-page (user-visible acknowledgments)

Updated in-flight changes with wanderer prior-art sections:
route-federation, route-discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:23:00 +02:00
Ullrich Schäfer
be4f7e4ae8
Merge pull request #556 from trails-cool/ci-journal-image-smoke-test
ci: smoke-test the journal production Docker image
2026-06-25 09:42:10 +02:00
Ullrich Schäfer
7eb2a20700 ci: smoke-test the journal production Docker image
Nothing in CI booted the journal's production image. typecheck / lint /
test / build all run against the source tree (where every file is
present), and the e2e job boots the journal via `react-router-serve` —
not the production `node server.ts` entrypoint. The runtime stage of
apps/journal/Dockerfile copies source files in by name, so a refactor
that adds a file server.ts imports without a matching COPY builds green
everywhere and only crash-loops once deployed (ERR_MODULE_NOT_FOUND).
That has taken prod down more than once: app/lib (8631c8f), app/jobs
(e16bd6d), and serve-static.ts (#554/#555, today's outage).

Add a job that builds the `runtime` stage and actually boots it against
a throwaway Postgres, polling /api/health for a 200. A missing static
OR dynamic import never reaches a healthy boot, so this fails the PR
instead of the deploy. Verified locally: the fixed image goes healthy;
the image with the serve-static.ts COPY removed exits with the exact
ERR_MODULE_NOT_FOUND and the job fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:37:17 +02:00
Ullrich Schäfer
ad35b3adcf
Merge pull request #555 from trails-cool/fix-journal-dockerfile-missing-serve-static
fix(journal): copy serve-static.ts into the runtime image
2026-06-25 09:27:22 +02:00
Ullrich Schäfer
4fe5382b4b fix(journal): copy serve-static.ts into the runtime image
PR #554 extracted serve-static.ts as a sibling of server.ts, but the
journal Dockerfile copies runtime source files explicitly by name and
the COPY list was never updated. The runtime runs `node server.ts`,
whose `import "./serve-static.ts"` then fails with ERR_MODULE_NOT_FOUND,
crash-looping both the production and staging journals (they share the
image). Caddy stayed up with no upstream, so the site hung.

Add the missing COPY line. serve-static.ts imports only node: builtins,
so this single file restores startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:23:15 +02:00
Ullrich Schäfer
96bca827ea
Merge pull request #554 from trails-cool/fix-journal-malformed-url-crash
fix(journal): don't crash the process on a malformed request URL
2026-06-24 15:46:09 +02:00
Ullrich Schäfer
26d45cf2cb fix(journal): don't crash the process on a malformed request URL
A request for path `//` (also `///`, `/\`, ...) makes `new URL(req.url,
base)` throw ERR_INVALID_URL. serveStatic runs synchronously inside the
createServer callback, so the throw is an uncaught exception that kills
the process. Docker (`unless-stopped`) restarts it, and a client looping
on `//` crash-loops the journal — a trivial unauthenticated DoS. This
fired the "Container restart loop" Grafana alert in production (journal
restarted ~10x in 6 minutes).

Guard the URL parse with try/catch and fall through to the React Router
handler, which 404s malformed paths cleanly (the same way it already
handles scanner probes like /root/.ssh/id_rsa).

Extract serveStatic into its own module so it can be unit-tested without
booting the HTTP server, and add a regression test covering the
malformed-path cases. Widen the journal vitest include to discover
co-located tests for root-level server infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:41:26 +02:00
Ullrich Schäfer
3d7dfda45f
Merge pull request #553 from trails-cool/dependabot/npm_and_yarn/nodemailer-9.0.1
build(deps): bump nodemailer from 8.0.11 to 9.0.1
2026-06-21 19:08:41 +02:00
dependabot[bot]
8b51e3ccca
build(deps): bump nodemailer from 8.0.11 to 9.0.1
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.11 to 9.0.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 17:04:34 +00:00
Ullrich Schäfer
316a368307
Merge pull request #551 from trails-cool/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-06-21 19:02:40 +02:00
Ullrich Schäfer
da310af684
Merge pull request #552 from trails-cool/dependabot/npm_and_yarn/production-7d38d0195e
build(deps): bump the production group with 18 updates
2026-06-21 19:02:27 +02:00
dependabot[bot]
9a6041c4be [github-actions] pnpm dedupe 2026-06-21 08:33:05 +00:00
dependabot[bot]
ae585290be
build(deps): bump the production group with 18 updates
Bumps the production group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.61.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.4` | `2.1.5` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.42` | `5.1.43` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.5.0` | `3.5.1` |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `56.0.11` | `56.0.12` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.17` | `56.0.18` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.17` | `56.0.18` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.10` | `56.2.11` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.8` | `4.1.9` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.8` | `4.1.9` |
| [@garmin/fitsdk](https://github.com/garmin/fit-javascript-sdk) | `21.205.0` | `21.208.0` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.19.1` | `12.20.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.58.0` | `10.59.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.59.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.21` | `22.20.0` |


Updates `@playwright/test` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `playwright` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `typescript-eslint` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

Updates `@logtape/logtape` from 2.1.4 to 2.1.5
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.5/packages/logtape)

Updates `isbot` from 5.1.42 to 5.1.43
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.42...v5.1.43)

Updates `@sentry/cli` from 3.5.0 to 3.5.1
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.5.0...3.5.1)

Updates `expo` from 56.0.11 to 56.0.12
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `expo-location` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.10 to 56.2.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `@vitest/browser` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/browser-playwright)

Updates `@garmin/fitsdk` from 21.205.0 to 21.208.0
- [Release notes](https://github.com/garmin/fit-javascript-sdk/releases)
- [Commits](https://github.com/garmin/fit-javascript-sdk/compare/21.205.0...21.208.0)

Updates `pg-boss` from 12.19.1 to 12.20.0
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.19.1...12.20.0)

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

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

Updates `@types/node` from 22.19.21 to 22.20.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: playwright
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo
  dependency-version: 56.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@garmin/fitsdk"
  dependency-version: 21.208.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 08:32:18 +00:00
dependabot[bot]
2dc88197bc
build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 08:22:41 +00:00
Ullrich Schäfer
89861d7f18
Merge pull request #550 from trails-cool/chore-bump-sentry-10.58
chore(deps): bump @sentry/node + @sentry/react to ^10.58.0
2026-06-19 11:25:58 +02:00
Ullrich Schäfer
bfcb2584be chore(deps): bump @sentry/node and @sentry/react to ^10.58.0
Routine SDK maintenance bump (catalog 10.57.0 -> 10.58.0). Keeps the
Sentry SDKs current.

Note: this is NOT the fix for the ongoing journal heap growth. Sentry's
recent memory-leak fixes (WeakRef for OTel context on scope, 10.49.0;
WeakRef for Span-Scope circular refs, 10.56.0) are already present in
10.57.0, and 10.58.0 contains no memory-related change. Tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:21:52 +02:00
Ullrich Schäfer
ce00376f3b
Merge pull request #549 from trails-cool/fix-journal-metrics-template-match
fix(journal): match metric route label against known route templates
2026-06-19 11:05:03 +02:00
Ullrich Schäfer
5643ce257a fix(journal): match metric route label against known templates
Follow-up to the route-label cardinality fix. The first cut collapsed
dynamic segments via regex (:id/:username/:provider) and capped the
distinct-route set at 200 with a /other overflow. In production that cap
filled almost entirely with vulnerability-scanner junk (`/.ssh/id_rsa`,
`/%00.aws/credentials`, …) on a first-come-first-served basis: only 3 real
templates made it in before the cap saturated, so legitimate routes hit
afterward were misbucketed into /other — the metric became useless for
per-route latency even though memory was bounded.

Replace the regex+cap approach with explicit matching against the journal's
known route templates (mirroring app/routes.ts). A `:param` segment matches
any single path segment; literals are preferred over params (so /routes/new
beats /routes/:id); anything matching no template collapses to /other
immediately — no per-path tracking, no cap race. Cardinality is hard-bounded
to (templates + 1) regardless of traffic, and scanner noise can never crowd
out real routes.

A co-located drift guard flattens the real route config and fails if
ROUTE_TEMPLATES and app/routes.ts ever diverge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:00:03 +02:00
Ullrich Schäfer
e6b88ff294
Merge pull request #548 from trails-cool/fix-journal-metrics-route-cardinality
fix(journal): bound Prometheus route label cardinality (memory leak)
2026-06-18 16:02:30 +02:00
Ullrich Schäfer
cd73093beb fix(journal): bound Prometheus route label cardinality
The httpRequestDuration histogram labeled every request with the raw URL
path (`url.split("?")[0]`), so every distinct path — `/activities/<uuid>`,
`/routes/<uuid>`, probed usernames, crawler junk — became a permanent
label-set. prom-client never evicts label-sets, so RSS grew linearly for
the life of the process and reset only on restart. On the flagship this
showed as ~25 MiB/day of unbounded journal memory growth; a live
`/api/metrics` scrape held 2,655 histogram series across 291 distinct
`route` values (344 KB body), dominated by per-UUID activity paths.

Add `normalizeRoute()` in metrics.server.ts: it collapses dynamic path
segments back to the `:param` templates declared in app/routes.ts (UUID
and numeric segments -> `:id`; `:username`/`:provider` slots keyed by
their preceding static segment), strips query/fragment, and enforces a
hard cap (MAX_ROUTES) that funnels anything beyond the known route table
into a single `/other` bucket as an absolute backstop. Cardinality is now
proportional to the route table, not to traffic.

Logs still record the raw path (`logger.info`) — only the metric label is
bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:58:01 +02:00
Ullrich Schäfer
9266233614
Merge pull request #547 from trails-cool/apply-surface-breakdown-phase2
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
2026-06-14 20:35:45 +02:00
Ullrich Schäfer
f6da405d2b
Merge branch 'main' into apply-surface-breakdown-phase2 2026-06-14 20:31:35 +02:00
Ullrich Schäfer
f98a72532e
Merge pull request #546 from trails-cool/apply-surface-breakdown-phase1
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
2026-06-14 20:31:04 +02:00
Ullrich Schäfer
279734c607
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
Covers routes/activities that enter the journal without BRouter waytags
(imports, uploads, pre-existing rows):

- overpass-ways.server.ts: server-side Overpass client (way[highway] + geom in
  a bbox, configurable OVERPASS_URLS, timeout + oversized-bbox guard).
- surface-match.server.ts: pure nearest-way map-matcher → per-segment
  surface/highway (unmatched → unknown). Unit-tested.
- surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass
  → match → computeSurfaceBreakdown → store → emit `surface_breakdown` SSE to
  the owner. Idempotent, retry-safe, best-effort; registered in server.ts.
- Enqueued from createActivity (imports/uploads) + owner-on-open in the route &
  activity detail loaders (non-Planner routes, old rows), deduped via singletonKey.
- useSurfaceBackfillUpdates: detail pages subscribe to /api/events and
  revalidate() when their row's backfill lands (live bars, no reload).
- Privacy manifest updated (DE + EN) for the Overpass bbox lookup.

Tests: matchSurfaces unit; surface-backfill job (mocked Overpass/db/events:
store + emit, skip-if-present, skip-if-no-ways). Verified the real
Overpass→match→breakdown pipeline against a live Berlin bbox (509 ways →
plausible asphalt/paving_stones/footway mix). typecheck + lint + unit
(journal 333) green.

Note: the worker runs via server.ts (prod/staging), not `react-router dev`, so
the live job + SSE exercise on a deployed instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:18:03 +02:00
Ullrich Schäfer
3a1c34317d
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
  weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
  routeData and sends it; `api.save-to-journal` forwards it; the journal route
  callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
  legend category · % · km largest-first, unknown → "other", hidden when empty)
  on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).

Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.

Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:46:49 +02:00
Ullrich Schäfer
6feab65ebb
Merge pull request #545 from trails-cool/revise-route-surface-breakdown-backfill
openspec: route-surface-breakdown — add async Overpass backfill + SSE
2026-06-14 18:29:54 +02:00
Ullrich Schäfer
e1e3dc5c81
openspec: revise route-surface-breakdown — add async Overpass backfill + SSE
Builds on the merged A-only proposal. Broadens the capability:
- breakdown now on routes AND activities;
- Path 1 (sync) unchanged: BRouter waytags → breakdown at Planner save;
- Path 2 (async): a `surface-backfill` pg-boss job map-matches geometry to OSM
  via Overpass for imports/uploads/older rows, stores the breakdown, and pushes
  a `surface_breakdown` SSE event so an open detail page fills in live;
- privacy note: backfill sends geometry to Overpass → route via the proxy /
  self-hostable Overpass; documented in the manifest.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:54:50 +02:00
Ullrich Schäfer
a81134332b
Merge pull request #544 from trails-cool/propose-route-surface-breakdown
openspec: propose route-surface-breakdown (surface/waytype bars)
2026-06-14 17:27:58 +02:00
Ullrich Schäfer
47f185598d
openspec: propose route-surface-breakdown
Surface + waytype proportion bars on route detail, from the BRouter waytags the
Planner already computes (and currently discards on save). Design records the
data-source decision: persist a compact distance-weighted breakdown for
Planner-created routes (accurate, no external call); Overpass map-matching for
imports/uploads is parked as a future option.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:13:44 +02:00
Ullrich Schäfer
ce9b5f5ae8
Merge pull request #543 from trails-cool/opsx-archive-profile
openspec: archive profile-stats + profile-weekly-distance
2026-06-14 17:07:15 +02:00
Ullrich Schäfer
c1d8fdc3fb
Merge pull request #542 from trails-cool/dependabot/npm_and_yarn/production-650660aaaa
build(deps): bump the production group with 6 updates
2026-06-14 16:35:20 +02:00
Ullrich Schäfer
2ad9898f9c
openspec: archive profile-stats + profile-weekly-distance, sync specs
Both changes are implemented and merged (#539, #541). Promote their deltas into
openspec/specs/, move the changes to changes/archive/2026-06-14-*, and add the
two capabilities to the CAPABILITIES index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:34:06 +02:00
dependabot[bot]
070cec00a5 [github-actions] pnpm dedupe 2026-06-14 14:31:16 +00:00
dependabot[bot]
ccca2c8aae
build(deps): bump the production group across 1 directory with 6 updates
Bumps the production group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.3` | `2.1.4` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.3` | `12.19.1` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.57.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |



Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `@logtape/logtape` from 2.1.3 to 2.1.4
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.4/packages/logtape)

Updates `pg-boss` from 12.18.3 to 12.19.1
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.3...12.19.1)

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

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-14 14:30:31 +00:00
Ullrich Schäfer
7e76bbd447
Merge pull request #541 from trails-cool/apply-profile-weekly-distance
profile-weekly-distance: weekly distance bar chart
2026-06-14 16:24:14 +02:00
Ullrich Schäfer
4754c229b9
profile-weekly-distance: make the chart legible (axis, grid, tracks, hover)
Addresses feedback that a lone bar conveyed nothing. Rewrite WeeklyDistanceChart
as a proper SVG chart:
- y-scale topped at the busiest week with 0 / half / peak gridlines + km labels;
- faint per-week track columns so the 12-week axis is always visible (empty
  weeks read as gaps, not nothing — no more floating bar);
- oldest→newest date bounds on the x-axis;
- a hover readout naming the week + its distance ("Week of {{date}} · X km").

i18n adds profileStats.weekOf. Component test updated for the SVG structure
(bar per non-zero week, peak-topped scale, hover readout). typecheck + lint +
unit (journal 322) green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:50:01 +02:00
Ullrich Schäfer
ceda9f1877
profile-weekly-distance: weekly distance bar chart on the profile
Implements the profile-weekly-distance change (specs/profile-weekly-distance):

- getWeeklyDistance(ownerId, { publicOnly, weeks=12 }) in activities.server.ts:
  one query that gap-fills in SQL — generate_series of week-starts LEFT JOINed
  to activities — so it returns exactly 12 contiguous { weekStart, distance }
  rows with matching Postgres week boundaries, viewer-scoped, no cache, no
  schema change.
- WeeklyDistanceChart: SVG bars normalized to the busiest week (empty weeks keep
  their slot as zero-height bars), per-bar title distance, localized label;
  renders nothing when there's no distance in the window. Mounted under the
  ProfileStats header.
- i18n journal.profileStats.weeklyDistance (en + de).

Tests: WeeklyDistanceChart component (jsdom: bar count incl. zero weeks, empty
→ hidden, normalization); e2e creates an activity with distance and asserts the
chart renders. typecheck + lint + unit (journal 321) green; verified in the
browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:46:06 +02:00
Ullrich Schäfer
e0a92b56aa
Merge pull request #540 from trails-cool/propose-profile-weekly-distance
openspec: propose profile-weekly-distance (weekly bars)
2026-06-13 07:36:01 +02:00
Ullrich Schäfer
46a7304421
Merge pull request #539 from trails-cool/apply-profile-stats
profile-stats: lifetime roll-up header on the profile
2026-06-13 07:29:41 +02:00
Ullrich Schäfer
d975cc7402
openspec: propose profile-weekly-distance (weekly bars)
A 12-week distance bar chart under the profile stats header, viewer-scoped,
built on profile-stats (#539). Design records the same no-cache rationale: one
indexed grouped aggregate over a 12-week slice (≤12 rows), gap-filled in JS.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:29:23 +02:00
Ullrich Schäfer
3d3c56aaf4
profile-stats: lifetime roll-up header on the profile
Implements the profile-stats change (specs/profile-stats):

- getActivityStats(ownerId, { publicOnly }) in activities.server.ts: one indexed
  aggregate over stored columns (count, sum distance/ascent/elapsed duration) +
  a rolling last-4-weeks count. No cache table, no schema change, no GPX parsing
  (design §D1).
- Profile loader computes it scoped to the viewer (public-only for visitors,
  full totals for the owner); ProfileStats header renders count · distance ·
  ascent · time + "N in the last 4 weeks" via the shared StatRow + stats.ts
  formatters; hidden when there are no visible activities.
- i18n journal.profileStats.* in en + de.

Tests: ProfileStats component (jsdom: totals, empty, last-4-weeks toggle);
e2e asserts the owner roll-up counts their activities. typecheck + lint + unit
(journal 318) green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:25:06 +02:00
Ullrich Schäfer
9845718dee
Merge pull request #538 from trails-cool/propose-profile-stats
openspec: propose profile-stats (profile roll-ups)
2026-06-13 07:13:39 +02:00
Ullrich Schäfer
977e0d8f5f
openspec: propose profile-stats (profile roll-ups)
Lifetime totals (count · distance · ascent · elapsed time) + a last-4-weeks
count on the profile, viewer-scoped (public-only for visitors, full for the
owner).

Design records the cost decision: compute on the fly with a single aggregate
over stored columns on the owner_id-leading indexes (cheap even for power
users) — no cache table, no schema change — with a documented escape hatch to
a user_activity_stats cache if profiling ever shows it's hot.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 06:56:06 +02:00
Ullrich Schäfer
b7e55d09db
Merge pull request #537 from trails-cool/fix-list-map-previews
fix: restore list map previews (batch geojson query)
2026-06-13 06:50:16 +02:00
Ullrich Schäfer
0ee4daaed1
fix: restore list map previews (batch geojson query)
Map previews were missing on every list view (activities, routes, home,
profile, feed): the batch geojson helpers used `WHERE id = ANY(${ids}::text[])`,
but drizzle expands a JS array in a sql template to `($1,$2,...)`, producing the
invalid `ANY((...)::text[])`. That throws, and the helpers' `try/catch` swallowed
it — so every card fell back to "No map preview".

Rewrite getSimplifiedActivityGeojsonBatch (activities) and getSimplifiedGeojsonBatch
(routes) to use the query builder: `inArray(table.id, ids)` for the id list +
a raw `ST_AsGeoJSON(ST_Simplify(geom, 0.001))` select column. Same result, valid
SQL.

E2E: list-map-previews.test.ts creates an activity with geometry and asserts a
Leaflet preview renders on /activities (no "No map preview"). Registered in
playwright.config. typecheck + lint + unit (journal 315) green; verified in the
browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:53:14 +02:00
Ullrich Schäfer
8cacc7d91e
Merge pull request #536 from trails-cool/home-statrow-consistency
home: shared StatRow + SportBadge on activity cards
2026-06-12 22:44:31 +02:00
Ullrich Schäfer
296e515ebb
Merge branch 'main' into home-statrow-consistency 2026-06-12 22:44:22 +02:00
Ullrich Schäfer
ee3eff193a
Merge pull request #535 from trails-cool/opsx-archive-catchup
openspec: archive detail catch-up changes (sync specs)
2026-06-12 22:44:07 +02:00
Ullrich Schäfer
acb1e43481
home: use the shared StatRow + SportBadge on activity cards
The home page's activity list (signed-in dashboard + anonymous public feed) was
the one surface still rendering the old `distance ↑ elevation` line, leaving it
inconsistent with the feed, detail, and profile after the activity-stats work.

Adopt the shared SportBadge + compact StatRow on both home card variants;
expose sportType in the home loader projection. No behavior change beyond the
consistent presentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 16:03:36 +02:00
Ullrich Schäfer
f1cae13605
openspec: archive the detail catch-up changes, sync specs
Archive the three implemented + merged changes (activity-sport-type,
activity-stats, journal-elevation-profile): their deltas are promoted into
openspec/specs/, the changes move to changes/archive/2026-06-12-*, and the
three new capabilities are added to the CAPABILITIES index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:58:17 +02:00
Ullrich Schäfer
1ba063a085
Merge pull request #534 from trails-cool/apply-journal-elevation-profile
journal-elevation-profile: elevation chart + map↔chart sync
2026-06-12 15:45:52 +02:00
Ullrich Schäfer
536a8f98b9
journal-elevation-profile: elevation chart + map↔chart sync
Implements the journal-elevation-profile change (specs/journal-elevation-profile):

- gpx: `elevationSeries(tracks)` → { d, e, lat, lng }[] with cumulative distance,
  downsampled (keeps first/last), empty when <2 points carry elevation.
- ElevationProfile: read-only SVG area chart (vertical gradient fill), highest/
  lowest summary + a hover readout. Reports hovered index (onActive) and clicked
  index (onSeek); draws a marker at the active index. Renders nothing for an
  empty series.
- RouteMapThumbnail: ActiveMarker (CircleMarker at the chart's active point),
  HoverTracker (route hover → nearest sample → onHoverIndex), Recenter (panTo on
  chart click). Props forwarded through ClientMap.
- Wired into the activity + route detail pages via a shared activeIndex/centerOn
  state; loaders expose the series (activity reuses the moving-time parse).
- i18n journal.elevation.{highest,lowest} in en + de.

Ascent/descent stay in the stat row (#532); the chart summary shows highest/
lowest to avoid duplication.

Tests: elevationSeries unit; ElevationProfile component (jsdom); e2e creates an
activity from an elevation GPX and asserts the chart renders. typecheck + lint +
unit (gpx 67, journal 315) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:34:45 +02:00
Ullrich Schäfer
1bfb6f1d86
Merge pull request #533 from trails-cool/fix-map-isolation
fix: isolate the Leaflet map's stacking context
2026-06-12 15:23:45 +02:00
Ullrich Schäfer
830eff7d39
fix: isolate the Leaflet map's stacking context
The mobile nav drawer's backdrop (z-40) was being painted over by the Leaflet
map: `.leaflet-container` doesn't establish its own stacking context, so its
internal high z-indexes (panes ~200–700, zoom controls ~1000) competed with the
page and bled over the drawer overlay — the map stayed bright while the rest of
the page dimmed.

Add `isolation: isolate` (Tailwind `isolate`) to the map container so those
z-indexes stay contained and the map sits below page overlays (menus, modals,
dialogs) like any other content. Verified in the browser with the drawer open
over an activity-detail map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:19:42 +02:00
Ullrich Schäfer
5e6fcde281
Merge pull request #532 from trails-cool/apply-activity-stats
activity-stats: shared StatRow + canonical metrics
2026-06-12 15:14:59 +02:00
Ullrich Schäfer
0325d01bca
activity-stats: shared StatRow + canonical metrics across surfaces
Implements the activity-stats change (specs/activity-stats):

- gpx: `movingTime(tracks)` — moving seconds from trackpoint timestamps,
  excluding stationary spans + long gaps; null when no timestamps.
- stats.ts: pure formatters (distance/elevation/duration/speed/pace),
  sport-aware `deriveRate` (pace for foot sports, speed otherwise, speed
  default), and `activityStatItems` encoding the canonical order
  (distance · time · [moving] · pace/speed · ascent · descent; compact subset).
- StatRow: one shared presentational component (size sm/lg), adopted on the
  activity detail (full set; loader derives moving time), route detail
  (distance/ascent/descent), feed card + profile list (compact).
- i18n: journal.stats.* in en + de.

Tests: movingTime (stationary/gap exclusion, moving ≤ elapsed), deriveRate,
formatters, activityStatItems ordering + compact, StatRow render (jsdom).
typecheck + lint + unit (gpx 63, journal 311) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:48:36 +02:00
Ullrich Schäfer
ede263d4d1
Merge pull request #531 from trails-cool/apply-activity-sport-type
activity-sport-type: sport/activity type on activities
2026-06-12 14:37:20 +02:00
Ullrich Schäfer
953c79befe
activity-sport-type: e2e test + register spec in playwright config
- e2e/activity-sport-type.test.ts: register → create activity with a sport →
  assert the sport badge renders on the detail page. Passes locally against an
  E2E=true server.
- playwright.config.ts: add the `activity-sport-type` project so the spec
  actually runs (specs only execute if a project testMatch matches them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:16:58 +02:00
Ullrich Schäfer
2cb32cd2d3
activity-sport-type: schema, contract, write/read paths, federation
Implements the activity-sport-type change (specs/activity-sport-type):

- db: nullable `sport_type` column on journal.activities + SportType /
  SPORT_TYPES (text().$type<> convention).
- api: optional sportType on the activity read + create schemas (mirrored
  SPORT_TYPES; @trails-cool/api stays zod-only).
- write: ActivityInput + createActivity persist it; mapSportType() normalizes
  provider strings (Komoot bulk import passes tour.sport; Garmin unset);
  threaded through the unified importActivity.
- read/display: sportType added to the detail/feed/profile loaders and the v1
  REST endpoints; shared SportBadge (glyph + i18n label) on detail, feed, and
  profile; sport-aware feed verb; create-form <select>.
- i18n: journal.activities.sport.* (labels + verbs) in en + de.
- federation: `sport` PropertyValue on the Note when set.

Tests: mapSportType unit table; federation asserts the sport attachment is
present when set and omitted when unset. typecheck + lint + unit all green.
E2E (create→badge) still to add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:11:41 +02:00
Ullrich Schäfer
ee76945f20
Merge pull request #530 from trails-cool/propose-detail-catchup-specs
openspec: propose journal detail catch-up (sport type, stats, elevation)
2026-06-12 13:57:24 +02:00
Ullrich Schäfer
2ccdc8dac2
Merge pull request #529 from trails-cool/chore-gitignore-internal-reviews
chore: gitignore internal review material
2026-06-12 13:56:38 +02:00
Ullrich Schäfer
26b59b9c95
openspec: propose journal route/activity detail catch-up changes
Three OpenSpec proposals to bring the journal's route/activity detail and feed
closer to best-in-class outdoor apps, in the order we'll implement them:

- activity-sport-type: nullable sport enum on activities (hike/walk/run/
  ride/gravel/mtb/ski/other), set on create or normalized on import,
  shown as a badge + feed verb, federated as a Note PropertyValue.
- activity-stats: one reusable StatRow + canonical metric set, sport-aware
  avg pace/speed, moving-vs-elapsed time. Derive-only, no schema change.
- journal-elevation-profile: elevation profile chart + chart<->map
  "active distance" hover/click sync on the read-only detail pages.

All three validate with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:56:24 +02:00
Ullrich Schäfer
e3e01a34ae
chore: gitignore internal working notes
docs/reviews/internal/ holds internal working notes we keep out of the
published repo. Commit the ignore rule so the folder is excluded for everyone,
not just on the local working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:56:08 +02:00
Ullrich Schäfer
b5aa61e3b7
Merge pull request #522 from trails-cool/dependabot/npm_and_yarn/production-9d29f9de3d
chore(deps): Bump the production group across 1 directory with 21 updates
2026-06-11 21:27:44 +02:00
dependabot[bot]
8bf555b57a [github-actions] pnpm dedupe 2026-06-11 19:23:24 +00:00
dependabot[bot]
19160bbe82
build(deps): Bump the production group across 1 directory with 21 updates
Bumps the production group with 21 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [turbo](https://github.com/vercel/turborepo) | `2.9.17` | `2.9.18` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.1` | `2.1.3` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.41` | `5.1.42` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.10` | `8.0.11` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `8.0.0` | `8.0.1` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.14` | `56.0.15` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.3.3` | `11.3.4` |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `56.0.9` | `56.0.11` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.17` | `56.0.18` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.19` | `56.0.20` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `56.0.7` | `56.0.8` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `56.0.13` | `56.0.14` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.16` | `56.0.17` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.16` | `56.0.17` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.9` | `56.2.10` |
| [expo-sqlite](https://github.com/expo/expo/tree/HEAD/packages/expo-sqlite) | `56.0.4` | `56.0.5` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.2` | `12.18.3` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.56.0` | `10.57.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.57.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.20` | `22.19.21` |



Updates `turbo` from 2.9.17 to 2.9.18
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.17...v2.9.18)

Updates `@logtape/logtape` from 2.1.1 to 2.1.3
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.3/packages/logtape)

Updates `isbot` from 5.1.41 to 5.1.42
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.41...v5.1.42)

Updates `nodemailer` from 8.0.10 to 8.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@expo/metro-runtime` from 56.0.14 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.3.3 to 11.3.4
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.3.3...v11.3.4)

Updates `expo` from 56.0.9 to 56.0.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `expo-constants` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 56.0.19 to 56.0.20
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-file-system` from 56.0.7 to 56.0.8
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 56.0.13 to 56.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.9 to 56.2.10
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-sqlite` from 56.0.4 to 56.0.5
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-sqlite/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-sqlite)

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

Updates `pg-boss` from 12.18.2 to 12.18.3
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.2...12.18.3)

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

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

Updates `@types/node` from 22.19.20 to 22.19.21
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo
  dependency-version: 56.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 56.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 56.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-sqlite
  dependency-version: 56.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 19:22:35 +00:00
Ullrich Schäfer
1128544c9c
Merge pull request #513 from trails-cool/dependabot/npm_and_yarn/vite-8.0.16
build(deps): Bump vite from 7.3.5 to 8.0.16
2026-06-11 20:59:40 +02:00
dependabot[bot]
681de1e515 [github-actions] pnpm dedupe 2026-06-11 18:52:10 +00:00
dependabot[bot]
0018a46f10
build(deps): Bump vite from 7.3.5 to 8.0.16
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 18:51:15 +00:00
Ullrich Schäfer
7092ea42ff
Merge pull request #527 from trails-cool/e2e-hydration-flake
e2e: close the interact-before-hydration race (cold-start flake)
2026-06-11 14:13:13 +02:00
Ullrich Schäfer
a05c8e87a1
e2e: close the interact-before-hydration race (cold-start flake)
Diagnosis (reproduced by looping cold-server runs): the recurring
"cold start" failures in the auth/follow specs were a hydration race,
not a Vite dep-reload (the server.warmup config already handles that —
no reload events in the logs). FollowButton is an onClick button, and
Playwright considers it actionable (visible + enabled) before React
attaches the handler on a freshly-navigated page. A click in that
window is dropped (or triggers a native form submit), so the expected
state change never happens — e.g. notifications.test.ts:59 clicked
"Request to follow" with no hydration wait and the button never
flipped to "Requested".

The existing waitForHydration guard was applied per-interaction and so
was easy to forget (test 1 had it, test 3 didn't). Fix it at the
navigation instead:

- add gotoHydrated(page, url) = goto + waitForHydration, documented as
  the default for "navigate then interact with a React control"
- use it before every FollowButton interaction in notifications +
  social
- consolidate the setProfileVisibility helper (copy-pasted in three
  specs, two missing the hydration wait) into e2e/helpers/profile.ts,
  built on gotoHydrated

Validation against a fresh Postgres, cold servers: notifications 0/10
failures (was the proven flaker); notifications+social+explore 0/5
(55 tests); planner unaffected (25/25).

Note: two other "cold-start"-attributed flakes are NOT this race and
are out of scope here — planner map-load latency (mitigated by warmup
+ CI retries) and the /explore directory assertion (a local artifact
of the scratch DB accumulating >20 public users; CI's fresh DB stays
under the page size, so it doesn't bite there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:09:00 +02:00
Ullrich Schäfer
f669969ad1
Merge pull request #526 from trails-cool/docs-review-decks
docs: archive the 2026-06-10 review decks under docs/reviews
2026-06-11 08:41:41 +02:00
Ullrich Schäfer
a38fdab69d
Merge branch 'main' into docs-review-decks 2026-06-11 08:37:43 +02:00
Ullrich Schäfer
f2677937d7
Merge pull request #525 from trails-cool/e2e-register-dead-specs
e2e: register and repair the settings/explore/social specs
2026-06-11 08:36:11 +02:00
Ullrich Schäfer
10a3a6f1c4
docs: archive the 2026-06-10 architecture + security review decks
Both were sitting untracked in the repo root. They're the source for
the "candidate N from the 2026-06-10 review" references in this
batch of follow-up PRs, so keeping them in-repo makes those references
resolvable. Self-contained HTML; a short README indexes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:33:48 +02:00
Ullrich Schäfer
7a290cd56f
e2e: register and repair the settings/explore/social specs
settings.test.ts, explore.test.ts, and social.test.ts weren't matched
by any Playwright project, so they had silently never run — which is
how they rotted. Register them (one project each) and repair the
selectors against the current UI:

- settings: the page was split into sibling sections
  (/settings/{profile,security,account}); the spec assumed one page.
  Navigate to the right sub-page per test, use the stable section-nav
  links + #id input locators (the Vite dev server transiently
  double-renders the profile form during hydration, breaking
  getByLabel), and wait for hydration before interacting with
  fetcher-backed forms and the avatar dropdown.
- explore: setProfileVisibility now targets /settings/profile and
  waits for hydration so the visibility save uses the fetcher.
- social: already green once registered.

Also fixes an app bug the specs surfaced: deleting a passkey redirected
to /settings#security, a stale anchor that now resolves to
/settings/profile — so you'd land on the wrong section. It now
redirects to /settings/security.

Verified locally against Postgres/BRouter: green under CI-style
retries (the residual registration flake is the same cold-start class
the rest of the suite has, which is why CI runs retries=2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:32:18 +02:00
Ullrich Schäfer
b4067301cc
Merge pull request #524 from trails-cool/sec-caddy-admin
security: bind Caddy admin API to loopback (split metrics to :2020)
2026-06-11 08:00:38 +02:00
Ullrich Schäfer
959a1c46ae
Merge branch 'main' into sec-caddy-admin 2026-06-11 07:56:59 +02:00
Ullrich Schäfer
d8fd4ff655
security: bind Caddy admin API to loopback, split metrics to :2020
The flagship Caddy ran its admin API on 0.0.0.0:2019, reachable by
every container on the Docker network. The admin API can rewrite
routes and proxy targets, so a journal/planner RCE could repoint
traffic with no further auth. (The brouter-host Caddyfile already
binds admin to localhost; the flagship didn't.)

The admin endpoint also served Prometheus metrics (global `metrics`
option → admin endpoint), and Prometheus scrapes caddy:2019
cross-container — so admin couldn't just move to loopback without
breaking metrics. Split them:

- admin localhost:2019 (loopback only). All reloads are in-container
  (`docker compose exec caddy caddy reload`) so they use this endpoint
  unaffected.
- a dedicated `:2020` server exposes the read-only metrics handler;
  prometheus.yml now scrapes caddy:2020.

Validated with `caddy validate` + `caddy adapt` against caddy:2:
admin.listen=localhost:2019, srv on :2020 carries the metrics handler,
the :443 site is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:56:30 +02:00
Ullrich Schäfer
9d11a4a158
Merge pull request #523 from trails-cool/sec-hardening-journal
security: log redaction, magic-link enumeration, federation doc cap
2026-06-11 07:56:05 +02:00
Ullrich Schäfer
769d1b5d31
security: log redaction, magic-link enumeration, federation doc cap
Three Low/Info hardening items from the 2026-06-10 security review.

OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
  failure; a provider error can embed the auth code / token response,
  which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
  to 200 chars before logging.

Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
  found for this email") when no account matches; the login route
  always responds { step: "magic-link-sent" }, minting a token and
  sending mail only for a real account. The public login form can no
  longer be used to probe which emails are registered. Registration's
  email/username "already in use/taken" messages are intentionally
  unchanged — standard signup UX, and the passkey ceremony can't be
  made to fake-succeed.

Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
  serialized, applied in the ingest fetchJson seam. Fedify owns the
  transfer (with its own SSRF + redirect limits) and our poll uses the
  authenticated loader for secure-mode instances, so this is a
  downstream guard on iteration/persistence, complementing the existing
  per-poll item cap.

Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:52:34 +02:00
Ullrich Schäfer
55f8758c70
Merge pull request #521 from trails-cool/sec-upload-validation
security: authorize & validate presigned upload requests
2026-06-10 22:27:40 +02:00
Ullrich Schäfer
31ace379d7
Merge branch 'main' into sec-upload-validation 2026-06-10 22:23:37 +02:00
Ullrich Schäfer
01d4832edb
security: authorize and validate presigned upload requests
POST /api/v1/uploads minted an upload key from caller input with no
ownership check, no content-type allowlist, and the raw filename
interpolated into the S3 key.

- Ownership: a caller may only mint upload URLs for a route/activity
  they own, enforced via the branded loadOwnedRoute/loadOwnedActivity
  (404 on miss, no existence leak). Closes writing into another
  user's resource key-space.
- Content type: the request schema now constrains contentType to an
  image/gpx allowlist, so active content (HTML/SVG/JS) that would
  execute if served inline is rejected at the request boundary.
- Filename: sanitizeUploadFilename() reduces the client filename to a
  safe basename (charset-restricted, no path components, no leading
  dots, length-bounded) before it becomes part of the key.

Tests: schema allow/deny + filename sanitization in @trails-cool/api;
handler tests covering owned-success, not-owner 404, disallowed
content-type, and the route-vs-activity ownership branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:23:14 +02:00
Ullrich Schäfer
b166c41d9b
Merge pull request #520 from trails-cool/sec-planner-ssrf
security: validate Planner callback URL (SSRF fix)
2026-06-10 22:22:27 +02:00
Ullrich Schäfer
43938473ad
Merge branch 'main' into sec-planner-ssrf 2026-06-10 22:18:52 +02:00
Ullrich Schäfer
c3454641df
security: validate Planner callback URL to close an SSRF sink
The session callbackUrl becomes a server-side fetch target in
api.save-to-journal (POSTed with the callback bearer token, and the
journal's response is reflected to the caller). The /new query-param
loader already validated it, but the programmatic POST /api/sessions
entry point — anonymous, since the Planner is stateless — stored it
unvalidated. An attacker could make the Planner backend POST to
arbitrary hosts, including 169.254.169.254 and other internal targets.

- validateFetchUrl now blocks private / loopback / link-local /
  CGNAT / cloud-metadata hosts (IPv4, IPv6, IPv4-mapped) when an
  explicit allowlist isn't set. Gated on NODE_ENV=production && !E2E
  (the requireSecret idiom) so the dev/e2e journal-on-localhost save
  flow is unaffected. An explicit PLANNER_CALLBACK_ALLOWED_HOSTS still
  takes precedence and remains the full-closure control (it also stops
  DNS-name-to-private rebinding, which literal blocking does not).
- POST /api/sessions now validates callbackUrl exactly as /new does.
- api.save-to-journal re-validates immediately before the fetch
  (defense in depth: covers sessions persisted before this change and
  narrows the create→save rebinding window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:18:18 +02:00
Ullrich Schäfer
0d2593a0c4
Merge pull request #508 from trails-cool/dependabot/npm_and_yarn/development-a6ed03596f
chore(deps-dev): Bump the development group across 1 directory with 4 updates
2026-06-10 08:03:55 +02:00
dependabot[bot]
04e86d4308 [github-actions] pnpm dedupe 2026-06-10 05:59:38 +00:00
dependabot[bot]
26c0a2d059
chore(deps-dev): Bump the development group across 1 directory with 4 updates
Bumps the development group with 4 updates in the / directory: [prettier](https://github.com/prettier/prettier), [turbo](https://github.com/vercel/turborepo), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [@testing-library/react-native](https://github.com/callstack/react-native-testing-library).


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

Updates `turbo` from 2.9.16 to 2.9.17
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.16...v2.9.17)

Updates `typescript-eslint` from 8.60.1 to 8.61.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/typescript-eslint)

Updates `@testing-library/react-native` from 13.3.3 to 14.0.0
- [Release notes](https://github.com/callstack/react-native-testing-library/releases)
- [Changelog](https://github.com/callstack/react-native-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/callstack/react-native-testing-library/compare/v13.3.3...v14.0.0)

---
updated-dependencies:
- dependency-name: "@testing-library/react-native"
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: turbo
  dependency-version: 2.9.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 05:58:57 +00:00
Ullrich Schäfer
e64155b490
Merge pull request #517 from trails-cool/package-shims
Remove the map and ui shim packages
2026-06-10 07:53:57 +02:00
Ullrich Schäfer
f684272eb5
Merge branch 'main' into package-shims 2026-06-10 07:50:17 +02:00
Ullrich Schäfer
5e54f35227
Merge pull request #516 from trails-cool/oauth-flow-module
journal: OAuth connect→callback→resume lifecycle as one module
2026-06-10 07:49:42 +02:00
Ullrich Schäfer
765c9f49a8
remove the map and ui shim packages
Both failed the deletion test in the telling direction:

- @trails-cool/ui: Button/Input/Card had zero consumers — both apps
  roll their own elements inline. The only live part was a 6-line
  styles.css (the Tailwind entry + one keyframe), which now lives in
  each app as app/styles.css.
- @trails-cool/map: MapView and RouteLayer had zero consumers; the
  package was otherwise a re-export of two map-core constants, and the
  two import sites now use @trails-cool/map-core directly. The
  "map components go in @trails-cool/map" convention had drifted long
  ago — the real map components live in apps/planner/app/components.

CLAUDE.md's repository structure and conventions updated to match
reality (including pointing shared-type guidance at the post-#515
sources: db row types, api contracts, Waypoint in types). Dockerfiles
no longer COPY the deleted package manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:47:59 +02:00
Ullrich Schäfer
bed9362004
Merge branch 'main' into oauth-flow-module 2026-06-10 07:45:35 +02:00
Ullrich Schäfer
5d6749618e
Merge pull request #511 from trails-cool/rntl-v14-async-render
mobile: await render() in smoke test for RNTL v14 compatibility
2026-06-10 07:45:25 +02:00
Ullrich Schäfer
f52de808f5
Merge branch 'main' into rntl-v14-async-render 2026-06-10 07:44:46 +02:00
Ullrich Schäfer
5e70aff9fa
Merge branch 'main' into oauth-flow-module 2026-06-10 07:44:29 +02:00
Ullrich Schäfer
6f366293c9
journal: OAuth connect→callback→resume lifecycle as one module
The PKCE verifier cookie, state encoding, redirect-URI construction,
and code exchange were coordinated across three route handlers that
each knew part of the protocol. Two latent bugs lived in the gaps: a
push-initiated re-authorization skipped PKCE entirely (harmless today
only because the sole pusher, Wahoo, is not a PKCE provider), and the
push-resume callback path never cleared the spent verifier cookie.

oauth-flow.server.ts now owns the lifecycle: initiateOAuthFlow builds
the provider redirect (state + verifier cookie, on every entry path),
and completeOAuthFlow consumes the callback — state decode, verifier
recovery, code exchange, connection linking — returning a
discriminated result the route maps to redirects. The three routes are
thin adapters; the next OAuth provider reuses the flow, not the
pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:44:04 +02:00
Ullrich Schäfer
7435353612
Merge pull request #515 from trails-cool/domain-type-unification
One source of truth for Route/Activity shapes; enforce api contracts at the seam
2026-06-10 07:44:01 +02:00
Ullrich Schäfer
06545c8e28
Merge branch 'main' into rntl-v14-async-render 2026-06-10 07:43:16 +02:00
Ullrich Schäfer
362fa11a88
Merge branch 'main' into domain-type-unification 2026-06-10 07:40:07 +02:00
Ullrich Schäfer
61a2d0085b
one source of truth for Route/Activity shapes; enforce api contracts
Route and Activity existed three times: hand-written interfaces in
packages/types, Zod contracts in packages/api, and Drizzle columns in
packages/db — each with different fields and nullability. The
hand-written ones had drifted so far they had zero importers; the Zod
contracts were advisory because v1 handlers hand-rolled Response.json
shapes nothing validated.

- packages/types keeps only what both apps actually share (Waypoint,
  WaypointPoiTags) and documents where row types and wire contracts
  live; the dead Route/RouteMetadata/RouteVersion/Activity interfaces
  are gone
- packages/db exports canonical inferred row types (RouteRow,
  ActivityRow, RouteVersionRow, UserRow)
- packages/api contracts are reconciled with the real wire format
  (RouteVersionSchema gains the id and createdBy fields the endpoint
  has always returned) and gain Create*ResponseSchemas
- apiJson(schema, payload) in api-guard parses every v1 response
  through its contract: drift is now a thrown ZodError in tests/CI,
  unknown keys are stripped, and payloads are compile-checked as
  z.input of the schema

Enforcement immediately caught two real drifts: nullable DB
descriptions could ship null where the contract promises string (now
coalesced at the boundary), and GET /api/v1/activities/:id was missing
the routeName and photos fields its contract declares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:39:50 +02:00
Ullrich Schäfer
ec87445550
Merge pull request #514 from trails-cool/e2e-fixtures
e2e: shared auth + journal seed helpers; flag unregistered specs
2026-06-10 07:36:39 +02:00
Ullrich Schäfer
bcd082dd37
Merge branch 'main' into e2e-fixtures 2026-06-10 07:32:19 +02:00
Ullrich Schäfer
0e267afac7
e2e: shared auth + journal seed helpers; flag unregistered specs
The virtual-authenticator setup and registerUser were copy-pasted into
six spec files and had drifted: the auth spec's copy lost the final
URL assertion, and the settings spec's variant skipped hydration and
the Terms checkbox entirely. Seed-route boilerplate and hardcoded
localhost URLs were repeated across three more files.

- helpers/auth.ts: setup/removeVirtualAuthenticator,
  submitRegistration (no outcome assertion, for expected-failure
  attempts), registerUser (asserts the signed-in redirect),
  registerFreshUser, logout
- helpers/journal.ts: JOURNAL/PLANNER base URLs, seedRoute,
  routeHasGeom, seedKomootConnection
- nine spec files now import the helpers instead of re-deriving them

Found while consolidating: settings.test.ts, explore.test.ts, and
social.test.ts are not matched by any playwright project and have
never run — which is how the settings spec's broken register helper
survived. Registering them (and fixing whatever has rotted) is a
follow-up; the config now carries a warning so the trap is at least
documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:31:51 +02:00
Ullrich Schäfer
5b2c5d53b5
Merge pull request #510 from trails-cool/gpx-save-stats
journal: gpx-save owns the validate-and-derive step (processGpx)
2026-06-10 07:27:25 +02:00
Ullrich Schäfer
00b61d1ebe
Merge branch 'main' into gpx-save-stats 2026-06-10 07:23:42 +02:00
Ullrich Schäfer
67e976f7cc
mobile: await render() in smoke test for RNTL v14 compatibility
RNTL v14 makes render() async; awaiting it is a no-op on v13, so this
works on both and unblocks the dependabot v14 bump (#508).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:29:07 +02:00
Ullrich Schäfer
9a0dae068b
journal: gpx-save owns the validate-and-derive step (processGpx)
createRoute, updateRoute, createActivity, createRouteFromActivity, and
the demo-bot each re-implemented the same choreography after
validateGpx: flatten tracks into [lon, lat] coords for writeGeom and
derive distance / elevation / dayBreaks / description / start time.
The stat derivation lived in a private computeRouteStats in
routes.server.ts that activities couldn't reach, so the two sides had
drifted (activities re-derived inline, with its own start-time logic).

processGpx() in gpx-save.server.ts now owns the whole step: parse +
validate (GpxValidationError as before), coords extraction, and stat
derivation, returning the parsed GpxData so nothing re-parses.
Callers keep their own precedence rules between derived and
caller-supplied stats — routes let explicit input win wholesale, the
activities importer prefers GPX distance unless it is zero. Extends
ADR-0006: the gpx-save module remains the only place that understands
GPX-to-database derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:28:13 +02:00
Ullrich Schäfer
b901d2710f
Merge pull request #509 from trails-cool/fix-expo-sdk56-version-alignment
mobile: realign native deps with Expo SDK 56 expectations
2026-06-10 03:27:38 +02:00
Ullrich Schäfer
ba64b2cd57
mobile: realign native deps with Expo SDK 56 expectations
Dependabot bumped react-native-reanimated (4.4.1), react-native-
safe-area-context (5.8.0), and react-native-worklets (0.9.1) past the
versions Expo SDK 56 pins, which broke the native iOS build: expo's
Swift macro API drifted between expo-modules-core patches and
expo-crypto stopped compiling (OptimizedFunction macro mismatch).
CI never caught it because nothing compiles native code; the first
EAS build since the SDK 56 upgrade (d627b3c1) failed with it.

Applied `npx expo install --fix` (expo ~56.0.9, reanimated 4.3.1,
safe-area-context ~5.7.0, worklets 0.8.3) + pnpm dedupe, and added
the SDK-pinned native packages to the dependabot ignore list so they
only move via SDK bumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:24:00 +02:00
Ullrich Schäfer
1a65b40d18
Merge pull request #507 from trails-cool/owned-entity-loading
journal: branded ownership loading for routes and activities
2026-06-10 02:48:21 +02:00
Ullrich Schäfer
e4958419dd
Merge branch 'main' into owned-entity-loading 2026-06-10 02:44:24 +02:00
Ullrich Schäfer
7a1dca378f
journal: branded ownership loading for routes and activities
Ownership was checked ad hoc: some handlers loaded-and-compared
ownerId, some lib mutators enforced it in WHERE clauses and silently
no-op'd for non-owners, and nothing tied the two together. Two real
authorization bugs hid in the gaps: linkActivityToRoute ignored its
ownerId parameter entirely (any logged-in user could relink any
activity), and createRouteFromActivity loaded any activity without an
ownership check (a non-owner could copy a private activity's GPX into
their own route). PUT /api/v1/routes/:id also returned ok:true for
non-owners without updating anything.

ownership.server.ts is now the single enforcement point:

- loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with
  their own error vocabulary) and requireOwnedRoute /
  requireOwnedActivity (throwing data() 404/403 for web handlers; 404
  by default so guessed ids don't leak existence)
- the returned entities carry an Owned<> brand; mutators (updateRoute,
  deleteRoute, deleteActivity, updateActivityVisibility,
  linkActivityToRoute, createRouteFromActivity) now require an
  OwnedRef, so skipping the check is a compile error
- vouchOwnership is the explicit, greppable escape hatch for the one
  non-session authorization path (the Planner JWT callback)
- WHERE ownerId clauses stay in the mutators as defense in depth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:35:00 +02:00
Ullrich Schäfer
d060f7890a
Merge pull request #505 from trails-cool/remove-stray-root-deps
Remove stray root dependencies block
2026-06-10 02:25:03 +02:00
Ullrich Schäfer
45d61082f2
Merge branch 'main' into remove-stray-root-deps 2026-06-10 02:21:48 +02:00
Ullrich Schäfer
1443a82f2b
Merge pull request #506 from trails-cool/jobs-typed-seam
journal: typed job seam + Komoot credentials through the manager
2026-06-10 02:09:26 +02:00
Ullrich Schäfer
855244747c
journal: typed job seam + Komoot credentials through the manager
Two related fixes from the architecture review:

Typed job seam. Job payloads were `unknown` end-to-end: every handler
opened with `job.data as SomePayload`, every enqueue site passed a bare
string queue name and an unchecked object, and a typo meant a runtime
failure in a background worker. packages/jobs gains defineJob(), which
keeps the payload typed inside the handler and performs the
contravariance cast once inside the package. The journal declares all
14 queues and their payload shapes in app/jobs/payloads.ts; enqueue()/
enqueueOptional() and defineJournalJob() key off that map, so enqueue
sites and handlers cannot drift and queue names are compile-checked.

Komoot credential bypass. The bulk-import route enqueued the raw
credentials JSONB in the pg-boss payload, skipping withFreshCredentials
entirely: credentials sat at rest in the job table, were never
refreshed if stale, and markNeedsRelink never fired. The payload now
carries only the serviceId; the handler resolves fresh credentials
through the ConnectedServiceManager at execution time, and marks the
import batch failed (instead of leaving it pending forever) when
credential resolution itself fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:05:24 +02:00
Ullrich Schäfer
e94e7ac19a
Merge pull request #504 from trails-cool/planner-route-data-seam
planner: typed schema seam for the Yjs document + shared GPX assembly
2026-06-10 01:51:49 +02:00
Ullrich Schäfer
bf0f7f8d9f
Merge branch 'main' into planner-route-data-seam 2026-06-10 01:48:18 +02:00
Ullrich Schäfer
d13abe80d6
Merge pull request #455 from trails-cool/dependabot/docker/apps/journal/node-26-slim
chore(deps): Bump node from 25-slim to 26-slim in /apps/journal
2026-06-10 01:44:39 +02:00
dependabot[bot]
ce5fd6da0f
chore(deps): Bump node from 25-slim to 26-slim in /apps/journal
Bumps node from 25-slim to 26-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 23:41:11 +00:00
Ullrich Schäfer
50ce4e69a0
Merge pull request #456 from trails-cool/dependabot/docker/apps/planner/node-26-slim
chore(deps): Bump node from 25-slim to 26-slim in /apps/planner
2026-06-10 01:39:45 +02:00
Ullrich Schäfer
a2735edd1c
Remove stray root dependencies block
The root package.json carried expo/react/react-native pins since
310f20e (added incidentally while debugging the Android dev server).
apps/mobile pins its own expo, and react/react-dom are governed by
the workspace catalog + overrides, so the block only forced a
parallel Expo 55 / react-native 0.83 tree into the lockfile
(-1569 lines) and pinned react at 19.2.0 next to the catalog's
19.2.7 — the likely cause of the react/react-dom version-mismatch
failures on the @testing-library/react-native bump (#480).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:38:27 +02:00
dependabot[bot]
154dccf312
chore(deps): Bump node from 25-slim to 26-slim in /apps/planner
Bumps node from 25-slim to 26-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 23:36:01 +00:00
Ullrich Schäfer
3b9672e0ff
planner: give the Yjs document a typed schema seam
The routeData Y.Map's ~15 string keys (geojson, coordinates,
segmentBoundaries, road metadata, profile, colorMode, baseLayer,
overlays, poiCategories) were read and written raw at ~30 call sites,
each with its own JSON parsing and casts; parseJsonArray existed twice
and waypoint extraction four times. GPX assembly was duplicated between
SaveToJournalButton and ExportButton, so the saved plan and the
exported file could silently diverge.

- new lib/route-data.ts owns the routeData (+ noGoAreas) schema:
  typed read/write, JSON encoding internal, ColorMode moves here
  (re-exported from ColoredRoute for existing importers)
- new lib/gpx-export.ts owns GPX assembly: buildRouteGpx /
  buildPlanGpx / buildDayGpxFiles / hasDayBreaks; multi-day splitting
  becomes a pure, tested function
- waypoint-ymap.ts gains extractWaypoints / extractWaypointData; the
  four hand-rolled copies (use-routing, use-waypoint-manager,
  WaypointSidebar, use-days) now share it, and WaypointSidebar's
  moveWaypoint reuses the round-trip helpers instead of re-listing
  every waypoint field
- all hooks/components consume the seam; no raw routeData key strings
  remain outside route-data.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:04 +02:00
Ullrich Schäfer
3ce7bf0991
Merge pull request #484 from trails-cool/dependabot/npm_and_yarn/production-e9efbc881e
chore(deps): bump the production group across 1 directory with 30 updates
2026-06-10 01:32:22 +02:00
Ullrich Schäfer
8eeb775f54
Merge branch 'main' into dependabot/npm_and_yarn/production-e9efbc881e 2026-06-10 01:28:40 +02:00
Ullrich Schäfer
06c385f6ac
Merge pull request #503 from trails-cool/fix/prometheus-hup-race
cd-infra: fix Prometheus 3.10 SIGHUP startup kill + add readiness gate
2026-06-09 16:03:37 +02:00
Ullrich Schäfer
92468a7f90
Merge branch 'main' into fix/prometheus-hup-race 2026-06-09 15:59:52 +02:00
Ullrich Schäfer
c9e5166106
Merge pull request #502 from trails-cool/skills-to-agents
Move skills to .agents/skills for cross-agent compatibility
2026-06-09 15:47:15 +02:00
Ullrich Schäfer
e4366d626b
Merge branch 'main' into skills-to-agents 2026-06-09 15:47:04 +02:00
Ullrich Schäfer
59bbbcd520 cd-infra: fix Prometheus 3.10 SIGHUP startup kill + add readiness gate
- Capture container ID before/after 'docker compose up -d' and only
  send SIGHUP when the same container persisted (config-only change).
  A recreated container already loaded the fresh config; sending HUP
  immediately after startup kills Prometheus 3.10 with exit code 2.
- Add Prometheus /-/ready gate alongside the postgres/journal health
  check to fail the deploy if monitoring stack never comes up.
- Observed 2026-06-09: infra deploy left trails-cool-prometheus-1
  Exited (2) for ~2h, causing a Grafana alert storm.
2026-06-09 15:44:50 +02:00
Ullrich Schäfer
19f2275b73 Move skills to .agents/skills for cross-agent compatibility
Skills now live in .agents/skills/ — the standard convention
aligned with Pi, OpenAI Codex, and the Agent Skills spec.

.claude/skills is a symlink back to .agents/skills/ so Claude
Code still discovers them.

Updated CLAUDE.md to document the setup.
2026-06-09 13:31:20 +02:00
Ullrich Schäfer
16375f8050
Merge pull request #501 from trails-cool/refactor/config-directory-mounts
refactor(infra): directory-mount configs so deploys actually apply them
2026-06-09 13:16:29 +02:00
Ullrich Schäfer
e60c9d7057 refactor(infra): mount config dirs not single files; reload on deploy
Single-file bind mounts (./foo.yml:/etc/foo.yml) pin to the host file's
inode at container-create time. The CD pipeline scp's a replacement file
(new inode), so the running container keeps reading the OLD inode —
`docker compose up -d` won't recreate on a content-only change, and
neither restart/SIGHUP/`caddy reload` re-reads the new file. Net effect:
config-only infra PRs deployed "successfully" but never took effect
(confirmed with PR #500's prometheus.yml; the WAL retention work only
applied because #498 also changed docker-compose.yml, forcing a
recreate). Caddyfile changes had the same latent gap.

Switch the four single-file config mounts to DIRECTORY mounts, which
resolve children live so a reload/restart picks up the new file:

- prometheus, loki, promtail: mount ./<svc> dir (loki/promtail
  --config.file paths updated to the real filenames).
- caddy: move Caddyfile into caddy/ and mount the dir; ./sites overlays
  /etc/caddy/sites (caddy/sites/.gitkeep keeps the mountpoint). Container
  path /etc/caddy/Caddyfile is unchanged, so every `caddy reload --config
  /etc/caddy/Caddyfile` ref still works. scp source paths in cd-infra and
  cd-apps updated to ship the caddy/ dir.

cd-infra now applies config-only changes after `up -d`: SIGHUP prometheus
(zero downtime), restart loki+promtail (no SIGHUP reload), caddy reload
(graceful). Mirrored prometheus/loki mounts in docker-compose.dev.yml.

Validated: docker compose config (both files), caddy validate from the
new path, read-only-parent + sites-overlay mount mechanics, workflow YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:10:35 +02:00
Ullrich Schäfer
8e41b09ac2
Merge pull request #500 from trails-cool/feat/monitoring-observability
feat(infra): self-monitor the observability stack + Overpass alert
2026-06-09 12:27:49 +02:00
Ullrich Schäfer
3a43784f07 feat(infra): self-monitor the observability stack + Overpass alert
Adds the visibility that would have caught the corrupt-WAL incident,
plus a direct Overpass upstream alert.

- prometheus.yml: self-scrape Prometheus (localhost:9090) and Loki
  (loki:3100). Prometheus scraped everything except itself, so TSDB
  health (compaction failures, WAL corruption, head series, retention
  deletions) was invisible.
- monitoring-health.json: new "Monitoring Health" dashboard — TSDB
  compaction/WAL failures, retention deletions/hour, head series,
  samples/s, block bytes vs size-retention limit, retention depth
  (oldest-sample age), Loki ingestion rate + memory chunks.
- alerts.yml: prometheus-compaction-failing (any compaction failure or
  WAL corruption in 1h) and overpass-upstream-unhealthy (>20% upstream
  failure over 10m — sustained public-Overpass degradation, distinct
  from the symptom-level Caddy-502 alert).

Validated: promtool check config, YAML parse, JSON parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:21:16 +02:00
Ullrich Schäfer
42b90a7b78
Merge pull request #499 from trails-cool/fix/overview-dashboard-json
fix(grafana): repair invalid JSON in overview dashboard
2026-06-09 12:18:28 +02:00
Ullrich Schäfer
34c7f24ef2
Merge pull request #498 from trails-cool/fix/prometheus-retention-size
fix(infra): right-size Prometheus storage (retention 6GB + 20s scrape + cAdvisor drops)
2026-06-09 12:18:07 +02:00
Ullrich Schäfer
715cd3dce8
Apply suggestion from @stigi 2026-06-09 12:17:54 +02:00
Ullrich Schäfer
6e48510d75 fix(grafana): repair invalid JSON in overview dashboard
overview.json was missing a comma between the "Health Status" and
"App Error Rate (from logs)" panel objects, making the whole file
invalid JSON. Grafana's file provisioner skips dashboards that fail to
parse, so the entire "trails.cool Overview" dashboard (5 panels) never
loaded — a silent gap in observability. Validated with json.load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:00:19 +02:00
Ullrich Schäfer
f38a224376 perf(infra): scrape every 20s + drop unused cAdvisor metric families
Two cardinality/volume cuts to keep Prometheus storage sustainable
within the size budget:

- scrape_interval 5s -> 20s (evaluation_interval too). 5s tripled the
  sample volume vs the 15s default for no benefit on a single-box
  deployment; all rules use [5m] windows and for: >= 1m.
- Drop high-cardinality cAdvisor families we never chart or alert on
  (container_fs_*, blkio_*, tasks_state, memory_failures_total,
  memory_numa_*, network_tcp*/udp*) on both cadvisor jobs. Dashboards
  only use container CPU, memory, and start_time. This was the bulk of
  the ~12.4k active series.

Validated with `promtool check config`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:59:27 +02:00
Ullrich Schäfer
114f6c2ce7 fix(infra): raise Prometheus retention.size 1GB -> 6GB
Prometheus size-based retention counts the WAL toward its budget. With
retention.size=1GB and a stuck/corrupt WAL that had grown to 2.1GB, the
total was permanently over budget, so Prometheus deleted every
freshly-compacted block the moment it was written. Net effect: only the
in-memory ~2h head block was ever queryable — metrics appeared to "start
this morning" no matter when you looked, and last night's Caddy 502
spike (planner /api/overpass upstream stall, 18:33-18:34 UTC) was
unobservable in Prometheus (logs survived in Loki).

The corrupt WAL was cleared out-of-band on the flagship. This raises the
size backstop to 6GB so compacted blocks survive to the intended 15d
time-based retention. Disk is 38G (43% used), so 6GB is safe headroom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:52:10 +02:00
Ullrich Schäfer
001c53294a
Merge pull request #497 from trails-cool/garmin/provider-import
feat(journal): Garmin activity import — provider, webhook pipeline, backfill (garmin-import §1–5)
2026-06-07 17:51:13 +02:00
Ullrich Schäfer
0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00
Ullrich Schäfer
192481fedb
Merge pull request #496 from trails-cool/openspec/garmin-import-proposal
docs(openspec): propose garmin-import change
2026-06-07 17:18:52 +02:00
Ullrich Schäfer
19f1c06d69 docs(openspec): propose garmin-import change
Garmin Connect as the third connected-services provider, modeled on
wahoo-import but adapted to Garmin's push-first API: OAuth2+PKCE on the
existing oauth credential kind, ping/push webhook ingestion with async
pg-boss processing and an SSRF allowlist on callback URLs, date-range
backfill instead of a pick list (Garmin has no activity-list endpoint),
mandatory deregistration handling. Route push (Courses API) explicitly
deferred to a follow-up, mirroring wahoo-route-push.

Build is fixtures-first; rollout tasks are gated on Garmin Connect
Developer Program approval, which runs in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:15:02 +02:00
Ullrich Schäfer
ebe1b6fd3f
Merge pull request #495 from trails-cool/openspec/archive-social-federation
docs(openspec): archive social-federation; sync delta specs into main
2026-06-07 16:51:04 +02:00
Ullrich Schäfer
d87a1e30c2 docs(openspec): archive social-federation; sync delta specs into main
The change shipped end-to-end (58/58 tasks; live on trails.cool since
2026-06-07, soaked against Mastodon and flagship⇄staging). Delta specs
synced into the mainline:
- specs/social-federation/spec.md created (8 requirements: actor
  objects + WebFinger, signing keypairs, narrow inbox, outbox, push
  delivery, trails-only outbound, poll ingestion, audience filtering)
- specs/social-follows/spec.md: social-activity-feed requirement
  updated for remote rows + audience gating; pending lifecycle for
  outbound remote follows added
- specs/public-profiles/spec.md: Pending follow-button state added

openspec validate --all: green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:47:35 +02:00
Ullrich Schäfer
4b342ad91b
Merge pull request #494 from trails-cool/federation/soak-complete
docs(openspec): social-federation 12.4 soaked — change complete (58/58)
2026-06-07 15:39:26 +02:00
Ullrich Schäfer
2106d345cf docs(openspec): social-federation 12.4 soaked live — change complete (58/58)
Flagship ⇄ staging, both directions, 2026-06-07: follows settled to
Accepted in seconds, first polls ingested each side's public
activities, both feeds render remote attribution. The cross-origin
Accept fix (#490) verified working in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:36:01 +02:00
Ullrich Schäfer
6cc3be6b11
Merge pull request #493 from trails-cool/federation/prod-flag
feat(infra): enable federation on the flagship (social-federation 12.5)
2026-06-07 14:50:35 +02:00
Ullrich Schäfer
f3a17cf78c feat(infra): enable federation on the flagship (social-federation 12.5)
Wiring follows the IS_FLAGSHIP pattern: the compose env defaults every
FEDERATION_* var to empty (off — the safe self-host default; all
federation surfaces 404), and cd-apps.yml appends
FEDERATION_ENABLED=true + FEDERATION_LOG_LEVEL=info to the flagship's
app.env. FEDERATION_KEY_ENCRYPTION_KEY was already in SOPS.

Rollout 12.2/12.3 soaked on staging against a real Mastodon
(2026-06-06/07); the trails↔trails Accept bug found by the §11 harness
is fixed and deployed. Rollback: drop the two echo lines, merge, rerun
cd-apps — instant off, follow rows persist.

First boot with the flag enqueues backfill-user-keypairs (idempotent)
and registers the federation jobs. Applied via manual cd-apps dispatch
after merge since workflow-file changes don't trigger the path filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:46:32 +02:00
Ullrich Schäfer
b7ae8d2aa0
Merge pull request #492 from trails-cool/federation/rollout-notes
docs(openspec): social-federation §12 rollout status notes
2026-06-07 13:49:33 +02:00
Ullrich Schäfer
819a5d2976 docs(openspec): social-federation §12 rollout status notes
12.1/12.2/12.3/12.6 marked with provenance (additive schema behind the
off flag on prod; the 2026-06-06/07 staging soak against a real
Mastodon covered inbound + push delivery; rollback documented in the
runbook). 12.4 annotated: protocol verified by the two-instance
harness, live staging⇄preview soak queued now that previews federate.
12.5 (prod flag flip) explicitly left as an operator decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:46:14 +02:00
Ullrich Schäfer
a560bde9b7
Merge pull request #491 from trails-cool/federation/preview-flag
ci(staging): enable federation on PR previews (rollout 12.4 surface)
2026-06-07 13:45:49 +02:00
Ullrich Schäfer
1a8ec4e8b9 docs(staging): un-stale the federation comment in compose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:41:36 +02:00
Ullrich Schäfer
24357948c3 ci(staging): enable federation on PR previews (rollout 12.4 surface)
Every PR preview becomes a second live trails instance, so the
trails-to-trails soak (social-federation 12.4) can run against real
DNS + TLS + Caddy: follow from staging.trails.cool to
pr-<N>.staging.trails.cool and back. Same flag + key wiring as
persistent staging; preview teardown leaves the remote side with
ordinary dead-instance delivery failures, which fediverse software
already expires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:41:36 +02:00
Ullrich Schäfer
0acef03e47
Merge pull request #490 from trails-cool/federation/two-instance-test
feat(journal): two-instance federation harness + trails↔trails Accept fix (§11)
2026-06-07 13:40:43 +02:00
Ullrich Schäfer
1eceb6f1f7 feat(journal): two-instance federation harness + trails↔trails Accept fix (§11)
The 11.4 harness — e2e/federation/: postgres (two DBs) + a Caddy with
an internal CA terminating real HTTPS between two complete journal
containers (journal-a.test / journal-b.test), driven by an opt-in
integration test (FEDERATION_TWO_INSTANCE=1, via run.sh). The driver
seeds users straight into each DB, mints a signed session cookie
(known SESSION_SECRET), follows across instances through the real
/follows/outgoing route, and asserts the full pipeline: Follow →
auto-Accept → settle → first outbox poll → bob's public activity
rendered in alice's /feed with @bob@journal-b.test attribution. Plus a
wire-level 11.3 check: unsigned Create(Note) → 4xx, no DB writes.

And the harness immediately earned its keep — it caught a real
trails↔trails bug Mastodon interop never could: our Follow ids are
fragment URIs on OUR domain, so the Follow embedded in another trails
instance's Accept is cross-origin and Fedify rightly distrusts it;
re-fetching the id returns the actor document, getObject() yields a
Person, and the Accept/Reject/Undo listeners bailed silently — follows
stayed Pending forever. The listeners now fall back to the wire
objectId (captured BEFORE getObject(), which memoizes the fetched
document and changes what objectId reports) validated against our
Follow-id shape + the personal inbox's ctx.recipient. Forgery-safe:
settleOutgoingFollow only matches a Pending row toward the
HTTP-Signature-authenticated sender.

Supporting changes:
- federation.server.ts: env-gated allowPrivateAddress
  (FEDERATION_ALLOW_PRIVATE_ADDRESS=true) so the harness's RFC 1918
  Docker network is fetchable — testing only, loudly documented.
- Root .dockerignore: local builds shipped a 1GB+ context and
  overlaid host (darwin) node_modules over the image's own install
  via COPY . . — CI never noticed because it builds from clean
  checkouts. Context is now ~5MB.
- tasks.md: 11.1–11.7 marked with provenance notes; design.md gains
  the cross-origin embedded-object lesson.

Gate: typecheck ✓ lint ✓ unit+integration (FEDERATION_INTEGRATION=1) ✓
e2e 71/72 + known komoot flake green isolated ✓ harness run clean from
scratch (2/2, teardown included) ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:36:35 +02:00
Ullrich Schäfer
314196847c
Merge pull request #489 from trails-cool/federation/poll-backoff
feat(journal): honor 429/Retry-After in outbox polling (§7.4)
2026-06-07 12:39:27 +02:00
Ullrich Schäfer
3c2bdfd2bd feat(journal): honor 429/Retry-After in outbox polling (§7.4)
Completes the 7.4 remainder. Fedify's FetchError carries the failed
Response, so a 429 from a remote's outbox now reads Retry-After
(delta-seconds or HTTP-date per RFC 9110), arms a per-host backoff
(15 min default, capped at the 1 h poll interval), and pollRemoteActor
skips backing-off hosts before doing any DB or network work. State is
in-process like the pacing map — a restart costs at most one extra
request that re-arms the backoff. last_polled_at is deliberately not
stamped on a rate-limited poll so the hourly sweep retries.

Unit tests cover the header parser (incl. the V8 footgun where
Date.parse reads bare negative integers as years), default/cap
behavior, and window expiry; an integration test drives the full
FetchError path and asserts the second poll never touches the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:35:59 +02:00
Ullrich Schäfer
60afacc69e
Merge pull request #487 from trails-cool/federation/feed-remote
feat(journal): audience-aware social feed with remote activities (§8+§9)
2026-06-07 12:35:27 +02:00
Ullrich Schäfer
bf2d8bfbd4 feat(journal): audience-aware social feed with remote activities (§8+§9)
8.1/8.2: listSocialFeed is now a UNION ALL of local and remote
branches, sorted on COALESCE(remote_published_at, created_at):
- local rows: visibility='public' from accepted local follows — and
  the previously missing accepted_at filter is added (the spec's
  'Pending follows contribute nothing' scenario)
- remote rows: gated structurally by joining the viewer's OWN accepted
  follow against the originating actor — which is exactly the
  followers-only audience rule (a row reaches only viewers whose
  follow brought it in); attribution from the remote_actors cache;
  cards link outward to the canonical origin page (no local detail
  page for remote rows)

9.1: annotated — local Pending button shipped with locked accounts;
remote Pending lives on /follows/outgoing.
9.2: already enforced + tested since §3 (actor/webfinger 404).
9.3 hardening: deliver-activity now re-checks the OWNER's profile
visibility at send time, closing the enqueue→delivery flip window
(enqueue-side and inbound-side gates already existed).

Integration tests: the §8 audience-leak guard (A sees followers-only,
B does not), public remote attribution + outward link, pending
contributes nothing, mixed local/remote COALESCE ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:32:11 +02:00
Ullrich Schäfer
f8b24f58ef
Merge pull request #486 from trails-cool/federation/outbox-polling
feat(journal): outbox-poll ingestion of remote trails activities (§7)
2026-06-07 12:30:32 +02:00
Ullrich Schäfer
b96bef91a9 feat(journal): outbox-poll ingestion of remote trails activities (§7)
Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as
remaining). Resolves the activities.owner_id open question.

Schema (design decision from the open question):
- activities.owner_id nullable + check constraint enforcing exactly
  one of (owner_id, remote_actor_iri) — the follows pattern again.
- remote_published_at carries the origin's publish time for the §8
  feed sort; index on remote_actor_iri for the feed join.
- Compiler-audited fallout: notification fan-out, the Note object
  dispatcher, and the activity detail loader explicitly skip/404
  remote rows (their canonical page is the origin; feed links there).
  Every other surface joins users on owner_id and excludes remote rows
  structurally.

Ingestion:
- federation-ingest.server.ts: parseOutboxItem targets exactly our own
  outgoing Create(Note) shape (PropertyValue stats, first-paragraph
  name, audience from to/cc); unknown items skipped, never fatal.
- ingestRemoteActivities: replay-safe via unique remote_origin_iri,
  conflict-streak early exit (outboxes are newest-first).
- pollRemoteActor: signed fetch (Authorized Fetch via a local
  follower's key), outbox resolution via remote_actors cache with
  actor-doc fallback (which refreshes the cache), 1 req/5 s per-host
  pacing, last_polled_at stamping. Network + pacing injectable.

Jobs: poll-remote-actor (per-actor; the §4 Accept listener already
enqueues it as the first poll) + poll-remote-outboxes (5-min cron
sweep over accepted remote follows not polled within the hour).

Tests: parse-shape units; integration suite for ingestion provenance,
audience→visibility mirroring, replay safety, the DB author invariant,
poll flow (actor-doc → collection → page), signer requirement, and
due-polling selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:27:10 +02:00
Ullrich Schäfer
9ada6ab7f3
Merge pull request #488 from trails-cool/federation/privacy-docs
docs(journal): federation privacy manifest + runbook + honest home blurb (§10)
2026-06-07 12:25:49 +02:00
Ullrich Schäfer
f4d2cf027c docs(journal): federation privacy manifest + runbook + honest home blurb (§10)
10.1 Privacy manifest (legal/privacy):
- German legal half: Föderation entry under Empfänger/Drittanbieter —
  what a public profile exposes, what accepted followers' servers
  receive, the loss-of-control over delivered copies, Art. 6(1)(a)
  basis, private profiles don't federate.
- English manifest half: dedicated Federation (ActivityPub) section —
  actor object contents, push delivery + retraction semantics (incl.
  the tombstone caveat), encrypted-at-rest signing keys, remote actor
  cache, ingested remote content with the followers-only viewer gate,
  inbox logging/rate limits. PRIVACY_LAST_UPDATED bumped.

10.2 docs/deployment.md federation runbook: enabling per environment,
key rotation posture, abuse monitoring, and the troubleshooting
checklist distilled from the 2026-06-06/07 soak (IP families first,
no outbox backfill, tombstones, the 10s delivery timeout, actor
re-fetch).

10.3 Home marketing blurb (en+de) aligned to what actually ships:
Mastodon can follow you + trails-to-trails outbound — no more 'follow
friends anywhere' overstatement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:21:51 +02:00
Ullrich Schäfer
965df640ad
Merge pull request #485 from trails-cool/federation/outbound-follows
feat(journal): outbound trails-to-trails follows (social-federation §6)
2026-06-07 11:54:14 +02:00
Ullrich Schäfer
62b40a25f3 feat(journal): outbound trails-to-trails follows (social-federation §6)
Tasks 6.1–6.6. A local user can follow a user on another trails
instance: WebFinger-resolve the handle, verify the target runs
trails.cool via NodeInfo — checked against the ACTOR IRI's host, never
the handle's domain (split-domain constraint) — record a Pending
follow row, deliver a signed Follow. The §4 inbox listeners already
settle (Accept) or drop (Reject) the row.

- federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow
  (Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup,
  NodeInfo, delivery) injectable for offline integration tests.
  Software allowlist: trails-cool.
- /follows/outgoing: follow-by-handle form + pending/accepted list +
  cancel; linked from the feed empty state; i18n en+de. Remote actors
  have no local profile page, so the remote Pending state lives here
  (the profile Pending button from locked accounts covers local).
- /.well-known/trails-cool now publishes software: trails-cool (6.2).
- Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed,
  not_trails (6.3).
- Tests: handle-parser + allowlist units; integration suite for the
  Pending lifecycle (create/idempotent/refuse-non-trails/refuse-
  unresolvable/own-host/cancel+Undo) with injected network deps; e2e
  anonymous-redirect guard for the new page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:50:35 +02:00
dependabot[bot]
8afa41690d [github-actions] pnpm dedupe 2026-06-07 09:49:00 +00:00
dependabot[bot]
a4582654e1
chore(deps): bump the production group across 1 directory with 30 updates
Bumps the production group with 29 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.19.3` | `0.19.4` |
| [@fission-ai/openspec](https://github.com/Fission-AI/OpenSpec) | `1.3.1` | `1.4.1` |
| [fit-file-parser](https://github.com/jimmykane/fit-parser) | `3.0.1` | `3.0.2` |
| [i18next](https://github.com/i18next/i18next) | `26.3.0` | `26.3.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.60.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.7` | `4.1.8` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.40` | `5.1.41` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.13` | `56.0.14` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.3.0` | `11.3.3` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.3` | `3.5.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.16` | `56.0.17` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.18` | `56.0.19` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.15` | `56.0.16` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.15` | `56.0.16` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.8` | `56.2.9` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.4.0` | `4.4.1` |
| [react-test-renderer](https://github.com/facebook/react/tree/HEAD/packages/react-test-renderer) | `19.2.6` | `19.2.7` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.7` | `4.1.8` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.7` | `4.1.8` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.16.0` | `7.17.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.16.0` | `7.17.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.16.0` | `7.17.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.55.0` | `10.56.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.56.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.19` | `22.19.20` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.16.0` | `7.17.0` |



Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@expo/fingerprint` from 0.19.3 to 0.19.4
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `@fission-ai/openspec` from 1.3.1 to 1.4.1
- [Release notes](https://github.com/Fission-AI/OpenSpec/releases)
- [Changelog](https://github.com/Fission-AI/OpenSpec/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Fission-AI/OpenSpec/compare/v1.3.1...v1.4.1)

Updates `fit-file-parser` from 3.0.1 to 3.0.2
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

Updates `i18next` from 26.3.0 to 26.3.1
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1)

Updates `typescript-eslint` from 8.60.0 to 8.60.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/typescript-eslint)

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

Updates `isbot` from 5.1.40 to 5.1.41
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.40...v5.1.41)

Updates `@expo/metro-runtime` from 56.0.13 to 56.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.3.0 to 11.3.3
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.3.0...v11.3.3)

Updates `@sentry/cli` from 3.4.3 to 3.5.0
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.3...3.5.0)

Updates `expo-constants` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 56.0.18 to 56.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-location` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.8 to 56.2.9
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `react-native-reanimated` from 4.4.0 to 4.4.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.4.1/packages/react-native-reanimated)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-test-renderer` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-test-renderer)

Updates `@vitest/browser` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/browser-playwright)

Updates `@react-router/dev` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.17.0/packages/react-router-dev)

Updates `@react-router/node` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.17.0/packages/react-router-node)

Updates `@react-router/serve` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.17.0/packages/react-router-serve)

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

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

Updates `@types/node` from 22.19.19 to 22.19.20
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `react-router` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.17.0/packages/react-router)

Updates `vite` from 7.3.3 to 7.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@fission-ai/openspec"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: fit-file-parser
  dependency-version: 3.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.60.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-test-renderer
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vite
  dependency-version: 7.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 09:48:14 +00:00
Ullrich Schäfer
be53f6670a
Merge pull request #483 from trails-cool/chore/fedify-2.2.5
chore(journal): upgrade @fedify/fedify 2.1.16 → 2.2.5
2026-06-07 11:29:55 +02:00
Ullrich Schäfer
6fd7329f15 chore(journal): upgrade @fedify/fedify 2.1.16 → 2.2.5
The 2.2.x line was uninstallable when federation landed
(@fedify/webfinger@2.2.x wasn't published); fixed upstream, so move to
latest. Exact pin stays deliberate — a federation library defines the
wire shapes remote instances see.

Verified: typecheck, lint, full unit + integration suites (245 tests,
including the wire-shape assertions: JRD, actor attachment arrays,
PropertyValue fields, dereferenceable Note ids) all green on 2.2.5.
Lockfile diff is a clean 24-line swap of the fedify package family.

Note: a transient cross-suite flake surfaced when running both
integration suites concurrently (shared journal.follows state) —
pre-existing, unrelated to the upgrade; clean re-run green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:26:01 +02:00
Ullrich Schäfer
7bac3353e0
Merge pull request #482 from trails-cool/docs/split-domain-notes
docs: split-domain handles idea + interop constraint for the trails check
2026-06-07 11:18:49 +02:00
Ullrich Schäfer
bf907976c4 docs: split-domain handles idea + interop constraint for the trails check
Prompted by Hollo's split-domain setup. Two parts:

- docs/ideas/split-domain-handles.md: offering split handle/server
  domains to self-hosters is config-level work — Fedify's origin
  option natively accepts { handleHost, webOrigin } — and handles are
  permanent identity, so apex handles matter. Single-domain stays the
  default (Decision #18); post-launch polish.
- Interop constraint pinned in social-federation task 6.1 and
  route-federation's gating decision: the trails-to-trails NodeInfo
  check must run against the actor IRI's host after WebFinger
  resolution, never the handle's domain — split-domain remote
  instances would otherwise be wrongly refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:18:13 +02:00
Ullrich Schäfer
76b468d422
Merge pull request #474 from trails-cool/fix/tombstone-aware-retraction
fix(journal): retract federated activities only on public→non-public
2026-06-07 11:12:20 +02:00
Ullrich Schäfer
e3b960e0ef
Merge pull request #476 from trails-cool/docs/fediverse-ideas
docs: capture post-v1 fediverse enhancement ideas from the soak
2026-06-07 11:00:19 +02:00
Ullrich Schäfer
5d31563008
Merge branch 'main' into fix/tombstone-aware-retraction 2026-06-07 10:57:26 +02:00
Ullrich Schäfer
09abd5708e docs: capture post-v1 fediverse enhancement ideas from the soak
Ullrich's notes from the first real federation exchange (2026-06-06/07
staging ↔ Mastodon soak), expanded with the constraints the soak
surfaced: route map images in Notes, fediverse kudos (inbound
Like/Announce), comments from the fediverse, planned routes as
Events (Mobilizon), Wanderer interop, federated explore.

The fourth note item — remote follower counted but not listed — was a
bug, fixed separately in #475.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:57:02 +02:00
Ullrich Schäfer
10a9ea523a
Merge pull request #478 from trails-cool/docs/architecture-vision-capture
docs: capture remaining architecture vision; draft route-federation change
2026-06-07 10:35:36 +02:00
Ullrich Schäfer
bf9787e56a docs: capture remaining architecture vision; draft route-federation change
Gap analysis of docs/architecture.md against openspec/ and docs/ideas/
found the envisioned-but-uncaptured remainder. This commit captures it:

New OpenSpec change (validated):
- route-federation — the collaboration half of the federation vision:
  routes as dereferenceable trails:Route objects, Create/Update
  fan-out, Invite/Accept collaboration mirroring (arch decision #2),
  cross-instance Planner edits via HTTP-Signature-requested scoped
  tokens (decisions #3/#12), mirror sync healing (#16). Depends on
  social-federation §6 + route-sharing; carries the 2026-06-07 soak
  lessons as design constraints.

New docs/ideas/ explorations:
- instance-administration — registration toggle, suspend/ban,
  federation blocklists, reports (moderation now gates the federated
  comments idea)
- social-interactions — local likes + comments (don't exist even
  locally; the foundation federated kudos/comments attach to)
- activity-participants — group tagging with confirm/decline +
  federated mentions (the participants jsonb column is an untyped stub)
- multi-day-collections — architecture open question #1, directions
  evaluated

architecture.md cleanup:
- Mastodon-compat section annotated with shipped/captured state + the
  live-soak interop lessons (attachment arrays, tombstones, 10s
  timeout, no backfill)
- api.trails.cool removed (contradicted resolved decision #18)
- Phase 1 ticked (shipped); Phase 2/3 items annotated with where each
  is tracked; specs/ → openspec/, activity.* → journal.*, cx21 → cx23
- brouter-web open question marked resolved-in-practice; new section
  pointing to where the unshipped vision is tracked

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:31:50 +02:00
Ullrich Schäfer
a26d59c804
Merge pull request #473 from trails-cool/fix/credential-kind-public
fix(db): allow 'public' credential_kind in connected_services check
2026-06-07 10:18:10 +02:00
Ullrich Schäfer
1a96a07a8a
Merge branch 'main' into fix/credential-kind-public 2026-06-07 10:14:10 +02:00
Ullrich Schäfer
c6167c517f
Merge pull request #477 from trails-cool/infra/scheduled-image-prune
ci: scheduled image prune + staging-deploy disk hygiene
2026-06-07 09:56:37 +02:00
Ullrich Schäfer
89caedca05 ci: scheduled image prune + staging-deploy disk hygiene
The 2026-06-07 disk-full outage (flagship 100%, postgres crash-looping
on its pidfile, prod + staging down): cd-apps DOES prune images after
deploys, but it had been failing early all day (migration bug), so its
prune never ran — while ~10 staging/preview deploys kept pulling fresh
images with no prune of their own.

- cd-staging: prune superseded layers (until=1h guard against racing
  in-flight pulls) after persistent staging and preview deploys.
- disk-maintenance.yml: NEW daily scheduled prune (04:30 UTC) that
  also FAILS when the disk is still ≥85% after pruning — a redundant
  alert channel for exactly the case where the Grafana disk alert
  drowns in other noise, as it did during the incident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:52:49 +02:00
Ullrich Schäfer
d5a8709688
Merge pull request #475 from trails-cool/fix/remote-followers-listing
fix(journal): show remote followers in follower/following lists
2026-06-07 09:17:23 +02:00
Ullrich Schäfer
4e63f7631a fix(journal): show remote followers in follower/following lists
countFollowers counts every accepted follow row, but listFollowers
inner-joined users on follower_id — so a federated follower (NULL
follower_id, follower_actor_iri set) bumped the count while never
appearing in the list. Observed live: profile said '1 follower', list
below was empty. listFollowing had the same latent bug for outbound
trails-to-trails follows.

Both lists now left-join users + remote_actors: local entries link to
the local profile as before; remote entries display cached actor data
(IRI parsing as fallback) and link out to the remote profile.

Integration test pins count/list consistency for a remote follower.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:13:44 +02:00
Ullrich Schäfer
20be961177 fix(journal): retract federated activities only on public→non-public
A received Delete is recorded by Mastodon as a permanent tombstone —
later Creates for the same URI are silently refused forever. The old
code sent Delete on every non-public save 'just in case' (the comment
even called over-sending harmless); on the 2026-06-07 soak this
tombstoned an unlisted activity's URI before its first real publish,
making its later flip to public invisible on the remote with no error
anywhere.

- visibilityTransitionAction(previous, next): Create on any transition
  to public (re-publish doubles as back-delivery; remotes dedupe by
  id), Delete only when leaving public, nothing for
  non-public→non-public.
- updateActivityVisibility reads the previous visibility and acts on
  the transition.
- design.md documents the tombstone permanence + the user-facing
  consequence (un-publish then re-publish won't resurrect the post on
  remotes that processed the retraction — same as Mastodon's own
  delete-and-redraft).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:52:23 +02:00
Ullrich Schäfer
5bf9358dc6 fix(db): allow 'public' credential_kind in connected_services check
The 0002 migration's allowed list predates the Komoot public-profile
connection mode (api.sync.komoot.verify.ts writes credentialKind
'public'). Against production data — which has such a connection —
the drop-then-add CHECK failed at ATRewriteTable and blocked every
cd-apps deploy since 2026-06-07 06:25 UTC.

Also documents 'public' in the schema comment with a pointer to keep
the two lists in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:48:27 +02:00
Ullrich Schäfer
95b21e2c44
Merge pull request #472 from trails-cool/federation/profile-fields-array
fix(journal): actor profile fields must serialize as a JSON array
2026-06-07 08:46:48 +02:00
Ullrich Schäfer
d74ce32cd0 fix(journal): actor profile fields must serialize as a JSON array
Mastodon's PropertyValue parser requires the actor's attachment to be
a JSON *array* and silently ignores a bare object — and Fedify
compacts single-element arrays to bare objects, so the 🥾 trails.cool
field shipped in #464 never rendered (verified: Mastodon stored
fields = {} after a forced actor refresh on the soak instance).

Ship two fields so the array survives serialization: the 🥾 profile
link (rel=me) plus an Instance link — the latter is genuinely useful
for self-hosted instances anyway. Test now asserts the array shape
explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:03 +02:00
Ullrich Schäfer
b0bfed4d4a
Merge pull request #471 from trails-cool/infra/caddy-global-metrics
fix(infra): move Caddy metrics to global option
2026-06-07 08:34:13 +02:00
Ullrich Schäfer
9ed6b7dd1d fix(infra): move Caddy metrics to global option
Caddy 2.9 deprecated the nested `servers { metrics }` form; every
deploy logs a deprecation warning. The global `metrics` option is the
replacement and emits the same Prometheus metrics on the admin
endpoint, so the existing scrape config is unaffected.

Validated with `caddy validate` against the caddy:2 image — config
valid, no deprecation warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:30:17 +02:00
Ullrich Schäfer
a4807aab1a
Merge pull request #469 from trails-cool/fix/boss-singleton-globalthis
fix(journal): pg-boss singleton must live on globalThis, not module scope
2026-06-07 08:18:40 +02:00
Ullrich Schäfer
433365d78d
Merge pull request #470 from trails-cool/infra/deploy-hardening
ci(deploy): fail loudly — set -e, drizzle output guards, health gates
2026-06-07 08:17:55 +02:00
Ullrich Schäfer
f790da2ed3 ci(deploy): fail loudly — set -e, drizzle output guards, health gates
Hardening from two incidents on 2026-06-06/07:

Schema drift (morning): drizzle-kit push exits 0 even when it aborts
on an interactive prompt it can't render in CI, so cd-staging's
set -euo pipefail never fired and a month of staging schema drift
accumulated silently until new code hit missing columns. All three
drizzle push call sites now tee output and fail the deploy on any
'Error:' line.

Production outage (overnight, ~9h): cd-infra and cd-apps had no
failure handling at all. A network-option change stopped postgres for
a network recreation that then deadlocked on containers from other
compose projects holding trails-shared; the script carried on, the
stack stayed down. Both scripts now run set -euo pipefail and gate on
container health at the end (postgres+journal for cd-infra,
journal+planner for cd-apps) so a deploy that leaves the stack down
is a red X, not a shrug.

docs/deployment.md gains the cross-project manual procedure for
network-changing deploys — the CD workflows only manage their own
compose project and cannot apply those safely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:15:17 +02:00
Ullrich Schäfer
c46dc0cbd6
Merge pull request #467 from trails-cool/federation/dereferenceable-notes
feat(journal): dereferenceable Note objects at /activities/:id
2026-06-07 08:12:54 +02:00
Ullrich Schäfer
a34bb0064e
Merge branch 'main' into fix/boss-singleton-globalthis 2026-06-07 08:09:18 +02:00
Ullrich Schäfer
d2be01d6ea
Merge branch 'main' into federation/dereferenceable-notes 2026-06-07 08:09:16 +02:00
Ullrich Schäfer
007845cb59
Merge pull request #468 from trails-cool/infra/trails-shared-ipv6
fix(infra): IPv6 belongs on trails-shared, not a staging default network
2026-06-06 23:25:13 +02:00
Ullrich Schäfer
e4d01f51ca fix(journal): pg-boss singleton must live on globalThis, not module scope
In production there are two copies of boss.server.ts in the process:
server.ts imports the TypeScript source directly (node
--experimental-strip-types) while route handlers live in the bundled
build/server/index.js with its own module instance. setBoss() from
server.ts wrote a module-local variable the bundle never saw, so EVERY
request-path enqueue failed with 'pg-boss not initialized' and was
swallowed by enqueueOptional's best-effort catch:

- notifications fan-out on activity publish: silently dropped
- komoot bulk-import kick-off: silently dropped
- federation activity push delivery: silently dropped (how we found
  it — flipping an activity public on staging delivered nothing)

Dev never reproduces this: vite serves one module graph. Caught live
during the social-federation staging soak.

Fix: store the instance on globalThis under a Symbol.for() key so both
module copies resolve the same boss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:23:59 +02:00
Ullrich Schäfer
2d8046b9a6 fix(infra): IPv6 belongs on trails-shared, not a staging default network
Follow-up to #466: staging and preview services attach only to the
externally-named trails-shared network (created by the production
compose project) — they have no per-project default network, so the
enable_ipv6 added to the staging file was dead config. Move the flag
to trails-shared where it takes effect.

Rollout requires recreating trails-shared on the flagship: detach
staging, previews, and production postgres, remove the network, re-up.
Production journal/planner are unaffected (they use the default
network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:21:25 +02:00
Ullrich Schäfer
5db983386d feat(journal): dereferenceable Note objects at /activities/:id
The Notes we emit (outbox + push delivery) use /activities/{id} as
their id, but that URL only served HTML — so Mastodon's search-fetch
of an activity URL failed, and strict instances that re-fetch pushed
objects to verify them would drop our deliveries.

- Fedify object dispatcher for Note at /activities/{id}: serves the
  same activityToNote mapping used everywhere else; 404 unless the
  activity is public AND the owner's profile is public (private owners
  don't federate, mirroring the actor).
- Content-negotiation middleware on the activity detail route, same
  pattern as the actor route: AP Accept headers short-circuit to
  Fedify, browsers get HTML.

Found during the staging soak (Mastodon showed the outbox post count
but couldn't fetch the posts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:18:30 +02:00
Ullrich Schäfer
fc8d35d7d4
Merge pull request #466 from trails-cool/infra/docker-ipv6-egress
feat(infra): enable IPv6 egress on Docker compose networks
2026-06-06 23:16:01 +02:00
Ullrich Schäfer
915b8121d0 feat(infra): enable IPv6 egress on Docker compose networks
The journal container could not reach v6-only fediverse instances
(e.g. social.ullrich.is) because the compose project network was
IPv4-only. The flagship host has working IPv6, and Docker >= 27
auto-allocates a ULA subnet with NAT66 masquerading when a network
sets enable_ipv6, so the fix is a one-line opt-in per network.

Applying it to an existing deployment requires recreating the
network (docker compose down && up -d) — a brief full-stack
restart on the flagship and the persistent staging project.
PR-preview networks are created fresh per PR and need nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:12:03 +02:00
Ullrich Schäfer
0cb2a901fe
Merge pull request #465 from trails-cool/federation/inbox-queue
feat(journal): async federation inbox via Fedify message queue
2026-06-06 22:05:32 +02:00
Ullrich Schäfer
7bcfa4cc76 feat(journal): async federation inbox via Fedify message queue
Mastodon gives inbox deliveries a 10s read timeout; without a queue,
Fedify runs inbox listeners AND outbound deliveries (the Accept we
push back after a Follow) inside the inbound request. A slow or
unreachable remote then blows the budget, the sender times out and
its circuit breaker (Stoplight on Mastodon) cuts us off — observed
live during the staging soak.

InProcessMessageQueue + manuallyStartQueue: the queue consumer starts
explicitly on first federation use (skipped under vitest so tests
don't leak timers). In-process queueing is acceptable for the
single-process journal: queued work lost on restart is recoverable
(remotes retry Follows; activity push delivery is owned by our own
pg-boss jobs). Revisit with a Postgres-backed MessageQueue if that
changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:02:04 +02:00
Ullrich Schäfer
74e50b2d6a
Merge pull request #464 from trails-cool/federation/actor-profile-fields
feat(journal): trails profile-metadata field on federated actors
2026-06-06 21:51:42 +02:00
Ullrich Schäfer
08d0a78c57 feat(journal): trails profile-metadata field on federated actors
Mastodon renders PropertyValue attachments as the profile metadata
table — adds a '🥾 trails.cool' field linking to the user's canonical
profile, so it's human-visible that an account is a trails profile.
(The machine-readable signal stays NodeInfo; this is flair.) The link
carries rel=me so Mastodon can verify it once our HTML profile links
back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:48:07 +02:00
Ullrich Schäfer
d13a0fcd18
Merge pull request #463 from trails-cool/federation/logtape
feat(journal): surface Fedify logs via LogTape console sink
2026-06-06 21:45:40 +02:00
Ullrich Schäfer
9a1ce61604 feat(journal): surface Fedify logs via LogTape console sink
Inbound Mastodon Follows are being rejected 401 (signature
verification) on staging and Fedify's diagnostics were invisible:
it logs through LogTape, which is silent until configured.

- Configure LogTape with a console sink for the 'fedify' category when
  the federation instance is built; level via FEDERATION_LOG_LEVEL
  (default info).
- Staging deploy sets FEDERATION_LOG_LEVEL=debug for the soak —
  signature-verification detail per request; dial down once proven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:41:28 +02:00
Ullrich Schäfer
b9b1541dc6
Merge pull request #462 from trails-cool/staging/enable-federation
feat(staging): enable federation on persistent staging
2026-06-06 15:51:08 +02:00
Ullrich Schäfer
69dbeb7841
Merge branch 'main' into staging/enable-federation 2026-06-06 15:47:44 +02:00
Ullrich Schäfer
57f9440cf5 feat(staging): enable federation on persistent staging
social-federation rollout step 12.2 — inbound soak. Persistent staging
gets FEDERATION_ENABLED=true; PR previews keep the flag empty so
preview journals never emit federation traffic.

- docker-compose.staging.yml: journal accepts FEDERATION_ENABLED +
  FEDERATION_KEY_ENCRYPTION_KEY (both default empty).
- cd-staging.yml: persistent staging env sets FEDERATION_ENABLED=true;
  the encryption key arrives via the existing SOPS decryption.
- secrets.app.env: new FEDERATION_KEY_ENCRYPTION_KEY (SOPS-encrypted,
  generated with openssl rand -hex 32).

Production is unaffected: the flagship compose doesn't pass these vars
and the flag defaults off in code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:45:44 +02:00
Ullrich Schäfer
1d4f364870
Merge pull request #461 from trails-cool/federation/outbox
feat(journal): federation outbox + push delivery to remote followers
2026-06-06 15:36:47 +02:00
Ullrich Schäfer
bc233e03e5 feat(journal): federation outbox + push delivery to remote followers
social-federation tasks 5.1–5.6. Completes the inbound-federation
story: a Mastodon follower now receives a trails user's new public
activities in their home timeline.

Outbox (5.1/5.2):
- /users/:username/outbox — paginated OrderedCollection of public
  activities as Create(Note), newest first; unlisted/private never
  federate. Private-user 404 enforced at the route layer because
  Fedify builds collection-level responses from counter/cursors
  without consulting the page dispatcher.
- Note shape: HTML content (escaped name/description/stats + link to
  the activity page) with structured PropertyValue attachments
  (distance-m, elevation-gain-m, duration-s) — Mastodon renders the
  text, trails consumers read the structured fields. Resolves the
  design open question toward Create(Note).
- Authorized Fetch: signed and unsigned outbox fetches deliberately
  see the same (public-only) content until locked accounts exist.

Push delivery (5.3–5.6):
- createActivity / updateActivityVisibility(→public) enqueue one
  deliver-activity job per accepted remote follower; flips away from
  public and hard deletes enqueue Delete(Tombstone) retractions
  (enqueued before the row disappears).
- deliver-activity job: re-reads the row at delivery time (skips if
  gone or no longer public), resolves the recipient inbox via the
  remote_actors cache with actor-document fetch fallback (priming the
  cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 +
  exponential backoff at enqueue time; outbound paced at 1 req/s per
  remote host.
- Actor objects now advertise the outbox IRI.
- @js-temporal/polyfill added (same range Fedify uses) for published
  timestamps; Fedify's types want the global esnext.temporal namespace,
  bridged with a documented cast.

Tests: 9 unit tests for the AS mapping (escaping, stats, attachments,
published fallback, stable ids, tombstones), 4 outbox integration
tests (collection count, page shape/visibility filtering, private-404,
delivery audience query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:32:52 +02:00
Ullrich Schäfer
bec249f93f
Merge pull request #460 from trails-cool/federation/inbox
feat(journal): federation inbox — Mastodon follows land here
2026-06-06 15:27:56 +02:00
Ullrich Schäfer
ae6c338cdc
Merge branch 'main' into federation/inbox 2026-06-06 15:23:32 +02:00
Ullrich Schäfer
5248256574
Merge pull request #459 from trails-cool/federation/schema
feat(journal): federation schema + per-user signing keypairs
2026-06-06 15:19:18 +02:00
Ullrich Schäfer
87b61ce53b
Merge branch 'main' into federation/schema 2026-06-06 15:15:39 +02:00
Ullrich Schäfer
b17685d58c feat(journal): federation inbox — Mastodon follows land here
social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:33:43 +02:00
Ullrich Schäfer
ae0e95fcd5
Merge pull request #458 from trails-cool/federation/fedify-spike
feat(journal): Fedify foundation — WebFinger + actor objects behind FEDERATION_ENABLED
2026-06-06 14:21:00 +02:00
Ullrich Schäfer
9a90b6db55 feat(journal): federation schema + per-user signing keypairs
social-federation tasks 2.1–2.5:

- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
  keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
  FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
  inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
  provenance + audience tagging for rows ingested from remote outboxes.
  Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
  factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
  RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
  when FEDERATION_ENABLED=true; only touches users with NULL keys, so
  re-runs are no-ops. New users get keys at registration (both passkey
  and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
  remote-ingested rows have no local owner; decide in task 7.2.

Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:15:51 +02:00
Ullrich Schäfer
4ef86e4dc2 feat(journal): Fedify spike — WebFinger + actor objects behind FEDERATION_ENABLED
social-federation tasks 1.1–1.3:

- Add @fedify/fedify pinned to exactly 2.1.16: every 2.2.x release
  depends on @fedify/webfinger@2.2.x which was never published to npm,
  so 2.1.16 is the newest installable version.
- app/lib/federation.server.ts: Federation instance with an actor
  dispatcher serving Person objects for public users; private and
  unknown users 404 (no existence leak). MemoryKvStore for now.
- /.well-known/webfinger resource route delegating to federation.fetch.
- ActivityPub content negotiation on /users/:username via route
  middleware (future.v8_middleware — no loader uses the context arg,
  so the flag is a no-op for existing code).
- FEDERATION_ENABLED env flag (default off) gating every federation
  surface.
- Unit tests exercise the Fedify dispatcher as a remote AP client:
  WebFinger resolution, actor fetch with Mastodon's Accept header,
  private-user 404s, flag-off 404s.

Spike verdict: Fedify fits — URL dispatch, JRD/AP serialization, and
visibility gating all work through framework routes without a custom
server layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:15:27 +02:00
Ullrich Schäfer
8638c2fdfa
Merge pull request #451 from trails-cool/dependabot/npm_and_yarn/production-3c42ababf0
chore(deps): bump the production group with 28 updates
2026-06-06 13:23:10 +02:00
Ullrich Schäfer
9297d1e511
Merge branch 'main' into dependabot/npm_and_yarn/production-3c42ababf0 2026-06-06 13:19:49 +02:00
Ullrich Schäfer
52dc571374
Merge pull request #457 from trails-cool/docs/federation-unblocked
docs: fix stale route-sharing → social-federation dependency
2026-06-06 13:18:41 +02:00
Ullrich Schäfer
81ba2d5de3 docs: fix stale route-sharing → social-federation dependency
The roadmap claimed social-federation depends on route-sharing for
public routes, but that prerequisite shipped independently via
public-content-visibility (2026-04-24) and social-feed (2026-04-25):
visibility columns, public profiles, and IRI-keyed follows are all in
place. Reorder Phase 1 to reflect that federation can start now.

Also flag route-sharing's proposal as needing re-scoping: tasks
1.1-1.3 and 3.1 duplicate already-shipped visibility work, and the
proposed enum contradicts the shipped text-column design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:16:06 +02:00
dependabot[bot]
4a21f4e115 [github-actions] pnpm dedupe 2026-05-31 08:37:48 +00:00
dependabot[bot]
2e6a83b063
chore(deps): bump the production group with 28 updates
Bumps the production group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.19.2` | `0.19.3` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.4.1` |
| [fit-file-parser](https://github.com/jimmykane/fit-parser) | `3.0.0` | `3.0.1` |
| [i18next](https://github.com/i18next/i18next) | `26.2.0` | `26.3.0` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.14` | `2.9.16` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.4` | `8.60.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.0` | `13.3.1` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.8` | `8.0.10` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.12` | `56.0.13` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.2.1` | `11.3.0` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.12.0` | `8.13.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.15` | `56.0.16` |
| [expo-crypto](https://github.com/expo/expo/tree/HEAD/packages/expo-crypto) | `56.0.3` | `56.0.4` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.15` | `56.0.18` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `56.0.11` | `56.0.13` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.13` | `56.0.15` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.13` | `56.0.15` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.6` | `56.2.8` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.3.1` | `4.4.0` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.7.0` | `5.8.0` |
| [react-native-worklets](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-worklets) | `0.8.3` | `0.9.1` |
| [yjs](https://github.com/yjs/yjs) | `13.6.30` | `13.6.31` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.15.1` | `7.16.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.15.1` | `7.16.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.15.1` | `7.16.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.55.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.55.0` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.15.1` | `7.16.0` |


Updates `@expo/fingerprint` from 0.19.2 to 0.19.3
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `eslint` from 10.4.0 to 10.4.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.4.1)

Updates `fit-file-parser` from 3.0.0 to 3.0.1
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

Updates `i18next` from 26.2.0 to 26.3.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.2.0...v26.3.0)

Updates `turbo` from 2.9.14 to 2.9.16
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.14...v2.9.16)

Updates `typescript-eslint` from 8.59.4 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/typescript-eslint)

Updates `@simplewebauthn/server` from 13.3.0 to 13.3.1
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

Updates `nodemailer` from 8.0.8 to 8.0.10
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.8...v8.0.10)

Updates `@expo/metro-runtime` from 56.0.12 to 56.0.13
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.2.1 to 11.3.0
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.2.1...v11.3.0)

Updates `@sentry/react-native` from 8.12.0 to 8.13.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.12.0...8.13.0)

Updates `expo-constants` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-crypto` from 56.0.3 to 56.0.4
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-crypto/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-crypto)

Updates `expo-dev-client` from 56.0.15 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-linking` from 56.0.11 to 56.0.13
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-linking/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 56.0.13 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.13 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-notifications/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.6 to 56.2.8
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-router/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `react-native-reanimated` from 4.3.1 to 4.4.0
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.4.0/packages/react-native-reanimated)

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

Updates `react-native-worklets` from 0.8.3 to 0.9.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/worklets-0.9.1/packages/react-native-worklets)

Updates `yjs` from 13.6.30 to 13.6.31
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.30...v13.6.31)

Updates `@react-router/dev` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.16.0/packages/react-router-dev)

Updates `@react-router/node` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.16.0/packages/react-router-node)

Updates `@react-router/serve` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.16.0/packages/react-router-serve)

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

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

Updates `react-router` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.16.0/packages/react-router)

---
updated-dependencies:
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: fit-file-parser
  dependency-version: 3.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-crypto
  dependency-version: 56.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 56.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-worklets
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: yjs
  dependency-version: 13.6.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-31 08:37:04 +00:00
Ullrich Schäfer
f7002be0bd
Merge pull request #450 from trails-cool/fix/extensionless-server-imports
fix: extensionless server-side imports (Grafana app-crash-log smoking gun)
2026-05-26 08:12:41 +02:00
Ullrich Schäfer
10deae88c9
fix: extensionless server-side imports + lint rule to catch them
## Symptom

Grafana's \`app-crash-log\` alert fires on every deploy. The Loki query
that backs it (\`ERR_|FATAL|uncaughtException|…\`) matches:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/app/apps/journal/app/lib/logger.server' imported from
  '/app/apps/journal/app/lib/email.server.ts'

A real bug, not a false positive: \`email.server.ts\` line 2 was
\`import { logger } from \"./logger.server\"\` — no extension.

## Why typecheck didn't catch it

\`tsconfig.base.json\` sets \`moduleResolution: \"bundler\"\`. The bundler
resolver accepts extensionless relative imports because Vite / esbuild
/ webpack resolve them at build time. TypeScript was happy.

Production runs \`node --experimental-strip-types server.ts\`, which
uses Node's NodeNext ESM resolver — strict about extensions. The two
resolvers disagree silently for files Node loads directly (server.ts,
\`.server.ts\` modules dynamically imported from it, and jobs).

## Fix

1. **Added \`.ts\` extensions to the 4 broken imports** I could find:
   - \`apps/journal/app/lib/email.server.ts\` → \`./logger.server.ts\`
     (the actual deploy-blocker)
   - \`apps/planner/app/lib/brouter.ts\` → \`./route-merge.ts\`, \`./http.server.ts\`
   - \`apps/planner/app/lib/use-nearby-pois.ts\` → \`./overpass.ts\`
   - \`packages/map/src/MapView.tsx\` → \`./layers.ts\` (caught by the rule)

2. **Added \`eslint-plugin-import-x\` with \`import-x/extensions: always\`**,
   scoped to files Node actually executes raw:
   - \`apps/*/server.ts\`
   - \`apps/*/app/lib/**/*.server.ts\`
   - \`apps/*/app/jobs/**/*.ts\`
   - \`packages/*/src/**/*.{ts,tsx}\`

   Ignored: route-scoped \`*.server.ts\` files (bundled by Vite into the
   React Router build, never loaded by Node), and test files (Vitest's
   own resolver).

## What's still possible

- Non-\`.server.ts\` files in \`app/lib\` (e.g. \`legal.ts\`, \`actor-iri.ts\`)
  could still ship extensionless imports. They're not in the lint
  scope. If one breaks at runtime we can extend the glob — for now
  they tend to be tiny utility modules that don't import other
  relatives.
- The deeper fix would be \`moduleResolution: nodenext\` for server-side
  tsconfig, or bundling the server code so Node never sees raw \`.ts\`.
  Bigger surgery; the lint rule covers the failure mode for now.

Full repo: pnpm typecheck / lint / test all green after \`pnpm install\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:08:19 +02:00
Ullrich Schäfer
c54f0361cf
Merge pull request #449 from trails-cool/chore/remove-eslint-disables
chore(ts): drop the last 2 `eslint-disable` comments in prod code
2026-05-26 07:38:34 +02:00
Ullrich Schäfer
7918ba052a
chore(ts): drop the last 2 \eslint-disable\ comments in prod code
After this, \`grep -rn 'eslint-disable' apps/ packages/\` returns 0
(excluding tests and node_modules).

**\`apps/planner/app/lib/brouter.ts\`** — \`while (true)\` in
\`readBodyWithCap\` tripped \`no-constant-condition\`. Replaced with
\`for (;;)\` which the rule explicitly allows. No behavior change.

**\`packages/gpx/src/parse.ts\`** — the sync \`parseGpx\` exported a
fallback path that did \`require(\"linkedom\")\` for non-browser sync
use, with an \`eslint-disable\` for \`no-require-imports\`. It was dead
code: \`packages/gpx/src/index.ts\` only re-exports \`parseGpxAsync\`,
and grepping the monorepo finds no caller of the sync version. Deleted
the function entirely; \`getDOMParser\` (the async helper) still serves
the async path with a clean ESM \`await import(\"linkedom\")\`.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:34:09 +02:00
Ullrich Schäfer
5bbb8bfbaa
Merge pull request #448 from trails-cool/chore/jobs-no-payload-generic
chore(ts): eliminate the remaining 4 `as any` casts in production code
2026-05-26 07:14:37 +02:00
Ullrich Schäfer
b0b58d36fb
chore(ts): eliminate the remaining 4 \as any\ casts in production code
Followup to #447. The audit ran on \`as unknown as\` first; this PR
closes out \`as any\` separately. After this, \`grep -rn ' as any\\b'
apps/ packages/\` returns 0 (excluding tests and node_modules).

## Sites fixed

**\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** —
\`komootBulkImportJob as any\`. The job had a typed payload
(\`JobDefinition<KomootBulkImportData>\`) but the worker's
\`JobDefinition[]\` array forced a contravariance cast at every site
that mixed typed and untyped jobs. Dropped the generic from
\`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only
one job (komoot-bulk-import) used the generic, so the surface is tiny.

**\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in
\`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any,
(err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types
require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\`
is structurally \`Buffer<ArrayBufferLike>\` (which includes
SharedArrayBuffer). The runtime accepts either, so narrowed the cast
to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what
we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc
callback typings; the library exports a proper \`FitParserCallback\`.

**\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler
took \`(event: any)\`. maplibre-react-native v11 generates the event
type via React Native codegen but doesn't re-export it as a public
TypeScript type. Replaced with a local structural slice of the parts
we actually read.

Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` /
\`test\` all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:02 +02:00
Ullrich Schäfer
2fc09165da
Merge pull request #447 from trails-cool/chore/tighten-typescript-coercions
chore(ts): replace 7 `as unknown as` shims with proper types
2026-05-26 01:06:11 +02:00
Ullrich Schäfer
a724f862b8
chore(ts): drop two more redundant casts
- RouteMapThumbnail.client.tsx: `{ type: 'Feature', ... } as unknown
  as GeoJsonObject` → `as GeoJsonObject`. The literal already
  satisfies the type; the unknown bridge was unnecessary.

- use-yjs.ts:102: keep the coercion (y-websocket's `.on()` signature
  doesn't include the legacy `"synced"` event even though the
  runtime still fires it), but document why with a comment so future
  readers don't try to drop it.
2026-05-26 01:02:49 +02:00
Ullrich Schäfer
985ec54023
chore(ts): replace 7 \as unknown as\ shims with proper types
Most of the existing coercions are legitimate (Drizzle raw-SQL row
shapes, linkedom DOMParser interop, fit-file-parser's loose \`any\`
callback API) — leaving those is honest. The ones cleaned up here
were shims around APIs that have real types we just hadn't wired:

- **\`window.__leafletMap\`** (4 sites: MapHelpers.tsx, SessionView.tsx ×3)
  Added \`Window\` declaration in \`apps/planner/app/types/global.d.ts\`.
  Callers now use plain \`window.__leafletMap\` typed as
  \`L.Map | undefined\`.

- **\`L.markerClusterGroup\`** (PoiPanel.tsx) — leaflet.markercluster
  augments the global \`L\` namespace at runtime but ships no types.
  Added a \`declare module \"leaflet\"\` block in the same global.d.ts
  with a minimal signature for what we actually use.

- **\`L.DomEvent.preventDefault / .stop\`** taking a Leaflet event
  rather than a native one (PlannerMap.tsx, RouteInteraction.tsx,
  NoGoAreaLayer.tsx). Leaflet events carry the native event under
  \`.originalEvent\`; using that drops the cast entirely.

- **\`_creds as unknown as OAuthCredentials\`** orphan in
  wahoo/webhook.ts — \`_creds\` was unused inside the callback (the
  void-cast was a no-op preserving the type for documentation).
  Replaced with \`async ()\`, dropped the unused import.

Net: 26 \`as unknown as\` / \`as any\` sites → 19, all remaining ones
gated by external lib interop or Drizzle raw-SQL.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:02:25 +02:00
Ullrich Schäfer
ec86f722d9
Merge pull request #444 from trails-cool/test/e2e-journal-planner-handoff
test(e2e): journal↔planner save handoff covers Phase A + Phase B
2026-05-26 01:01:27 +02:00
Ullrich Schäfer
8160393e55
Merge branch 'main' into test/e2e-journal-planner-handoff 2026-05-26 00:57:57 +02:00
Ullrich Schäfer
ce9da71f10
Merge pull request #446 from trails-cool/fix/planner-brouter-response-size
fix(planner/brouter): cap upstream response size to 10MB
2026-05-26 00:57:00 +02:00
Ullrich Schäfer
89a6c5f900
Merge branch 'main' into test/e2e-journal-planner-handoff 2026-05-26 00:56:42 +02:00
Ullrich Schäfer
e1c696d598
Merge branch 'main' into fix/planner-brouter-response-size 2026-05-26 00:53:52 +02:00
Ullrich Schäfer
206f58b1b9
test(e2e/journal-planner-save): pass waypoints in URL + mock BRouter
The first revision opened the planner session URL without waypoints,
so the in-browser Yjs doc was empty, no route was computed, and the
Save button shipped an empty GPX → journal callback returned 400 →
\"Saved!\" never appeared and the test timed out.

The real journal→planner handoff (in
\`apps/journal/app/routes/api.routes.\$id.edit-in-planner.ts\`) encodes
the planner's session-creation response (initialWaypoints / noGoAreas /
notes) as URL query params on the redirect. The test now mirrors that,
passing a 2-waypoint encoded blob via the \`?waypoints=\` param.

Also mocks BRouter via the existing \`mockBRouter(page)\` fixture so the
route compute is deterministic — \`planner-coloring.test.ts\` uses the
same pattern. Otherwise the test would race against a real BRouter
cold-start on CI.

Asserts canvas is visible before clicking Save (proxy for \"routeData
is populated\" — the same condition that gates a non-empty GPX in
\`SaveToJournalButton\`).
2026-05-26 00:53:16 +02:00
Ullrich Schäfer
6c5d4e6510
Merge pull request #445 from trails-cool/fix/planner-api-sessions-limit
fix(planner): bound /api/sessions listing (default 50, max 200)
2026-05-26 00:50:35 +02:00
Ullrich Schäfer
6f2b4450df
fix(planner/brouter): cap upstream response size to 10MB
Planner-audit #9. \`fetchSegment\` previously \`await response.json()\`
on any body BRouter returned. A misbehaving or compromised upstream
could OOM the planner process by returning gigabytes of JSON.

New \`readBodyWithCap()\`:
- Reject upfront when \`content-length\` declares over the cap.
- Stream the body and abort the reader once received bytes exceed
  the cap (handles the case where upstream lies about content-length
  or omits it).

Cap chosen at 10 MB — real per-segment GeoJSON is <100 KB; even the
longest realistic multi-day route stays well under 2 MB. Above 10 MB
is upstream bug or abuse; we'd rather error.

Applied to both \`fetchSegment\` (GeoJSON path) and \`computeSegmentGpx\`
(GPX path).

Tests: 3 cases (within cap, content-length over cap, streamed body
mid-cap abort).
2026-05-26 00:48:45 +02:00
Ullrich Schäfer
6f18ce8099
fix(planner): bound /api/sessions listing (default 50, max 200)
Addresses planner-audit #8. `listSessions()` had no LIMIT — every call
to `GET /api/sessions` returned every non-closed session and did a
full table scan ordered by last_activity. On a long-running planner
host that's both a memory cliff and a query-plan footgun.

Now: defaults to 50 rows, clamps to [1, 200], accepts `?limit=` for
pagination-light scenarios. `sessions_last_activity_idx` (added in
#438) backs the ORDER BY + LIMIT efficiently.
2026-05-26 00:46:38 +02:00
Ullrich Schäfer
be64e2df5c
test(e2e): journal↔planner save handoff covers Phase A + Phase B
New \`e2e/journal-planner-save.test.ts\` exercises the full save flow:

1. Seed a routeId + JWT via \`/api/e2e/seed\` (journal).
2. POST to \`/api/sessions\` on the planner with \`callbackUrl\` +
   \`callbackToken\` — mirrors what the journal's \`edit-in-planner\`
   action does server-to-server.
3. Open the planner session in a real browser.
4. Click \"Save to Journal\". The button POSTs sessionId+GPX to the
   planner's \`/api/save-to-journal\` action (Phase A); the action
   forwards to the journal callback with the Bearer.

Test 1 asserts:
- The journal's route ends up with geometry (round-trip works).
- The exact JWT string is **never** present in any browser-issued
  request body or \`Authorization\` header — i.e. Phase A correctly
  keeps the token server-side.

Test 2 asserts:
- A second click on Save reuses the same stored JWT, the journal's
  jti consumer rejects it, and the planner UI surfaces the error.
  This is the Phase B replay guard exercised end-to-end through the
  UI rather than just the API.

Added \`journal-planner-save\` Playwright project (no baseURL — the
test navigates both apps using absolute URLs).

Full repo: pnpm typecheck / lint / test all green (cached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:43:56 +02:00
Ullrich Schäfer
11edcbccb3
Merge pull request #443 from trails-cool/fix/journal-jwt-jti-single-use
fix(journal): single-use JWT for route callback tokens (#2 Phase B)
2026-05-26 00:41:49 +02:00
Ullrich Schäfer
b4c64a40e7
fix(journal): single-use JWT enforcement for route callback tokens (#2 Phase B)
After Phase A (#442) moved the journal callback token off the browser,
the token was still replayable on the wire until \`exp\` (7 days). This
PR makes each token strictly single-use.

Changes:

- **\`journal.consumed_jwt_jti\` table** — \`jti TEXT PRIMARY KEY,
  consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ\`. Picked up by
  drizzle-kit push on deploy.
- **\`createRouteToken\` now sets a \`jti\` claim** (\`randomUUID()\`).
- **\`verifyRouteToken\` atomically consumes the jti** via
  \`INSERT … ON CONFLICT DO NOTHING RETURNING jti\`. Postgres
  serializes the insert, so exactly one concurrent caller wins; the
  rest see an empty result and throw \`TokenAlreadyConsumedError\`.
  Tokens without a \`jti\` claim (i.e. minted before this PR) are
  also rejected — the right call: any in-flight legacy token sitting
  in a planner session is replayable, and we'd rather fail-loud than
  silently grandfather them in.
- **\`consumed-jti-sweep\` job** — daily 03:45 UTC cron that
  \`DELETE WHERE expires_at < now()\`. Keeps the table tiny; offset
  from the other purge jobs to spread load.
- **e2e replay test** — \`integration.test.ts\` now exercises a
  same-token double-submit and asserts the second returns 401 with
  \`/consumed|already/i\`.

UX implication worth flagging: a user who clicks \"Save\" twice (or whose
network retries a failed POST) sees an error on the second attempt.
They go back to the journal for a fresh \"Edit in Planner\" link.

Full repo: pnpm typecheck / lint / test all green (177 + 31 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:38:08 +02:00
Ullrich Schäfer
c525240d0e
Merge pull request #442 from trails-cool/fix/planner-save-token-server-side
fix(planner): keep journal callback token off the client (#2 Phase A)
2026-05-26 00:32:51 +02:00
Ullrich Schäfer
0917de6080
fix(planner): keep journal callback token off the client (#2 Phase A)
The Save-to-Journal flow had the browser fetch the journal with a
\`Bearer \${callbackToken}\` header. The JWT was visible in DevTools,
exfiltratable via any XSS or browser extension, and the planner's
\`loader\` shipped it down to the client as part of the page payload.

Now:

- **New action**: \`POST /api/save-to-journal\` (\`routes/api.save-to-journal.ts\`).
  Body: \`{ sessionId, gpx }\`. The action loads \`callbackUrl\` +
  \`callbackToken\` from \`planner.sessions\` (set at /new time when the
  user came from the journal), POSTs to the journal server-to-server
  with the Bearer, and forwards the response.

- **\`SaveToJournalButton\`**: drops the \`callbackUrl\` + \`callbackToken\`
  props. Takes \`sessionId\` only and POSTs to the planner action.

- **\`session.\$id.tsx\` loader**: stops returning \`callbackUrl\` /
  \`callbackToken\` to the client. Returns a single \`hasJournalCallback\`
  boolean so the button still knows whether to render.

- **\`SessionView\`**: same prop simplification.

Trust model is unchanged: the same \`sessionId\` that grants Yjs
membership grants save authority. Knowing the URL = ability to act.
The action only adds a server-side hop so the JWT never reaches
browser JS.

Phase B (jti single-use enforcement on the journal side) follows in
a separate PR — needs a journal DB column + verifier change.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:29:24 +02:00
Ullrich Schäfer
3dcc17152b
Merge pull request #441 from trails-cool/fix/planner-ws-session-existence-check
fix(planner): require session row before accepting Yjs WebSocket upgrade
2026-05-26 00:27:35 +02:00
Ullrich Schäfer
4e2c5f7d6b
fix(planner): require session row before accepting Yjs WebSocket upgrade
Addresses planner-audit #1 (CRITICAL — WebSocket joins unauthenticated)
with the narrow fix. The upgrade handler used to immediately
\`handleUpgrade\` for any \`/sync/<id>\` path, then \`getOrLoadDoc\` *created*
a Y.Doc on demand. An attacker connecting to random sessionIds could:

  - exhaust process memory by spinning up Y.Doc objects for sessions
    that don't exist (no DB row backed them), and
  - resurrect closed/expired sessions by reconnecting (closed rows
    were still cacheable to \`docs\`).

Now: the upgrade handler calls \`getSession(sessionId)\` first
(\`SELECT \\* FROM planner.sessions WHERE id = ? AND closed = false\`).
Missing or closed → \`socket.destroy()\` before handshake. DB unreachable
also closes — fail closed; the client reconnects after backoff.

Costs one DB roundtrip per upgrade. Upgrades are rare vs message
volume, so the overhead is negligible.

Note: we are NOT adding a join token. The planner is anonymous-by-design
(see CLAUDE.md \"sessions are anonymous and ephemeral\") — knowing the
URL still equals membership. The check here only guards against
attacker-supplied sessionIds with no backing row.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:24:16 +02:00
Ullrich Schäfer
643266e619
Merge pull request #440 from trails-cool/fix/planner-yjs-doc-size-cap
fix(planner): cap per-WS-frame + per-session Yjs doc size
2026-05-26 00:16:40 +02:00
Ullrich Schäfer
2b0717fe21
fix(planner): cap per-WS-frame + per-session Yjs doc size
Addresses planner audit #5 — a connected client could feed unlimited
waypoints / no-go / notes into the Yjs doc, growing the in-memory map
and the persisted \`sessions.yjs_state\` blob until OOM or DB blowout.

Two guards:

1. **Per-frame**: \`MAX_MESSAGE_BYTES = 256 KB\`. Anything bigger arrives
   from a buggy or hostile client — a single waypoint add is hundreds
   of bytes. Oversized frame → \`ws.close(1008)\` and drop the message.

2. **Per-doc**: \`MAX_DOC_BYTES = 5 MB\`. After a sync-message apply
   succeeds, recompute \`Y.encodeStateAsUpdate(doc).byteLength\`. If it
   crossed the cap, mark the session \"quarantined\": close every
   connected client (\`1008 policy violation\`), and short-circuit
   subsequent handleMessage calls so no further bytes can be applied
   and the debounced save can't write an oversized blob to Postgres.

The 5 MB limit is generous — a typical multi-day route's serialized
state is well under 100 KB. The cap exists to make abuse expensive,
not to constrain real use.

Exports \`MAX_MESSAGE_BYTES\`, \`MAX_DOC_BYTES\`, and \`docByteSize\`
for testing. Tests cover:
- constants are positive and ordered
- docByteSize is 0 for unknown sessions
- a realistic 500-waypoint route stays well under the cap
- ~6MB of garbage in a Y.Text trips the cap (smoke test for the guard)

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:13:01 +02:00
Ullrich Schäfer
078e68feaf
Merge pull request #439 from trails-cool/fix/planner-url-validation
fix(planner): validate callback/returnUrl + cap session URL-param payloads
2026-05-26 00:09:39 +02:00
Ullrich Schäfer
51e6b8a0d7
fix(planner): validate callback/returnUrl + cap session URL-param payloads
Addresses planner audit #3 (SSRF via callbackUrl) and #7 (URL-param
size). Two attack surfaces hardened:

1. /new loader — \`callback\`, \`token\`, \`returnUrl\`, \`gpx\` query
   params now validated:
   - callbackUrl: must be a valid absolute http(s) URL ≤ 2048 chars.
     If \`PLANNER_CALLBACK_ALLOWED_HOSTS\` is set (comma-separated),
     the host must match — defense-in-depth SSRF guard for self-
     hosted instances. Unset = no allowlist (dev / open self-host).
   - token: max 2048 chars.
   - returnUrl: must be a same-origin path or absolute http(s) URL
     ≤ 2048 chars. Rejects \`javascript:\`, \`data:\`, and
     protocol-relative \`//host\` (which would resolve to a remote
     origin on HTTPS pages).
   - gpx: ≤ 2 MB encoded.
   Invalid input throws 400 from the loader.

2. /session/:id default-export component — \`waypoints\`, \`noGoAreas\`,
   \`notes\`, \`returnUrl\` URL params now bounded before
   \`JSON.parse\` / use:
   - waypoints / noGoAreas: ≤ 50KB each; over-cap returns undefined
     (component starts with empty initial state, same as malformed).
   - notes: ≤ 10KB.
   - returnUrl: ≤ 2KB + same scheme rules as #1.

Pulled the URL validation into \`lib/url-validation.server.ts\` so
both routes (and any future caller) share the same rules.

Tests: \`url-validation.server.test.ts\` (14 cases — schemes,
allowlist, length caps, protocol-relative guards, env parsing).

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:05:47 +02:00
Ullrich Schäfer
e35a8e27c8
Merge pull request #438 from trails-cool/fix/planner-sessions-lastactivity-idx
fix(planner): add index on sessions.last_activity for the expire job
2026-05-26 00:03:04 +02:00
Ullrich Schäfer
d2b28a1164
fix(planner): add index on sessions.last_activity for the expire job
The hourly `expireSessions()` cron runs `DELETE FROM planner.sessions
WHERE last_activity < cutoff`. Without an index on `last_activity`,
that's a full table scan growing linearly with the total sessions
ever created (planner sessions are never user-deleted; expiry is the
only churn path).

`drizzle-kit push` picks this up on next deploy.
2026-05-25 23:59:10 +02:00
Ullrich Schäfer
d07207464e
Merge pull request #437 from trails-cool/fix/planner-brouter-timeout
fix(planner/brouter): add 30s timeout to outbound fetch
2026-05-25 23:57:04 +02:00
Ullrich Schäfer
d70d6ee8a8
fix(planner/brouter): add 30s timeout to outbound fetch
Mirrors journal PR #5. `fetchSegment()` and `computeSegmentGpx()` in
lib/brouter.ts called `fetch()` with no AbortSignal — a hung or slow
BRouter would stall the request handler indefinitely.

New `lib/http.server.ts::fetchWithTimeout()` (same as the journal's
helper) wraps fetch with `AbortSignal.timeout(30_000)`, composable
with a caller-supplied signal via `AbortSignal.any()`.

Tests: lib/http.server.test.ts (2 cases — timeout abort, happy path).
2026-05-25 23:53:19 +02:00
Ullrich Schäfer
e647a29633
Merge pull request #436 from trails-cool/fix/e2e-planner-coloring-flake
test(e2e/planner-coloring): bump canvas timeout 10s→20s to absorb cold-CI lazy load
2026-05-25 23:48:38 +02:00
Ullrich Schäfer
d38b808a11
test(e2e/planner-coloring): bump canvas timeout 10s→20s
The three planner-coloring tests wait for the elevation chart's
\`<canvas>\` after seeing the Yjs \"Connected\" text. \`ElevationChart\`
returns \`null\` until \`points.length >= 2\` — i.e. until the BRouter
response (even mocked) has flowed through the Yjs round-trip and
updated \`yjs.routeData\`. The chart module is also lazy-imported
(\`Suspense\`) — cold CI runs need to fetch the chunk before mounting.

Saw the failure on PR #424's CI run: canvas \"element(s) not found\"
after the 10s window, both initial run and retry. Other planner
suites that wait on \`.leaflet-container\` (which renders
unconditionally and isn't behind a lazy boundary) don't see this.

Bumped \`CANVAS_TIMEOUT\` to 20s. Not masking a real regression — the
lazy chunk + Yjs apply is a real serial cost that the test budget
didn't reflect.
2026-05-25 23:44:53 +02:00
Ullrich Schäfer
f02edd346e
Merge pull request #435 from trails-cool/fix/ci-run-integration-tests
ci: wire integration tests into the CI E2E job
2026-05-25 23:31:48 +02:00
Ullrich Schäfer
ded70a5404
ci: wire integration tests into the CI E2E job
The five \`*.integration.test.ts\` files in apps/journal (explore, follow,
demo-bot, notifications, notifications-fanout — 31 tests total) were
gated behind \`EXPLORE_INTEGRATION=1\` / \`FOLLOW_INTEGRATION=1\` /
\`DEMO_BOT_INTEGRATION=1\` / \`NOTIFICATIONS_INTEGRATION=1\`. The unit-test
job doesn't have Postgres, so they correctly skipped there — but no
CI job set the env vars, so they were effectively dead code.

Added a step in the E2E job (which already has Postgres + schema
pushed) that flips all four gates and runs the integration files
serially (\`--no-file-parallelism\` — they share the schema and trip FK
constraints if run in parallel; ~2.5s sequential anyway).

Wiring them up surfaced two real issues, both fixed here:

1. **\`createRoute\` silently dropped \`input.visibility\`** — every
   caller passing \`visibility: \"public\"\` (including the demo-bot)
   was getting the column's \`private\` default. Spread now mirrors
   \`createActivity\`'s pattern. Demo-bot routes have actually been
   private in production all this time — they were rendering on the
   home feed only via the \`activity_published\` fan-out from the
   *activity*, not as visible *routes*.

2. **The demo-bot test's fetch stub was incomplete** — it stubbed
   \`.text()\` but the planner-session preflight calls \`.json()\`. The
   stub now branches on URL: \`/api/sessions\` returns
   \`{ sessionId: 'test-session' }\` JSON, the BRouter call returns the
   stub GPX text.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green
(177 unit-test pass + 31 integration pass = 208 total, no skips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:27:44 +02:00
Ullrich Schäfer
1263372eef
Merge pull request #434 from trails-cool/fix/sentry-dsn-no-hardcoded-fallback
fix(sentry): remove hardcoded DSN fallbacks; supply via env in CI
2026-05-25 23:12:55 +02:00
Ullrich Schäfer
edb618fe40
fix(sentry): remove hardcoded DSN fallbacks; supply via env in CI
Self-hosted instances no longer inherit the trails.cool flagship Sentry
DSNs. Code paths now read DSN strictly from env — unset = no Sentry
init, no events sent.

Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml
inject the public DSNs as workflow env vars, which feed into:
- runtime via \`infrastructure/app.env\` / \`staging.env\`
  (\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service)
- build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only;
  planner has no client Sentry init).

Sentry DSNs are public-by-design (transmitted unencrypted from the
client JS bundle), so embedding them as plaintext workflow env vars
is no worse than the runtime exposure. Forks should replace these with
their own DSNs or remove the workflow env lines to ship Sentry-free.

Files changed:
- apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback
- apps/planner/server.ts: same
- apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\`
  only; falsy = skip init
- apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build
- infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service
- infrastructure/docker-compose.staging.yml: same
- .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo
- .github/workflows/cd-staging.yml: same

Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:07:07 +02:00
Ullrich Schäfer
f239b4adee
Merge pull request #433 from trails-cool/fix/infra-compose-secret-defaults
fix(infra): refuse compose up when production secrets are missing
2026-05-25 23:03:06 +02:00
Ullrich Schäfer
e2bf3ddb94
fix(infra): refuse compose up when production secrets are missing
infrastructure/docker-compose.yml had \`\${VAR:-default}\` fallbacks for
JWT_SECRET, SESSION_SECRET, and POSTGRES_PASSWORD that silently
substituted known-weak values (\`change-me-in-production\`, \`trails\`)
when the env wasn't set. A misconfigured prod deploy would happily come
up with these defaults — JWT/session forgery + a guessable Postgres
password.

Switched the four critical substitutions to \`\${VAR:?message}\` so
\`docker compose up\` fails loud instead. Mirrors the existing pattern
already used for BROUTER_URL/BROUTER_AUTH_TOKEN and (in staging) for
JWT_SECRET/SESSION_SECRET.

Sites fixed:
- journal:   DATABASE_URL (POSTGRES_PASSWORD), JWT_SECRET, SESSION_SECRET
- planner:   DATABASE_URL (POSTGRES_PASSWORD)
- postgres:  POSTGRES_PASSWORD (container itself)
- exporter:  DATA_SOURCE_NAME (POSTGRES_PASSWORD)
- staging:   both DATABASE_URLs

Production already provides all three via SOPS-encrypted
secrets.app.env, so this is a defense-in-depth change with no behavior
change on a properly-configured deploy.

Updated infrastructure/.env.example to make SESSION_SECRET explicit
(was missing) and call out the trio as required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:59:09 +02:00
Ullrich Schäfer
c85c757066
Merge pull request #432 from trails-cool/fix/planner-db-url-and-health-pool
fix(planner): fail-loud DATABASE_URL + dedicated /health pool
2026-05-25 22:52:47 +02:00
Ullrich Schäfer
4f902accd7
fix(planner): fail-loud DATABASE_URL + dedicated /health pool
Mirrors #422 (fail-loud DB URL) and #430 (health-check pool reuse) for
the planner app, which still had:

- two \`process.env.DATABASE_URL ?? \"postgres://trails:trails@localhost:5432/trails\"\`
  call sites that would silently boot a misconfigured prod against
  localhost
- a /health handler that opened a fresh postgres client + connection on
  every probe (no pool, per-call TCP/TLS handshake)

Both now route through @trails-cool/db's \`getDatabaseUrl()\` (refuses to
start in production if unset / matches the dev default; E2E=true is the
opt-out) and a module-level singleton client (max: 2, idle_timeout: 30).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:49:16 +02:00
Ullrich Schäfer
ca2388e41c
Merge pull request #431 from trails-cool/fix/planner-request-id-tracing
feat(planner): per-request requestId propagated through logs
2026-05-25 22:47:33 +02:00
Ullrich Schäfer
4ba98fa8f2
feat(planner): per-request requestId propagated through logs
Mirrors PR #429 for the planner app. Each HTTP request now gets a
requestId (inbound X-Request-Id honored, otherwise a fresh UUID),
echoed on the response, and propagated to every downstream log call via
AsyncLocalStorage + pino's \`mixin\`.

Tests: planner logger.server.test.ts (2 cases, same shape as the
journal version).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:43:58 +02:00
Ullrich Schäfer
59687162b8
Merge pull request #423 from trails-cool/deps/expo-sdk-56-packages
Bump Expo SDK 56 packages
2026-05-25 22:38:31 +02:00
Ullrich Schäfer
fb5bdff579
Upgrade TypeScript to 6.0.3 across the entire monorepo
Replaces the i18next peer dep override workaround with a proper single
TypeScript version in the workspace catalog. All 15 packages typecheck
cleanly with TypeScript 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:34:23 +02:00
Ullrich Schäfer
7cf554f85a
Fix i18n singleton split caused by TypeScript peer dep duplication
Mobile's TypeScript 6 devDep created a second peer-resolution variant of
i18next (i18next(typescript@6)) alongside the web apps' (i18next(typescript@5)).
Under pnpm's hoisted nodeLinker the server and client ended up with different
instances of the i18n singleton, so SSR rendered raw keys and hydration
mismatched.

Pin i18next and react-i18next TypeScript peer deps to 5.9.3 in root overrides
so the entire workspace resolves a single i18next instance regardless of which
app's TypeScript version is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:05:23 +02:00
Ullrich Schäfer
c3509b064a
ci: trigger CI after merging Copilot fix 2026-05-25 21:40:30 +02:00
copilot-swe-agent[bot]
e2460374de
fix(i18n): normalize html language tags in client detection
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/7546ba18-5628-4f50-bc8b-104fa7ba6afe

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-05-25 13:24:11 +00:00
Ullrich Schäfer
aa3afeeaec
Merge branch 'main' into deps/expo-sdk-56-packages 2026-05-24 21:11:19 +02:00
copilot-swe-agent[bot]
dafa5634e0
chore: deduplicate pnpm lockfile
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/05d47928-38a7-43b0-a06b-034302981e77

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-05-24 15:49:35 +00:00
Ullrich Schäfer
d014edb64c
Merge pull request #430 from trails-cool/fix/journal-health-pool
fix(journal): reuse a dedicated pool for /api/health instead of per-call connect
2026-05-24 12:41:11 +02:00
Ullrich Schäfer
8675c1f7c3
fix(journal): reuse a dedicated pool for /api/health instead of per-call connect
The previous handler opened a fresh postgres client (max: 1) on every
call to /api/health and tore it down in the finally block. Under the
prod monitoring cadence (probes every few seconds), that's a fresh
TCP + TLS + auth handshake on every probe, plus connection-table
churn on the Postgres side — fine for the trickle of curl-ish manual
checks, slow-bleed under blackbox monitoring.

Now we cache a module-level singleton postgres client dedicated to
/api/health (max: 2, idle_timeout: 30) and reuse it across calls.
Separate from the app's main DB pool (via @trails-cool/db's createDb)
on purpose — so a starvation event on the main pool doesn't fail the
liveness check and trigger a restart loop.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:37:09 +02:00
Ullrich Schäfer
dfdb8b6daf
Merge pull request #429 from trails-cool/fix/journal-request-id-tracing
feat(journal): per-request requestId propagated through logs
2026-05-24 12:35:13 +02:00
Ullrich Schäfer
f070914362
feat(journal): per-request requestId propagated through logs
Every HTTP request now gets a requestId (inbound X-Request-Id header is
honored, otherwise a fresh UUID is minted) and the value is echoed on
the response. The server wraps the request in
\`requestContext.run({ requestId }, ...)\` — an AsyncLocalStorage scope —
so pino's \`mixin\` callback can read it on every log call without the
caller threading it through.

Net effect: \`logger.info({ ... }, \"db error\")\` from a loader, action,
or downstream lib now lands in JSON with a \`requestId\` field, making
cross-handler debugging trivial (\`grep requestId=abc-123\` returns the
full request trace).

Out of scope here:
- Planner gets the same treatment (separate, smaller PR after this lands).
- BRouter / Fedify outbound calls don't propagate the requestId yet —
  those are HTTP boundaries where we'd add it as a header, but the
  audit value was the in-process trace.

Tests:
- logger.server.test.ts (2 cases — als-bound info tags requestId; no
  context = no tag).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:30 +02:00
Ullrich Schäfer
3d34e215b9
Merge pull request #428 from trails-cool/fix/sentry-dsn-env-driven
fix(sentry): make DSN env-driven so self-hosters can opt out
2026-05-24 12:28:58 +02:00
Ullrich Schäfer
f05165c594
fix(sentry): make DSN env-driven so self-hosters can opt out
The Sentry DSNs were hardcoded in journal/server.ts, planner/server.ts,
and journal/app/lib/sentry.client.ts. Self-hosted instances inheriting
the trails.cool flagship DSN would silently ship their errors to our
Sentry account.

Now each init site reads its DSN from env:

- journal/server.ts: SENTRY_DSN (server runtime env)
- planner/server.ts: SENTRY_DSN (server runtime env)
- journal/app/lib/sentry.client.ts: VITE_SENTRY_DSN (build-time bake)

The flagship DSN is kept as the fallback so the production deploy keeps
reporting without an infra change — but self-hosters can:
- set SENTRY_DSN=\"\" / VITE_SENTRY_DSN=\"\" to ship their own builds
  without Sentry, or
- set SENTRY_DISABLED=true to skip init entirely at runtime, or
- set SENTRY_DSN=/VITE_SENTRY_DSN= to their own DSN.

Follow-up: a future PR can remove the hardcoded fallbacks once
infrastructure/docker-compose.yml + the cd-apps.yml workflow are wired
to pass SENTRY_DSN explicitly. That requires SOPS edits + workflow
changes I want isolated from this purely-code change.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:24:54 +02:00
Ullrich Schäfer
9614b68a13
Merge pull request #427 from trails-cool/fix/journal-magic-token-toctou
fix(journal/auth): atomic magic-token consume to close TOCTOU
2026-05-24 12:21:25 +02:00
Ullrich Schäfer
f22bec5a13
fix(journal/auth): atomic magic-token consume to close TOCTOU
verifyLoginCode, verifyMagicToken, and verifyEmailChange previously did
SELECT WHERE used_at IS NULL → UPDATE … SET used_at = now. Two concurrent
verifications could both pass the SELECT and both succeed, accepting the
same single-use token twice.

Collapsed each to a single UPDATE … WHERE … RETURNING * statement.
Postgres serializes row-level locks within an UPDATE, so exactly one
concurrent caller observes a returned row; the rest see an empty array
and get \"Invalid or expired\". The token is also marked used as part of
the same statement — no second write needed.

verifyEmailChange's tertiary email-availability check now runs *after*
the consume; we keep the original semantics where the token is burned
on a clash (the previous code explicitly did the same with a separate
UPDATE).

No behavior change on the happy path. Closes a credential-reuse
window that mattered most for the 6-digit login codes (small search
space, more likely to race).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:17:42 +02:00
Ullrich Schäfer
6113b66846
Merge pull request #426 from trails-cool/fix/journal-wahoo-importone-paginate
fix(journal/wahoo): paginate importOne instead of giving up after page 1
2026-05-24 12:15:19 +02:00
Ullrich Schäfer
c43737526e
fix(journal/wahoo): paginate importOne instead of giving up after page 1
The previous \`importOne\` only fetched page 1 of /v1/workouts and errored
with \"not found on page 1\" if the workout wasn't there — silently
breaking import for any workout older than roughly the most recent 30
entries (per_page default). Webhook-driven imports happen to land on
page 1 by definition, so this only bit on user-initiated catch-up
imports of older workouts.

Now we paginate forward, using the \`total / per_page\` returned by page 1
to compute a stop condition, with a \`MAX_PAGES=100\` ceiling so a
misbehaving API can't loop us. We also stop early on an empty page.

Tests:
- new pagination case (workout on page 2, expect 2 fetch calls)
- new \"not found on any page\" case

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:11:33 +02:00
Ullrich Schäfer
8729ad03c7
Merge pull request #425 from trails-cool/fix/journal-dynamic-import-warnings
fix(journal): remove ineffective dynamic imports
2026-05-24 12:08:45 +02:00
Ullrich Schäfer
9c6407423a
fix(journal): remove ineffective dynamic imports
Rollup was warning on 5 modules that were both dynamically and statically
imported. With static importers in the same chunk, the dynamic forms
buy no chunking benefit — they were leftovers from earlier
cycle-avoidance workarounds that no longer apply.

Converted to static:
- @trails-cool/gpx (routes.\$id.server.ts, sync.import.\$provider.server.ts)
- logger.server (boss.server.ts — comment claimed test cycles, but tests pass)
- boss.server (activities.server.ts at two sites)
- connected-services/manager (komoot/importer.ts, wahoo/importer.ts)
- notifications.server (root.tsx)

Kept dynamic: fit-file-parser in sync.import.\$provider.server.ts — it's
heavy and only the FIT ingestion path needs it, no other static
importers exist, so the dynamic actually does chunk-split it.

Build is now warning-free.

Full repo: pnpm typecheck, pnpm lint, pnpm test, pnpm build all green.
192 tests passed (up from 181 — rate-limit test from #424 + others).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:08 +02:00
Ullrich Schäfer
9d99a8a3c1
Merge branch 'main' into deps/expo-sdk-56-packages 2026-05-24 12:00:36 +02:00
Ullrich Schäfer
c6d135945a
Merge pull request #424 from trails-cool/fix/journal-rate-limit-auth
fix(journal): rate-limit auth endpoints
2026-05-24 12:00:09 +02:00
Ullrich Schäfer
5fef45fb68
fix: bypass rate limiter when E2E=true
E2E suite drives registrations from one IP at parallel volume;
production limits trip and flake tests. Same opt-out pattern as the
fail-loud secret/DB-URL guards.
2026-05-24 11:57:01 +02:00
Ullrich Schäfer
81c40f0c6d
Add Jest mock for expo-crypto
expo-crypto@56 introduced a native AES class that throws in Jest's Node.js
environment on import. Mock the functions actually used (getRandomBytes,
digestStringAsync) so the api-client test suite can run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:54:50 +02:00
Ullrich Schäfer
532b22c5c0
Complete Expo SDK 56 upgrade
- Upgrade expo, expo-router, expo-dev-client, expo-crypto, expo-localization,
  expo-location, expo-navigation-bar, expo-notifications, expo-secure-store,
  expo-status-bar, expo-system-ui, expo-web-browser to SDK 56 versions
- Upgrade react-native 0.83.4 → 0.85.3
- Upgrade TypeScript 5.9 → 6.0.3 (required by SDK 56)
- Add react-native-worklets (required peer dep for react-native-reanimated)
- Remove expo-dev-menu as a direct dependency (transitive, managed by Expo)
- Move splash config from top-level ExpoConfig to expo-splash-screen plugin
  (ExpoConfig.splash was removed in SDK 56 types)
- Add expo-localization and expo-splash-screen to plugins array
- Fix metro.config.js watchFolders to merge with Expo defaults instead of
  overwriting them (fixes expo-doctor Metro config check)
- Add types: ["jest"] to tsconfig.json (required for jest globals in TS 6)
- Exclude @sentry/react-native from Expo version validation (bundledNativeModules
  has a stale entry; Sentry 8.x officially supports Expo 51+ and RN 0.73+)

expo-doctor: 18/18 checks passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:52:11 +02:00
Ullrich Schäfer
6afd996e19
fix(journal): rate-limit auth endpoints
Adds per-process in-memory fixed-window rate limiting (lib/rate-limit.server.ts)
and applies it across /api/auth/login and /api/auth/register:

- All login steps: 30 attempts per IP per minute (defense against
  step-fanout flooding).
- finish-passkey: 10 per IP per minute (assertions don't usefully retry
  faster).
- magic-link generation: 3 per email per 5 min + 10 per IP per 5 min
  (defeats inbox-spam-the-victim + cross-email IP fanout).
- verify-code: 10 per email per 15 min — makes 6-digit code brute force
  (10^6 search) infeasible before code expiry.
- /api/auth/register: 10 per IP per hour (legitimate signup completes
  in 2-3 requests; sustained churn from one IP is account spam).

Single-instance is fine for the flagship's current topology (one journal
container). When we horizontally scale we revisit with a Postgres- or
Redis-backed store. Buckets self-clean on next read and a 5-minute
background sweep drops stale entries.

Client IP: honors X-Forwarded-For first entry (Caddy in front of the
journal sets it), falls back to a stable "unknown" bucket so the
limiter still bites when the header is missing.

Tests: rate-limit.server.test.ts (8 cases — exhaustion, independent
scopes/keys, remaining countdown, reset window, XFF parsing, fallback,
trimming). Existing auth tests still pass; limits sized to not trip
during the ~7 test cases that all share the "unknown" IP bucket.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:51:43 +02:00
Ullrich Schäfer
84babc3ec7
Merge pull request #422 from trails-cool/fix/journal-fail-loud-secrets
fix(journal): fail loud in production when secrets are unset
2026-05-24 11:48:46 +02:00
Ullrich Schäfer
ebedfa257b
fix: add E2E opt-out for fail-loud secret/DB-URL guards
Playwright runs the server via `react-router serve` with
NODE_ENV=production but against a local dev Postgres and local cookie
secrets. The guards added in 5a7bb76 refused to start under that
configuration. `E2E=true` (already set by the CI E2E job) is now the
explicit opt-out: in real production this env var is never set, so the
guard still bites.
2026-05-24 11:45:09 +02:00
Ullrich Schäfer
9d48d26a6e
ci: supply throwaway JWT/SESSION secrets for E2E (NODE_ENV=production)
pnpm test:e2e runs via react-router serve which boots with
NODE_ENV=production; the new requireSecret() guard refuses to start
without explicit values. CI now supplies CI-only throwaway secrets so
the guard still bites in real prod deploys without breaking the test
job.
2026-05-24 11:40:24 +02:00
Ullrich Schäfer
afd7bf09f8
Bump Expo SDK 56 packages (combined dependabot PRs)
Closes #408, #409, #410, #411, #412, #413, #414, #415, #416

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:39:47 +02:00
Ullrich Schäfer
5a7bb76ff1
fix(journal): fail loud in production when secrets are unset
Adds `requireSecret(name, devFallback)` in lib/config.server.ts and
`getDatabaseUrl()` in @trails-cool/db. Both:
- return the env var when set,
- fall back to the dev default in non-production,
- throw at boot in production if the env var is missing OR matches the
  known dev fallback (which would otherwise silently ship a public
  secret / point at localhost).

Applied to:
- JWT_SECRET (lib/jwt.server.ts) — was `?? "dev-jwt-secret-change-in-production"`
- SESSION_SECRET (lib/auth/session.server.ts) — was `?? "dev-secret-change-in-production"`
- DATABASE_URL (server.ts health + boss; packages/db migrate-data) — was
  `?? "postgres://trails:trails@localhost:5432/trails"`

Why: these strings are in the repo and known to attackers. A
misconfigured prod deploy that forgot to set them would either run with
guessable signing keys (full session/JWT forgery) or connect to a
non-existent localhost DB. Better to refuse to start than to silently
operate insecurely.

Tests:
- packages/db/src/get-database-url.test.ts (5 cases)
- lib/config.server.test.ts gains `requireSecret` cases (4 new)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:37:20 +02:00
Ullrich Schäfer
742065c319
Merge pull request #420 from trails-cool/spec-drift/high-severity
Fix high-severity spec drift (10 specs)
2026-05-24 11:31:13 +02:00
Ullrich Schäfer
dd0098e35c
Merge branch 'main' into spec-drift/high-severity 2026-05-24 11:27:33 +02:00
Ullrich Schäfer
0448a58e19
Fix remaining OpenSpec validation failures (medium-severity specs)
- komoot-import: add SHALL to Credential storage requirement body
- local-dev-environment: add SHALL to Mobile app dev requirement body
- multi-day-routes: demote ### Note heading to plain paragraph

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:26:32 +02:00
Ullrich Schäfer
6186ffa062
Merge pull request #407 from trails-cool/dependabot/npm_and_yarn/production-05de8b6350
Bump the production group with 14 updates
2026-05-24 11:26:03 +02:00
Ullrich Schäfer
bbb729ffdd
Fix OpenSpec validation failures on high-severity specs
- road-type-coloring: add proper ## Purpose/## Requirements structure (redirect file)
- planner-journal-handoff: add inline #### Scenario: blocks to each requirement; add SHALL to JWT token requirement
- osm-tile-overlays: add SHALL keyword to profile-aware requirement body
- shared-packages: add #### Scenario: blocks to ui, api, db, jobs, sentry-config requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:20:14 +02:00
Ullrich Schäfer
2b48e2a8e1
Merge pull request #421 from trails-cool/spec-drift/medium-severity
Fix medium-severity spec drift (17 specs)
2026-05-24 11:12:02 +02:00
Ullrich Schäfer
0cf87b72ab
Fix medium-severity spec drift across 17 specs
- authentication-methods: document completeAuth mode param ("redirect"|"json");
  clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
  matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
  implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
  document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
  retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
  requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
  object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
  note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
  split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
  cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
  includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
  is also displayed
- local-dev-environment: add mobile app (Expo) dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:11:31 +02:00
Ullrich Schäfer
ebf77b9b17
Merge pull request #419 from trails-cool/fix/journal-audit-server-split-all
fix(journal): extract loaders/actions for the remaining 21 mixed routes
2026-05-24 11:09:09 +02:00
Ullrich Schäfer
47eb2615ec
Fix high-severity spec drift across 10 specs
- rate-limiting: correct BRouter limit to 300/hour (was 60); add Overpass
  rate-limit requirement (120/min per IP)
- security-hardening: BROUTER_AUTH_TOKEN lives in secrets.app.env, not infra.env
- account-management: email-change verification does not re-auth; existing
  session stays valid
- planner-journal-handoff: full rewrite — documents the actual JWT callback
  architecture (edit-in-planner → POST /api/sessions → callback endpoint),
  token claims, notes round-trip via GPX <metadata><desc>, session lifecycle
- route-drag-reshape: rewrite to describe permanent segment midpoint handles
  (not proximity hover ghost marker); click-to-insert + waypoint drag model
- route-splitting: rewrite to match midpoint handle model; notes geometric
  midpoint placement (not cursor-snapped)
- road-type-coloring: redirect to route-coloring (all requirements already
  covered there)
- osm-tile-overlays: mark profile-aware auto-enable as not yet implemented
  (profileOverlayDefaults exported but not wired)
- osm-poi-overlays: zoom threshold is 10 not 12; user override persistence
  marked as not yet implemented
- shared-packages: add all 7 missing packages (map-core, fit, api, db, jobs,
  sentry-config, correct map description); document map vs map-core boundary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:07:03 +02:00
Ullrich Schäfer
df562742e1
fix(journal): extract loaders/actions for the remaining 21 mixed routes
Completes the .server.ts split started in #418. Every route that mixes
a default-export component with a server-only loader/action now has a
sibling <route>.server.ts holding the data-fetching helpers; the route
.tsx is a thin delegator.

Routes converted (21):
  activities._index, activities.$id, activities.new, auth.accept-terms,
  auth.verify, explore, feed, notifications, routes._index, routes.$id,
  routes.$id.edit, routes.new, settings, settings.account,
  settings.connections.komoot, settings.profile, settings.security,
  sync.import.$provider, sync.import.komoot, users.$username.followers,
  users.$username.following

Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx):
- loader → `return data(await loadX(request, params?))`
- action → `return await xAction(request, params?)`
- All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the
  .server.ts sibling.
- `throw redirect(...)` and `throw data(...)` propagate through the
  delegator unchanged.

No behavior changes — pure module-graph cleanup. Component modules no
longer transitively import the DB client; Vite's tree-shake of
server-only code is now backed by an explicit, file-local contract.

Verified:
- pnpm typecheck — green
- pnpm lint — green
- pnpm test — 181 passed, 31 integration-gated skipped
- pnpm --filter @trails-cool/journal build — succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:05:40 +02:00
Ullrich Schäfer
a57637868b
Merge pull request #418 from trails-cool/fix/journal-audit-7-8-helpers
fix(journal): centralize auth helpers + extract .server.ts siblings
2026-05-24 10:52:13 +02:00
Ullrich Schäfer
951d2a507a
Merge branch 'main' into fix/journal-audit-7-8-helpers 2026-05-24 10:48:53 +02:00
Ullrich Schäfer
588447efd3
Merge pull request #417 from trails-cool/roadmap
Add roadmap and fix CLAUDE.md drift
2026-05-24 10:47:25 +02:00
Ullrich Schäfer
8eba5b2d9e
fix(journal): centralize session-auth helpers + extract .server.ts siblings
Follow-up to PR #406 — addresses the two items deferred from the audit:

#7 — Centralize auth helpers
- New `requireSessionUser(request)` in lib/auth/session.server.ts that
  returns the user or throws a redirect to /auth/login.
- New `requireSessionUserJson(request)` companion that throws a 401 JSON
  response (for fetcher/JSON endpoints).
- Replace the repeated
    const user = await getSessionUser(request);
    if (!user) return redirect("/auth/login");
  pattern across 18 route loaders/actions. Removes the duplicated guard
  preamble and gives a single chokepoint to evolve later (e.g., for
  terms-version gating).

#8 — Extract heavy loaders into .server.ts siblings
- routes/home.tsx → home.server.ts (DB count query + listActivities +
  listRecentPublicActivities)
- routes/users.$username.tsx → users.$username.server.ts (user lookup +
  follow state + counts + listPublicRoutes/Activities + persona check)
- routes/settings.connections.tsx → settings.connections.server.ts
  (connected_services join + manifest merge)

Each route file shrinks to a thin delegator: `loader` calls
`loadXxx(request)`. The component module no longer transitively pulls
`getDb` and Drizzle schema into its import graph — Vite's tree-shake
already strips server-only code from the client bundle, but the
explicit `.server.ts` suffix makes that contract local and auditable.

Other 17 routes that mix loader/action with components are left as-is
for now: they're each small enough that the split adds churn without
buying much clarity. The pattern is documented by the three examples;
the rest can convert opportunistically when they grow.

Tests:
- lib/auth/session.server.test.ts (4 cases — redirect for missing
  cookie, redirect for ghost userId, success path, JSON 401 variant)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green
(181 passed | 31 integration-gated skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:44:33 +02:00
Ullrich Schäfer
12f6e6be51
Update self-host-overpass README with accurate OVERPASS_URLS env var
The proxy now supports OVERPASS_URLS (comma-separated, round-robin fallback)
with OVERPASS_URL as a single-entry backward-compat alias; update the switch
path note to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:43:03 +02:00
Ullrich Schäfer
d20ec30bce
Add docs/roadmap.md and update CLAUDE.md
- Add docs/roadmap.md: strategic four-phase launch plan (feature complete →
  polish → beta → announce) with launch-blocking changes, post-launch backlog,
  and links to ideas/
- Update CLAUDE.md: fix stale phase-1-mvp OpenSpec reference, remove "Phase 2"
  label from Federation, add roadmap + ideas pointers, expand packages list
  with all 11 packages and accurate descriptions (including map vs. map-core
  split rationale)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:41:51 +02:00
Ullrich Schäfer
265cf34dc1
Merge pull request #406 from trails-cool/fix/journal-audit-omnibus
fix(journal): architectural audit omnibus (8 fixes)
2026-05-24 10:32:20 +02:00
dependabot[bot]
095dd744a5 [github-actions] pnpm dedupe 2026-05-24 08:31:54 +00:00
dependabot[bot]
01d8f433f1
Bump the production group with 14 updates
Bumps the production group with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.16.7` | `0.19.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.59.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.6` | `4.1.7` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.7` | `8.0.8` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.2` | `3.4.3` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.11.1` | `8.12.0` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.7.0` | `5.8.0` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.25.0` | `4.25.2` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.14` | `19.2.15` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.6` | `4.1.7` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.6` | `4.1.7` |
| [@garmin/fitsdk](https://github.com/garmin/fit-javascript-sdk) | `21.202.0` | `21.205.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.53.1` |


Updates `@expo/fingerprint` from 0.16.7 to 0.19.2
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `typescript-eslint` from 8.59.3 to 8.59.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/typescript-eslint)

Updates `vitest` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

Updates `nodemailer` from 8.0.7 to 8.0.8
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.8)

Updates `@sentry/cli` from 3.4.2 to 3.4.3
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.2...3.4.3)

Updates `@sentry/react-native` from 8.11.1 to 8.12.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.11.1...8.12.0)

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

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

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

Updates `@vitest/browser` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/browser-playwright)

Updates `@garmin/fitsdk` from 21.202.0 to 21.205.0
- [Release notes](https://github.com/garmin/fit-javascript-sdk/releases)
- [Commits](https://github.com/garmin/fit-javascript-sdk/compare/21.202.0...21.205.0)

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

---
updated-dependencies:
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.25.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@garmin/fitsdk"
  dependency-version: 21.205.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-24 08:31:11 +00:00
Ullrich Schäfer
4de6c86d41
fix(journal): architectural audit omnibus
Addresses 8 issues from the Journal architecture audit:

1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
   these tables were full table scans; adds composite indexes matching
   the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
   destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
   in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
   a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
   retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
    lib/config.server.ts::getOrigin() across 14 call sites.

Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.

Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
  caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
  the new welcome-email enqueue assertion)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:33 +02:00
Ullrich Schäfer
a91085ab52
Merge pull request #405 from trails-cool/stigi/openspec-archive-cleanup
Clean up komoot-import openspec archive and SOPS secrets
2026-05-23 21:37:35 +02:00
Ullrich Schäfer
771ae57931
Archive komoot-import openspec change and update SOPS secrets
- Stage deletions of openspec/changes/komoot-import/ (files were moved
  to archive via shell mv, not git mv, so deletions weren't staged)
- Include SOPS secrets.app.env update adding INTEGRATION_SECRET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:33:53 +02:00
Ullrich Schäfer
579b1477f2
Merge pull request #404 from trails-cool/stigi/integration-secret-wiring
Wire INTEGRATION_SECRET into docker-compose and CI
2026-05-23 21:13:44 +02:00
Ullrich Schäfer
ae23d31a5f
Wire INTEGRATION_SECRET into docker-compose and CI; archive komoot-import
- Add INTEGRATION_SECRET to journal service in docker-compose.yml with
  :? guard so a missing value fails loudly at compose-up time
- Add INTEGRATION_SECRET to E2E test step in ci.yml via GitHub secret
  (unit tests already set their own value in the test file)
- Archive openspec/changes/komoot-import → archive/2026-05-23-komoot-import
- Sync delta specs: new openspec/specs/komoot-import/spec.md,
  updated openspec/specs/route-management/spec.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:10:08 +02:00
Ullrich Schäfer
5158298424
Merge pull request #403 from trails-cool/stigi/disconnect-redirect-fix
Return to originating settings page after disconnecting a service
2026-05-23 20:52:36 +02:00
Ullrich Schäfer
ec371ac400
Simplify: typed status, skip-write guard, remove redundant comment
- Use ImportBatchStatus type instead of raw string literals in sweep job
- Add a COUNT pre-check so the sweep UPDATE is skipped when no stale
  batches exist (avoids an unconditional write every minute)
- Remove comment in disconnect route that explained what the code does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:49:09 +02:00
Ullrich Schäfer
da3659e07a
Also redirect to /settings/connections when disconnecting from a provider page 2026-05-23 20:46:52 +02:00
Ullrich Schäfer
12371d8c89
Return to the settings page the user came from after disconnecting
Previously always redirected to /settings (which resolves to /settings/profile).
Now reads the Referer header and redirects back to the originating /settings/*
page, defaulting to /settings/connections if no valid referer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:46:29 +02:00
Ullrich Schäfer
20821f3153
Merge pull request #402 from trails-cool/stigi/import-batches-sweep
Add cron sweep for stale import batches
2026-05-23 19:12:32 +02:00
Ullrich Schäfer
a181dfe6b8
Add cron job to sweep stale import batches every minute
Batches stuck in pending or running for more than 10 minutes (server
restart mid-import, pg-boss job dropped) are marked failed with a
user-visible message. Runs every minute via pg-boss cron with a 55s
expiry so overlapping runs are dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:08:49 +02:00
Ullrich Schäfer
64624cb13c
Merge pull request #401 from trails-cool/sorting
Add activity sort toggle on activities page
2026-05-23 18:26:06 +02:00
Ullrich Schäfer
22ff86c585
Merge pull request #400 from trails-cool/stigi/komoot-bulk-import
Add background bulk import for Komoot
2026-05-23 18:22:50 +02:00
Ullrich Schäfer
88078090e6
Add activity sort toggle on activities page
Same sort toggle as the user profile page (#399): default is activity
date (startedAt), switchable to "Date added" (createdAt) via ?sort=addedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:21:04 +02:00
Ullrich Schäfer
5ff7af8a81
Add background bulk import for Komoot
Replace the per-tour import UI with a fire-and-forget background job:

- Add `import_batches` DB table tracking status, found/imported/duplicate counts
- Add `runKomootBulkImport` server function that pages all Komoot tours,
  fetches GPX, creates activities, and deduplicates via sync_imports
- Add `komoot-bulk-import` pg-boss job registered at server startup
- Add POST /api/sync/komoot/import to enqueue the job
- Add GET /api/sync/komoot/import-status to return the latest batch
- Replace the Komoot import page with a progress UI that polls every 2s
  while a batch is running and shows found/imported/skipped counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:19:10 +02:00
Ullrich Schäfer
386867ef6a
Merge pull request #399 from trails-cool/stigi/activity-sort-toggle
Add activity sort toggle on user profile
2026-05-23 18:09:41 +02:00
Ullrich Schäfer
8641b0ad90
Add activity sort toggle on user profile page
Default sort is by activity date (startedAt); users can switch to
"Date added" (createdAt) via a URL query param (?sort=addedAt).
Activities without a startedAt fall to the bottom when sorted by date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:06:06 +02:00
Ullrich Schäfer
3f1d043377
Merge pull request #398 from trails-cool/stigi/komoot-import
Fix import-all skipping every other workout
2026-05-23 13:08:09 +02:00
Ullrich Schäfer
b25c5a9505
Fix import-all skipping every other workout
When a workout is imported and the loader revalidates, importableWorkouts
shrinks. The snapshot-update useEffect was overwriting the ref with the
shorter list, causing the index to point to the wrong item and skip every
alternate workout. Remove the snapshot update so the original list is used
throughout the import-all session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:00:43 +02:00
Ullrich Schäfer
45b61342be
Merge pull request #397 from trails-cool/stigi/komoot-import
Add Komoot import (public bio verification + authenticated)
2026-05-23 11:12:39 +02:00
Ullrich Schäfer
45c40ecea3
Make listImportable resilient to Komoot API errors; fix import E2E test
Return empty list instead of throwing when Komoot API is unavailable (e.g.
in CI with a synthetic user ID). Replaces direct API call in the import
test with a page-context test that verifies the page loads correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:05:15 +02:00
Ullrich Schäfer
4a5319fa72
Rewrite komoot E2E tests to use server-side seed endpoint
page.route() only intercepts browser requests, not server-side fetch calls.
Add /api/e2e/komoot seed endpoint that creates a Komoot connection directly
and returns a session cookie, so tests can verify UI state without mocking
the Komoot API at the network layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:57:16 +02:00
Ullrich Schäfer
25f7d35e03
Fix E2E: associate labels with inputs via htmlFor/id; increase test timeouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:49:18 +02:00
Ullrich Schäfer
9959ce90e6
Fix E2E: accept Terms of Service checkbox before passkey registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:34:26 +02:00
Ullrich Schäfer
5c299ab9f3
Mark all komoot-import tasks complete 2026-05-23 10:19:28 +02:00
Ullrich Schäfer
03304c354b
Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking
that the user's trails.cool profile URL appears in their Komoot bio — no
credentials stored. Authenticated mode uses email + password (AES-256-GCM
encrypted) to import private tours as well.

Includes unit tests for crypto/komoot client and E2E tests for the full
connect + import flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:18:46 +02:00
Ullrich Schäfer
b63fd1a303
Merge pull request #396 from trails-cool/stigi/komoot-import-two-mode-spec
Komoot import: add public mode via bio verification
2026-05-23 10:00:21 +02:00
Ullrich Schäfer
fef4051838
Add public import mode to Komoot import spec
Extends the Komoot import change to support two connection modes:

- Public mode: user places their trails.cool profile URL in their Komoot
  bio field; trails.cool verifies ownership via the unauthenticated public
  API (content_text field), then imports public tours with no credentials stored
- Authenticated mode: existing email + password flow, imports all tours
  including private ones

The profile URL verification doubles as cross-platform discovery — the
link stays in the Komoot bio permanently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:59:51 +02:00
Ullrich Schäfer
08449b01c9
Merge pull request #395 from trails-cool/stigi/e2e-planner-split
Split planner E2E tests into focused feature files
2026-05-22 22:05:41 +02:00
Ullrich Schäfer
d1a3701720
Fix e2e-testing spec: add missing Purpose section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:01:33 +02:00
Ullrich Schäfer
0b146f9b32
Split planner E2E tests into focused feature files
Breaks the monolithic planner.test.ts (581 lines, 25 tests) into five
focused files grouped by feature area, with BRouter mocked by default
via test.beforeEach in files that need routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:58:10 +02:00
Ullrich Schäfer
72c01eb18c
Merge pull request #394 from trails-cool/stigi/waypoint-notes-journal
Propagate waypoint notes and POI data through full round-trip
2026-05-18 21:07:27 +02:00
Ullrich Schäfer
9e2ca5595e
Centralize waypoint Yjs serialization in waypointFromYMap/waypointToYMap
Introduce waypoint-ymap.ts with typed helpers so all Yjs↔Waypoint
conversions go through one place. New Waypoint fields now only need
to be added once rather than in every consumer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:02:27 +02:00
Ullrich Schäfer
02f8a8be44
Propagate waypoint notes through session init, save-to-journal, and Journal display
- use-yjs: seed note from initialWaypoints on session open
- api.sessions: include note in initialWaypoints type
- SaveToJournalButton: include note in GPX waypoints on save
- Journal routes.: map and display waypoint notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:52:12 +02:00
Ullrich Schäfer
353dcb2c13
Merge pull request #393 from trails-cool/stigi/waypoint-notes-e2e
Add E2E tests for waypoint notes + nearby POI snap; archive waypoint-notes
2026-05-18 07:42:25 +02:00
Ullrich Schäfer
aef67aab7a
Include waypoint note in GPX export and extractWaypoints type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:38:55 +02:00
Ullrich Schäfer
845301f0ae
Persist waypoint note from GPX drag-drop import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:30:31 +02:00
Ullrich Schäfer
112a8ab714
Rewrite note E2E: test GPX roundtrip via drag-drop instead of UI blur
The controlled textarea blur via el.blur() leaves noteEditValue stale
in the React closure. Instead, import a GPX with <desc> on a <wpt>
via drag-drop and verify it roundtrips correctly through export —
this tests the actual guarantee (parse+generate) more directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:12:48 +02:00
Ullrich Schäfer
e9e80c4e54
Fix E2E note save: use el.blur() and wait for textarea to disappear
sidebar h2 click doesn't reliably blur the textarea in headless Chrome.
el.blur() directly fires the native blur event; waiting for textarea
to disappear confirms onBlur executed and the note was saved to Yjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:30:38 +02:00
Ullrich Schäfer
1ad5c6be52
Fix E2E: click ▾ chevron to open export dropdown, not Export GPX button
The Export GPX text button directly downloads a route-only GPX.
The ▾ chevron opens the dropdown with Export Plan (includes waypoints).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 +02:00
Ullrich Schäfer
9cd08f0ebb
Fix E2E: wait for Export dropdown before clicking Export Plan
The dropdown renders asynchronously after clicking Export GPX;
wait for 'Export Plan' text to be visible before clicking it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:20:48 +02:00
Ullrich Schäfer
5d61553e02
Fix E2E: click Export Plan (not Export Route) for GPX with waypoints
The Export GPX button shows a dropdown; "Export Route" omits waypoints
while "Export Plan" includes them with notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:14:47 +02:00
Ullrich Schäfer
b4dd01db41
Fix spec format and E2E test robustness
- Fix openspec/specs/waypoint-notes/spec.md: replace delta-spec header
  with proper ## Purpose + ## Requirements structure so validation passes
- Fix GPX export E2E assertion: check gpxText directly instead of regex
  matching on lat coordinate; use click-elsewhere-to-blur instead of
  .blur() for more reliable note save timing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:10:34 +02:00
Ullrich Schäfer
41217ef658
Add E2E tests for waypoint notes and nearby POI snap; archive waypoint-notes
- E2E: type a note, blur, verify it persists and appears in GPX <desc> on <wpt>
- E2E: mock Overpass, select waypoint, verify Nearby list, snap to POI,
  verify note prefix prepended
- Sync waypoint-notes delta spec → openspec/specs/waypoint-notes/spec.md
- Archive waypoint-notes change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:06:07 +02:00
Ullrich Schäfer
763aa475ea
Merge pull request #392 from trails-cool/stigi/waypoint-notes
Add per-waypoint notes and nearby POI snap to Planner
2026-05-18 00:01:45 +02:00
Ullrich Schäfer
c2abb64ee0
Add per-waypoint notes and nearby POI snap to Planner
- `note` field on Waypoint type, stored in Yjs Y.Map and exported as
  `<desc>` inside `<wpt>` in GPX
- Inline textarea note editing in WaypointSidebar with auto-resize,
  character counter (500 max), save-on-blur, Escape cancel
- Note indicator dot on map markers; note tooltip on hover
- `useNearbyPois` hook: fetches POIs within 500m of selected waypoint
  via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit
  suppression
- NearbyPoiMarkers component: renders POI markers on map for selected
  waypoint with snap-to-POI on click
- Nearby section in WaypointSidebar: list with snap buttons, spinner,
  empty/rate-limited states, "Show more" toggle (5 → all)
- Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs
  transaction behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:58:21 +02:00
Ullrich Schäfer
bcf551cd27
Merge pull request #391 from trails-cool/stigi/journal-poi-details
Show POI details on Journal route detail page
2026-05-17 23:38:37 +02:00
Ullrich Schäfer
d55981d1bf
Add docs/gpx-extensions.md documenting the trails: GPX namespace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:35:06 +02:00
Ullrich Schäfer
faf2227896
Archive journal-poi-details change; sync journal-route-detail spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:33:37 +02:00
Ullrich Schäfer
861701e881
Show POI details (phone, website, opening hours) on Journal route detail page
Waypoints snapped to OSM POIs in the Planner now carry their metadata all
the way through to the Journal:

- Extend Waypoint type with osmId and poiTags fields
- Extract osmId/poiTags from Yjs Y.Map in ExportButton and SaveToJournalButton
- Encode POI metadata as <trails:poi> extensions in GPX <wpt> elements
- Parse <trails:poi> extensions back in the GPX parser
- Display phone, website, opening hours, address on Journal route detail
- E2E test for the full roundtrip; seed endpoint now defaults to public visibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:32:38 +02:00
Ullrich Schäfer
f1a314a70d
Merge pull request #390 from trails-cool/stigi/update-vite
Update vite to 8.0.13
2026-05-17 23:21:33 +02:00
Ullrich Schäfer
cff5b20f0f
Fix catalog key ordering in pnpm-workspace.yaml after pnpm install
pnpm reordered catalog keys alphabetically; also locks vite to ^8.0.13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:17:18 +02:00
Ullrich Schäfer
6c7bf6baeb
Merge pull request #389 from trails-cool/stigi/local-dev-stack
Local dev stack improvements
2026-05-17 23:15:39 +02:00
Ullrich Schäfer
c3c0ffeeab
Update vite to 8.0.13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:14:14 +02:00
Ullrich Schäfer
970e0a0755
Archive local-dev-stack change; sync delta spec to main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:24 +02:00
Ullrich Schäfer
03791e981d
Mark verification tasks complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:24 +02:00
Ullrich Schäfer
d2a0b6e398
Fix BRouter segment cache key: include version to avoid format mismatch
The segment format changed between BRouter 1.7.8 and 1.7.9. The old
cache key 'brouter-segment-E10_N50' served the v1.7.8 segment to the
v1.7.9 binary, causing 'lookup version mismatch (old rd5?)' errors on
every routing request.

Include the BRouter version in the cache key so a fresh segment is
downloaded whenever the binary version changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
66ce852e96
Add BRouter routing wait diagnostic output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
1dcc518242
Pre-seed BRouter volume in CI instead of bind mount override
The bind mount override (docker-compose.ci.yml) failed because file
permissions on the cached segment prevented the entrypoint from seeing
the file, causing a fresh download on every CI run.

Instead: create the named volume and copy the cached segment into it
before compose starts, using alpine with explicit chmod. The entrypoint
then finds the segment and skips the download, so BRouter starts in ~4s
instead of ~2min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
bea391c9fd
Increase BRouter routing wait to 120s in CI
BRouter needs time to parse the segment file into memory before it can
serve routing requests. 90s was insufficient; 120s matches the margin
the old explicit startup loop provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
134cf4ccf1
Simplify BRouter healthcheck to port liveness; add routing wait in CI
The routing probe healthcheck was too strict — BRouter is up but may
not have the segment loaded yet, causing all probes to fail. Use a
simple liveness check (curl exit 0 when server responds on any code)
so --wait unblocks as soon as the server is up. Add an explicit
routing readiness wait step in CI that actually confirms a route can
be computed before running E2E tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
4d0a865437
Fix BRouter healthcheck: use literal | and increase retry window
Percent-encoding the | may confuse older curl; use a literal | inside
single quotes instead. Also bump retries from 12 to 18 (total window
30s start + 180s probing) to cover slow first-run segment downloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
8fd3dda8c6
Add BRouter healthcheck so compose --wait blocks until routing is ready
Without a healthcheck, --wait considers BRouter healthy as soon as the
container starts, before it has loaded segments and opened its port.
The healthcheck probes the routing endpoint directly, matching the old
manual curl loop in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
a2d125922e
Fix Grafana dev provisioning: mount only datasources+dashboards
Mounting the full production provisioning dir pulled in the alerting
config which requires Pushover secrets. Mount only the two subdirs
that make sense locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
10e0a8d41a
Local dev stack improvements
- Extend docker-compose.dev.yml with optional monitoring profile
  (Prometheus, Grafana, Loki) via --profile monitoring
- Align dev PostgreSQL with production: pg_stat_statements + init scripts
- Add scripts/seed.ts with idempotent Berlin test data (user, route, activity)
- Add pnpm db:seed and pnpm dev:reset scripts
- Simplify CI e2e job: replace manual docker run + BRouter setup with
  docker compose up --wait; add db:seed step
- Improve scripts/dev.sh: Docker check, --wait health checks, --monitoring
  flag, seed step
- Add scripts/reset-dev.sh to wipe and restart the local stack
- Add .env.development.example with documented local defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:22 +02:00
Ullrich Schäfer
d7b3c7c1a5
Merge pull request #385 from trails-cool/dependabot/github_actions/stefanzweifel/git-auto-commit-action-7
Bump stefanzweifel/git-auto-commit-action from 5 to 7
2026-05-17 20:53:52 +02:00
Ullrich Schäfer
9926b5ccd6
Merge pull request #386 from trails-cool/dependabot/npm_and_yarn/production-dec2a9cf1b
Bump the production group with 40 updates
2026-05-17 20:52:21 +02:00
Ullrich Schäfer
6c3f8a3c38
Merge pull request #388 from trails-cool/stigi/archive-staging-environments-spec
Archive staging-environments change; update local-dev spec
2026-05-17 20:46:07 +02:00
Ullrich Schäfer
61b787ca84
Fix staging-environment spec: add missing Purpose section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:43:00 +02:00
Ullrich Schäfer
0e77ac7831
Update local-dev-stack proposal: staging now exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:41:03 +02:00
Ullrich Schäfer
9d4c7d2b41
Archive staging-environments change; update local-dev spec
Staging environments are live and fully operational. Marks the remaining
verification tasks complete, syncs the delta spec to main specs, and
archives the change.

Also adds a "When to Use Local vs. Staging" reference table to the
local-dev-environment spec so the boundary between the two environments
is documented in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:38:07 +02:00
dependabot[bot]
f04e9995ed [github-actions] pnpm dedupe 2026-05-17 08:46:41 +00:00
dependabot[bot]
78a986b946
Bump the production group with 40 updates
Bumps the production group with 40 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.23` | `55.0.24` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [@sentry/vite-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) | `5.2.1` | `5.3.0` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.0` |
| [i18next](https://github.com/i18next/i18next) | `26.0.10` | `26.2.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.7` | `17.0.8` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.12` | `2.9.14` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.2` | `8.59.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.1.1` | `11.2.1` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.1` | `3.4.2` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.11.0` | `8.11.1` |
| [expo-crypto](https://github.com/expo/expo/tree/HEAD/packages/expo-crypto) | `55.0.14` | `55.0.15` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.32` | `55.0.34` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.27` | `55.0.29` |
| [expo-device](https://github.com/expo/expo/tree/HEAD/packages/expo-device) | `55.0.16` | `55.0.17` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.19` | `55.0.20` |
| [expo-localization](https://github.com/expo/expo/tree/HEAD/packages/expo-localization) | `55.0.13` | `55.0.14` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `55.1.9` | `55.1.10` |
| [expo-navigation-bar](https://github.com/expo/expo/tree/HEAD/packages/expo-navigation-bar) | `55.0.12` | `55.0.13` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.22` | `55.0.23` |
| [expo-secure-store](https://github.com/expo/expo/tree/HEAD/packages/expo-secure-store) | `55.0.13` | `55.0.14` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.20` | `55.0.21` |
| [expo-sqlite](https://github.com/expo/expo/tree/HEAD/packages/expo-sqlite) | `55.0.15` | `55.0.16` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.17` | `55.0.18` |
| [expo-web-browser](https://github.com/expo/expo/tree/HEAD/packages/expo-web-browser) | `55.0.15` | `55.0.16` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.24.0` | `4.25.0` |
| [use-latest-callback](https://github.com/satya164/use-latest-callback) | `0.3.3` | `0.3.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.42.1` | `6.43.0` |
| [ws](https://github.com/websockets/ws) | `8.20.0` | `8.20.1` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.5` | `4.1.6` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.5` | `4.1.6` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.15.0` | `7.15.1` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.15.0` | `7.15.1` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.15.0` | `7.15.1` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.52.0` | `10.53.1` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.53.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.18` | `22.19.19` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.15.0` | `7.15.1` |


Updates `expo` from 55.0.23 to 55.0.24
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `@playwright/test` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `@sentry/vite-plugin` from 5.2.1 to 5.3.0
- [Release notes](https://github.com/getsentry/sentry-javascript-bundler-plugins/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/5.2.1...5.3.0)

Updates `eslint` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.0)

Updates `i18next` from 26.0.10 to 26.2.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.10...v26.2.0)

Updates `playwright` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `react-i18next` from 17.0.7 to 17.0.8
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.7...v17.0.8)

Updates `turbo` from 2.9.12 to 2.9.14
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.12...v2.9.14)

Updates `typescript-eslint` from 8.59.2 to 8.59.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.3/packages/typescript-eslint)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

Updates `@maplibre/maplibre-react-native` from 11.1.1 to 11.2.1
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.1.1...v11.2.1)

Updates `@sentry/cli` from 3.4.1 to 3.4.2
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.1...3.4.2)

Updates `@sentry/react-native` from 8.11.0 to 8.11.1
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.11.0...8.11.1)

Updates `expo-crypto` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-crypto/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-crypto)

Updates `expo-dev-client` from 55.0.32 to 55.0.34
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.27 to 55.0.29
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-menu/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-device` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-device/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-device)

Updates `expo-file-system` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-localization` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-localization/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-localization)

Updates `expo-location` from 55.1.9 to 55.1.10
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-navigation-bar` from 55.0.12 to 55.0.13
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-navigation-bar/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-navigation-bar)

Updates `expo-notifications` from 55.0.22 to 55.0.23
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-notifications/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-secure-store` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-secure-store/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-secure-store)

Updates `expo-splash-screen` from 55.0.20 to 55.0.21
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-splash-screen/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-sqlite` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-sqlite/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-sqlite)

Updates `expo-system-ui` from 55.0.17 to 55.0.18
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-system-ui/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `expo-web-browser` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-web-browser/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-web-browser)

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

Updates `use-latest-callback` from 0.3.3 to 0.3.4
- [Release notes](https://github.com/satya164/use-latest-callback/releases)
- [Changelog](https://github.com/satya164/use-latest-callback/blob/main/CHANGELOG.md)
- [Commits](https://github.com/satya164/use-latest-callback/compare/v0.3.3...v0.3.4)

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

Updates `ws` from 8.20.0 to 8.20.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

Updates `@vitest/browser` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/browser-playwright)

Updates `@react-router/dev` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.15.1/packages/react-router-dev)

Updates `@react-router/node` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.15.1/packages/react-router-node)

Updates `@react-router/serve` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.15.1/packages/react-router-serve)

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

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

Updates `@types/node` from 22.19.18 to 22.19.19
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-router` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.1/packages/react-router)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/vite-plugin"
  dependency-version: 5.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: playwright
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-crypto
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-device
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-localization
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 55.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-navigation-bar
  dependency-version: 55.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-secure-store
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-sqlite
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-web-browser
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: use-latest-callback
  dependency-version: 0.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 08:45:54 +00:00
dependabot[bot]
7bf8a918af
Bump stefanzweifel/git-auto-commit-action from 5 to 7
Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 08:22:36 +00:00
Ullrich Schäfer
264276d1de
Merge pull request #384 from trails-cool/stigi/visual-diff-pr-comment
Post visual diff comment and job summary on visual test failure
2026-05-10 21:56:43 +02:00
Ullrich Schäfer
e059f36db9
Remove broken data URI images from job summary 2026-05-10 21:53:17 +02:00
Ullrich Schäfer
e662212dca
Post visual diff comment and job summary on visual test failure
When visual tests fail on a PR:
- Writes diff images (base64 data URIs) to the job summary for inline viewing
- Posts a PR comment listing failing tests with a link to the job summary
- Uploads .vitest-attachments/ as an artifact (include-hidden-files: true)

The job now has pull-requests: write permission for posting the comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:51:28 +02:00
Ullrich Schäfer
81334e977c
Merge pull request #383 from trails-cool/stigi/visual-diff-pr-comment
Post visual diff images as PR comment on failure
2026-05-10 20:13:30 +02:00
Ullrich Schäfer
7c74626464
Post visual diff images as PR comment on failure
When visual tests fail on a PR, upload each diff PNG to GitHub's CDN
via the issue assets endpoint and post a comment with the images
embedded inline. Also fixes the artifact upload path to point at
.vitest-attachments/ where the actual/diff files live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:10:03 +02:00
Ullrich Schäfer
2829ec2137
Merge pull request #381 from trails-cool/stigi/planner-gitignore
Ignore .vitest-attachments in planner
2026-05-10 19:57:18 +02:00
Ullrich Schäfer
eb30dddbfc
Ignore .vitest-attachments in planner
Vitest browser mode writes ephemeral screenshot attachments here during
test runs; only the __screenshots__/ reference snapshots belong in the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:53:42 +02:00
Ullrich Schäfer
e1e9fc6527
Merge pull request #380 from trails-cool/stigi/visual-tests-ci
Add visual-tests job to CI
2026-05-10 19:27:04 +02:00
Ullrich Schäfer
e5cb10fadc
Merge branch 'main' into stigi/visual-tests-ci 2026-05-10 19:23:13 +02:00
stigi
78a5a37214 chore: update visual snapshots [skip ci] 2026-05-10 17:15:46 +00:00
Ullrich Schäfer
4f8af21550
Fix update-visual-snapshots workflow: action versions + node version
- Switch checkout/setup-node/setup-pnpm to @v6 to match CI
- Replace node-version-file (.nvmrc doesn't exist) with node-version: 24
- Switch upload-artifact to @v7 to match CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:14:33 +02:00
Ullrich Schäfer
7c924827c9
Add initial visual snapshots and fix update script flag
Generate baseline screenshots (macOS/Chromium) for the elevation-chart
visual regression suite. The CI update-visual-snapshots workflow will
overwrite these with Linux variants on the first run.

Also fix the test:visual:update script: --update-snapshots is not a
valid Vitest 4 flag; the correct short form is -u.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:09:00 +02:00
Ullrich Schäfer
76028d277d
Remove invalid oxc.transform config from browser vitest config
OxcOptions doesn't have a transform key — Vite 8 handles TSX automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:05:15 +02:00
Ullrich Schäfer
fcc4f88379
Fix Vitest browser provider setup and test cleanup
- Add @vitest/browser-playwright; use playwright() factory (not string)
- Import page from vitest/browser (not deprecated @vitest/browser/context)
- Add afterEach(cleanup) so each test gets a fresh DOM
- Use oxc transform for JSX instead of esbuild (avoids Vite 8 warning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:01:24 +02:00
Ullrich Schäfer
49c0b2d1f4
Add visual-tests job to CI
Runs Vitest browser visual regression tests (drawElevationChart) on every
PR and push to main. Uses the same Playwright/Chromium cache as the e2e job.

On first run (no committed snapshots yet) the tests create the snapshots and
pass. On subsequent runs they compare against committed snapshots and fail on
visual regression. Failed runs upload a visual-snapshots-diff artifact so the
diff is visible in the Actions UI.

To update snapshots after an intentional visual change:
  Actions → "Update visual snapshots" → Run workflow (or add the
  `update-snapshots` label to the PR).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:56:57 +02:00
Ullrich Schäfer
01ab34c8d7
Merge pull request #379 from trails-cool/stigi/planner-component-split
Split ElevationChart + PlannerMap into deep modules; add visual regression testing
2026-05-10 16:45:15 +02:00
Ullrich Schäfer
34529a9432
Split ElevationChart + PlannerMap into deep modules; add visual regression testing
ElevationChart (1013 lines) split into:
- elevation-chart-draw.ts — pure drawElevationChart(ctx, w, h, params) function
- use-elevation-data.ts — useElevationData(routeData) hook
- ElevationChart.tsx — ~300 lines, interaction + render only

PlannerMap (869 lines) split into:
- MapHelpers.tsx — 7 Leaflet sub-components (MapExposer, RouteFitter, MapClickHandler,
  CursorTracker, NoGoAreaButton, OverlaySync, PoiRefresher)
- use-waypoint-manager.ts — all waypoint CRUD + route data sync
- use-gpx-drop.ts — GPX drag-and-drop hook
- PlannerMap.tsx — ~200 lines, orchestration only

Add Vitest browser visual regression tests for drawElevationChart via
@vitest/browser + Playwright (toMatchScreenshot). Tests cover plain, grade,
elevation, surface color modes plus hover and drag-select states.

Add update-visual-snapshots.yml workflow: triggered by workflow_dispatch or
the `update-snapshots` PR label; commits snapshots back to the branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:41:23 +02:00
Ullrich Schäfer
f843915c5a
Merge pull request #378 from trails-cool/stigi/spec-drift-catchup
Spec drift catch-up: FIT location, notification payload fields, shared-packages
2026-05-10 16:05:09 +02:00
Ullrich Schäfer
6c5fb6df0e
Spec drift catch-up: FIT location, notification payload fields, shared-packages index
- wahoo-import: clarify fitToGpx lives at connected-services/fit.ts (shared
  across providers) not inside the wahoo directory
- notifications: correct follow payload field names to followerUsername,
  followerDisplayName, targetUsername, targetDisplayName (matches code)
- shared-packages: add @trails-cool/fit package entry + CAPABILITIES.md index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:02:13 +02:00
Ullrich Schäfer
e6babd012a
Merge pull request #377 from trails-cool/stigi/arch-deepening-3-1-6
Deepen three architectural seams: FIT consolidation, host election, injectable db
2026-05-10 15:55:43 +02:00
Ullrich Schäfer
e387e1f798
Deepen three architectural seams: FIT consolidation, host election extraction, injectable db
- Extract shared fitToGpx into connected-services/fit.ts (FIT is a Garmin
  open standard used by Wahoo, Coros, Garmin — not provider-specific)
- Add importActivity() to sync/imports.server.ts so providers call one
  function instead of createActivity + recordImport separately; eliminates
  direct dependency on activities.server.ts from provider adapters
- Update wahoo importer + webhook to use both shared helpers
- Extract useHostElection(yjs) hook from useRouting so host election is
  independently testable without mounting the full routing stack
- Add setDb() to journal db.ts for module-level injection in unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:52:31 +02:00
Ullrich Schäfer
48d97be8f1
Merge pull request #376 from trails-cool/stigi/callback-e2e-test
Add e2e tests for Planner callback → geometry stored atomically
2026-05-10 15:27:59 +02:00
Ullrich Schäfer
557244ee87
Fix unused import lint error in api.e2e.route.$id.ts 2026-05-10 15:24:31 +02:00
Ullrich Schäfer
e7a0c132b9
Add e2e tests for Planner callback → geometry stored atomically
Introduces two E2E=true-gated test endpoints:
- POST /api/e2e/seed — creates a test user + bare route, returns routeId + JWT
- GET /api/e2e/route/:id — returns { hasGeom } for post-callback assertions

Three new integration tests:
- valid GPX via callback stores geometry (hasGeom = true)
- invalid GPX (< 2 track points) returns 400, geometry not stored
- missing token returns 401

CI: E2E=true added to the "Run E2E tests" step so the seed endpoints
are enabled when react-router-serve runs during the Playwright job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:18:33 +02:00
Ullrich Schäfer
4189187dd4
Merge pull request #375 from trails-cool/stigi/atomic-gpx-save
Atomic GPX save: validate + persist row + geometry in one transaction
2026-05-10 15:15:05 +02:00
Ullrich Schäfer
abc3fbaa5b
Archive atomic-gpx-save + sync gpx-save spec to main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:09:15 +02:00
Ullrich Schäfer
b17f8eb02a
Revert archive of atomic-gpx-save (will redo via skill) 2026-05-10 15:07:54 +02:00
Ullrich Schäfer
b4ef8b0e0e
Archive atomic-gpx-save change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:06:55 +02:00
Ullrich Schäfer
78b8b8f55f
Atomic GPX save: validate + persist row + geometry in one transaction
Eliminates the silent-failure pattern where a route/activity row could
be committed with gpx IS NOT NULL but geom IS NULL if the PostGIS write
failed after the row insert.

- New gpx-save.server.ts owns all GPX validation (GpxValidationError,
  validateGpx) and PostGIS geometry writes (writeGeom, tx-aware)
- createRoute, updateRoute, createActivity, createRouteFromActivity all
  wrapped in db.transaction() covering row + geom + version snapshot
- demo-bot uses createRoute/createActivity instead of raw inserts;
  errors propagate loudly
- Callback endpoint returns 400 for GpxValidationError instead of 401
- ADR-0006 documents the invariant for future explorers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:02:56 +02:00
Ullrich Schäfer
8ae58a2d26
Merge pull request #373 from trails-cool/dependabot/npm_and_yarn/development-c6c1c67e4a
Bump fit-file-parser from 2.3.3 to 3.0.0 in the development group across 1 directory
2026-05-10 13:27:13 +02:00
Ullrich Schäfer
890e1f3655
Merge branch 'main' into dependabot/npm_and_yarn/development-c6c1c67e4a 2026-05-10 13:24:03 +02:00
Ullrich Schäfer
b3003e8e80
Merge pull request #371 from trails-cool/dependabot/github_actions/peter-evans/find-comment-4
Bump peter-evans/find-comment from 3 to 4
2026-05-10 13:23:44 +02:00
Ullrich Schäfer
0aba220212
Merge branch 'main' into dependabot/github_actions/peter-evans/find-comment-4 2026-05-10 13:19:57 +02:00
Ullrich Schäfer
2ba43767db
Merge pull request #370 from trails-cool/dependabot/github_actions/peter-evans/create-or-update-comment-5
Bump peter-evans/create-or-update-comment from 4 to 5
2026-05-10 13:19:37 +02:00
dependabot[bot]
0e8978452a
Bump peter-evans/find-comment from 3 to 4
Bumps [peter-evans/find-comment](https://github.com/peter-evans/find-comment) from 3 to 4.
- [Release notes](https://github.com/peter-evans/find-comment/releases)
- [Commits](https://github.com/peter-evans/find-comment/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/find-comment
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:11:47 +00:00
dependabot[bot]
8f635fd5d1
Bump peter-evans/create-or-update-comment from 4 to 5
Bumps [peter-evans/create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) from 4 to 5.
- [Release notes](https://github.com/peter-evans/create-or-update-comment/releases)
- [Commits](https://github.com/peter-evans/create-or-update-comment/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/create-or-update-comment
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:11:45 +00:00
Ullrich Schäfer
984b67963a
Merge pull request #374 from trails-cool/fix/preview-deploy-race
Fix concurrent preview deploy race: per-PR env files + server flock
2026-05-10 13:10:46 +02:00
dependabot[bot]
9c5cd7d6e8
Bump fit-file-parser in the development group across 1 directory
Bumps the development group with 1 update in the / directory: [fit-file-parser](https://github.com/jimmykane/fit-parser).


Updates `fit-file-parser` from 2.3.3 to 3.0.0
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

---
updated-dependencies:
- dependency-name: fit-file-parser
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:09:33 +00:00
Ullrich Schäfer
be69abc0ee
Merge branch 'main' into fix/preview-deploy-race 2026-05-10 13:06:48 +02:00
Ullrich Schäfer
c42dff7f37
Merge pull request #372 from trails-cool/dependabot/npm_and_yarn/production-69e9f3c6a7
Bump the production group with 41 updates
2026-05-10 13:06:38 +02:00
Ullrich Schäfer
e23751f29e
Fix concurrent preview deploy race: per-PR env files + server flock
Three changes:

1. Skip GH-Actions-only Dependabot PRs (dependabot/github_actions/*) in
   deploy-preview — there is no app image to preview, and these PRs were
   landing phantom containers with mismatched ports.

2. Use per-PR env files (staging-pr-{N}.env) instead of the shared
   staging.env for preview deploys and teardowns. Concurrent SCP transfers
   to the same filename were overwriting each other, causing wrong
   JOURNAL_HOST_PORT / JOURNAL_IMAGE_TAG values to be used.

3. Serialize server-side deploy operations with a flock on
   /tmp/trails-preview-deploy.lock (300s timeout). Eviction + compose up
   must be atomic; without the lock, two simultaneous jobs could both see
   "3 active previews" and both evict different projects, or one could
   start compose up against a just-evicted env.

Triggered by a Dependabot batch today (PRs 371-373) that opened
simultaneously and produced a phantom trails-pr-371 container running
the pr-370 image on port 3940 while the Caddyfile expected 3942,
causing sustained 502s on pr-371.staging.trails.cool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:03:52 +02:00
Ullrich Schäfer
6cd779ae02
Merge branch 'main' into dependabot/npm_and_yarn/production-69e9f3c6a7 2026-05-10 13:03:21 +02:00
Ullrich Schäfer
952480e077
Merge pull request #367 from trails-cool/architecture/unify-auth-completion
Centralize web auth completion in completeAuth
2026-05-10 12:55:21 +02:00
Ullrich Schäfer
c86b444ece
Merge branch 'main' into architecture/unify-auth-completion 2026-05-10 12:51:23 +02:00
dependabot[bot]
9c985d908c [github-actions] pnpm dedupe 2026-05-10 08:43:29 +00:00
dependabot[bot]
9cc08a725a
Bump the production group with 41 updates
Bumps the production group with 41 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.19` | `55.0.23` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.5` | `19.2.0` |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.16.6` | `0.16.7` |
| [i18next](https://github.com/i18next/i18next) | `26.0.8` | `26.0.10` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.6` | `17.0.7` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.8` | `2.9.12` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.1` | `8.59.2` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.39` | `5.1.40` |
| [zod](https://github.com/colinhacks/zod) | `4.4.2` | `4.4.3` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `55.0.10` | `55.0.11` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.13` | `5.2.14` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.0.2` | `11.1.1` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.10.0` | `8.11.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `55.0.15` | `55.0.16` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.30` | `55.0.32` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.26` | `55.0.27` |
| [expo-device](https://github.com/expo/expo/tree/HEAD/packages/expo-device) | `55.0.15` | `55.0.16` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.17` | `55.0.19` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `55.0.14` | `55.0.15` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `55.1.8` | `55.1.9` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `55.0.13` | `55.0.14` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.19` | `55.0.20` |
| [expo-status-bar](https://github.com/expo/expo/tree/HEAD/packages/expo-status-bar) | `55.0.5` | `55.0.6` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.16` | `55.0.17` |
| [expo-web-browser](https://github.com/expo/expo/tree/HEAD/packages/expo-web-browser) | `55.0.14` | `55.0.15` |
| [react-native-gesture-handler](https://github.com/software-mansion/react-native-gesture-handler) | `2.31.1` | `2.31.2` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.3.0` | `4.3.1` |
| [jest-expo](https://github.com/expo/expo/tree/HEAD/packages/jest-expo) | `55.0.16` | `55.0.17` |
| [react-test-renderer](https://github.com/facebook/react/tree/HEAD/packages/react-test-renderer) | `19.2.5` | `19.2.6` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.41.1` | `6.42.1` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.14.2` | `7.15.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.14.2` | `7.15.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.14.2` | `7.15.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.52.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.52.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.4` | `4.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.17` | `22.19.18` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.5` | `19.2.6` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.2` | `7.15.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.4` | `4.3.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.2` | `7.3.3` |


Updates `expo` from 55.0.19 to 55.0.23
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `react` from 19.2.5 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react)

Updates `@expo/fingerprint` from 0.16.6 to 0.16.7
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `i18next` from 26.0.8 to 26.0.10
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.8...v26.0.10)

Updates `react-i18next` from 17.0.6 to 17.0.7
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.6...v17.0.7)

Updates `turbo` from 2.9.8 to 2.9.12
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.8...v2.9.12)

Updates `typescript-eslint` from 8.59.1 to 8.59.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.2/packages/typescript-eslint)

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

Updates `zod` from 4.4.2 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.2...v4.4.3)

Updates `@expo/metro-runtime` from 55.0.10 to 55.0.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@gorhom/bottom-sheet` from 5.2.13 to 5.2.14
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.13...v5.2.14)

Updates `@maplibre/maplibre-react-native` from 11.0.2 to 11.1.1
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.0.2...v11.1.1)

Updates `@sentry/react-native` from 8.10.0 to 8.11.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.10.0...8.11.0)

Updates `expo-constants` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 55.0.30 to 55.0.32
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.26 to 55.0.27
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-menu/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-device` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-device/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-device)

Updates `expo-file-system` from 55.0.17 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-linking/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 55.1.8 to 55.1.9
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-router` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-router/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-splash-screen` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-splash-screen/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-status-bar` from 55.0.5 to 55.0.6
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-status-bar/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-status-bar)

Updates `expo-system-ui` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-system-ui/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `expo-web-browser` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-web-browser/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-web-browser)

Updates `react-native-gesture-handler` from 2.31.1 to 2.31.2
- [Release notes](https://github.com/software-mansion/react-native-gesture-handler/releases)
- [Commits](https://github.com/software-mansion/react-native-gesture-handler/compare/v2.31.1...v2.31.2)

Updates `react-native-reanimated` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.3.1/packages/react-native-reanimated)

Updates `jest-expo` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/jest-expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/jest-expo)

Updates `react-test-renderer` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-test-renderer)

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

Updates `@react-router/dev` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.15.0/packages/react-router-dev)

Updates `@react-router/node` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.15.0/packages/react-router-node)

Updates `@react-router/serve` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.15.0/packages/react-router-serve)

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

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

Updates `@tailwindcss/vite` from 4.2.4 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-vite)

Updates `@types/node` from 22.19.17 to 22.19.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-dom` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

Updates `react-router` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.0/packages/react-router)

Updates `tailwindcss` from 4.2.4 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

Updates `vite` from 7.3.2 to 7.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.16.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 55.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-device
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 55.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-status-bar
  dependency-version: 55.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-web-browser
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-gesture-handler
  dependency-version: 2.31.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: jest-expo
  dependency-version: 55.0.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-test-renderer
  dependency-version: 19.2.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.42.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vite
  dependency-version: 7.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 08:42:40 +00:00
Ullrich Schäfer
e268aeec10
Archive unify-auth-completion + sync spec delta (task 5.1)
Applies the ADDED requirement from
openspec/changes/unify-auth-completion/specs/authentication-methods/
into openspec/specs/authentication-methods/spec.md:

- Single web auth completion chokepoint (completeAuth at
  apps/journal/app/lib/auth/completion.server.ts) with five scenarios
  covering passkey register/login finish, magic-link verify-code,
  magic-link click-through, and returnTo sanitization.

Path in the synced requirement is .server.ts (the post-rename name),
not the .ts the delta originally captured.

Change moved to openspec/changes/archive/2026-05-08-unify-auth-completion/.
15/15 tasks complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:02:51 +02:00
Ullrich Schäfer
b9aac2859a
Drop auth.server.ts re-exports + rename to .server.ts convention (task 5.2)
Two cleanups in one pass:

1. Update import paths app-wide from `~/lib/auth.server` to
   `~/lib/auth/session.server` for the four session helpers
   (sessionStorage, createSession, getSessionUser, destroySession).
   ~40 files: 33 simple path swaps where the file imported only session
   symbols, 5 splits where it also imported per-method auth functions
   (auth.verify.tsx, api.settings.email.ts, activities.\$id.tsx,
   routes.\$id.tsx, auth.accept-terms.tsx) — those keep one import
   from auth.server (for verifyMagicToken, canView, recordTermsAcceptance,
   etc.) and gain a second import from auth/session.server.
   Two more files used relative paths and were missed by the first
   grep pass (lib/oauth.server.ts and routes/oauth.authorize.tsx) —
   migrated too.
   The @deprecated re-exports block in auth.server.ts is gone.

2. Rename the new auth files to follow the project's `.server.ts`
   convention so Vite/React Router treat them as server-only (they
   read process.env.SESSION_SECRET, hit the DB, etc. — must NOT enter
   the client bundle):
   - auth/session.ts → auth/session.server.ts
   - auth/completion.ts → auth/completion.server.ts
   - auth/completion.test.ts → auth/completion.server.test.ts
   Done with `git mv` so blame is preserved.

Verified: typecheck + lint green; 126 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:01:30 +02:00
Ullrich Schäfer
fc2f938cf5
Mark task 4.3 (manual smoke) complete
All four flows verified locally over plain HTTP: passkey register,
passkey login, logout, magic-link 6-digit verify-code, magic-link
click-through. Session cookie set + correct redirect every time.

13/14 tasks done. Only 5.1 (spec delta sync at /opsx:archive time)
and 5.2 (optional follow-up to drop the auth.server.ts re-exports)
remain — neither blocks merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:55:19 +02:00
Ullrich Schäfer
2fdbe5281d
Merge pull request #368 from trails-cool/ci-openspec-validate
ci: validate openspec specs and changes
2026-05-08 02:40:38 +02:00
Ullrich Schäfer
d64c47614d
Implement completeAuth chokepoint + caller migration
Implements all of unify-auth-completion (12/14 tasks done; manual
smoke + archive-time spec sync remain).

Design refinement during implementation: completeAuth supports two
response shapes via a `mode` parameter:
- mode: 'redirect' (loaders / direct browser navigation; auth.verify.tsx)
- mode: 'json' (action handlers called by imperative fetch from
  client forms; api.auth.login, api.auth.register)

Both modes share createSession + safeReturnTo + Set-Cookie. JSON mode
carries `{ ok: true, step: "done", redirectTo }` (the `step` field
preserves the existing client-form check).

Why two modes: passkey ceremonies are inherently imperative
(start → browser WebAuthn API → finish), so action handlers can't
move to <Form>/useFetcher. Picking option (B) from the design grill —
the chokepoint owns destination selection while clients navigate —
required this dual shape. The 3 hardcoded client-side targets
(returnTo ?? "/", "/", "/?add-passkey=1") collapse into 1 server-side
sanitization pass (safeReturnTo) inside completeAuth.

New module:
- apps/journal/app/lib/auth/session.ts: cookie session storage
  (sessionStorage, createSession, getSessionUser, destroySession)
  moved from auth.server.ts. Legacy import path kept via re-exports
  with @deprecated JSDoc.
- apps/journal/app/lib/auth/completion.ts: completeAuth + safeReturnTo.
- apps/journal/app/lib/auth/completion.test.ts: 10 contract tests
  covering both modes, returnTo sanitization (path-relative, protocol-
  relative, absolute-URL, malformed), Set-Cookie attachment, redirect
  status, JSON shape.

Caller migration:
- api.auth.register.ts passkey-finish → completeAuth(json)
- api.auth.login.ts finish-passkey → completeAuth(json)
- api.auth.login.ts verify-code → completeAuth(json)
- auth.verify.tsx magic-link consumer → completeAuth(redirect)

Client form updates:
- auth.login.tsx: pass returnTo in fetch body, read result.redirectTo
  on done.
- auth.register.tsx: pass returnTo: "/?add-passkey=1" for the magic-
  link verify-code path (preserves the post-register passkey prompt
  via the chokepoint's safeReturnTo, instead of hardcoding it
  client-side).

Verified:
- pnpm typecheck && pnpm lint: green across all 15 workspaces.
- pnpm --filter @trails-cool/journal test: 126 passed.
- pnpm test:e2e auth: 4/4 passed without modification — confirms the
  refactor is behaviour-preserving for the user-facing flows that
  matter most (passkey register + login).

Spec delta in openspec/changes/unify-auth-completion/specs/ applies at
/opsx:archive time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:38:15 +02:00
Ullrich Schäfer
9cf6398a76
ci: pin openspec via devDependency
Adds @fission-ai/openspec ^1.3.1 as a root devDependency so local and CI
run the same lockfile-pinned version, and dependabot can bump it. CI now
runs `pnpm openspec validate --all --strict` instead of npx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:36:53 +02:00
Ullrich Schäfer
5b6fecf416
ci: fix openspec package name
The npm package `openspec` is an unrelated 0.0.0 placeholder; the real
CLI ships as `@fission-ai/openspec`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:35:10 +02:00
Ullrich Schäfer
b78c7b77e0
ci: validate openspec specs and changes
Adds a fast standalone job that runs `openspec validate --all --strict`
via npx, gating PRs on spec/change well-formedness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:33:39 +02:00
Ullrich Schäfer
7e22f1260b
Add unify-auth-completion architecture artifacts
Extract the post-verify orchestration shared across passkey
register-finish, passkey login-finish, magic-link verify-code, and
magic-link click-through into a single completeAuth function. Two
ADRs record the decision:

- ADR-0004: centralize web auth completion (record terms + create
  session + redirect) in apps/journal/app/lib/auth/completion.ts.
- ADR-0005: explicitly no AuthMethod polymorphism. Passkey + magic-
  link is the entire identity surface; OAuth2/PKCE is session
  transport, not a peer method. Recorded as a negative decision so
  future architecture passes don't re-suggest extracting the
  interface.

CONTEXT.md gains an Authentication section covering completeAuth, the
two methods, the OAuth2-as-transport distinction, and where the Terms
gate enforcement lives (root loader for web, requireApiUser for API
per the just-merged mobile-terms-gate).

OpenSpec change unify-auth-completion captures the proposal, design
(5 decisions including the negative-scope choices), spec delta on
authentication-methods, and 14 tasks. Implementation follows on this
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:18:06 +02:00
Ullrich Schäfer
1a0a212139
Merge pull request #366 from trails-cool/fix/mobile-terms-gate
fix: enforce Terms gate on bearer-token API requests
2026-05-08 02:06:19 +02:00
Ullrich Schäfer
1e43e96732
Archive mobile-terms-gate + sync spec delta
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:02:24 +02:00
Ullrich Schäfer
067e2ebd0b
fix: enforce Terms gate on bearer-token API requests
Mobile API requests authenticated via OAuth2 bearer tokens bypassed the
Terms gate that the root loader applies to web cookie sessions. Extend
requireApiUser to compare the user's termsVersion with TERMS_VERSION
and return a structured 403 { code: "TERMS_OUTDATED", currentTermsVersion }
on mismatch so mobile clients can surface their own re-acceptance UI.

Spec delta on journal-auth captures the new requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:59:28 +02:00
Ullrich Schäfer
eee5689a75
Merge pull request #361 from trails-cool/architecture/deepen-connected-services
Deepen connected-services architecture (sync provider seam)
2026-05-08 01:39:44 +02:00
Ullrich Schäfer
7cd785e937
Archive deepen-connected-services + sync spec deltas
Task 7.3: applies the MODIFIED + ADDED requirements from
changes/deepen-connected-services/specs/ into openspec/specs/:

- connected-services/spec.md:
  - MODIFIED: OAuth token storage (renamed sync_connections → connected_services
    with credential_kind discriminator + JSONB credentials shape).
  - ADDED: Capability seams for providers (Importer / RoutePusher /
    WebhookReceiver per ADR-0002, no unified SyncProvider).
  - ADDED: Centralized credential lifecycle via ConnectedServiceManager.

- wahoo-import/spec.md: Provider-agnostic framework rewritten to reference
  capability seams + per-provider manifest. Token refresh now goes through
  withFreshCredentials. Renames sync_connections → connected_services
  throughout.

- wahoo-route-push/spec.md: Renames sync_connections → connected_services.
  ADDED: RoutePusher capability seam — shape (service, route) →
  {remoteId, version} per ADR-0003; Wahoo workarounds stay inside the
  adapter.

Change directory moved to openspec/changes/archive/2026-05-08-deepen-connected-services/.

29/29 tasks complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:36:19 +02:00
Ullrich Schäfer
14da6e6b53
Mark manual smoke test complete (task 6.3)
Verified live against local dev with HTTPS:
- Wahoo OAuth connect flow
- Import list (Importer.listImportable + withFreshCredentials)
- Single workout import
- Route push (POST then PUT after edit)
- Disconnect + reconnect (unique constraint upsert)

28/29 tasks complete. Only 7.3 (spec deltas at archive) remains —
applied automatically by /opsx:archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:33:12 +02:00
Ullrich Schäfer
e6212ad6fc
Annotate komoot-import design with deepen-connected-services supersession
Task 7.2: when komoot-import is implemented, it must use the
connected_services + web-login credential kind shape, not a separate
journal.integrations table. Note added to komoot-import/design.md
referencing ADR-0001 and CONTEXT.md.

Also marks tasks 6.2 (no Wahoo e2e tests exist; nothing to run) and
7.1 (no CONTEXT.md term changes during impl) complete in tasks.md.

Remaining: 6.3 (manual smoke), 6.4 (staging migration test), 7.3
(spec deltas at archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:26:21 +02:00
Ullrich Schäfer
8e5b6d6fe9
Wahoo capability adapters + caller migration (groups 3-5)
Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of
deepen-connected-services. Built TDD: contract test red, adapter green
for each capability seam.

Capability adapters (providers/wahoo/):
- importer.ts: Importer seam — listImportable + importOne against
  Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't
  share third-party data). 2 contract tests green.
- pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId,
  version}. FIT-Course conversion, route:<id> external_id, PUT-vs-POST
  decision, PUT->POST-on-404 fallback all internal to the adapter
  (per ADR-0003). Idempotency via sync_pushes preserved. 4 contract
  tests green.
- webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes
  events to local users via provider_user_id; unknown user returns
  silently. 6 contract tests green.
- manifest.ts: declares credential_kind=oauth, OAuth config, scopes,
  buildAuthUrl, exchangeCode, and references each capability adapter.

Module shape:
- connected-services/index.ts: side-effect imports providers/index.ts
  to register manifests, then re-exports manager + registry + types.
- connected-services/providers/index.ts: barrel that calls
  registerManifest(wahooManifest).
- connected-services/oauth-state.server.ts: OAuth state encode/decode
  (extracted from legacy pushes.server.ts).
- connected-services/push-action.server.ts: orchestration above the
  RoutePusher seam — load route, ownership check, scope check, build
  RoutePushInput, invoke pusher, return PushOutcome union. Replaces
  the legacy pushRouteToProvider in lib/sync/pushes.server.ts.

Caller migration (group 5):
- api.sync.connect.$provider.ts -> manifest.buildAuthUrl
- api.sync.callback.$provider.ts -> manifest.exchangeCode + link()
- api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls
  best-effort revoke via the credential adapter, then deletes locally)
- api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch
- api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider
- sync.import.$provider.tsx -> manifest.importer.listImportable +
  inline FIT->GPX in the action (form-supplied metadata bypasses the
  Importer seam which is reserved for automatic / webhook imports)
- routes.$id.tsx -> getService instead of getConnection
- settings.connections.tsx -> getAllManifests instead of legacy registry

Legacy lib/sync/ deleted except imports.server.ts (which manages the
sync_imports table, untouched by this change).

DB migration verified locally (task 1.3): pnpm db:migrate-data renamed
the table and backfilled credentials JSONB; pnpm db:push then dropped
the legacy access_token/refresh_token/expires_at columns. Final shape
matches the schema; check constraints + unique index in place.

Test status (task 6.1):
- pnpm typecheck: green across all 15 workspaces
- pnpm lint: green
- @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than
  before because pushes.server.test.ts and the legacy wahoo.test.ts
  were deleted. Their coverage is replaced by the new contract tests
  (importer/pusher/webhook) plus manager.test.ts + oauth.test.ts.

Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test),
7.1-7.3 (followups + spec deltas at archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:25:33 +02:00
Ullrich Schäfer
5dda69ab49
Lint fix: drop unused ProviderOAuthConfig type import
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
6de516718d
Schema rename + ConnectedServiceManager foundation (groups 1-2)
Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:

DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
  credentials JSONB (shape per kind), status column, and a unique index
  on (user_id, provider) lifting the previously app-only invariant
  into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
  Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.

Code:
- New apps/journal/app/lib/connected-services/ module:
  - types.ts: ConnectedService, CredentialKind, OAuthCredentials,
    NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
  - credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
    revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
  - manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
    markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
  - registry.ts: ProviderManifest type + capability seam interfaces
    (Importer, RoutePusher, WebhookReceiver). Manifests register
    themselves at import time.

Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
  ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
  refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.

Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
  translating the legacy TokenSet API onto the JSONB-shaped table so
  existing callers (routes, pushes.server.ts) keep working until tasks
  5.x migrate them to the manager. To be deleted in task 5.6.

Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
cfba3146e2
Add deepen-connected-services architecture artifacts
Reshape the sync-providers seam before Komoot (web-login) and Apple
Health (device) adapters land. Captures the decisions in three ADRs,
seeds CONTEXT.md with Connected Services vocabulary, and proposes the
OpenSpec change covering schema rename + ConnectedServiceManager +
capability seams (Importer / RoutePusher / WebhookReceiver).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
ede480e652
Merge pull request #365 from trails-cool/e2e-hydration-and-workers
Stabilize e2e against Vite dev hydration race
2026-05-08 01:13:33 +02:00
Ullrich Schäfer
e6dc809e08
Drop workers=1 — hydration helper is sufficient
The hydration helper alone fixes the flake; restoring CPU-count
workers locally cuts the suite from 55s to 22s. Cover the two
remaining /auth/login navigations the prior commit missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:10:15 +02:00
Ullrich Schäfer
b7ed54ba72
Stabilize e2e against Vite dev hydration race
Two issues caused the same class of flake locally:

1. Default workers were CPU-count, but the journal/planner are served
   by Vite dev (not the production build CI uses). Cold-compiling
   `/api/auth/register` under N parallel hits produced 30s timeouts
   on a quarter of runs. Set workers to 1 in both environments for
   parity with CI.
2. Even sequentially, a button is clickable per Playwright's
   actionability check before React has hydrated its `onClick`. So
   the first click after a navigation could fire native form submit
   (or do nothing), which manifested as "URL never changed to /" or
   "menuitem Log Out never appeared". Add a `waitForHydration` helper
   that polls for React fibers (`__reactProps$<id>`) attached to a
   DOM node and call it after each cross-page navigation that ends
   in an interactive form or dropdown.

CI is unaffected (production builds hydrate fast and didn't expose
either bug), but the helper is harmless there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:06:27 +02:00
Ullrich Schäfer
fc2d0a02d5
Merge pull request #364 from trails-cool/e2e-silent-drop-sentry
Silently drop Sentry envelope requests in e2e fixture
2026-05-08 00:59:18 +02:00
Ullrich Schäfer
796dbea804
Silently drop Sentry envelope requests in e2e fixture
Two e2e tests intermittently failed with "unmocked external request"
when the Sentry browser SDK emitted an envelope to the real ingest
endpoint despite `enabled: false`. The BrowserTracing integration
captures the page-load transaction before init's enabled flag fully
propagates, so a single envelope leaks on cold start.

Add a SILENT_DROP list that aborts matching requests without recording
them as blocked, so legitimate missing-mock failures still surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:53:41 +02:00
Ullrich Schäfer
ddbf95f6bf
Merge pull request #363 from trails-cool/brouter-auto-segments
Auto-download BRouter segments on first container start
2026-05-08 00:47:17 +02:00
Ullrich Schäfer
46743a91db
Auto-download BRouter segments on first container start
Fresh `pnpm dev:services` checkouts came up with an empty
brouter_segments volume, so every routing request returned
"datafile not found" and the e2e suite's BRouter tests failed.

Add an entrypoint that runs download-segments.sh when /data/segments
has no rd5 files, then exec's the existing server command. Subsequent
starts find the populated volume and skip straight to the server.
Keep wget in the final image (previously stripped) so the entrypoint
can fetch segments at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:43:43 +02:00
Ullrich Schäfer
f1963aab7a
Merge pull request #362 from trails-cool/fit-typecheck
Add typescript to root devDependencies
2026-05-08 00:41:29 +02:00
Ullrich Schäfer
588323730f
Add typescript to root devDependencies
Workspace packages run `tsc` in their typecheck scripts but don't declare
typescript as a dep — they relied on it being hoisted. On a clean local
install the binary wasn't present at node_modules/.bin/tsc, so every
package failed with `tsc: command not found`. Declaring typescript at the
root makes the dependency explicit and removes the latent fragility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:37:42 +02:00
Ullrich Schäfer
946cfe7e44
Merge pull request #360 from trails-cool/stigi/preview-comment-update
Update single PR comment across preview deploy + teardown
2026-05-04 07:21:24 +02:00
Ullrich Schäfer
bdf9270f88
Update single PR comment across preview deploy + teardown
The deploy job was creating a fresh comment on every push, so PRs
accumulated a wall of preview-URL comments. Now we look up the existing
comment by an HTML marker (`<!-- cd-staging:preview -->`) and edit it
in place — both on push and on teardown. Comment also gains a link to
the GitHub Actions run that produced the preview, plus the head SHA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:17:57 +02:00
Ullrich Schäfer
fbe9882455
Merge pull request #359 from trails-cool/stigi/caddyfile-staging-ports
Update Caddyfile staging upstreams to ports 3110/3111
2026-05-03 22:52:14 +02:00
Ullrich Schäfer
8f5de34e50
Update Caddyfile staging upstreams to ports 3110/3111
Companion to PR #358 which moved the staging compose ports off 3100/3101
(Loki conflict on the vSwitch). The Caddyfile staging blocks have the
upstream ports baked in so they need bumping too — already applied
manually on the flagship to unblock staging; this lands it in-repo so it
survives the next cd-infra deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:48:53 +02:00
Ullrich Schäfer
32ff7c81ec
Merge pull request #358 from trails-cool/stigi/staging-port-fix
Move persistent staging off port 3100 to avoid Loki conflict
2026-05-03 22:42:29 +02:00
Ullrich Schäfer
e93cfd2adb
Trigger cd-staging on its own config changes + workflow_dispatch
So a port bump or compose edit doesn't sit unapplied until the next
apps/ push, and so persistent staging can be redeployed manually
without forcing a no-op apps/ change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:39:16 +02:00
Ullrich Schäfer
34711550fc
Move persistent staging off port 3100 to avoid Loki conflict
Loki binds 10.0.0.2:3100 on the vSwitch interface so the BRouter host can
ship logs in. Persistent staging was trying to publish 0.0.0.0:3100 which
conflicts because Linux refuses 0.0.0.0:<P> when any specific-interface
:<P> is already in use. Move staging journal to 3110, planner to 3111.

PR previews are unaffected — they're already on 3200+2N.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:38:38 +02:00
Ullrich Schäfer
c703bf4c76
Merge pull request #357 from trails-cool/stigi/preview-test
Test cd-staging preview lifecycle
2026-05-03 22:33:09 +02:00
Ullrich Schäfer
25d21161c5
Fix staging port binding + diagnostic ps env file
Two bugs in the staging-environments rollout:

1. Staging containers published on 127.0.0.1 are unreachable from the
   production Caddy container, which connects via the Docker bridge IP
   (host.docker.internal:host-gateway resolves to the bridge, not
   loopback). Bind to 0.0.0.0 instead — Hetzner Cloud firewall blocks
   ports 3000+ from the public internet, so it stays internal-only.

2. The trailing 'docker compose ps' diagnostic in deploy-staging /
   deploy-preview was missing --env-file staging.env, so compose
   failed env interpolation and the job exited non-zero even when the
   actual deploy succeeded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:30:05 +02:00
Ullrich Schäfer
7615ecb6e7
Test PR for staging preview lifecycle
Adds a one-line clarifying comment near TERMS_GATE_ALLOWLIST. The point
of this PR is to exercise cd-staging.yml end-to-end (preview deploy on
open, update on push, teardown on close).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:18:06 +02:00
Ullrich Schäfer
de5c96351c
Merge pull request #356 from trails-cool/stigi/staging-envs
Add staging + PR-preview environments on the flagship
2026-05-03 22:13:55 +02:00
Ullrich Schäfer
4c62c0b5aa
Merge branch 'main' into stigi/staging-envs 2026-05-03 22:10:25 +02:00
Ullrich Schäfer
c8a7a0b253
Add staging + PR-preview environments on the flagship
Implements the staging-environments OpenSpec change. Persistent staging at
staging.trails.cool / planner.staging.trails.cool deploys from main; PR
opens get a journal-only preview at pr-<N>.staging.trails.cool that shares
the persistent planner. cd-staging.yml builds tagged images, manages
per-PR Postgres databases and Caddyfile snippets, evicts the oldest
preview at the cap of 3, and tears everything down on PR close.
staging-cleanup.yml runs weekly to sweep orphaned previews.

DNS records (staging + *.staging A/AAAA) already applied to production
via tofu.

Caddy approach: per-PR Caddyfile snippets imported from /etc/caddy/sites/
and reloaded on each PR event — no wildcard / on-demand TLS, no router
service. Production compose gains a trails-shared network for the staging
project to reach Postgres, and host.docker.internal on Caddy so it can
reverse-proxy staging containers published on the host loopback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:03:38 +02:00
Ullrich Schäfer
de96c85172
Merge pull request #355 from trails-cool/stigi/index-capabilities
Index wahoo-route-push, demo-activity-bot, background-jobs
2026-05-03 21:43:35 +02:00
Ullrich Schäfer
cc1d5194b8
Index wahoo-route-push, demo-activity-bot, background-jobs in CAPABILITIES
Catch-up entries: wahoo-route-push (added in this PR's archive),
demo-activity-bot and background-jobs (created by PR #353 but missed
in the index). Renamed "Imports" group to "Imports & exports" so the
push capability has a sensible home.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:40:10 +02:00
Ullrich Schäfer
795ec2215c
Merge pull request #354 from trails-cool/stigi/archive-wahoo-route
Archive wahoo-route-update
2026-05-03 21:39:36 +02:00
Ullrich Schäfer
6f6010cb51
Archive wahoo-route-update
Fold the wahoo-route-update delta into openspec/specs/wahoo-route-push/spec.md
(POST→PUT logic with 404 fallback, stable external_id, push-status UI) and
move the change directory to openspec/changes/archive/. Task 4.3 (Playwright
E2E) skipped — contract is fully covered by unit/integration tests in
wahoo.test.ts and pushes.server.test.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:36:15 +02:00
Ullrich Schäfer
a62b249aed
Merge pull request #352 from trails-cool/dependabot/npm_and_yarn/production-6f66162dcb
Bump the production group with 19 updates
2026-05-03 21:30:01 +02:00
Ullrich Schäfer
84cf0490d2
Merge branch 'main' into dependabot/npm_and_yarn/production-6f66162dcb 2026-05-03 21:26:49 +02:00
Ullrich Schäfer
16b82c6348
Merge pull request #353 from trails-cool/stigi/archivable-specs
Archive demo-activity-bot, pg-boss-background-jobs, configurable-demo-persona
2026-05-03 21:20:50 +02:00
Ullrich Schäfer
91e80ace36
Archive demo-activity-bot, pg-boss-background-jobs, configurable-demo-persona
Fold completed deltas into main specs (activity-feed, route-management,
infrastructure, planner-session), add new background-jobs and
demo-activity-bot capability specs, and move the three change dirs to
openspec/changes/archive/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:17:39 +02:00
dependabot[bot]
30ea93ae35 [github-actions] pnpm dedupe 2026-05-03 08:34:27 +00:00
dependabot[bot]
eec904971d
Bump the production group with 19 updates
Bumps the production group with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.17` | `55.0.19` |
| [@sentry/vite-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) | `5.2.0` | `5.2.1` |
| [eslint](https://github.com/eslint/eslint) | `10.2.1` | `10.3.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.2` | `29.1.1` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.4` | `17.0.6` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.6` | `2.9.8` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [jose](https://github.com/panva/jose) | `6.2.2` | `6.2.3` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.6` | `8.0.7` |
| [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.4.2` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.10` | `5.2.13` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.0` | `3.4.1` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.9.1` | `8.10.0` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.28` | `55.0.30` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.24` | `55.0.26` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.20` | `55.0.22` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.0` | `12.18.2` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.50.0` | `10.51.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.49.0` | `10.51.0` |


Updates `expo` from 55.0.17 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `@sentry/vite-plugin` from 5.2.0 to 5.2.1
- [Release notes](https://github.com/getsentry/sentry-javascript-bundler-plugins/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/5.2.0...5.2.1)

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

Updates `jsdom` from 29.0.2 to 29.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.2...v29.1.1)

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

Updates `turbo` from 2.9.6 to 2.9.8
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.6...v2.9.8)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

Updates `jose` from 6.2.2 to 6.2.3
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.2...v6.2.3)

Updates `nodemailer` from 8.0.6 to 8.0.7
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.6...v8.0.7)

Updates `zod` from 4.3.6 to 4.4.2
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.2)

Updates `@gorhom/bottom-sheet` from 5.2.10 to 5.2.13
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.10...v5.2.13)

Updates `@sentry/cli` from 3.4.0 to 3.4.1
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.0...3.4.1)

Updates `@sentry/react-native` from 8.9.1 to 8.10.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.9.1...8.10.0)

Updates `expo-dev-client` from 55.0.28 to 55.0.30
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.24 to 55.0.26
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-notifications` from 55.0.20 to 55.0.22
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `pg-boss` from 12.18.0 to 12.18.2
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.0...12.18.2)

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

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

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/vite-plugin"
  dependency-version: 5.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: jose
  dependency-version: 6.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: zod
  dependency-version: 4.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 08:33:39 +00:00
Ullrich Schäfer
0254b2afd7
Merge pull request #351 from trails-cool/stigi/wahoo-route-update
Apply wahoo-route-update: PUT on re-push
2026-05-01 12:12:21 +02:00
Ullrich Schäfer
db3eeed60f
Apply wahoo-route-update: PUT on re-push instead of duplicate POST
Re-pushing an edited route to Wahoo now updates the existing remote
route via PUT against the stored remote_id instead of POSTing a new
copy. external_id drops the version suffix and identifies the logical
route. sync_pushes is keyed by (user, route, provider) and tracks
last_pushed_version for the "local newer" UI state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:08:16 +02:00
Ullrich Schäfer
4853a116ff
Merge pull request #350 from trails-cool/stigi/wahoo-openspec
Add openspec changes for Wahoo production cutover and route updates
2026-05-01 11:52:50 +02:00
Ullrich Schäfer
6a752e4933
Add openspec changes for Wahoo production cutover and route updates
- wahoo-production-cutover: ops checklist for moving the Wahoo Cloud
  API integration from sandbox to production tier (new app
  registration, logo upload, forced reauthorization).
- wahoo-route-update: switch the route push pipeline from POST-per-
  version to POST-then-PUT so re-pushing an edited route updates the
  existing Wahoo route instead of creating a duplicate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 11:37:42 +02:00
Ullrich Schäfer
7752cea7d0
Merge pull request #349 from trails-cool/stigi/wahoo-data-uri 2026-05-01 10:57:43 +02:00
Ullrich Schäfer
5ea7342f4e
Send Wahoo route file as data URI, not raw base64
Wahoo's POST /v1/routes expects route[file] as
'data:application/vnd.fit;base64,<base64>'. We were sending raw
base64, which Wahoo silently discarded — the route would appear in
the user's list with metadata (distance, ascent come from request
fields) but file.url stayed null and the Wahoo app rendered no track.
Confirmed by re-fetching route 50197876 via GET /v1/routes/:id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:29:43 +02:00
Ullrich Schäfer
8349b9492b
Merge pull request #348 from trails-cool/stigi/fit-course-capabilities
Fix FIT Course capabilities and lap duration
2026-05-01 10:28:26 +02:00
Ullrich Schäfer
dbba1c1607
Fix FIT Course capabilities and lap duration
The Course message advertised capabilities=0x04 (time only), telling
consumers the course had no position data. Wahoo accepted the route
(distance/elevation come from request fields) but rendered an empty
map. Set capabilities to valid|distance|position (0x1A).

Also write a real lap totalElapsedTime/totalTimerTime derived from the
1Hz record timestamps; a zero-duration lap can be rejected as malformed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:24:50 +02:00
Ullrich Schäfer
2865ec748f
Merge pull request #347 from trails-cool/stigi/wahoo-routes-read
Request routes_read Wahoo scope
2026-05-01 10:18:55 +02:00
Ullrich Schäfer
8b1d473902
Request routes_read Wahoo scope
GET /v1/routes/:id needs routes_read; routes_write only covers writes.
Existing connections will need to reauthorize to gain the new scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:18:13 +02:00
Ullrich Schäfer
8da3001e64
Merge pull request #346 from trails-cool/stigi/wahoo-revoke
Revoke Wahoo tokens on disconnect; surface OAuth errors
2026-05-01 10:17:05 +02:00
Ullrich Schäfer
b60cd96736
Revoke Wahoo tokens on disconnect; surface OAuth errors
Disconnecting a sync provider now calls the provider's revoke endpoint
(DELETE /v1/permissions for Wahoo) before dropping the local row, so
tokens don't accumulate against Wahoo's per-(app,user) cap. The token
exchange now classifies Wahoo's "Too many unrevoked access tokens" 400
as a distinct OAuthError code, and the connections settings page shows
a localized banner for that and other connect failures instead of a
silent ?error=sync_failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:03:50 +02:00
Ullrich Schäfer
d673860fb5
Merge pull request #345 from trails-cool/stigi/wahoo-form-encoded
Use form-encoded bodies for Wahoo OAuth token requests
2026-05-01 09:30:33 +02:00
Ullrich Schäfer
fee27f1cd6
Use form-encoded bodies for Wahoo OAuth token requests
Wahoo's /oauth/token endpoint returns 400 for JSON bodies. OAuth 2.0
requires application/x-www-form-urlencoded for token requests; switch
exchangeCode and refreshToken to URLSearchParams. Also include the
response body in the thrown error so future failures are diagnosable
without scraping container logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:29:56 +02:00
Ullrich Schäfer
357606ea21
Merge pull request #344 from trails-cool/stigi/fix-oauth-return
Fix OAuth callback returnTo after settings split
2026-05-01 09:23:20 +02:00
Ullrich Schäfer
70a1387998
Fix OAuth callback returnTo after settings split
The connect loader was setting state to a raw user.id, but the callback
parses state as base64-JSON via decodeOAuthState and falls back to {}
when the parse fails. That sent users back to /settings, which now
redirects to /settings/profile, hiding the freshly saved connection on
a different tab.

Encode the state as a proper PushOAuthState with returnTo set to
/settings/connections so the callback lands on the page that initiated
the flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:20:19 +02:00
Ullrich Schäfer
b0f8be2765
Merge pull request #341 from trails-cool/stigi/archive-wahoo-push
Archive wahoo-route-push change
2026-05-01 08:17:04 +02:00
Ullrich Schäfer
add16c398f
Merge branch 'main' into stigi/archive-wahoo-push 2026-05-01 08:13:47 +02:00
Ullrich Schäfer
e7b00e920a
Merge pull request #342 from trails-cool/stigi/fix-dockerfile-fit
Add packages/fit to journal and planner Dockerfiles
2026-05-01 08:13:32 +02:00
Ullrich Schäfer
c89c5798bb
Add packages/fit to journal and planner Dockerfiles
The deps stage was missing the COPY for packages/fit/package.json, so
@trails-cool/fit was absent from the workspace at install time and
@garmin/fitsdk could not be resolved during the journal build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:10:15 +02:00
Ullrich Schäfer
b9c559469a
Archive wahoo-route-push change
Move completed wahoo-route-push change to archive and sync delta specs:
update wahoo-import for routes_write scope, add new wahoo-route-push capability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:52:34 +02:00
Ullrich Schäfer
405f434d1a
Merge pull request #340 from trails-cool/stigi/wahoo-push-ui
Add Send to Wahoo UI, i18n, and privacy disclosure
2026-05-01 07:47:53 +02:00
Ullrich Schäfer
c607ceaf1f
Merge pull request #339 from trails-cool/stigi/wahoo-push-action
Add Wahoo push action route + OAuth callback resume
2026-05-01 07:47:39 +02:00
Ullrich Schäfer
ba007104eb
Merge pull request #338 from trails-cool/stigi/wahoo-push-provider
Add Wahoo pushRoute and SyncProvider push interface
2026-05-01 07:47:16 +02:00
Ullrich Schäfer
77bca46c5f
Merge pull request #337 from trails-cool/stigi/wahoo-push-db
Add sync_pushes table + granted_scopes column
2026-05-01 07:47:03 +02:00
Ullrich Schäfer
76c3e49de2
Add Send to Wahoo UI, i18n, and privacy disclosure
Route detail page now renders one of three states for the owner of a
route with a Wahoo connection:

- "Send to Wahoo" button + privacy tooltip when no successful push
  exists for the current version, plus the inline last-error blurb
  if the previous attempt failed
- "Sent to Wahoo on <date>" pill when sync_pushes has a pushedAt
  for the current version
- Banner row at the top mapping the ?push= and ?code= query params
  the action route appends to user-facing copy

i18n strings cover all 8 banner states (success, needs_permission,
no_connection, no_geometry, validation, rate_limit, token_expired,
generic) in English and German.

Privacy disclosure lives at /legal/privacy (apps/journal/app/routes/
legal.privacy.tsx), the in-app surface this repo uses instead of a
docs/privacy.md manifest. The new bullet declares that route
geometry, name, and description are transmitted to Wahoo on opt-in
via the Send to Wahoo button.

E2E tests for the push and re-auth flows (tasks 9.4/9.5) are
deferred — they need a server-side Wahoo mock harness that doesn't
exist yet. Slice-4 unit tests cover the pipeline against the
provider mock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:44:50 +02:00
Ullrich Schäfer
a9c0093877
Add Wahoo push action route + OAuth callback resume
Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:

- resolves the latest route_versions row and uses that GPX (not
  routes.gpx) so the bytes Wahoo gets match the snapshot the user
  sees
- short-circuits on (user, route, version, provider) idempotency:
  a successful prior push returns the existing remote_id without
  re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
  getAuthUrl with a base64url-encoded state carrying pushAfter +
  returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
  retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
  update on retry) so the UI can show success/failure state

The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.

Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:41:24 +02:00
Ullrich Schäfer
5b0bf40b97
Add Wahoo pushRoute and SyncProvider push interface
Extends the SyncProvider interface with an optional pushRoute method
plus PushRoutePayload, PushRouteResult, and a typed PushError so the
slice-4 action route can map error codes to user-facing copy without
parsing HTTP statuses itself.

Wahoo provider:
- adds routes_write to the requested OAuth scope set
- implements pushRoute: base64-encodes the FIT, builds the form body
  Wahoo's POST /v1/routes expects, parses the remote id out of the
  response, and throws typed PushError on 401/403/422/429/5xx
- saveConnection now persists the requested scopes as grantedScopes
  on exchangeCode (Wahoo doesn't return a scope field, so the
  requested set is the granted set)

Token refresh on 401 is deferred to the slice-4 action route — same
pattern as the existing webhook handler in this repo, which does
inline refresh rather than wrapping every call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:36:45 +02:00
Ullrich Schäfer
19f4c45c74
Merge pull request #336 from trails-cool/stigi/wahoo-push-apply
Add @trails-cool/fit GPX→FIT Course encoder
2026-04-30 22:35:31 +02:00
Ullrich Schäfer
c7a09e865d
Add sync_pushes table and granted_scopes column
sync_pushes tracks outbound route pushes to providers, keyed by
(user_id, route_id, route_version, provider). Successful rows hold
the remote_id; failed rows hold the error and can be retried in
place. The unique index makes idempotent push trivial.

granted_scopes records the OAuth scope set we requested at
exchangeCode time. Wahoo doesn't return a scope field in token
responses and grants scopes all-or-nothing, so the requested set is
the source of truth. Defaults to an empty array, which means any
pre-existing connection will be flagged as scope-mismatched on the
first routes_write push — the intended UX for the slice 3 re-auth
flow.

Adjusts tasks 2.3/2.4 in the spec to match repo reality: this
project runs `drizzle-kit push --force` schema-first, with no
checked-in migrations directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:33:43 +02:00
Ullrich Schäfer
8ba5554a67
Add @trails-cool/fit package with GPX→FIT Course encoder
Wraps @garmin/fitsdk to emit FIT Course files from GPX, the binary
format Wahoo's POST /v1/routes API requires. Server-side only — the
~1 MB SDK never ships to the planner browser bundle.

Round-trip tests use fit-file-parser as an independent oracle and
assert lat/lon parity within 1e-4 deg and altitude within 0.5 m
across short flat, alpine, multi-day, and single-point fixtures.

Updates design.md decision #1: the original hand-rolled-encoder plan
was justified largely by ESM friction in the Garmin SDK, but as of
v21.202.0 the SDK is pure ESM with zero deps. Wrapping it saves us
~400 LOC of binary plumbing and ongoing maintenance against future
FIT spec updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:31:37 +02:00
Ullrich Schäfer
a45a3808d9
Merge pull request #335 from trails-cool/stigi/wahoo-route-sync
Propose wahoo-route-push: send planned routes to Wahoo head units
2026-04-30 22:08:28 +02:00
Ullrich Schäfer
4a97ef6a10
Fix wahoo-route-push spec: correct schema path and id types
Update sync_pushes schema to use text ids matching the existing journal
tables, and fix the schema path references to packages/db/src/schema/journal.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:03:28 +02:00
Ullrich Schäfer
efe70c2c3a Propose wahoo-route-push: send planned routes to a user's Wahoo account
Wahoo's Cloud API exposes POST /v1/routes (scope routes_write), which
syncs to the Wahoo App and to ELEMNT/BOLT/ROAM head units. This change
proposes the push pipeline: GPX -> FIT Course (new @trails-cool/fit
package) -> Wahoo, with sync_pushes for idempotency, an explicit
re-auth flow for the new scope, and a "Send to Wahoo" affordance on
the route detail page. Read-only Wahoo import is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:38:14 +02:00
Ullrich Schäfer
2b1ab3f019
Merge pull request #334 from trails-cool/expand-bruno-flavor
Expand Bruno's content pools for more variety
2026-04-26 21:57:06 +02:00
Ullrich Schäfer
e9b073c9f9 Expand Bruno's content pools for more variety
Roughly tripled the demo persona's name + description pools so the
demo bot's daily output doesn't feel canned within a few weeks of
watching it. Same Bruno: dog-park-inspector deadpan, Berlin-set,
absurd bureaucratic tone. New entries grouped into clear categories
inside the file (more Berlin neighborhoods, time-of-day variants,
bureaucratic-deadpan one-liners, specific events, wildlife
encounters, stick + tennis-ball lore, self-aware moments, Berlin
weather) so it's easy to add to one bucket without rewriting the
whole list.

Counts before → after:
- en names:        12 → 35
- de names:        12 → 35
- en descriptions: 10 → 30
- de descriptions: 10 → 30

No schema or behavior change. Tests unchanged — the persona schema
still parses; the existing pool-shape-agnostic tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:53:50 +02:00
Ullrich Schäfer
86e75cb991
Merge pull request #333 from trails-cool/spec-drift-catchup
Spec drift catch-up: URLs, settings split aftermath, navbar shape
2026-04-26 21:22:30 +02:00
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
201342bd57
Merge pull request #332 from trails-cool/cd-apps-reload-caddy
cd-apps: reload Caddy after deploy so Caddyfile changes take effect
2026-04-26 20:13:09 +02:00
Ullrich Schäfer
7e28e72c29 cd-apps: reload Caddy after deploy so Caddyfile changes take effect
cd-apps already scp's `infrastructure/Caddyfile` to the server
alongside docker-compose.yml — but the running Caddy container
doesn't auto-pick-up config changes. cd-infra is the workflow that
calls `caddy reload`, and it only triggers on `infrastructure/`
paths. So any Caddyfile edit shipped through cd-apps (e.g. the
`lb_try_duration` block in PR #329, deployed as part of an apps
push) sits on disk unapplied until the next cd-infra run.

This was real today: PR #329 added `lb_try_duration 30s` to silence
deploy-time 502s. PR #331 (a vite.config edit, apps path) merged
right after and triggered cd-apps, which copied the new Caddyfile
but didn't reload Caddy. The very next planner restart in that
deploy promptly produced three 502s — the exact thing #329 was
supposed to prevent. We applied the reload manually via SSH after
the fact.

This commit makes that reload happen on every cd-apps deploy. The
operation is idempotent (Caddy validates first, swaps live, no
downtime) so doing it even when Caddyfile is unchanged costs
nothing. `|| true` keeps the deploy from failing if Caddy is
unhealthy at deploy time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:09:43 +02:00
Ullrich Schäfer
1583028d95
Merge pull request #331 from trails-cool/warmup-vite-deps-for-e2e
Pre-warm Vite dep discovery on the journal dev server
2026-04-26 19:55:05 +02:00
Ullrich Schäfer
97cc759dca Pre-warm Vite dep discovery for the journal dev server
Adds `server.warmup.clientFiles` so Vite eagerly transforms the
journal entry + every page route at server startup. Without it,
the first visit to a previously-unseen route triggers Vite's
"new dependencies optimized → reloading" path, which is a full
page reload — fine as a one-time blip in everyday dev, painful
when an e2e run is hitting many routes back-to-back on a fresh
server (each new route reload races with whatever the test was
doing).

This is a partial improvement, not a complete fix, for the local
auth e2e flakiness investigated earlier today. The deeper issue
there is a separate React-hydration-vs-Playwright-click race in
dev mode (production CI doesn't see it because production JS
hydrates faster). That needs a different fix at the test/fixture
layer; this commit just removes one source of noise from the
investigation by eliminating the dep-discovery reload.

Glob picks up `.tsx` files only — `.ts` route handlers under
app/routes/ are server-only API endpoints and Vite refuses to
transform them for the client (errors "Server-only module
referenced by client"). Page components alone drive the
dep-discovery we care about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:51:39 +02:00
Ullrich Schäfer
ba3a619160
Merge pull request #330 from trails-cool/document-https-dev-special-cases
Document why HTTPS=1 dev exists, and the one case that still needs it
2026-04-26 12:30:12 +02:00
Ullrich Schäfer
1f15330961 Document why HTTPS=1 dev exists, and the one case that still needs it
Most contributors don't need HTTPS=1 locally — plain HTTP is the
default and the right choice for everything except Wahoo OAuth
callback testing. WebAuthn (passkeys), magic links, sessions, the
Terms gate, SSE all work over HTTP because the WebAuthn spec treats
localhost as a secure context regardless of scheme. CI proves the
point: the e2e suite runs over plain HTTP and passes cleanly.

The original HTTPS=1 plumbing landed in 20b91ef (2026-04-05) bundled
into a Wahoo import fix, with no inline rationale. Once you've
forgotten the reason it tends to leak into the default workflow,
which then breaks the local e2e suite (Playwright always uses HTTP
baseURL; an https ORIGIN env mismatches what it sends) and creates
unnecessary divergence from CI.

Documenting the single legitimate use case so the next contributor
(or future-me) doesn't have to re-derive it from git blame:

- apps/journal/vite.config.ts — expanded the comment near the
  basic-ssl plugin to spell out:
  * What works on HTTP (everything except Wahoo)
  * What needs HTTPS=1 (Wahoo OAuth specifically)
  * The norm: don't add new HTTPS-only paths without writing them
    down here too, so the assumption stays auditable
- apps/journal/.env.example — new tracked file showing the env vars
  the journal app reads, with `ORIGIN=https://localhost:3000`
  marked as HTTPS-only and accompanied by an explanation of the
  ORIGIN/Playwright mismatch trap. Future contributors don't have
  to discover this through a failing e2e run.
- CLAUDE.md — new "Local HTTPS dev (rare)" subsection under
  Development Commands. Includes the exact command for Wahoo testing
  (`HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter
  @trails-cool/journal dev`), the turbo-doesn't-pass-HTTPS gotcha,
  and the rule of thumb: don't set ORIGIN in your .env unless you
  also always run with HTTPS=1.

No code/behavior changes; documentation only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:26:58 +02:00
Ullrich Schäfer
99ca7b0ab4
Merge pull request #327 from trails-cool/dependabot/npm_and_yarn/vite-8.0.10
Bump vite from 7.3.2 to 8.0.10
2026-04-26 12:09:08 +02:00
dependabot[bot]
b891029069
Bump vite from 7.3.2 to 8.0.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 10:02:46 +00:00
Ullrich Schäfer
f1548c2df9
Merge pull request #326 from trails-cool/dependabot/npm_and_yarn/production-6dcdd079e8
Bump the production group with 28 updates
2026-04-26 12:00:52 +02:00
Ullrich Schäfer
fdb2baada0
Merge branch 'main' into dependabot/npm_and_yarn/production-6dcdd079e8 2026-04-26 11:57:54 +02:00
Ullrich Schäfer
c0d1dafdff
Merge pull request #329 from trails-cool/deploy-no-502-and-fix-annotation
Stop caddy-502-rate alert firing on every deploy
2026-04-26 11:57:16 +02:00
Ullrich Schäfer
55c9154f05 Revert cd-apps annotation path: GRAFANA_SERVICE_TOKEN is in .env, not app.env
The token lives in `secrets.infra.env`, which `cd-infra.yml` merges
together with `secrets.app.env` into the server's
`/opt/trails-cool/.env`. The cd-apps workflow's own `app.env`
intentionally does NOT carry the token (apps don't need it at
runtime), so the original `grep ... .env` was correct. My earlier
edit in this branch swapped the path to `app.env` and would have
broken the annotation hook the moment it actually worked.

Restored `.env` and updated the inline comment to make the file
ownership explicit (cd-infra populates it; cd-apps reads it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:55 +02:00
Ullrich Schäfer
5c4b6fd9af Stop the caddy-502-rate alert firing on every deploy
The journal/planner deploy in cd-apps.yml does `docker compose up -d
journal planner`, which stops the old container and starts the new
one — Caddy keeps forwarding requests during the ~10–30s gap and
returns 502s. The caddy-502-rate alert (threshold > 0 for 2m)
correctly trips, every time.

Two production changes plus a long-broken workflow detail:

- infrastructure/Caddyfile — add `lb_try_duration 30s` /
  `lb_try_interval 250ms` to the journal and planner reverse_proxy
  blocks. Caddy now holds and retries the upstream for up to 30s
  during a restart instead of 502'ing immediately. Real outages
  (upstream unreachable longer than 30s) still 502 and the alert
  still fires for those.
- infrastructure/grafana/provisioning/alerting/alerts.yml — add a
  comment documenting why caddy-502-rate stays at threshold > 0:
  with lb_try_duration in front of it, the alert no longer
  conflates "deploy in flight" with "real outage."
- .github/workflows/cd-apps.yml — fix a long-silent bug: the
  Grafana deploy-annotation step was reading
  GRAFANA_SERVICE_TOKEN from `.env`, but the secrets file we scp
  to /opt/trails-cool is named `app.env`. The token check failed
  silently and the curl was being skipped on every deploy.
  Switching to `app.env` so deploys actually annotate Grafana.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:51:13 +02:00
Ullrich Schäfer
153f133093
Merge pull request #328 from trails-cool/explore-include-demo-bot
Include demo persona on /explore
2026-04-26 11:46:39 +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
dependabot[bot]
4e32189250 [github-actions] pnpm dedupe 2026-04-26 08:38:21 +00:00
dependabot[bot]
c65b909aef
Bump the production group with 28 updates
Bumps the production group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.15` | `55.0.17` |
| [i18next](https://github.com/i18next/i18next) | `26.0.6` | `26.0.8` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.2` | `8.59.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.5` | `8.0.6` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `55.0.9` | `55.0.10` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.9` | `5.2.10` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.0.0` | `11.0.2` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.3.5` | `3.4.0` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.8.0` | `8.9.1` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `55.0.14` | `55.0.15` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.27` | `55.0.28` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.23` | `55.0.24` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.16` | `55.0.17` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `55.0.13` | `55.0.14` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.19` | `55.0.20` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `55.0.12` | `55.0.13` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.18` | `55.0.19` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.15` | `55.0.16` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.15.0` | `12.18.0` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.14.1` | `7.14.2` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.14.1` | `7.14.2` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.14.1` | `7.14.2` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.49.0` | `10.50.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.48.0` | `10.49.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.2` | `4.2.4` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.1` | `7.14.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.2` | `4.2.4` |


Updates `expo` from 55.0.15 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

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

Updates `typescript-eslint` from 8.58.2 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

Updates `nodemailer` from 8.0.5 to 8.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.5...v8.0.6)

Updates `@expo/metro-runtime` from 55.0.9 to 55.0.10
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@gorhom/bottom-sheet` from 5.2.9 to 5.2.10
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.9...v5.2.10)

Updates `@maplibre/maplibre-react-native` from 11.0.0 to 11.0.2
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.0.0...v11.0.2)

Updates `@sentry/cli` from 3.3.5 to 3.4.0
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.3.5...3.4.0)

Updates `@sentry/react-native` from 8.8.0 to 8.9.1
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.8.0...8.9.1)

Updates `expo-constants` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 55.0.27 to 55.0.28
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.23 to 55.0.24
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-file-system` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-notifications` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 55.0.12 to 55.0.13
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-splash-screen` from 55.0.18 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-system-ui` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `pg-boss` from 12.15.0 to 12.18.0
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.15.0...12.18.0)

Updates `@react-router/dev` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.14.2/packages/react-router-dev)

Updates `@react-router/node` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.14.2/packages/react-router-node)

Updates `@react-router/serve` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.14.2/packages/react-router-serve)

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

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

Updates `@tailwindcss/vite` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite)

Updates `react-router` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.2/packages/react-router)

Updates `tailwindcss` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 55.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 55.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 08:37:27 +00:00
Ullrich Schäfer
b647c493d7
Merge pull request #325 from trails-cool/refresh-ia-doc-after-streams
Refresh IA doc — all six streams shipped
2026-04-26 10:06:31 +02:00
Ullrich Schäfer
c7331ed056 Refresh IA doc — all six streams shipped on 2026-04-26
Streams A, B, C, D, E, F all landed today. This refresh marks them
shipped with their PR numbers, brings the sitemap and navbar
diagrams in sync with the deployed state, and rewrites the
"Notifications vs follow requests" and "Settings" sections to
describe the new structure rather than the historical split.

Snapshot date bumped to "2026-04-26 (post-streams)" so a future
re-read knows what state this snapshot reflects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:03:05 +02:00
Ullrich Schäfer
218389e9f8
Merge pull request #324 from trails-cool/navbar-redesign-avatar-dropdown
Redesign navbar: avatar dropdown + mobile drawer
2026-04-26 09:59:56 +02:00
Ullrich Schäfer
4e2bfb0bbe Redesign navbar with avatar dropdown and mobile drawer
Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.

- New apps/journal/app/components/Avatar.tsx — initials-only avatar
  (no image-upload story yet; the component is the single place to
  add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
  avatar trigger to reveal Profile / Settings / Log Out menuitems.
  Click-outside + Escape-to-close. role="menu" + role="menuitem" for
  a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
  trigger + slide-out drawer at md and below. Contains all primary
  nav (Feed, Explore, Routes, Activities, Notifications) plus the
  account cluster (Profile, Settings, Log Out). Auto-closes on
  navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
  * Brand + primary nav links hide at md and below (drawer covers
    them).
  * Right cluster: bell on every viewport; avatar dropdown on
    desktop, hamburger on mobile.
  * Loader exposes user.displayName so Avatar can compute initials.
  * Drops the now-unused Form import; Form moved into the
    dropdown/drawer components.

E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
  aria-label = displayName||username, then clicks the Log Out
  menuitem. Username assertions in nav switch from getByText to
  getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
  avatar dropdown first, then asserts the Settings menuitem.

i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:56:20 +02:00
Ullrich Schäfer
65e6699dc4
Merge pull request #323 from trails-cool/split-settings-into-pages
Split /settings into 4 sub-pages with a sidebar layout
2026-04-26 09:51:17 +02:00
Ullrich Schäfer
c0c1a53322 Split /settings into 4 sub-pages with a sidebar layout
Stream E from docs/information-architecture.md. The settings page was
a single scrollable list of five concerns; split it into four
deep-linkable sections behind a shared sidebar layout, so each
concern has a stable URL, a focused loader (only fetches what that
section needs), and a meta title that reflects the section.

Sections:
- /settings/profile — display name, bio, profile visibility
- /settings/account — email change + danger-zone account deletion
- /settings/security — passkeys
- /settings/connections — sync providers (Wahoo today)

URL pattern: nested routes under a layout. /settings itself
redirects to /settings/profile so the bare URL still lands somewhere
useful without rendering an empty container.

- apps/journal/app/routes/settings.tsx — converted from a single
  scrollable page to a layout that renders the sidebar nav + Outlet.
- apps/journal/app/routes/settings.{profile,account,security,
  connections,_index}.tsx — new files, each with its own loader and
  meta. _index redirects to /settings/profile.
- apps/journal/app/routes.ts — registers the four children + index
  under the settings layout route.
- packages/i18n/src/locales/{en,de}.ts — new settings.nav.{profile,
  account,security,connections} keys for the sidebar labels.

Specs (profile-settings, account-management, connected-services)
are unchanged — they describe behavior, not URL structure. The
journal-landing spec is also unchanged; the navbar still has a
single "Settings" link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:48:00 +02:00
Ullrich Schäfer
8d60b6d7bc
Merge pull request #322 from trails-cool/archive-explore-and-promote-specs
Archive add-explore-page and promote specs
2026-04-26 09:44:10 +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
3b1d0d9456
Merge pull request #321 from trails-cool/implement-explore-page
Implement /explore page (Stream F)
2026-04-26 09:38:48 +02:00
Ullrich Schäfer
37f7a349b9 Implement /explore: local user discovery directory
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.

- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
  ordered by MAX(public-activity created_at) DESC NULLS LAST,
  tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
  days), countDirectory, batched countFollowersBatch and
  getFollowStateBatch helpers so the page issues two extra queries
  regardless of page size. Excludes private profiles and the demo
  persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
  queries in parallel; component renders an "Active recently" strip
  (hidden when empty) above the main directory; FollowButton inlines
  per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
  users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
  "Browse who's here →" link to /explore alongside the Planner
  escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
  home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
  (EXPLORE_INTEGRATION=1) integration tests covering inclusion,
  exclusion, ordering, NULLS LAST behavior, "Active recently"
  30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
  private profile is excluded from the directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:24:19 +02:00
Ullrich Schäfer
65f8313f2b
Merge pull request #320 from trails-cool/add-explore-proposal
Propose /explore page: local user discovery directory
2026-04-26 09:19:53 +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
dc7a781e74
Merge pull request #319 from trails-cool/feed-followed-public-toggle
Add Followed/Public toggle to /feed for signed-in users
2026-04-26 09:07:33 +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
7d1999d037
Merge pull request #318 from trails-cool/drop-feed-button-on-home
Drop redundant Feed button on logged-in home page
2026-04-26 08:47:41 +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
34f40d6744
Merge pull request #317 from trails-cool/add-ia-and-spec-drift-skills
Add /ia-review and /spec-drift-review process skills
2026-04-26 08:42:31 +02:00
Ullrich Schäfer
a847ff2e52 Add /ia-review and /spec-drift-review process skills
Captures two recurring review processes as repeatable Claude skills so
they don't have to be reinvented from scratch each time:

- /ia-review: walks the route tables + nav surfaces, builds the
  sitemap, flags drift, and writes/refreshes
  docs/information-architecture.md. Refresh mode preserves the user's
  accumulated decisions and implementation backlog while only
  rewriting the snapshot sections.

- /spec-drift-review: walks every spec in openspec/specs/, compares
  it to shipped code, and produces a categorized drift report
  (high/medium/low severity) plus code-without-spec findings and
  structural suggestions (split/merge/new specs, CAPABILITIES.md
  catch-up). Excludes specs touched by active openspec changes since
  that mismatch is intended in-flight work, not drift.

Also extends the IA doc with the items resolved/deferred during the
review (observations 1-10), three new streams (D: drop redundant Feed
button on logged-in /, E: break Settings apart, F: propose /explore
spec), and an "Open exploration" section for the routes-vs-activities
terminology question.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:41:07 +02:00
Ullrich Schäfer
ad11624c83
Merge pull request #316 from trails-cool/merge-follow-requests-into-notifications
Merge /follows/requests into /notifications as a tabbed inbox
2026-04-26 08:28:24 +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
3cd01fe9c0
Merge pull request #315 from trails-cool/chore/spec-catchup
Spec catchup: drift fixes, account-settings split, notifications archive
2026-04-26 02:06:53 +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
0530dd3e59
Merge pull request #314 from trails-cool/feat/notifications-cursor-pagination
Cursor-based pagination for /notifications
2026-04-26 01:45:55 +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
2892cf9360
Merge pull request #313 from trails-cool/feat/notifications
Implement notifications + supporting fixes
2026-04-26 01:41:49 +02:00
Ullrich Schäfer
abb754e6a5 Replace waitForLoadState("networkidle") in e2e helpers
The SSE connection to /api/events (added with notifications) keeps the
network in-flight forever, so `networkidle` never resolves. Each save
in the affected helpers timed out at 30s × 3 retries, which both broke
the run and made it suspiciously long.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:51:19 +02:00
Ullrich Schäfer
ff09775091 Fix e2e fallout from default-private profile_visibility
Three places assumed the old default-public model:

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00
940 changed files with 56652 additions and 12994 deletions

View file

@ -0,0 +1,275 @@
---
name: ia-review
description: Take a snapshot of the apps' information architecture (sitemap, navigation, audience-gating per route) and write or refresh `docs/information-architecture.md`. Use when the user wants to review the IA, plan a navigation/surface redesign, or check for drift since the last review.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the apps' route table + navigation surfaces, build a sitemap, surface
tensions and open questions, and capture the result in
`docs/information-architecture.md` (fresh write or refresh against the
prior snapshot).
The output is a *review document for the user* — not a unilateral plan.
Decisions are made by the user during the conversation that follows; the
doc captures the snapshot and the open questions that need answering.
---
## When to use
- The user explicitly asks for an IA review ("review the IA", "what's
the information architecture look like").
- Before a navigation/surface redesign, so the redesign is informed by
a current snapshot rather than a vibe.
- After a chunk of new features — pages, modals, settings sections —
to check whether the IA is drifting (busy navbar, duplicated surfaces,
orphaned routes).
- When the user says "we should do another IA review" after the prior
doc has aged.
---
## Steps
1. **Detect mode (fresh vs refresh)**
Check whether `docs/information-architecture.md` already exists.
- **No file:** fresh review. Build the snapshot from scratch.
- **File exists:** refresh review. Read it first; preserve the
decisions/backlog the user has accumulated and only update the
snapshot sections (sitemap, navigation, observations, open
questions). Decisions previously crossed out stay crossed out.
In refresh mode, also read the snapshot date at the top — anything
shipped *since* that date is what your refresh should focus on.
2. **Identify the apps in scope**
trails.cool ships two front-ends; the IA question lives mostly in
the Journal:
- `apps/journal/` — user accounts, social, content. Main IA
surface.
- `apps/planner/` — anonymous, ephemeral. ~5 routes; include for
completeness but don't dwell.
If the project structure has changed and there's a new app, include
it.
3. **Read the route tables**
For each app:
```
apps/<app>/app/routes.ts
```
This is the authoritative URL → route-file mapping. Both apps use
explicit registration (per CLAUDE.md), so `routes.ts` is complete.
4. **Read the navigation surfaces**
- `apps/journal/app/root.tsx` (and `apps/planner/app/root.tsx` if
it has navigation) — the top navbar lives here. Read both the
loader (to see what data the navbar consumes — counts, badges,
user fields) and the `NavBar` component (to see what entries
render).
- `apps/journal/app/components/Footer.tsx` — the footer.
- Any auth-gate / Terms-gate logic in the root loader.
5. **Sample key route loaders to understand audience**
For each top-level route, scan its loader to determine:
- Does it require a session? (loaders typically `redirect("/auth/login")`
for anonymous visitors when so.)
- Does it serve different content per session? (e.g., `home.tsx`
branches on `user`.)
- Does it have an access rule beyond auth? (locked-account 404s,
visibility checks, etc.)
You don't need to read every route — pick the top-level ones and
any that look like they might gate differently than the URL hints.
6. **Build the sitemap**
Group routes by audience: **Public surface** (anonymous-reachable)
and **Authenticated surface** (signed-in only). Within each group,
order by topic (auth, profile, content, settings, legal, etc.).
Use plain code blocks with one URL per line and a one-line gloss
per entry. Keep the format scannable; don't repeat what the URL
already says.
7. **Map navigation surfaces**
Two short tables/snippets:
- **Navbar (signed-in)** — entries left-to-right.
- **Navbar (signed-out)** — entries left-to-right.
- **Footer** — links + any meta text.
Note any entry whose visibility is conditional (badge counts, etc.).
8. **Identify "feed concepts" and other duplications**
trails.cool has historically had multiple feed-like surfaces. Any
IA review should ask: how many lists of activities are there? Is
the same data reachable from multiple URLs? Is there a URL that
shows different products to different audiences?
Capture these in a small table or section if they exist.
9. **Map cross-app linking**
Journal ↔ Planner cross-links (JWT callback URLs, "Try the
Planner" buttons, etc.). One short list.
10. **List observations**
Walk the snapshot and call out tensions worth discussing. Useful
prompts:
- **Busy clusters** — three or more controls for the same concept
side-by-side in the navbar.
- **Redundant paths** — same destination reachable from multiple
surfaces with no clear reason.
- **Dead-end routes** — pages reachable only by typing the URL,
no in-app link.
- **Missing surfaces** — common user need with no in-app path
(e.g., "find people to follow" with no `/explore`).
- **Audience mismatch** — same URL serving meaningfully different
products to anon vs auth.
- **Visual inconsistency** — adjacent navbar entries with
different treatment (icon vs text, different baselines).
- **Mobile hazards** — clusters that will wrap badly under
small viewport widths.
Each observation should be one short paragraph. Each is a
question for the user to answer, not a decision you've made.
11. **List open IA questions**
Distinct from observations: these are larger directional choices
where the answer determines what other observations even matter.
Examples: "Should `/` and `/feed` merge for signed-in users?",
"Where does an `/explore` page live, if at all?", "Mobile
pattern — hamburger? Bottom tab bar?"
Keep these as bullets the user can answer in one line each.
12. **Write the doc**
Output to `docs/information-architecture.md`. Use this top
structure:
```markdown
# Information Architecture Review
*Snapshot date: YYYY-MM-DD.* If the navbar, route table, or feed
model has shifted since then, treat this doc as stale and refresh
against `apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`.
A snapshot of where every page lives, who sees it, and how visitors
navigate between them. Intended for review — flag anything that
doesn't make sense or should change.
## Apps
[...]
## Journal sitemap
### Public surface (logged-out)
[...]
### Authenticated surface (logged-in)
[...]
### Navigation surfaces
[...]
## Logged-in vs logged-out home
[...if `/` does double duty...]
## [Any "N feed concepts" / duplication sections]
[...]
## Cross-app linking
[...]
## Planner sitemap
[...short...]
## Observations worth discussing
[...]
## Open IA questions
[...]
```
Use today's date for the snapshot. Reference the source-of-truth
files at the top so the next review knows what to compare against.
13. **In refresh mode, preserve the user's accumulated decisions**
The prior doc may already contain:
- Resolved observations (struck through with a *Resolved: ...*
note).
- An "Implementation backlog" section with streams.
- An "Open exploration" section.
These are the *user's work*, not the snapshot. Carry them forward
untouched unless one is plainly obsolete (e.g., the feature it
references no longer exists). When in doubt, leave it and let the
user prune.
If a previously-flagged observation is no longer present in the
current code (e.g., it was implemented), update its status note
rather than removing it — preserves history.
14. **Surface a ranked next-action list**
After writing the doc, summarize in 46 lines what changed since
the prior review (or what the most actionable observations are if
fresh). End with a question: which open IA question does the user
want to tackle first?
Don't start implementation work — this skill is for the
*snapshot*. The decisions and the implementation backlog grow
through the conversation that follows.
---
## What this skill is NOT
- **Not an implementation skill.** Don't write code, don't open PRs.
The doc is the deliverable; decisions and code follow in normal
conversation.
- **Not a unilateral redesign.** Observations are questions for the
user. Don't bake "decisions" into the snapshot — those go in the
backlog only when the user has actually answered the question.
- **Not a spec change.** OpenSpec specs describe what's shipped; this
doc describes the IA *as it stands* with tensions flagged. Any
resulting spec updates happen during implementation, not during the
review.
---
## Guardrails
- Always include the snapshot date at the top of the output doc — IA
drifts; future-you needs to know whether to trust the snapshot or
refresh it.
- Reference the source-of-truth files (`routes.ts`, `root.tsx`) so the
next review's diff is mechanical.
- Keep observations as questions, not decrees. The user makes the
call.
- In refresh mode, preserve the user's accumulated decisions verbatim.
Only the snapshot sections are yours to rewrite.
- Don't invent routes or features. If you can't find evidence for it
in the code, don't put it in the snapshot.
- Keep it scannable. The doc is for review; verbose explanations bury
the signal.

View file

@ -1,16 +1,19 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -30,6 +33,7 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -39,7 +43,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -51,7 +55,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,16 +1,19 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -30,6 +33,7 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -52,7 +56,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -67,19 +71,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
- If no: Move `changeRoot` to the archive directory
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
@ -98,7 +102,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.

View file

@ -1,12 +1,13 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
@ -15,6 +16,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
@ -102,11 +105,10 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -115,7 +117,7 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |

View file

@ -1,12 +1,13 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Propose a new change - create the change and generate all artifacts in one step.
@ -20,6 +21,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
@ -37,7 +40,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -46,6 +49,7 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -63,10 +67,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

View file

@ -0,0 +1,216 @@
---
name: spec-drift-review
description: Walk every spec in `openspec/specs/`, compare it to the shipped code, and produce a categorized drift report (high/medium/low severity per spec, plus code-without-spec findings and structural suggestions). Use when the user wants to check spec drift, after a chunk of features has shipped, or when planning a spec catch-up PR.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the specs directory + the shipped code, compare them claim by
claim, and produce a structured drift report. The report is the
deliverable; fixes happen in a follow-up PR after the user has reviewed
the findings.
The goal is to keep `openspec/specs/` honest — specs are useless if they
don't describe the actual product, and worse than useless if they
contradict it.
---
## When to use
- The user explicitly asks to check spec drift ("are the specs in sync",
"review specs against code").
- After a multi-feature chunk has shipped without per-feature spec
promotion — drift accumulates fastest in catch-up phases.
- Before reorganizing the specs directory (split / merge / rename).
- When a spec contradicts the codebase and you're not sure which is
right.
---
## Steps
1. **Get the lay of the land**
Run these in parallel:
```bash
ls openspec/specs/
ls openspec/changes/ # in-flight work — NOT drift
cat openspec/CAPABILITIES.md # if it exists, it's the index
openspec list --json # any active changes that explain drift
```
**Important:** any spec referenced in an active openspec change is
*expected* to drift from current code — that drift is the work in
progress. Note these and exclude them from the report.
2. **Identify the code-side anchors per spec**
For each spec at `openspec/specs/<capability>/spec.md`, find the
primary code locations that implement it. Most capability specs map
to one or more of:
- **Routes:** `apps/journal/app/routes/*.tsx` / `*.ts`
authoritative for URL behavior, redirects, access gating.
- **Server lib:** `apps/journal/app/lib/<topic>.server.ts` — most
business logic.
- **Schema:** `packages/db/src/schema/journal.ts` — table shapes,
visibility values, defaults, indexes.
- **i18n:** `packages/i18n/src/locales/{en,de}.ts` — user-facing
strings, often telling.
- **Tests:** integration tests are a great oracle for the *intended*
behavior; mismatch with the spec usually means the spec is stale.
Don't read every file. Pick the 13 anchors per spec that the
requirements most plausibly map to.
3. **Compare claim by claim**
For each requirement in a spec, ask:
- **Does the code do this?** If not, is it because the requirement
was retired or because it was never shipped?
- **Does the code do *more* than this?** New scenarios shipped
without a spec update.
- **Does the code do this *differently*?** Different URL,
different default, different status code, different rule.
- **Does this requirement reference a name that no longer exists?**
Renamed routes, deleted helpers, removed tables.
Track findings with severity:
| Severity | What it means |
|----------|---------------|
| **High** | Spec actively misleads — claims a behavior the code does not exhibit. A reader implementing against the spec would write the wrong code. |
| **Medium** | Spec is incomplete — code has scenarios the spec doesn't describe. Reader gets less information than they should but isn't actively misled. |
| **Low** | Wording drift — comments/cross-refs mention a renamed thing, but the requirement statements are still accurate. |
4. **Find code-without-spec**
Walk the route tree in `apps/journal/app/routes.ts` and the topic
files in `apps/journal/app/lib/`. For each top-level concept ask:
- Is this concept covered by a spec?
- If yes, does the spec mention this surface?
- If no, should it be?
Genuine "no spec" cases are usually: new feature shipped without
spec promotion, or piece of infrastructure deemed too internal for a
spec. Both are valid; flag the former, leave the latter alone.
5. **Structural review**
Spec organization itself can drift:
- **Specs that grew too big** — multiple unrelated requirements
under one spec. Candidates for a split (we previously split
`account-settings` into `profile-settings`, `account-management`,
`connected-services`).
- **Specs that overlap** — the same requirement appears in two
specs, or one spec keeps cross-referencing another. Candidate for
a merge or a clearer ownership boundary.
- **Specs that should exist but don't** — a capability is shipped
and substantial enough to merit its own spec but is currently
squeezed into another. (We added `sse-broker` and `notifications`
this way.)
- **`CAPABILITIES.md` drift** — if the index exists, check that
every spec dir has an entry and every entry points at a real
spec. The index is the easy thing to forget when adding a spec.
6. **Compose the report**
Output to the conversation as a markdown structure. Don't write a
doc unless the report is unusually large and the user asks for one —
most drift reports get acted on inside a single PR and don't need a
long-lived artifact.
Suggested structure:
```markdown
# Spec drift review — YYYY-MM-DD
**Active changes excluded from this review:**
- <name> (touches: <specs>)
## High-severity drift
### `<spec-name>`
- **Requirement: <name>** says <X>; code at `<file:line>` does <Y>.
[link / one-line action]
- …
## Medium-severity drift
### `<spec-name>`
- <description + code anchor>
- …
## Low-severity drift (wording / cross-refs)
- `<spec-name>`: <description>
- …
## Code-without-spec
- <feature> at `<file>` — should this be in <spec-name>, or a new
spec? Recommendation: <…>
## Structural suggestions
- Split: <spec-name><new-1>, <new-2>
- Merge: <spec-a> + <spec-b><new>
- New spec: <name> covering <area>
- `CAPABILITIES.md` updates: <list>
```
7. **Propose next actions, then stop**
End the review with three concrete options the user can pick from:
- **Ship a catch-up PR for everything** — works when drift is
mostly low/medium and the fix is mechanical.
- **Fix high-severity first, defer the rest** — works when high
items are urgent and the rest can wait for natural per-feature
spec updates.
- **Restructure first, then catch up** — works when structural
suggestions (split / merge / new spec) are large enough that
fixing claims inside the wrong spec shape would just have to be
redone.
Don't pick for them. Don't start fixing yet.
---
## What this skill is NOT
- **Not a fixer.** This skill produces a report. Apply happens after
the user has decided which findings to act on.
- **Not a CI check.** It's interactive — judgment calls (severity,
splits, merges) are part of the value, not an automation target.
- **Not for in-flight work.** Active openspec changes legitimately
cause spec/code mismatch; flag them in the "excluded" section and
move on.
- **Not a code review.** The question is "does the spec match the
code", not "is the code good." Code-quality observations belong in
PR review, not here.
---
## Guardrails
- Always start by reading active openspec changes — drift caused by
in-flight work is not drift.
- Don't write spec edits during the review. The user picks which
findings to act on; edits happen after.
- When code and spec disagree, the prevailing rule on this project is
**code is source of truth** unless the user says otherwise. Surface
the conflict; don't preemptively decide.
- Skip generated/scaffold files (e.g. `.react-router/types/**`) — they
are derived, not product.
- Don't pad the report with low-severity wording drift unless the user
asks for it. Concentrate signal.
- Keep observations specific (file + line + claim), not abstract. A
finding without an anchor isn't actionable.

View file

@ -1,12 +1,15 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -26,6 +29,7 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -35,7 +39,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- Context file paths (varies by schema)
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -47,7 +51,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,12 +1,15 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -26,6 +29,7 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -48,7 +52,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -63,19 +67,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
- If no: Move `changeRoot` to the archive directory
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
@ -94,7 +98,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
@ -107,7 +111,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
@ -120,7 +124,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
@ -137,7 +141,7 @@ Review the archive if this was not intentional.
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.

View file

@ -1,6 +1,7 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
@ -11,6 +12,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
@ -107,11 +110,10 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -120,7 +122,7 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |

View file

@ -1,6 +1,7 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
@ -16,6 +17,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
@ -33,7 +36,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -42,6 +45,7 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -59,10 +63,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

1
.claude/skills Symbolic link
View file

@ -0,0 +1 @@
../.agents/skills

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
# Keep Docker build contexts small and hermetic. CI builds from a clean
# checkout so this mostly matters for local builds (e2e/federation
# harness), where node_modules would otherwise bloat the context to
# gigabytes — and worse, `COPY . .` would overlay host-installed
# node_modules over the image's own pnpm install.
**/node_modules
**/.turbo
**/build
**/.react-router
.git
e2e/results
playwright-report
test-results
**/*.log

10
.env.development.example Normal file
View file

@ -0,0 +1,10 @@
# Root-level local dev defaults.
# Copy to `.env.development` (gitignored) to override.
# All values here work out of the box — no changes needed for standard local dev.
DATABASE_URL=postgres://trails:trails@localhost:5432/trails
BROUTER_URL=http://localhost:17777
# Change these for any environment that is not purely local.
JWT_SECRET=dev-secret-not-for-production
SESSION_SECRET=dev-secret-not-for-production

View file

@ -20,10 +20,22 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# react-native is pinned by the Expo SDK — upgrade it via an Expo
# SDK bump, not on its own. Community react-native-* libraries are
# intentionally NOT ignored here; they can be bumped independently.
# These packages are version-pinned by the Expo SDK (see
# expo/bundledNativeModules.json) — upgrade them via an Expo SDK
# bump / `npx expo install --fix`, never on their own. Dependabot
# bumping them past the SDK's expected version broke the native
# build (expo-modules-core macro mismatch, June 2026) because CI
# never compiles native code. `react` stays unignored: the web
# apps own its version via the workspace catalog, and apps/mobile
# excludes it from expo version checks.
- dependency-name: "react-native"
- dependency-name: "react-native-gesture-handler"
- dependency-name: "react-native-reanimated"
- dependency-name: "react-native-safe-area-context"
- dependency-name: "react-native-screens"
- dependency-name: "react-native-worklets"
- dependency-name: "@sentry/react-native"
- dependency-name: "jest-expo"
- package-ecosystem: "github-actions"
directory: "/"

View file

@ -13,6 +13,15 @@ concurrency:
group: deploy-apps
cancel-in-progress: true
# Public Sentry DSNs for the trails.cool flagship instance. Public by
# design — Sentry DSNs are transmitted unencrypted from the client JS
# bundle, embedding them in this workflow is no worse than embedding
# them in the runtime env. Self-hosted forks should either replace
# these with their own DSNs or remove the lines to ship without Sentry.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
build-images:
name: Build & Push Docker Images
@ -25,7 +34,7 @@ jobs:
matrix:
app: [journal, planner]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
@ -48,8 +57,12 @@ jobs:
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
# VITE_SENTRY_DSN bakes the client-side DSN into the journal's
# built bundle. Only journal has a client Sentry init; planner
# ignores the build-arg if present.
build-args: |
SENTRY_RELEASE=${{ github.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -59,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
@ -70,6 +83,16 @@ jobs:
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
# Federation on (social-federation rollout 12.5, flipped
# 2026-06-07 after the staging + Mastodon soak). The
# FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env
# decrypted above. Rollback: delete these two lines, merge,
# rerun cd-apps — instant off, federation surfaces 404.
echo "FEDERATION_ENABLED=true" >> infrastructure/app.env
echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env
# Sentry DSNs (public — see workflow top-level env for context).
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1
@ -77,7 +100,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
source: "infrastructure/docker-compose.yml,infrastructure/caddy"
target: /opt/trails-cool
strip_components: 1
@ -98,6 +121,10 @@ jobs:
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort the deploy on the first failure. Without this, a failed
# schema push deploys new code against an old schema (the
# 2026-06-06 schema-drift incident).
set -euo pipefail
cd /opt/trails-cool
# Login to ghcr.io
@ -106,17 +133,69 @@ jobs:
# Pull and deploy app containers
docker compose --env-file app.env pull journal planner
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# Hand-written data migrations (idempotent) run BEFORE drizzle-kit
# push so unique-key reshapes can collapse duplicate rows first.
docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt it can't show (no TTY in CI) — that exact lie hid a
# month of staging schema drift. Treat any Error in its output
# as a failed deploy.
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
# --remove-orphans cleans up containers whose service was deleted
# from the compose file, matching cd-infra's behaviour.
docker compose --env-file app.env up -d --remove-orphans journal planner
# Clean up
docker image prune -af
# Gate on container health: a deploy that leaves journal or
# planner unhealthy must fail loudly, not report green.
for svc in journal planner; do
for i in $(seq 1 24); do
status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$svc did not become healthy (last status: $status)"
docker compose --env-file app.env logs "$svc" --tail 50 || true
exit 1
fi
done
# Reload Caddy with the Caddyfile we just scp'd. cd-apps
# ships infrastructure/Caddyfile alongside docker-compose.yml
# (see scp step above), but containers don't auto-pick-up
# config changes. cd-infra reloads Caddy as part of its
# deploy; cd-apps did NOT, which meant any Caddyfile change
# touching only `apps/`/`packages/` paths sat on disk
# unapplied until the next cd-infra run. The reload is
# idempotent (Caddy validates first, swaps live, no
# downtime) so doing it on every cd-apps deploy is safe
# even when Caddyfile is unchanged. `|| true` keeps the
# deploy from failing if Caddy itself is unhealthy.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Clean up (best-effort). This runs AFTER the containers are
# swapped + Caddy reloaded, so the deploy already succeeded —
# a transient "a prune operation is already running" collision
# with a concurrent deploy/disk-maintenance prune must NOT fail
# an otherwise-green deploy. disk-maintenance.yml is the real
# image-prune safety net.
docker image prune -af || true
docker compose ps
# Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null)
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
# in secrets.infra.env (decrypted by cd-infra.yml into the
# merged /opt/trails-cool/.env on the server). cd-apps's
# own app.env intentionally does NOT carry it — apps don't
# need it at runtime. So we read from the merged `.env`
# that cd-infra populated. If cd-infra has never run on
# this host, .env may not exist; the `2>/dev/null` and
# the `if -n` guard make the annotation a silent no-op
# rather than a deploy failure in that case.
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \

View file

@ -20,7 +20,7 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
@ -42,7 +42,7 @@ jobs:
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Decrypt shared secret
id: decrypt
@ -87,6 +87,7 @@ jobs:
TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \
docker-compose.yml Caddyfile promtail-config.yml \
download-segments.sh .env \
poi-extract \
| base64 -w0)
# One SSH session: untar, (re)start containers, report status
@ -99,7 +100,7 @@ jobs:
set -euo pipefail
mkdir -p ~/brouter && cd ~/brouter
echo "$TARBALL_B64" | base64 -d | tar -xzf -
chmod +x download-segments.sh
chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py
# Segment seeding is a one-shot operator task (~10 GB, a few
# minutes). CD must not rerun it on every deploy.

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
@ -43,7 +43,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts"
target: /opt/trails-cool
strip_components: 1
@ -64,6 +64,11 @@ jobs:
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort on first failure. The 2026-06-06/07 outage: a network
# recreation stopped postgres, a later step failed, and the
# deploy left production down for ~9h while the job's partial
# progress looked plausible. Fail fast, verify health at the end.
set -euo pipefail
cd /opt/trails-cool
# .env was placed by the SCP step (decrypted app + infra secrets)
@ -79,20 +84,77 @@ jobs:
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
fi
# Capture whether prometheus was recreated. A fresh container already
# reads the new config on startup; sending it SIGHUP immediately can
# kill Prometheus 3.10 during early boot (exit 2 observed on
# 2026-06-09).
PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
docker compose --env-file .env up -d --remove-orphans
else
# Restart infra services (except Caddy — just reload its config).
# Restart infra services (config reloads handled below).
# --remove-orphans cleans up containers whose service was deleted
# from the compose file (e.g., the flagship `brouter` removal in
# PR #297 left an orphan that had to be removed by hand).
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
fi
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Apply config-only changes that `up -d` skips — it recreates a
# container only when its compose *definition* changes, not when a
# mounted config file's content changes. The configs are mounted as
# directories (not single files), so a reload/restart re-reads the
# freshly scp'd file; a single-file mount would have pinned the old
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
# the same container stayed up; a recreated container already loaded
# the new config on startup. Loki and Promtail reload their main
# config only on restart; Caddy reloads gracefully (validates, swaps
# live, no downtime).
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
docker compose --env-file .env kill -s SIGHUP prometheus
fi
docker compose --env-file .env restart loki promtail
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
docker compose ps
# Gate on the stack actually being up: postgres healthy, journal
# back to healthy after the DB bounce, and Prometheus answering
# /-/ready. A deploy that leaves any of them down must fail loudly
# (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus
# startup/HUP regression).
for ctr in trails-cool-postgres-1 trails-cool-journal-1; do
for i in $(seq 1 36); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$ctr did not become healthy (last status: $status)"
exit 1
fi
done
PROMETHEUS_READY=
for i in $(seq 1 36); do
prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing)
if [ "$prom_status" = "running" ]; then
prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1)
if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then
PROMETHEUS_READY=1
break
fi
fi
sleep 5
done
if [ -z "$PROMETHEUS_READY" ]; then
echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)"
exit 1
fi
# Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then

520
.github/workflows/cd-staging.yml vendored Normal file
View file

@ -0,0 +1,520 @@
name: CD Staging
# Builds, deploys, and tears down the persistent staging stack and per-PR
# preview environments. See `openspec/changes/staging-environments/` for the
# design (decisions on shared Postgres, port allocation, per-PR Caddyfile
# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view.
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
# Also redeploy when the staging plumbing itself changes, so a port
# bump or compose edit doesn't sit unapplied until the next apps/ push.
- "infrastructure/docker-compose.staging.yml"
- ".github/workflows/cd-staging.yml"
pull_request:
# `labeled`/`unlabeled` so toggling the `preview` label on an existing PR
# starts / tears down its preview (see the opt-in gate on the jobs below).
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
# Per-target concurrency: persistent staging deploys serialize against
# themselves, each PR's preview lifecycle serializes against itself.
concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true
# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the
# "public by design" rationale.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
# ── Build ─────────────────────────────────────────────────────────────
# Tags:
# main push → :staging + :<sha>
# PR open/sync/reopen → :pr-<N> + :pr-<N>-<sha>
# Skipped entirely on PR close (teardown doesn't need new images).
build-images:
name: Build & Push Docker Images
# PR previews are opt-in to keep flagship disk in check (each preview is a
# journal container + database): build a PR's images only when it carries
# the `preview` label or a `<!-- preview -->` marker in its description.
# Main-push / dispatch always build.
if: >
github.event_name != 'pull_request' ||
(github.event.action != 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->')))
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
outputs:
tag_primary: ${{ steps.tags.outputs.primary }}
tag_sha: ${{ steps.tags.outputs.sha }}
steps:
- uses: actions/checkout@v7
- id: tags
name: Compute image tags
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
PRIMARY="pr-${{ github.event.number }}"
SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}"
else
PRIMARY="staging"
SHA="${{ github.sha }}"
fi
echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt Sentry auth token
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }}
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
# ── Deploy persistent staging (main push or manual dispatch) ─────────
deploy-staging:
name: Deploy Staging
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
needs: [build-images]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=staging.trails.cool"
echo "STAGING_DATABASE=trails_staging"
# Loki binds 10.0.0.2:3100 on the vSwitch interface, so the
# staging stack can't share 3100 — see CLAUDE.md "Staging &
# Previews" port table.
echo "JOURNAL_HOST_PORT=3110"
echo "PLANNER_HOST_PORT=3111"
echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation soak on persistent staging only (social-federation
# rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above; previews never set this flag.
echo "FEDERATION_ENABLED=true"
# Verbose Fedify logs during the soak (signature verification
# detail). Dial down to info once federation is proven out.
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging.env
- name: Copy compose file + env to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/trails-cool
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Bootstrap the shared network so cd-staging works regardless of
# whether cd-infra has already run with the new docker-compose.yml.
# Once cd-infra runs, postgres is permanently joined via compose;
# until then, attach it imperatively.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Ensure trails_staging database exists with postgis. Production
# init scripts only run on first data-dir init, so a freshly
# created database has no extensions.
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='trails_staging'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails trails_staging
docker compose exec -T postgres psql -U trails -d trails_staging -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull and deploy staging containers (journal + planner via "persistent" profile)
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt (no TTY in CI) — set -e alone can't catch it. That lie
# hid a month of staging schema drift (2026-06-06 incident).
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans
# Reload Caddy so new staging routes (or Caddyfile changes shipped
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Reclaim superseded image layers. cd-apps prunes after its own
# deploys, but a day of staging/preview deploys while cd-apps is
# red can fill the disk on its own (2026-06-07: 100% full,
# postgres down). The 1h filter avoids racing layers another
# in-flight deploy just pulled.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
deploy-preview:
name: Deploy PR Preview
# Opt-in only (see build-images): the `preview` label or a `<!-- preview -->`
# marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there
# is no app image to preview.
if: >
github.event_name == 'pull_request' &&
github.event.action != 'closed' &&
!startsWith(github.head_ref, 'dependabot/github_actions/') &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))
needs: [build-images]
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v7
- id: ports
name: Compute preview ports + project name
run: |
PR=${{ github.event.number }}
# journal = 3200 + 2N, planner unused (PR previews are journal-only)
JOURNAL_PORT=$((3200 + 2 * PR))
PLANNER_PORT=$((3201 + 2 * PR))
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT"
echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT"
echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- name: Decrypt secrets + assemble env
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Use a per-PR filename so concurrent SCP transfers don't overwrite each other.
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=${{ steps.ports.outputs.host }}"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}"
echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}"
echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
# PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation on previews too (social-federation rollout 12.4):
# makes any PR preview a second live trails instance that
# persistent staging can follow across — the trails-to-trails
# soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above. Preview teardown orphans remote
# follower rows on the other side; remotes handle dead
# instances via ordinary delivery-failure expiry.
echo "FEDERATION_ENABLED=true"
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Generate per-PR Caddyfile snippet
run: |
mkdir -p infrastructure/sites
cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile <<EOF
# Auto-generated by cd-staging.yml for PR ${{ steps.ports.outputs.pr }}. Do not edit.
${{ steps.ports.outputs.host }} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:${{ steps.ports.outputs.journal_port }} {
lb_try_duration 30s
lb_try_interval 250ms
}
}
EOF
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
target: /opt/trails-cool
strip_components: 1
- name: Deploy preview via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB,JOURNAL_PORT
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Serialize all preview deploys with a server-side lock so concurrent
# CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state.
exec 9>/tmp/trails-preview-deploy.lock
flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; }
# Same network bootstrap as deploy-staging — see comment there.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Concurrent preview limit (max 3): if we're at the cap and this
# PR isn't already running, evict the oldest preview project.
ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true)
if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then
COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true)
if [ "$COUNT" -ge 3 ]; then
# Pick the oldest by container CreatedAt of any service in the project.
OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \
| awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \
| sort -k2 \
| head -1 \
| awk '{print $1}')
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
echo "At cap; evicting oldest preview: $OLDEST"
OLD_PR=${OLDEST#trails-pr-}
OLD_ENV="staging-pr-${OLD_PR}.env"
# Fall back to base secrets if the per-PR env was already cleaned up.
EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" )
docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV"
fi
fi
fi
# Ensure per-PR database exists with postgis. (See deploy-staging
# for why we have to enable the extension explicitly.)
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$DB'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails "$DB"
docker compose exec -T postgres psql -U trails -d "$DB" -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull, migrate, deploy (journal-only — no --profile means planner skipped)
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal
# Same drizzle-kit exit-code-0-on-error guard as the persistent
# staging deploy above.
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the preview deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
# Same disk-hygiene prune as the persistent staging deploy —
# preview pushes are the highest-volume image source.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather
# than spamming a new one each push. The marker line at the bottom of
# the body is what `body-includes` matches on.
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Upsert preview comment on PR
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.number }}
edit-mode: replace
body: |
🚀 **PR preview deployed**
- **Journal:** https://${{ steps.ports.outputs.host }}
- **Planner (shared staging):** https://planner.staging.trails.cool
- **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging)
- **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}`
Updates automatically on push. Tears down when this PR closes.
<!-- cd-staging:preview -->
# ── PR preview teardown ──────────────────────────────────────────────
teardown-preview:
name: Tear Down PR Preview
# Tear down on close, or when the `preview` opt-in is removed (label pulled
# and no `<!-- preview -->` marker left) so a de-flagged PR doesn't orphan
# its preview stack on the flagship.
if: >
github.event_name == 'pull_request' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' &&
!(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))))
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- id: ports
name: Compute project + database name
run: |
PR=${{ github.event.number }}
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
- name: Decrypt secrets (needed to satisfy compose env vars during down)
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Copy compose + env (teardown still needs the file)
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env"
target: /opt/trails-cool
strip_components: 1
- name: Tear down via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
# Stop and remove containers + volumes for this PR
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true
# Drop the per-PR database (idempotent)
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
# Remove the per-PR Caddy snippet, env file, and reload
rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE"
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Update preview comment on close
if: steps.find-comment.outputs.comment-id
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}).
Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared.
[Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
<!-- cd-staging:preview -->

View file

@ -19,7 +19,7 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Gitleaks
@ -42,14 +42,27 @@ jobs:
name: Dockerfile Package Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: bash scripts/check-dockerfiles.sh
openspec:
name: OpenSpec Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm openspec validate --all --strict --no-interactive
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -62,7 +75,7 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -75,7 +88,7 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -88,7 +101,7 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -97,14 +110,14 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
e2e:
name: E2E Tests
needs: build
visual-tests:
name: Visual Tests
runs-on: ubuntu-latest
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
@ -112,64 +125,78 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache PostGIS Docker image
id: postgis-cache
uses: actions/cache@v5
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: /tmp/postgis-image.tar
key: postgis-16-3.4
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Load or pull PostGIS image
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright deps only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run visual regression tests
id: visual-tests
run: pnpm --filter @trails-cool/planner test:visual
- name: Post diff comment on PR
if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -f /tmp/postgis-image.tar ]; then
docker load < /tmp/postgis-image.tar
else
docker pull postgis/postgis:16-3.4
docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar
fi
diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort)
if [ -z "$diffs" ]; then exit 0; fi
- name: Start PostgreSQL
run: |
docker run -d --name postgres \
-e POSTGRES_USER=trails \
-e POSTGRES_PASSWORD=trails \
-e POSTGRES_DB=trails \
-p 5432:5432 \
postgis/postgis:16-3.4
# Wait for pg_isready
for i in $(seq 1 30); do
docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break
sleep 1
done
# Wait for PostGIS extension to be ready
for i in $(seq 1 10); do
docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break
sleep 1
artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
body="## Visual regression failures"$'\n\n'
body+="The following tests produced screenshot diffs:"$'\n\n'
for diff in $diffs; do
name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g')
body+="- \`$name\`"$'\n'
done
body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n'
body+="To update snapshots if the change is intentional:"$'\n'
body+="\`\`\`"$'\n'
body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n'
body+="\`\`\`"
- name: Push database schema
run: pnpm db:push
gh pr comment ${{ github.event.pull_request.number }} --body "$body"
- name: Build and cache BRouter
id: brouter-cache
uses: actions/cache@v5
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v7
with:
path: /tmp/brouter
key: brouter-1.7.8
name: visual-snapshots-diff
path: apps/planner/.vitest-attachments/
include-hidden-files: true
retention-days: 7
- name: Download BRouter
if: steps.brouter-cache.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/brouter
wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip
cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip
e2e:
name: E2E Tests
needs: build
runs-on: ubuntu-latest
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache BRouter segment
id: segment-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: /tmp/brouter-segments
key: brouter-segment-E10_N50
key: brouter-segment-E10_N50-v1.7.9
- name: Download Berlin segment
if: steps.segment-cache.outputs.cache-hit != 'true'
@ -177,26 +204,54 @@ jobs:
mkdir -p /tmp/brouter-segments
wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5
- name: Start BRouter
- name: Pre-seed BRouter segment volume
run: |
cd /tmp/brouter
java -Xmx256M -Xms64M \
-DmaxRunningTime=300 \
-cp brouter-1.7.8-all.jar \
btools.server.RouteServer \
/tmp/brouter-segments profiles2 profiles2 \
17777 2 &
# Wait for BRouter to start
for i in $(seq 1 30); do
curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break
sleep 2
done
docker volume create trails_brouter_segments
docker run --rm \
-v /tmp/brouter-segments:/src:ro \
-v trails_brouter_segments:/dst \
alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5"
- name: Start services
run: docker compose -f docker-compose.dev.yml up -d --wait --build
env:
BROUTER_URL: http://localhost:17777
- name: Wait for BRouter routing
run: |
for i in $(seq 1 60); do
curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break
[ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1
sleep 2
done
- name: Push database schema
run: pnpm db:push
- name: Seed database
run: pnpm db:seed
- name: Run integration tests
# These talk to real Postgres. The unit-test job has no DB so
# the `*.integration.test.ts` files skip there; this job has
# the DB up + schema pushed, so flip the gate env vars to "1"
# and let them run. Each gate is read by one file — see
# `runIntegration` in each test.
#
# --no-file-parallelism: integration tests share the journal
# schema and clean up by `DELETE FROM ... WHERE email LIKE
# '%@example.test'`. Parallel files step on each other's rows
# and trip FK constraints. Running sequentially is still <3s.
run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts
env:
EXPLORE_INTEGRATION: "1"
FOLLOW_INTEGRATION: "1"
DEMO_BOT_INTEGRATION: "1"
NOTIFICATIONS_INTEGRATION: "1"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
@ -218,6 +273,12 @@ jobs:
run: pnpm test:e2e
env:
BROUTER_URL: http://localhost:17777
# E2E=true is the explicit opt-out from the fail-loud
# requireSecret() / getDatabaseUrl() guards — playwright boots
# the server via `react-router serve` (NODE_ENV=production) but
# against the local dev Postgres + local cookie secrets.
E2E: "true"
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
- name: Playwright job summary
if: ${{ !cancelled() }}
@ -255,3 +316,87 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
journal-image-smoke:
# Build the journal's *production* Docker image (the `runtime` stage)
# and actually boot it. Nothing else in CI does this: typecheck /
# lint / test / build all run against the source tree, and the e2e
# job boots the journal via `react-router-serve`, not the production
# `node server.ts` entrypoint. The runtime stage copies source files
# in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor
# that adds a file `server.ts` imports — without a matching COPY —
# builds green everywhere and only crash-loops once deployed
# (ERR_MODULE_NOT_FOUND). That has taken prod down more than once
# (app/lib, app/jobs, serve-static.ts). Booting the real image and
# hitting /api/health closes that gap: a missing static OR dynamic
# import never reaches a healthy 200.
name: Journal Image Smoke Test
runs-on: ubuntu-latest
services:
postgres:
image: imresamu/postgis:16-3.4
env:
POSTGRES_USER: trails
POSTGRES_PASSWORD: trails
POSTGRES_DB: trails
ports:
- 5432:5432
# The postgis image restarts mid-init while it creates the
# extension; the health check only passes once the final server
# is up, so dependents don't race the init restart.
options: >-
--health-cmd "pg_isready -U trails"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# seedOAuthClient + the demo/notifications job worker run on boot
# and write to real tables, so the image needs a schema to come up
# healthy. The postgis extension is auto-created by the image.
- name: Push database schema
run: pnpm db:push
- name: Build journal runtime image
run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke .
- name: Boot image and wait for healthy
run: |
# --network host: reach the service Postgres at localhost:5432
# and publish the server on localhost:3000 in one shot.
# E2E=true is the documented opt-out from the fail-loud
# getDatabaseUrl() prod guard (CI points at a local Postgres).
docker run -d --name journal-smoke --network host \
-e NODE_ENV=production -e E2E=true \
-e DATABASE_URL="$DATABASE_URL" \
journal-smoke
code=000
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000)
echo "attempt $i: /api/health -> $code"
[ "$code" = "200" ] && break
if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then
echo "::error::journal container exited during boot"
break
fi
sleep 2
done
if [ "$code" != "200" ]; then
echo "::error::journal production image failed to boot healthy (see logs below)"
docker logs journal-smoke 2>&1 || true
exit 1
fi
echo "journal production image booted healthy"
- name: Container logs
if: always()
run: docker logs journal-smoke 2>&1 | tail -40 || true

View file

@ -0,0 +1,94 @@
name: Dependabot auto-fix
# Post-processing that runs on every dependabot PR and pushes the result
# back to the PR branch. Two fixups, in one workflow so there is a single
# checkout / commit / push (two workflows racing to push the same branch
# would collide on a non-fast-forward):
#
# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a
# package (e.g. two versions of i18next, each holding their own
# singleton state). That split caused a hydration mismatch on #272
# until a manual `pnpm dedupe` collapsed them.
#
# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*)
# and opsx slash commands (.claude/commands/opsx/*) are generated files
# stamped with the CLI version that produced them. When dependabot bumps
# @fission-ai/openspec they go stale until regenerated (see PR #567,
# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force`
# rewrites them to match the freshly-installed CLI.
#
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
# fine-grained PAT (or GitHub App token) with `contents: write` on
# this repo. The default `GITHUB_TOKEN` would work for the push but
# would NOT trigger a subsequent CI run on that push (GitHub's
# anti-loop safeguard), leaving the PR with stale green CI from
# before the fixup commit. A PAT re-triggers CI so the reviewer
# sees test results for the state they'd actually be merging.
on:
pull_request:
branches: [main]
permissions:
contents: write
jobs:
autofix:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Require DEPENDABOT_DEDUPE_TOKEN
env:
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
echo "::error::requires a PAT so the fixup commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-auto-fix.yml header."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
# With `persist-credentials: true`, this PAT is stashed by the
# action (under $RUNNER_TEMP in recent versions) and made
# available to subsequent git operations in this workspace —
# so the later `git push` authenticates as the PAT, which is
# what gets CI to re-trigger on the fixup commit. `true` is
# the checkout default; pinned explicitly here because it's
# load-bearing for this workflow.
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile=false
- name: Dedupe lockfile
run: pnpm dedupe
- name: Regenerate OpenSpec tool files
# --force so the generated files always match the installed CLI,
# even if `update` would otherwise consider them up to date. No-op
# (no diff) when @fission-ai/openspec wasn't bumped in this PR.
run: pnpm exec openspec update --force
- name: Commit fixups
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx
if git diff --cached --quiet; then
echo "Nothing to fix up — lockfile deduped and OpenSpec files current."
exit 0
fi
# Build a message naming only the parts that actually changed.
parts=""
git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe"
if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then
parts="${parts:+$parts + }openspec update"
fi
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git commit -m "[github-actions] $parts"
git push
echo "Pushed fixups: $parts"

View file

@ -1,74 +0,0 @@
name: Dependabot dedupe
# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't
# dedupe peer copies of packages (e.g. two versions of i18next, each
# holding their own singleton state). That split caused a hydration
# mismatch on #272 until a manual `pnpm dedupe` collapsed them.
#
# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
# pushes the resulting lockfile update back to the PR branch so the
# subsequent CI run tests the deduped tree.
#
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
# fine-grained PAT (or GitHub App token) with `contents: write` on
# this repo. The default `GITHUB_TOKEN` would work for the push but
# would NOT trigger a subsequent CI run on that push (GitHub's
# anti-loop safeguard), leaving the PR with stale green CI from
# before the dedupe commit. A PAT re-triggers CI so the reviewer
# sees test results for the state they'd actually be merging.
on:
pull_request:
branches: [main]
permissions:
contents: write
jobs:
dedupe:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Require DEPENDABOT_DEDUPE_TOKEN
env:
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
echo "::error::requires a PAT so the dedupe commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-dedupe.yml header."
exit 1
fi
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
# With `persist-credentials: true`, this PAT is stashed by the
# action (under $RUNNER_TEMP in recent versions) and made
# available to subsequent git operations in this workspace —
# so the later `git push` authenticates as the PAT, which is
# what gets CI to re-trigger on the dedupe commit. `true` is
# the checkout default; pinned explicitly here because it's
# load-bearing for this workflow.
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile=false
- run: pnpm dedupe
- name: Commit dedupe changes
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "[github-actions] pnpm dedupe"
git push
echo "Deduped lockfile pushed."
else
echo "Lockfile already deduped."
fi

56
.github/workflows/disk-maintenance.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Disk Maintenance
# Daily safety net for flagship disk usage. The deploy workflows prune
# superseded image layers after their own runs, but that protection
# disappears exactly when it's needed most: a deploy that fails early
# never reaches its prune step, while other workflows keep pulling
# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
# deploys, disk 100% full, postgres down on a Saturday morning).
#
# Also doubles as a redundant alert channel: the run FAILS when the
# disk is still above the threshold after pruning, so a scheduled-run
# failure email lands even if the Grafana disk alert drowns in other
# noise (which is what happened during the incident).
on:
schedule:
# Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
- cron: "30 4 * * *"
workflow_dispatch: {}
concurrency:
group: disk-maintenance
cancel-in-progress: false
jobs:
prune-flagship:
name: Prune unused images (flagship)
runs-on: ubuntu-latest
environment: production
steps:
- name: Prune and check disk headroom
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
echo "before: $(df -h / | tail -1)"
# 12h filter: never touch layers a same-day deploy may still
# be assembling; running containers' images are never pruned.
# `|| true`: a concurrent deploy's prune can make this collide
# with "a prune operation is already running" — that's benign
# (the other prune is freeing space too), and the disk-% gate
# below is the real assertion, so don't fail on the collision.
docker image prune -af --filter "until=12h" || true
echo "after: $(df -h / | tail -1)"
PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$PCT" -ge 85 ]; then
echo "Disk still at ${PCT}% after pruning — needs a human."
echo "Largest docker consumers:"
docker system df
exit 1
fi
echo "Disk at ${PCT}% — healthy."

112
.github/workflows/staging-cleanup.yml vendored Normal file
View file

@ -0,0 +1,112 @@
name: Staging Cleanup
# Sweeps the production server for orphaned PR-preview resources whose PRs
# have closed without the cd-staging teardown job running (e.g., the
# teardown failed, the workflow file was changed mid-flight, or the PR was
# closed while runners were down). Runs weekly and can be triggered ad-hoc.
on:
schedule:
# Every Monday at 04:00 UTC
- cron: "0 4 * * 1"
workflow_dispatch: {}
concurrency:
group: staging-cleanup
cancel-in-progress: false
jobs:
cleanup:
name: Sweep orphaned previews
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
pull-requests: read
steps:
- name: List active preview projects
id: list
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
cd /opt/trails-cool
# Emit one project name per line, e.g. "trails-pr-123"
docker compose ls --format json --filter "name=trails-pr-" \
| python3 -c 'import json,sys
try:
data = json.load(sys.stdin)
except Exception:
data = []
for d in data:
n = d.get("Name","")
if n.startswith("trails-pr-"):
print(n)' \
|| true
- name: Determine which PRs are still open
id: orphans
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECTS: ${{ steps.list.outputs.stdout }}
run: |
set -euo pipefail
ORPHANS=()
if [ -z "${PROJECTS:-}" ]; then
echo "No active preview projects."
echo "orphans=" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r project; do
[ -z "$project" ] && continue
pr="${project#trails-pr-}"
# If gh can't find the PR (deleted) or it's not OPEN, treat as orphan.
state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING")
if [ "$state" != "OPEN" ]; then
echo "Orphan: PR #$pr (state=$state) → tear down $project"
ORPHANS+=("$pr")
fi
done <<< "${PROJECTS}"
IFS=,
echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT"
- name: Tear down orphans
if: steps.orphans.outputs.orphans != ''
env:
ORPHANS: ${{ steps.orphans.outputs.orphans }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: ORPHANS
script: |
set -euo pipefail
cd /opt/trails-cool
IFS=, read -ra PRS <<< "$ORPHANS"
for PR in "${PRS[@]}"; do
[ -z "$PR" ] && continue
PROJECT="trails-pr-$PR"
DB="trails_pr_$PR"
echo "→ tearing down $PROJECT"
# `down` needs the same env file the deploy used; staging.env on
# disk may belong to a different PR, so synthesize a minimal one.
cat > /tmp/cleanup.env <<EOF
DOMAIN=pr-$PR.staging.trails.cool
STAGING_DATABASE=$DB
JOURNAL_HOST_PORT=$((3200 + 2 * PR))
PLANNER_HOST_PORT=$((3201 + 2 * PR))
JWT_SECRET=cleanup
SESSION_SECRET=cleanup
BROUTER_URL=http://placeholder
BROUTER_AUTH_TOKEN=placeholder
EOF
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env"
done
rm -f /tmp/cleanup.env
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true

View file

@ -0,0 +1,103 @@
name: Update visual snapshots
# How to use this workflow
# ========================
#
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
# committed to the repo. They are generated by Vitest browser mode running
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
#
# When to update snapshots:
# - You intentionally changed the look of the elevation chart (new color mode,
# layout change, etc.) and the old snapshots are now wrong.
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
#
# Two ways to trigger this workflow:
#
# 1. Manual dispatch (workflow_dispatch):
# Go to Actions → "Update visual snapshots" → "Run workflow".
# Choose the branch you want updated. The workflow will commit the new
# snapshots back to that branch.
#
# 2. PR label (update-snapshots):
# Add the `update-snapshots` label to any PR. The workflow will update
# snapshots on the PR's head branch. Remove the label after to avoid
# re-triggering on every subsequent push.
#
# After the workflow commits updated snapshots, pull the branch locally:
# git pull origin <your-branch>
#
# Running locally:
# pnpm --filter @trails-cool/planner test:visual # run tests
# pnpm --filter @trails-cool/planner test:visual:update # update snapshots
#
# Platform note:
# Snapshots are generated on ubuntu-latest to keep CI and local results
# consistent. Snapshots generated on macOS or Windows will have subtle
# font-rendering differences and will fail on CI. Always use this workflow
# (or a Linux machine / Docker) to produce the canonical snapshots.
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to update snapshots on"
required: false
default: ""
pull_request:
types: [labeled]
jobs:
update-snapshots:
# Only run for manual dispatch, or when the label is "update-snapshots"
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots')
runs-on: ubuntu-latest
permissions:
contents: write # needed to push snapshot commits back
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
# Use a token with push rights so the commit-back step can push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm exec playwright install chromium --with-deps
- name: Update visual snapshots
run: pnpm --filter @trails-cool/planner test:visual:update
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: update visual snapshots [skip ci]"
file_pattern: "apps/planner/app/**/__screenshots__/**"
commit_user_name: "github-actions[bot]"
commit_user_email: "github-actions[bot]@users.noreply.github.com"
- name: Upload snapshots as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: visual-snapshots
path: apps/planner/app/**/__screenshots__/
if-no-files-found: ignore

2
.gitignore vendored
View file

@ -10,8 +10,10 @@ dist/
.crit.json
e2e/results/
test-results/
.env.development
playwright-report/
playwright-results.json
.claude/worktrees/
.claude/settings.local.json
.claude/scheduled_tasks.lock
docs/reviews/internal/

101
CLAUDE.md
View file

@ -9,7 +9,9 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with
Full architecture: `docs/architecture.md`
Philosophy: `docs/philosophy.md`
OpenSpec change: `openspec/changes/phase-1-mvp/`
Roadmap: `docs/roadmap.md`
Ideas (pre-spec explorations): `docs/ideas/`
OpenSpec changes: `openspec/changes/`
## Principles
@ -25,7 +27,7 @@ OpenSpec change: `openspec/changes/phase-1-mvp/`
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
- **Maps**: Leaflet + OpenStreetMap tiles
- **CRDT**: Yjs + y-websocket (Planner only)
- **Federation**: Fedify (Journal only, Phase 2)
- **Federation**: Fedify (Journal only)
- **Database**: PostgreSQL + PostGIS
- **Media storage**: S3-compatible (Garage)
- **Routing engine**: BRouter (Java, runs as separate Docker container)
@ -39,11 +41,15 @@ apps/
planner/ — Planner app (React Router 7)
journal/ — Journal app (React Router 7 + Fedify)
packages/
types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
ui/ — Shared React components (Tailwind)
map/ — Leaflet map wrappers and tile layer configs
types/ — Shared wire types both apps exchange (Waypoint)
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
gpx/ — GPX parsing, generation, validation
fit/ — FIT file generation (Wahoo route push)
i18n/ — react-i18next config + translations
api/ — Shared API contracts (endpoints, pagination, error types, versioning)
db/ — Drizzle schema, database client, migration helpers
jobs/ — pg-boss setup, worker, and background job types
sentry-config/ — Shared Sentry configuration
infrastructure/ — Terraform + Docker Compose
openspec/ — OpenSpec specs and changes
docs/ — Architecture, philosophy, tooling docs
@ -68,6 +74,40 @@ pnpm db:push # Push Drizzle schema to local PostgreSQL
pnpm db:studio # Open Drizzle Studio (DB browser)
```
### Local HTTPS dev (rare — most contributors never need this)
The default dev loop runs the journal on plain HTTP at
`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the
Terms gate, SSE — everything works over HTTP because the WebAuthn spec
treats `localhost` as a secure context regardless of scheme. CI's e2e
suite runs over plain HTTP too.
There is exactly one feature that requires local HTTPS: **Wahoo OAuth**.
Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the
`/api/sync/connect/wahoo` callback flow can only complete against an
HTTPS dev server. To run that flow:
```bash
HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev
```
`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN
HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the
WebAuthn server expect the HTTPS origin (set this together with HTTPS=1
or you'll get origin-mismatch errors). Use `pnpm --filter` (not
`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to
its `globalPassThroughEnv``pnpm --filter` bypasses turbo entirely.
**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env`
unless you intend to always run with `HTTPS=1`.** Mismatched values
break the e2e suite and generic dev. See
`apps/journal/.env.example` for what each var means.
If you find yourself wanting `HTTPS=1` for any reason other than Wahoo
testing, write it down here so the assumption stays auditable —
"everything but Wahoo works over HTTP locally" is what keeps CI and
local config symmetric.
## Testing Strategy
- **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic.
@ -83,8 +123,8 @@ pnpm db:studio # Open Drizzle Studio (DB browser)
- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build.
- All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings)
- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
- Map components go in `@trails-cool/map`, not in individual apps
- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types`
- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them
- GPX parsing/generation goes in `@trails-cool/gpx`
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
@ -143,13 +183,15 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a
## Deployment
Three separate CD workflows triggered by path:
Five CD workflows triggered by path or event:
| Workflow | Triggers on | Deploys | Target |
|----------|-------------|---------|--------|
| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) |
| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) |
| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) |
| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) |
| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship |
### Hosts
@ -181,6 +223,49 @@ ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is
### Grafana
`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org)
### Staging & Previews
A persistent staging stack and ephemeral PR previews share the flagship server with production.
| Surface | URL | Database | Triggered by |
|---------|-----|----------|--------------|
| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` |
| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` |
| PR preview journal | `https://pr-<N>.staging.trails.cool` | `trails_pr_<N>` | PR open/sync |
PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it.
**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`):
- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface)
- PR `<N>` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews)
**Compose project namespacing** keeps each preview isolated:
- Persistent staging: `-p trails-staging`
- PR `<N>`: `-p trails-pr-<N>`
The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile.
**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-<N>.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert.
**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews.
**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying.
**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran.
**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
## Agent Skills & Config
Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink).
```
.agents/skills/ ← canonical location
.claude/skills ← symlink → ../.agents/skills
```
Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup.
## OpenSpec Workflow
Specs live in `openspec/`. Use these slash commands:

198
CONTEXT.md Normal file
View file

@ -0,0 +1,198 @@
# trails.cool domain glossary
This file names the domain concepts used in the codebase. New terms get added
here as decisions crystallize during architecture work; the goal is that one
concept has one name everywhere — specs, code, conversations.
If you're naming a new module, a new column, or a new UI surface, look here
first. If the term you need isn't here, propose it (don't invent a synonym).
---
## GPX Save
The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction.
### gpx-save module
`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly.
### GpxValidationError
Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat 90..90, lon 180..180). Callers catch this to return a user-facing 400.
### validateGpx
`(gpx: string) → Promise<ParsedGpx>`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write.
### atomic GPX save
The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path.
---
## Connected Services
The user-facing surface for linking external accounts and devices to a Journal
account. Spec: `openspec/specs/connected-services/`.
### ConnectedService
A single linked external account or device, owned by one user. Stored in the
`connected_services` table (renamed from `sync_connections`). At most one row
per `(user_id, provider)`.
### provider
String identifier for the external system: `wahoo`, `komoot`, `apple-health`,
and future `coros`, `garmin`, `strava`. The provider determines the
`credential_kind` and which capabilities (import / push / webhook) the
connection has, via the provider's manifest.
### credential kind
Discriminator on `connected_services` describing the credential shape stored
in the `credentials` JSONB blob. Three kinds today:
- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the
expected shape for Coros / Garmin / Strava.
- **web-login** — email + encrypted password + session jar. Komoot. No
official API; we authenticate against the provider's normal web login and
reuse the resulting session cookies. Refresh = re-login. Web-login
breakage (form changes, captchas, password rotation) surfaces at the
import layer, not at the credential layer.
- **device** — no remote credential. Apple Health (and future Health Connect
on Android). Data arrives via authenticated mobile API uploads, not
server-initiated pulls. The `credentials` blob is empty; the connection
exists so the UI can show "Apple Health is paired."
Credential kind is determined by the provider via its manifest, but stored
explicitly on the row so queries don't need to join the manifest.
### granted_scopes
Column on `connected_services`, populated only for `credential_kind = oauth`,
NULL otherwise. Lists the OAuth scopes the user actually granted (e.g.
`routes_write`). Feature gates query this column directly; missing a scope
triggers re-authorization.
### provider_user_id
The external service's identifier for the user. Used to route incoming
webhooks to the right local user. Nullable (Apple Health has none in the
remote-id sense).
### CredentialAdapter
Per-kind module that knows how to maintain credentials of that kind:
- `oauth.refresh(creds) → creds | NeedsRelink`
- `web-login.relogin(creds) → creds | InvalidCredentials`
- `device` — no-op
Adapters do not import data, push routes, or handle webhooks. They only own
the credential lifecycle.
### ConnectedServiceManager
The deep module callers see. Owns:
- `link(userId, provider, credentials)` / `unlink(serviceId)`
- `withFreshCredentials(serviceId, fn)` — refreshes via the right
`CredentialAdapter` if expired, calls `fn(creds)`, marks the connection
`needs_relink` if refresh fails.
- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook
layers when they observe a credential failure (e.g. Komoot web-login
fails, Wahoo returns 401 after a successful refresh).
Per-provider importers / pushers / webhook handlers always go through
`withFreshCredentials` — they never read the `credentials` JSONB directly.
### provider manifest
Per-provider declaration co-located with the provider's code
(`providers/wahoo/manifest.ts`, etc.). Declares:
- the provider's `credential_kind`
- which capabilities the provider implements (import? push? webhook?)
- references to the per-capability modules
A small `providers/registry.ts` imports each manifest. Adding a provider is
one new directory plus one import line.
## Sync Capabilities
Three orthogonal capabilities a provider may implement. Each is its own seam
when there are ≥2 adapters; today most are single-adapter and held to a
named shape so the second adapter doesn't reshape the interface.
### Importer
Pulls workouts / activities from the external service into the Journal.
Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all
implement this, with very different mechanics. Dedup via `sync_imports`.
### RoutePusher
Pushes a Journal route out to the external service. One adapter today
(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it.
The public seam is `pushRoute(connectedService, route) → {remoteId, version}`.
Provider-specific concerns — FIT Course conversion, the `route:<id>`
`external_id` convention, the PUT→POST-on-404 fallback — are **handled
internally by the adapter**, not exposed on the seam. Idempotency is tracked
via `sync_pushes`.
### WebhookReceiver
Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming
webhooks to the right user via `provider_user_id`. Unknown
`provider_user_id` returns 200 and is silently dropped (no leak).
## Storage tables
- `connected_services` — user ↔ provider links (renamed from
`sync_connections`).
- `sync_imports` — dedup cache for imported workouts, keyed by
`(user_id, provider, workout_id)`.
- `sync_pushes` — push state per `(user_id, route_id, provider)`
`remote_id`, `last_pushed_version`.
---
## Authentication
User identity in the Journal. Two **authentication methods** are supported
and are intentionally the entire surface (see ADR-0005): **passkey**
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
(email) as a fallback for users without passkey support or for cross-device
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
already deliver the one-tap UX, and adding centralized identity providers
would conflict with the privacy-first ethos and ActivityPub federation.
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
method. It is a **session transport** for native clients: users still
authenticate via passkey or magic-link in a WebView, then the mobile app
exchanges the resulting authorization code for long-lived bearer tokens.
The peer of OAuth2 transport is the cookie session, not passkey or
magic-link.
### completeAuth
The single chokepoint for the post-verify orchestration of every web
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
ADR-0004). Called by every route handler that has just verified a
user's identity (passkey login finish, magic-link code verify, magic
link consumer). Does three things in order:
1. If `isNewRegistration`, records the accepted Terms version
(`recordTermsAcceptance`).
2. Creates the session cookie via `createSession`.
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
header attached.
Identity-method-specific work (WebAuthn ceremony verification, magic
token consumption) stays in the per-method functions and runs *before*
`completeAuth`. The chokepoint deliberately knows nothing about how
identity was proved.
### Terms gate
Cross-cutting middleware enforcing that `users.terms_version` matches
the current `TERMS_VERSION` constant before any non-allow-listed
authenticated request succeeds. Two enforcement points:
- **Web (cookie sessions)**: the root loader redirects stale-terms users
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
allow-list, so OAuth code issuance is gated by this same redirect
before mobile sees an authorization code.
- **API (bearer tokens)**: `requireApiUser` returns
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
`completeAuth` only **records** terms on registration; it does not
enforce them. Enforcement remains middleware's job.

198
FEDERATION.md Normal file
View file

@ -0,0 +1,198 @@
# Federation protocol
trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/),
implemented with [Fedify](https://fedify.dev). This document describes the
wire protocol precisely enough for another implementation to interoperate
deliberately — actor discovery, the object and activity types we emit and
accept, addressing, signatures, deduplication, delivery retry, and
moderation. Examples use `trails.example` for our instance and
`remote.example` for a peer.
Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off,
every federation surface returns 404 — a disabled instance is
indistinguishable from one without the feature. Only users with
`profile_visibility = 'public'` federate; a private user's actor,
WebFinger, inbox, and outbox all 404, so their existence never leaks.
## Actor discovery
### WebFinger
`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a
handle to an actor IRI:
```json
{
"subject": "acct:alice@trails.example",
"links": [
{ "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
]
}
```
### Actor
`GET https://trails.example/users/alice` with `Accept: application/activity+json`
returns a `Person`. The actor IRI and the human profile `url` are the same
by design (browsers get HTML at that URL via content negotiation):
```json
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://trails.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"summary": "trail runner",
"url": "https://trails.example/users/alice",
"inbox": "https://trails.example/users/alice/inbox",
"outbox": "https://trails.example/users/alice/outbox",
"publicKey": {
"id": "https://trails.example/users/alice#main-key",
"owner": "https://trails.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
},
"assertionMethod": [ { "type": "Multikey", "…": "…" } ],
"attachment": [
{ "type": "PropertyValue", "name": "🥾 trails.cool", "value": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
]
}
```
`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification;
`assertionMethod` carries the same keys as Multikeys for newer stacks.
### NodeInfo (software discovery)
`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`:
```json
{
"version": "2.1",
"software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
"protocols": ["activitypub"],
"usage": { "users": {}, "localPosts": 0, "localComments": 0 }
}
```
`software.name` is the machine-readable "this is a trails instance" marker
used by the trails-to-trails outbound check. Usage counts are deliberately
zeroed — publishing per-instance counts is a privacy decision we have not
made.
## Objects and activities
Activities correspond to a user's journal entries. The object model is
deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content`
summarizes the activity and whose `url` links to the journal detail page.
(A first-class `trails:Route` object type is planned with route-federation;
today everything is a Note.)
### Note
```json
{
"id": "https://trails.example/activities/01H…",
"type": "Note",
"attributedTo": "https://trails.example/users/alice",
"content": "<p>Morning trail run — 12.4 km, 480 m up</p>",
"url": "https://trails.example/activities/01H…",
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
```
The Note IRI (`/activities/<id>`) is dereferenceable and serves
`application/activity+json` — Mastodon's search-fetch and strict re-fetch
of pushed objects both rely on this.
### Create / Delete
A publish is a `Create` wrapping the Note; the activity id is the object IRI
with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone`
at the same object IRI:
```json
{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
"actor": "https://trails.example/users/alice",
"object": { "…": "the Note above" },
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"] }
```
Note that a `Delete` poisons the object URI on the remote forever (remotes
tombstone it); re-publishing the same URI after a Delete is silently
refused by strict remotes.
### Follow graph
The inbox is **narrow** — only follow-graph activities are processed;
anything else is acknowledged (`202`) and dropped.
| Inbound | Effect |
|---|---|
| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` |
| `Undo(Follow)` | removes the follow |
| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll |
| `Reject(Follow)` | drops our pending outgoing Follow |
## Addressing
Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public`
and **push-delivered** to each accepted remote follower's inbox (fan-out,
one delivery per follower). We do not implement shared-inbox delivery.
Remotes do not backfill history — only pushed or individually-fetched
objects appear on a peer.
## Signatures
All inbound activities must carry a valid HTTP Signature; Fedify verifies it
against the sending actor's `publicKey` (fetched and cached). Unsigned or
badly-signed requests are rejected. Outbound deliveries are signed with the
sending user's key. An actor changing keys requires the remote to re-fetch
the actor document.
## Deduplication
Delivery is at-least-once, so receivers must be idempotent. trails dedups
inbound activities two ways:
- **`Create(Note)`** — idempotent via a unique constraint on the activity's
origin IRI (`remote_origin_iri`); a redelivered Create is a no-op.
- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the
activity IRI is recorded in `federation_processed_activities` on first
receipt (insert-or-drop before any side effect); a redelivery is dropped
and counted (`federation_inbox_dropped_total{reason="duplicate"}`).
Records are retained ≥ 30 days, which comfortably exceeds the
HTTP-Signature date-freshness window, then swept.
## Delivery retry
Delivery queueing and retry state are **durable** — they survive process
restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
retry policy). On a `5xx` or timeout, a delivery retries with exponential
backoff, giving up after a bounded budget (~8 attempts spanning roughly a
day) before a permanent failure is logged. Deliveries are paced to at most
1 request/second per remote host. Metrics:
`federation_delivery_total{outcome}` and `federation_queue_depth`.
## Moderation
An operator can block a federation instance by domain (exact-host match).
A blocked instance is **inert in both directions**:
- its inbound activities are silently dropped (`202`, no error oracle) and
counted (`federation_inbox_dropped_total{reason="blocked"}`);
- it receives no deliveries (blocked recipients are filtered from fan-out);
- we never fetch its actors or outboxes.
Blocking is effective immediately (checked per request / per job, no cache).
The operator procedure (a SQL insert/delete against
`journal.federation_blocked_instances`) is documented in the
[deployment runbook](docs/deployment.md#blocking-an-instance).
---
*Kept current as federation capabilities change. Specs:
`openspec/specs/social-federation` and `openspec/specs/federation-operations`.*

View file

@ -96,6 +96,14 @@ docker compose up -d
See [docs/architecture.md](docs/architecture.md) for details on self-hosting
configuration.
## Federation
The Journal federates over ActivityPub. The wire protocol — actor
discovery, object/activity types with JSON examples, addressing,
signatures, deduplication, delivery retry, and moderation — is documented
in [FEDERATION.md](FEDERATION.md), which is precise enough for another
implementation to interoperate against.
## Philosophy
- **Privacy by design** — The Planner collects zero user data

63
apps/journal/.env.example Normal file
View file

@ -0,0 +1,63 @@
# Journal local-dev environment.
# Copy to `.env` (gitignored) and edit. Most contributors don't need
# anything in this file — the journal app boots on plain HTTP at
# http://localhost:3000 with sensible defaults.
# ────────────────────────────────────────────────────────────────────
# DO NOT SET unless you know you need it
# ────────────────────────────────────────────────────────────────────
#
# `ORIGIN` is what the WebAuthn ceremony uses as `expectedOrigin`.
# When unset it defaults to `http://localhost:3000`, which is what
# Playwright sends and what the browser sees in plain-HTTP dev.
#
# The ONLY reason to set this is if you're running the dev server
# over HTTPS (see HTTPS=1 below) and want passkey registration to
# succeed against the HTTPS origin. If you set it to `https://...`
# without also running the server over HTTPS, you'll get
# "Unexpected registration response origin" errors in dev and the
# local e2e suite (which always hits HTTP) will fail registration.
#
# ORIGIN=https://localhost:3000
# Flagship marker. Renders the project marketing block on the
# anonymous home and gates a few "this is the canonical instance"
# behaviors. Self-hosted instances leave this unset; flagship CI
# sets it to `true`. Either is fine for local dev — set it if you
# want to see the full marketing layout, leave it unset if you
# want the self-host home view.
# IS_FLAGSHIP=true
# ────────────────────────────────────────────────────────────────────
# Wahoo OAuth (only needed when actively testing the Wahoo import)
# ────────────────────────────────────────────────────────────────────
#
# Wahoo's OAuth flow is the one feature that genuinely needs HTTPS
# locally: the provider rejects `http://` redirect URIs. To exercise
# the connect/disconnect/import flow end-to-end, you need:
#
# 1. Local HTTPS dev server: run `HTTPS=1 pnpm --filter
# @trails-cool/journal dev` (the basic-ssl plugin in
# vite.config.ts wires up the cert; see the comment there for
# the ALPN workaround).
# 2. ORIGIN=https://localhost:3000 (uncomment above).
# 3. Wahoo client credentials below, and a redirect URI of
# `https://localhost:3000/api/sync/callback/wahoo` registered
# in your Wahoo developer dashboard.
#
# WAHOO_CLIENT_ID=
# WAHOO_CLIENT_SECRET=
# WAHOO_WEBHOOK_TOKEN=
# Garmin Connect Developer Program credentials (spec: garmin-import).
# Requires an approved program application; without these the Garmin
# provider is hidden on /settings/connections. The OAuth callback to
# register with Garmin is `<origin>/api/sync/callback/garmin`, the
# notification endpoint `<origin>/api/sync/webhook/garmin`.
# GARMIN_CLIENT_ID=
# GARMIN_CLIENT_SECRET=
# Integration test secret (only needed if running the integration
# test suite that drives the API directly). Generate with
# `openssl rand -hex 32`.
# INTEGRATION_SECRET=

View file

@ -1,4 +1,4 @@
FROM node:25-slim AS base
FROM node:26-slim AS base
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
# trixie-slim and ships neither curl nor wget by default).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
@ -9,24 +9,29 @@ FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/journal/package.json apps/journal/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/ui/package.json packages/ui/
COPY packages/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/
COPY packages/fit/package.json packages/fit/
RUN pnpm install --frozen-lockfile
FROM base AS build
ARG SENTRY_RELEASE
# Client-side Sentry DSN baked into the bundle at build time. Empty (or
# unset) produces a Sentry-free client. Public-by-design: the DSN
# appears in the shipped client JS regardless.
ARG VITE_SENTRY_DSN=""
COPY --from=deps /app/ ./
COPY . .
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \
SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
SENTRY_RELEASE="$SENTRY_RELEASE" \
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
pnpm --filter @trails-cool/journal build
FROM base AS runtime
@ -35,6 +40,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules
COPY --from=build /app/apps/journal/build ./apps/journal/build
COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts
COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts
COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib
COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json

View file

@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { Form, Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
export function AccountDropdown({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Click-outside + Escape close. Mounted only while open to keep the
// listener cost zero in the steady state.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={user.displayName ?? user.username}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Avatar displayName={user.displayName} username={user.username} size="md" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-20 mt-2 w-56 origin-top-right rounded-md border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
>
<div className="border-b border-gray-100 px-3 py-2">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
<Link
to={`/users/${user.username}`}
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.profile")}
</Link>
<Link
to="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
role="menuitem"
className="block w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,42 @@
// Initials-only avatar. We don't have an image-upload story yet (the
// `users` table has no avatar URL column), so initials are the
// implementation. When images land, this component is the single
// place to add the image fallback.
interface Props {
displayName: string | null;
username: string;
size?: "sm" | "md" | "lg";
className?: string;
}
function initialsOf(displayName: string | null, username: string): string {
const source = (displayName ?? username).trim();
if (source.length === 0) return "?";
// Two-letter initials: first letter of the first two whitespace-
// separated words, falling back to the first two characters when
// the source is a single token.
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
return source.slice(0, 2).toUpperCase();
}
const SIZE_CLASS: Record<NonNullable<Props["size"]>, string> = {
sm: "h-7 w-7 text-[11px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
export function Avatar({ displayName, username, size = "md", className = "" }: Props) {
const initials = initialsOf(displayName, username);
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-700 ${SIZE_CLASS[size]} ${className}`}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -3,10 +3,14 @@ import { useLocale } from "./LocaleContext";
/**
* Renders a date formatted with the server-detected locale,
* ensuring SSR and client output match (no hydration flicker).
* Pass `withTime` for surfaces where the hour/minute matter
* (e.g. notifications), keeping plain dates as the default.
*/
export function ClientDate({ iso }: { iso: string }) {
export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) {
const locale = useLocale();
return (
<time dateTime={iso}>{new Date(iso).toLocaleDateString(locale)}</time>
);
const d = new Date(iso);
const text = withTime
? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" })
: d.toLocaleDateString(locale);
return <time dateTime={iso}>{text}</time>;
}

View file

@ -4,6 +4,9 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
}
interface Props {
@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => (
<li key={entry.username} className="px-4 py-3">
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
<a
href={`/users/${entry.username}`}
href={entry.profileUrl}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center justify-between hover:underline"
>
<span className="text-sm font-medium text-gray-900">

View file

@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { ElevationProfile } from "./ElevationProfile.tsx";
import type { ElevationSample } from "@trails-cool/gpx";
afterEach(cleanup);
const labels = { highest: "Highest", lowest: "Lowest" };
const series: ElevationSample[] = [
{ d: 0, e: 100, lat: 0, lng: 0 },
{ d: 500, e: 160, lat: 0, lng: 0.005 },
{ d: 1000, e: 120, lat: 0, lng: 0.01 },
];
describe("ElevationProfile", () => {
it("renders nothing for a too-short series", () => {
const { container } = render(
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).toBeNull();
});
it("renders the chart and highest/lowest summary", () => {
const { container, getByText } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).not.toBeNull();
// area + line paths
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2);
expect(getByText("160 m")).toBeTruthy(); // highest
expect(getByText("100 m")).toBeTruthy(); // lowest
});
it("draws an active marker when activeIndex is set", () => {
const { container } = render(
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("circle")).not.toBeNull();
expect(container.querySelector("line")).not.toBeNull();
});
it("reports seek on pointer down", () => {
const onSeek = vi.fn();
const { container } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={onSeek} labels={labels} />,
);
const svg = container.querySelector("svg")!;
// jsdom getBoundingClientRect returns zeros; we only assert the handler fires.
fireEvent.pointerDown(svg, { clientX: 10 });
expect(onSeek).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,122 @@
import { useRef } from "react";
import type { ElevationSample } from "@trails-cool/gpx";
import { formatElevationM, formatDistanceKm } from "~/lib/stats";
const W = 1000;
const H = 220;
const PAD = { top: 16, right: 8, bottom: 22, left: 46 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
/**
* Read-only elevation profile chart (SVG, responsive via viewBox). Distance on
* x, elevation on y, with a gradient area fill. Reports the hovered sample
* index via `onActive` (for the map marker) and the clicked index via `onSeek`
* (centre the map), and draws a marker at `activeIndex` (set by the map when the
* route line is hovered). Renders nothing for an empty/too-short series.
*/
export function ElevationProfile({
series,
activeIndex,
onActive,
onSeek,
labels,
className,
}: {
series: ElevationSample[];
activeIndex: number | null;
onActive: (index: number | null) => void;
onSeek: (index: number) => void;
labels: { highest: string; lowest: string };
className?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
if (series.length < 2) return null;
const maxD = series[series.length - 1]!.d || 1;
let minE = Infinity;
let maxE = -Infinity;
for (const s of series) {
if (s.e < minE) minE = s.e;
if (s.e > maxE) maxE = s.e;
}
const eRange = Math.max(1, maxE - minE);
const baseY = PAD.top + PLOT_H;
const x = (d: number) => PAD.left + (d / maxD) * PLOT_W;
const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H;
const linePath = series
.map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`)
.join(" ");
const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`;
const active = activeIndex != null ? series[activeIndex] : null;
function indexFromClientX(clientX: number): number {
const rect = ref.current!.getBoundingClientRect();
const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units
const d = ((px - PAD.left) / PLOT_W) * maxD;
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const delta = Math.abs(series[i]!.d - d);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
}
return (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
{" · "}
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
</span>
{active && (
<span className="tabular-nums">
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
</span>
)}
</div>
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-40 w-full touch-none select-none"
role="img"
aria-label="Elevation profile"
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
onPointerLeave={() => onActive(null)}
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
>
<defs>
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#elev-fill)" />
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{active && (
<g>
<line
x1={x(active.d)}
y1={PAD.top}
x2={x(active.d)}
y2={baseY}
stroke="#9ca3af"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
</div>
);
}

View file

@ -8,21 +8,36 @@ interface FollowState {
interface Props {
username: string;
// Whether the followed profile is private/locked. Drives the "Request to
// follow" label vs. plain "Follow" before any click happens.
isPrivateTarget: boolean;
initialState: FollowState | null;
}
export function FollowButton({ username, initialState }: Props) {
type Display = "follow" | "request" | "pending" | "unfollow";
function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display {
if (state?.following) return "unfollow";
if (state?.pending) return "pending";
return isPrivateTarget ? "request" : "follow";
}
export function FollowButton({ username, isPrivateTarget, initialState }: Props) {
const { t } = useTranslation("journal");
const [state, setState] = useState<FollowState>(
initialState ?? { following: false, pending: false },
);
const [isPending, startTransition] = useTransition();
const [isInFlight, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const display = displayFor(state, isPrivateTarget);
const onClick = () => {
setError(null);
startTransition(async () => {
const path = state.following
// For "pending" we treat the click as cancel-request: same /unfollow
// endpoint deletes the row whether it's accepted or pending.
const path = state.following || state.pending
? `/api/users/${username}/unfollow`
: `/api/users/${username}/follow`;
try {
@ -40,21 +55,33 @@ export function FollowButton({ username, initialState }: Props) {
});
};
const label = state.following ? t("social.unfollow") : t("social.follow");
const label = (() => {
switch (display) {
case "unfollow":
return t("social.unfollow");
case "pending":
return t("social.pendingCancel");
case "request":
return t("social.requestToFollow");
case "follow":
default:
return t("social.follow");
}
})();
const baseClass = display === "follow" || display === "request"
? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
: "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50";
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onClick}
disabled={isPending}
className={
state.following
? "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
}
disabled={isInFlight}
className={baseClass}
>
{isPending ? "…" : label}
{isInFlight ? "…" : label}
</button>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>

View file

@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { Form, Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
// Mobile drawer for the navbar. Replaces the "everything in a row"
// desktop layout with a hamburger trigger and a slide-out panel that
// holds all the same destinations. Bell + the dropdown's account
// section move inside; the bell badge still surfaces on the trigger
// itself via the parent navbar.
export function MobileNavMenu({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const location = useLocation();
// Close the drawer on navigation. React Router pushes a new location
// when a Link is followed; this effect picks that up.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
// Close on Escape; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open]);
const linkClass = (path: string) => {
const active = location.pathname === path || location.pathname.startsWith(path + "/");
return `block rounded-md px-3 py-2 text-base font-medium ${
active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50"
}`;
};
return (
<>
<button
type="button"
aria-label={t("nav.openMenu")}
aria-expanded={open}
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-6 w-6"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-40 md:hidden">
{/* Backdrop */}
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="absolute inset-0 bg-black/30"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
className="absolute right-0 top-0 flex h-full w-72 max-w-[85%] flex-col bg-white shadow-xl"
>
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div className="flex items-center gap-3">
<Avatar displayName={user.displayName} username={user.username} size="md" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
</div>
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="rounded-md p-2 text-gray-500 hover:bg-gray-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-2 py-3">
<Link to="/feed" className={linkClass("/feed")}>
{t("social.feed.title")}
</Link>
<Link to="/explore" className={linkClass("/explore")}>
{t("nav.explore")}
</Link>
<Link to="/routes" className={linkClass("/routes")}>
{t("nav.routes")}
</Link>
<Link to="/activities" className={linkClass("/activities")}>
{t("nav.activities")}
</Link>
<Link to="/notifications" className={linkClass("/notifications")}>
{t("notifications.title")}
</Link>
<hr className="my-2 border-gray-200" />
<Link to={`/users/${user.username}`} className={linkClass(`/users/${user.username}`)}>
{t("nav.profile")}
</Link>
<Link to="/settings" className={linkClass("/settings")}>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</nav>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { ProfileStats } from "./ProfileStats.tsx";
afterEach(cleanup);
describe("ProfileStats", () => {
it("renders nothing when there are no activities", () => {
const { container } = render(
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.firstChild).toBeNull();
});
it("renders formatted totals", () => {
const { container, getByText } = render(
<ProfileStats
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
/>,
);
// Values come from the formatters (i18n-independent).
expect(getByText("42")).toBeTruthy();
expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer
expect(getByText("↑ 5120 m")).toBeTruthy();
expect(getByText("2h 30m")).toBeTruthy();
// last-4-weeks line present when > 0
expect(container.querySelector("p")).not.toBeNull();
});
it("omits the last-4-weeks line when zero", () => {
const { container } = render(
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.querySelector("p")).toBeNull();
});
});

View file

@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { StatRow } from "./StatRow.tsx";
import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats";
// Structural shape (mirrors ActivityStats from activities.server) so this
// presentational component doesn't import from a `.server` module.
export interface ProfileStatsData {
count: number;
distance: number;
elevationGain: number;
duration: number;
last4Weeks: number;
}
/**
* Lifetime roll-up header on the profile: activity count · distance · ascent ·
* elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the
* (viewer-scoped) count is zero.
*/
export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) {
const { t } = useTranslation("journal");
if (stats.count === 0) return null;
return (
<div className={className}>
<StatRow
size="lg"
items={[
{ label: t("profileStats.activities"), value: String(stats.count) },
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
{ label: t("profileStats.ascent"), value: `${formatElevationM(stats.elevationGain)}` },
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
]}
/>
{stats.last4Weeks > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
</p>
)}
</div>
);
}

View file

@ -1,9 +1,63 @@
import { useEffect, useRef } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet";
import L from "leaflet";
import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css";
/** Marker shown at the position the elevation chart is pointing at. */
function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) {
if (!point) return null;
return (
<CircleMarker
center={[point.lat, point.lng]}
radius={6}
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
/>
);
}
/** Reports the route sample nearest the cursor so the chart can highlight it. */
function HoverTracker({
series,
onHoverIndex,
}: {
series: Array<[number, number]>;
onHoverIndex: (index: number | null) => void;
}) {
useMapEvents({
mousemove(e) {
const { lat, lng } = e.latlng;
let best = -1;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const [slat, slng] = series[i]!;
const delta = (slat - lat) ** 2 + (slng - lng) ** 2;
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
onHoverIndex(best >= 0 ? best : null);
},
mouseout() {
onHoverIndex(null);
},
});
return null;
}
/** Pans the map when the chart is clicked (centerOn.v bumps per click). */
function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) {
const map = useMap();
const lastV = useRef<number | null>(null);
useEffect(() => {
if (!centerOn || centerOn.v === lastV.current) return;
lastV.current = centerOn.v;
map.panTo([centerOn.lat, centerOn.lng], { animate: true });
}, [centerOn, map]);
return null;
}
function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap();
const fitted = useRef(false);
@ -80,16 +134,35 @@ interface RouteMapProps {
dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null;
/** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */
activePoint?: { lat: number; lng: number } | null;
hoverSeries?: Array<[number, number]>;
onHoverIndex?: (index: number | null) => void;
centerOn?: { lat: number; lng: number; v: number } | null;
}
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
export function RouteMapThumbnail({
geojson,
interactive,
className,
dayBreaks,
highlightedDay,
activePoint,
hoverSeries,
onHoverIndex,
centerOn,
}: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
<MapContainer
center={[50, 10]}
zoom={6}
className={className ?? "h-36 w-full rounded"}
// `isolate` gives the Leaflet container its own stacking context so its
// internal high z-indexes (panes ~200700, zoom controls ~1000) stay
// contained and can't paint over page overlays like the mobile nav
// drawer's backdrop.
className={`${className ?? "h-36 w-full rounded"} isolate`}
zoomControl={interactive ?? false}
attributionControl={interactive ?? false}
dragging={interactive ?? false}
@ -114,6 +187,11 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks,
fullData={data}
/>
)}
<ActiveMarker point={activePoint} />
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
)}
<Recenter centerOn={centerOn} />
</MapContainer>
);
}
@ -142,7 +220,7 @@ function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObj
type: "Feature",
geometry: { type: "LineString", coordinates: seg.coords },
properties: {},
} as unknown as GeoJsonObject;
} as GeoJsonObject;
return (
<GeoJSON
key={`${i}-${highlightedDay}`}

View file

@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import type { SportType } from "@trails-cool/db/schema/journal";
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
// label carries the meaning; the glyph is decorative (aria-hidden).
const SPORT_EMOJI: Record<SportType, string> = {
hike: "🥾",
walk: "🚶",
run: "🏃",
ride: "🚴",
gravel: "🚲",
mtb: "🚵",
ski: "⛷️",
other: "📍",
};
/**
* Small pill (glyph + localized label) shown next to an activity title on the
* detail page, feed cards, and the profile list. Renders nothing when the
* sport type is unset.
*/
export function SportBadge({
sportType,
className,
}: {
sportType: SportType | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!sportType) return null;
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
>
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
{t(`activities.sport.${sportType}`)}
</span>
);
}

View file

@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { StatRow } from "./StatRow.tsx";
afterEach(cleanup);
describe("StatRow", () => {
it("renders nothing for an empty item list", () => {
const { container } = render(<StatRow items={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders each item's value and label, in order", () => {
const { container } = render(
<StatRow
items={[
{ label: "Distance", value: "30.0 km" },
{ label: "Time", value: "1h 0m" },
{ label: "Avg speed", value: "30.0 km/h" },
]}
/>,
);
const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent);
const values = [...container.querySelectorAll("dd")].map((el) => el.textContent);
expect(labels).toEqual(["Distance", "Time", "Avg speed"]);
expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]);
});
});

View file

@ -0,0 +1,34 @@
import type { StatItem } from "~/lib/stats";
/**
* The one shared headline-stat presentation. Surfaces pass an ordered list of
* {value, label} items (built via `activityStatItems`); the component never
* fetches or formats. `size="lg"` is the detail-page treatment; the default
* compact size is for feed cards and the profile list.
*/
export function StatRow({
items,
size = "sm",
className,
}: {
items: StatItem[];
size?: "sm" | "lg";
className?: string;
}) {
if (items.length === 0) return null;
const valueCls =
size === "lg"
? "text-2xl font-bold text-gray-900"
: "text-sm font-semibold text-gray-900";
const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1";
return (
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
{items.map((it) => (
<div key={it.label}>
<dd className={valueCls}>{it.value}</dd>
<dt className="text-xs text-gray-500">{it.label}</dt>
</div>
))}
</dl>
);
}

View file

@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
afterEach(cleanup);
describe("SurfaceBreakdown", () => {
it("renders nothing without a breakdown", () => {
const { container } = render(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 2 surface + 1 waytype = 3 legend entries
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
});
it("sorts segments largest-first within a dimension", () => {
const { container } = render(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// first bar's first segment is the larger (asphalt 70%)
const firstBar = container.querySelector("div.flex.h-3");
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
expect(widths[0]).toBe("70%");
expect(widths[1]).toBe("30%");
});
});

View file

@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import {
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
} from "@trails-cool/map-core";
import { formatDistanceKm } from "~/lib/stats";
export interface SurfaceBreakdownData {
surface: Record<string, number>;
highway: Record<string, number>;
}
function Bar({
title,
data,
colorFor,
}: {
title: string;
data: Record<string, number>;
colorFor: (category: string) => string;
}) {
const { t } = useTranslation("journal");
const entries = Object.entries(data)
.filter(([, m]) => m > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((s, [, m]) => s + m, 0);
if (total <= 0) return null;
const label = (cat: string) =>
cat === "unknown"
? t("surface.other")
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
return (
<div className="mt-3 first:mt-0">
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
<div className="flex h-3 w-full overflow-hidden rounded">
{entries.map(([cat, m]) => (
<div
key={cat}
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
title={`${label(cat)} · ${formatDistanceKm(m)}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{entries.map(([cat, m]) => (
<li key={cat} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorFor(cat) }}
aria-hidden
/>
<span>{label(cat)}</span>
<span className="tabular-nums text-gray-400">
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
* when there's no breakdown data. Colours come from the shared map-core
* palettes; unknown tags collapse into "other".
*/
export function SurfaceBreakdown({
breakdown,
className,
}: {
breakdown: SurfaceBreakdownData | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!breakdown) return null;
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
if (!hasSurface && !hasHighway) return null;
return (
<div className={className}>
<Bar
title={t("surface.surface")}
data={breakdown.surface}
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
/>
<Bar
title={t("surface.waytype")}
data={breakdown.highway}
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
/>
</div>
);
}

View file

@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
afterEach(cleanup);
const weeks = (distances: number[]) =>
distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance }));
describe("WeeklyDistanceChart", () => {
it("renders nothing when every week is zero", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
expect(container.firstChild).toBeNull();
});
it("renders the chart with a bar only for non-zero weeks", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
expect(container.querySelector("svg")).not.toBeNull();
// bars only for the two non-zero weeks; empty weeks keep their slot via the track rect
expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2);
});
it("tops the y-scale at the busiest week", () => {
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
// peak gridline label = 10 km (>= 10 → integer)
expect(getByText("10 km")).toBeTruthy();
// half gridline
expect(getByText("5.0 km")).toBeTruthy();
});
it("shows a hover readout with the week's distance", () => {
// max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the
// readout for week 0, so it only appears once that week is hovered.
const { container, queryByText } = render(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
expect(queryByText("3.0 km")).toBeNull();
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
fireEvent.mouseEnter(hit);
expect(queryByText("3.0 km")).not.toBeNull();
});
});

View file

@ -0,0 +1,136 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDistanceKm } from "~/lib/stats";
export interface WeeklyDistanceBucket {
weekStart: string;
distance: number;
}
// SVG layout (user units; rendered responsive via viewBox).
const W = 480;
const H = 132;
const PAD = { top: 10, right: 6, bottom: 18, left: 36 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
const BASE_Y = PAD.top + PLOT_H;
function axisLabel(km: number): string {
if (km <= 0) return "0";
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
}
/**
* Weekly-distance bar chart for the profile (last N weeks, oldest newest).
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
* and a hover readout naming the week + its distance. Hidden when there is no
* distance in the window.
*/
export function WeeklyDistanceChart({
weeks,
className,
}: {
weeks: WeeklyDistanceBucket[];
className?: string;
}) {
const { t, i18n } = useTranslation("journal");
const [hover, setHover] = useState<number | null>(null);
const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
if (maxM <= 0) return null;
const maxKm = maxM / 1000;
const n = weeks.length;
const colW = PLOT_W / n;
const barW = colW * 0.6;
const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2;
const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H;
const fmtWeek = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
const gridFracs = [0, 0.5, 1];
const active = hover != null ? weeks[hover] : null;
const first = weeks[0];
const last = weeks[n - 1];
const firstLabel = first ? fmtWeek(first.weekStart) : "";
const lastLabel = last ? fmtWeek(last.weekStart) : "";
return (
<div className={className}>
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
<span>{t("profileStats.weeklyDistance")}</span>
{active && (
<span className="tabular-nums text-gray-700">
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
</span>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-28 w-full"
role="img"
aria-label={t("profileStats.weeklyDistance")}
onMouseLeave={() => setHover(null)}
>
{/* gridlines + y-axis labels (0 · half · peak) */}
{gridFracs.map((f) => {
const yy = BASE_Y - f * PLOT_H;
return (
<g key={f}>
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
{axisLabel(maxKm * f)}
</text>
</g>
);
})}
{weeks.map((w, i) => {
const isActive = hover === i;
return (
<g key={w.weekStart}>
{/* faint week-slot track so empty weeks stay visible */}
<rect
x={x(i)}
y={PAD.top}
width={barW}
height={PLOT_H}
rx="1.5"
fill={isActive ? "#dbeafe" : "#f3f4f6"}
/>
{/* distance bar */}
{w.distance > 0 && (
<rect
data-week-bar
x={x(i)}
y={yFor(w.distance)}
width={barW}
height={BASE_Y - yFor(w.distance)}
rx="1.5"
fill={isActive ? "#1d4ed8" : "#3b82f6"}
/>
)}
{/* full-height hover hit area */}
<rect
x={PAD.left + i * colW}
y={PAD.top}
width={colW}
height={PLOT_H}
fill="transparent"
onMouseEnter={() => setHover(i)}
>
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
</rect>
</g>
);
})}
</svg>
<div className="flex justify-between px-px text-[10px] text-gray-400">
<span>{firstLabel}</span>
<span>{lastLabel}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useEffect } from "react";
import { useRevalidator } from "react-router";
/**
* Live-updates a route/activity detail page when its async surface backfill
* completes. Subscribes to `/api/events` and, on a `surface_breakdown` event
* matching this row, re-runs the loader (so the bars appear without a reload).
*
* Enable only while it's worth it i.e. the viewer is the owner (the backfill
* emits to the owner's user stream) and the breakdown isn't present yet. Once
* the breakdown lands, the loader revalidates, `enabled` flips false, and the
* connection is torn down.
*/
export function useSurfaceBackfillUpdates(
kind: "route" | "activity",
id: string,
enabled: boolean,
): void {
const revalidator = useRevalidator();
useEffect(() => {
if (!enabled) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onEvent = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { kind?: string; id?: string };
if (parsed.kind === kind && parsed.id === id) revalidator.revalidate();
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("surface_breakdown", onEvent as EventListener);
return () => {
es.removeEventListener("surface_breakdown", onEvent as EventListener);
es.close();
};
}, [kind, id, enabled, revalidator]);
}

View file

@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
/**
* Subscribes to /api/events for live `notifications.unread` updates.
* Returns the live unread count, seeded with the loader-provided
* baseline so the badge renders correctly before the SSE handshake
* completes. Native EventSource handles reconnects with the
* server-suggested `retry:` interval.
*/
export function useUnreadNotifications(initialCount: number, signedIn: boolean): number {
const [count, setCount] = useState(initialCount);
// Keep the in-component count in sync if the loader-provided baseline
// changes (e.g., on navigation).
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
useEffect(() => {
if (!signedIn) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onUnread = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { count: number };
if (typeof parsed.count === "number") setCount(parsed.count);
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("notifications.unread", onUnread as EventListener);
return () => {
es.removeEventListener("notifications.unread", onUnread as EventListener);
es.close();
};
}, [signedIn]);
return count;
}

View file

@ -0,0 +1,26 @@
import { defineJournalJob } from "./payloads.ts";
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* One-shot backfill: generate a federation keypair for every user who
* predates federation (spec: "Existing-user backfill at deploy"). The
* server enqueues this once at startup whenever FEDERATION_ENABLED is
* on (singleton-keyed, so repeat startups don't stack runs); each run
* only touches users whose public_key IS NULL, so re-runs are no-ops.
* New users get keys at registration and never appear in this workload.
*/
export const backfillUserKeypairsJob = defineJournalJob({
name: "backfill-user-keypairs",
retryLimit: 3,
expireInSeconds: 300,
async handler() {
const ids = await listUsersWithoutKeypair();
let generated = 0;
for (const id of ids) {
if (await ensureUserKeypair(id)) generated++;
}
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
return { candidates: ids.length, generated };
},
});

View file

@ -0,0 +1,31 @@
import { defineJournalJob } from "./payloads.ts";
import { lt } from "drizzle-orm";
import { consumedJwtJti } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup for the JWT replay-protection table. Each row was
* inserted by `verifyRouteToken` to mark a token as consumed; once
* the token's `exp` claim has passed, the row is no longer useful
* (the JWT itself would fail signature verification before reaching
* the consume step). Bound the table to keep it tiny.
*
* See planner-audit #2 Phase B.
*/
export const consumedJtiSweepJob = defineJournalJob({
name: "consumed-jti-sweep",
cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const db = getDb();
const result = await db
.delete(consumedJwtJti)
.where(lt(consumedJwtJti.expiresAt, new Date()))
.returning({ jti: consumedJwtJti.jti });
const purged = result.length;
logger.info({ purged }, "consumed-jti-sweep");
return { purged };
},
});

View file

@ -0,0 +1,134 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq } from "drizzle-orm";
import { activities, users } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { getOrigin } from "../lib/config.server.ts";
import { getFederation } from "../lib/federation.server.ts";
import {
activityToCreate,
activityToDelete,
type FederatableActivity,
} from "../lib/federation-objects.server.ts";
import {
getCachedRemoteActor,
upsertRemoteActor,
type DeliveryPayload,
} from "../lib/federation-delivery.server.ts";
import { logger } from "../lib/logger.server.ts";
import { federationDeliveryTotal } from "../lib/metrics.server.ts";
/**
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
* host. In-process map is sufficient pg-boss works this queue
* sequentially in the single journal process.
*/
const lastSendPerHost = new Map<string, number>();
const MIN_INTERVAL_MS = 1000;
async function paceHost(host: string): Promise<void> {
const last = lastSendPerHost.get(host) ?? 0;
const wait = last + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastSendPerHost.set(host, Date.now());
}
/**
* Deliver one activity to one remote follower's inbox (spec 5.3/5.4).
* One job per (activity, recipient) so each delivery retries with
* exponential backoff independently (configured at enqueue time
* see enqueueActivityDeliveries). A thrown error marks the attempt
* failed and pg-boss retries; exhausting the budget is the permanent
* failure, logged by the final catch.
*/
export const deliverActivityJob = defineJournalJob({
name: "deliver-activity",
expireInSeconds: 60,
async handler(jobs) {
for (const job of jobs) {
const p = job.data;
try {
const outcome = await deliverOne(p);
federationDeliveryTotal.inc({ outcome });
} catch (err) {
federationDeliveryTotal.inc({ outcome: "failed" });
logger.warn(
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
);
throw err;
}
}
},
});
async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> {
const federation = getFederation();
const ctx = federation.createContext(new URL(getOrigin()), undefined);
// Build the activity to send. For `create`, re-read the row at
// delivery time: if it was deleted or un-publicized since enqueue,
// skip rather than leak.
let activity;
if (p.action === "create") {
const db = getDb();
const [row] = await db
.select()
.from(activities)
.where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public")))
.limit(1);
if (!row) {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
return "skipped";
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
const [owner] = await db
.select({ profileVisibility: users.profileVisibility })
.from(users)
.where(eq(users.username, p.ownerUsername))
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
return "skipped";
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
activity = activityToDelete(p.objectIri, p.ownerUsername);
}
// Resolve the recipient's inbox: cached remote_actors row first,
// actor-document fetch as fallback (which also primes the cache).
const recipientIri = new URL(p.recipientActorIri);
let inboxUrl: URL;
const cached = await getCachedRemoteActor(p.recipientActorIri);
if (cached?.inboxUrl) {
inboxUrl = new URL(cached.inboxUrl);
} else {
await paceHost(recipientIri.host);
const actor = await ctx.lookupObject(recipientIri);
const fetchedInbox =
actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null;
if (!fetchedInbox) {
throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`);
}
inboxUrl = fetchedInbox;
await upsertRemoteActor({
actorIri: p.recipientActorIri,
inboxUrl: inboxUrl.href,
domain: recipientIri.host,
});
}
await paceHost(inboxUrl.host);
await ctx.sendActivity(
// Identifier and username are the same thing in our actor model.
{ identifier: p.ownerUsername },
{ id: recipientIri, inboxId: inboxUrl },
activity,
);
logger.info(
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity: delivered",
);
return "delivered";
}

View file

@ -1,4 +1,4 @@
import type { JobDefinition } from "@trails-cool/jobs";
import { defineJournalJob } from "./payloads.ts";
import {
DEMO_BACKFILL_TARGET,
DEMO_DAILY_CAP,
@ -21,7 +21,7 @@ import { logger } from "../lib/logger.server.ts";
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
*/
export const demoBotGenerateJob: JobDefinition = {
export const demoBotGenerateJob = defineJournalJob({
name: "demo-bot-generate",
cron: "0,30 * * * *",
retryLimit: 1,
@ -57,4 +57,4 @@ export const demoBotGenerateJob: JobDefinition = {
await refreshDemoBotGauges();
return { mode: "single", routeId: id };
},
};
});

View file

@ -1,4 +1,4 @@
import type { JobDefinition } from "@trails-cool/jobs";
import { defineJournalJob } from "./payloads.ts";
import {
demoRetentionDays,
isDemoBotEnabled,
@ -11,7 +11,7 @@ import { logger } from "../lib/logger.server.ts";
* Daily prune. Deletes synthetic rows older than
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
*/
export const demoBotPruneJob: JobDefinition = {
export const demoBotPruneJob = defineJournalJob({
name: "demo-bot-prune",
cron: "15 3 * * *",
retryLimit: 1,
@ -24,4 +24,4 @@ export const demoBotPruneJob: JobDefinition = {
await refreshDemoBotGauges();
return { days, ...counts };
},
};
});

View file

@ -0,0 +1,22 @@
import { defineJournalJob } from "./payloads.ts";
import { sweepProcessedActivities } from "../lib/federation-replay.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of federation_processed_activities rows older than 30
* days (spec: federation-operations "Inbound replay defense"). Replays of
* activities that old are already rejected by HTTP-signature date
* freshness, so the dedup record is no longer needed; this keeps the
* table bounded.
*/
export const federationDedupSweepJob = defineJournalJob({
name: "federation-dedup-sweep",
cron: "30 4 * * *", // daily at 04:30 UTC (offset from federation-kv-sweep at 04:15)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await sweepProcessedActivities();
logger.info({ purged }, "federation-dedup-sweep");
return { purged };
},
});

View file

@ -0,0 +1,20 @@
import { defineJournalJob } from "./payloads.ts";
import { PostgresKvStore } from "../lib/federation-kv.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of expired federation_kv rows (Fedify replay-protection
* nonces and caches carry TTLs; reads already filter expired rows, this
* keeps the table from growing unbounded).
*/
export const federationKvSweepJob = defineJournalJob({
name: "federation-kv-sweep",
cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await new PostgresKvStore().sweepExpired();
logger.info({ purged }, "federation-kv-sweep");
return { purged };
},
});

View file

@ -0,0 +1,30 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts";
// Garmin webhook notifications enqueue here (spec: garmin-import,
// "Push-notification activity import"): the webhook answers 200
// immediately and this job does the slow part — authorized file
// download, FIT→GPX, activity creation. Backfill bursts deliver many
// notifications at once; the queue absorbs them and pg-boss retries
// transient download failures.
export const garminImportActivityJob = defineJournalJob({
name: "garmin-import-activity",
retryLimit: 3,
expireInSeconds: 300,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const data = job.data;
try {
await runGarminActivityImport(data);
} catch (err) {
logger.warn(
{ err, externalId: data.externalId },
"garmin-import-activity failed (pg-boss will retry)",
);
throw err;
}
}
},
});

View file

@ -0,0 +1,42 @@
import { defineJournalJob } from "./payloads.ts";
import { and, lt, inArray, count } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
import { logger } from "../lib/logger.server.ts";
const STALE_MS = 10 * 60 * 1000;
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
export const importBatchesSweepJob = defineJournalJob({
name: "import-batches-sweep",
cron: "* * * * *",
retryLimit: 0,
expireInSeconds: 55,
async handler() {
const db = getDb();
const cutoff = new Date(Date.now() - STALE_MS);
const staleFilter = and(
inArray(importBatches.status, STALE_STATUSES),
lt(importBatches.startedAt, cutoff),
);
// Skip the write when nothing is stale to avoid an unconditional UPDATE every minute.
const rows = await db
.select({ staleCount: count() })
.from(importBatches)
.where(staleFilter);
if ((rows[0]?.staleCount ?? 0) === 0) return;
const result = await db
.update(importBatches)
.set({
status: "failed" satisfies ImportBatchStatus,
errorMessage: "Import timed out — the server may have restarted mid-import. Click 'Run again' to retry.",
completedAt: new Date(),
})
.where(staleFilter)
.returning({ id: importBatches.id });
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
},
});

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const withFreshCredentials = vi.fn();
vi.mock("../lib/connected-services/manager.ts", () => ({
withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args),
}));
const runKomootBulkImport = vi.fn();
const markBatchFailed = vi.fn();
vi.mock("../lib/komoot-bulk-import.server.ts", () => ({
runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args),
markBatchFailed: (...args: unknown[]) => markBatchFailed(...args),
}));
import { komootBulkImportJob } from "./komoot-bulk-import.ts";
type HandlerJobs = Parameters<typeof komootBulkImportJob.handler>[0];
function jobWith(data: unknown): HandlerJobs {
return [{ id: "j1", data }] as unknown as HandlerJobs;
}
const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" };
describe("komoot-bulk-import job", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves credentials through the manager — only the serviceId crosses the queue", async () => {
const creds = { mode: "public", komootUserId: "k1" };
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds));
runKomootBulkImport.mockResolvedValue(undefined);
await komootBulkImportJob.handler(jobWith(PAYLOAD));
expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function));
expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds);
expect(markBatchFailed).not.toHaveBeenCalled();
});
it("marks the batch failed and rethrows when credential resolution fails", async () => {
withFreshCredentials.mockRejectedValue(new Error("needs relink"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink");
expect(runKomootBulkImport).not.toHaveBeenCalled();
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink");
});
it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => {
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" }));
runKomootBulkImport.mockRejectedValue(new Error("komoot 500"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500");
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500");
});
});

View file

@ -0,0 +1,36 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
import {
markBatchFailed,
runKomootBulkImport,
type KomootCreds,
} from "../lib/komoot-bulk-import.server.ts";
export const komootBulkImportJob = defineJournalJob({
name: "komoot-bulk-import",
retryLimit: 1,
expireInSeconds: 1800,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const { batchId, userId, serviceId } = job.data;
logger.info({ batchId, userId }, "komoot bulk import job started");
try {
// Credentials are resolved through the ConnectedServiceManager at
// execution time — the payload carries only the serviceId, so
// nothing credential-shaped sits in the job table and a relink
// between enqueue and execution is picked up here.
await withFreshCredentials(serviceId, (creds) =>
runKomootBulkImport(batchId, userId, creds as KomootCreds),
);
} catch (err) {
// runKomootBulkImport marks its own failures; this covers errors
// before it ran (service missing/not active/needs relink), where
// the batch would otherwise stay "pending" forever.
await markBatchFailed(batchId, err instanceof Error ? err.message : String(err));
throw err;
}
}
},
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { fanout } from "./notifications-fanout.ts";
import { listForUser } from "../lib/notifications.server.ts";
// Same opt-in flag as the rest of the notifications integration tests.
const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1";
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${opts.username}@example.test`,
username: opts.username,
domain: "test.local",
profileVisibility: opts.profileVisibility ?? "public",
});
return id;
}
async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) {
const db = getDb();
const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u;
await db.insert(follows).values({
id: randomUUID(),
followerId,
followedActorIri: `https://test.local/users/${followedUsername}`,
followedUserId: followedId,
acceptedAt: opts.accepted ? new Date() : null,
});
}
async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") {
const db = getDb();
const id = randomUUID();
await db.insert(activities).values({
id,
ownerId,
name,
visibility,
});
return id;
}
async function wipe() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
}
describe.skipIf(!runIntegration)("notifications-fanout integration", () => {
beforeAll(async () => {
const db = getDb();
await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`);
});
afterEach(wipe);
it("inserts exactly one row per accepted follower; pending followers are skipped", async () => {
const owner = await makeUser({ username: `nf_o_${Date.now()}` });
const a1 = await makeUser({ username: `nf_a1_${Date.now()}` });
const a2 = await makeUser({ username: `nf_a2_${Date.now()}` });
const p1 = await makeUser({ username: `nf_p1_${Date.now()}` });
const p2 = await makeUser({ username: `nf_p2_${Date.now()}` });
await makeFollow(a1, owner, { accepted: true });
await makeFollow(a2, owner, { accepted: true });
await makeFollow(p1, owner, { accepted: false });
await makeFollow(p2, owner, { accepted: false });
const activityId = await makeActivity(owner, "public", "Public Walk");
await fanout(activityId);
expect((await listForUser(a1)).rows.length).toBe(1);
expect((await listForUser(a2)).rows.length).toBe(1);
expect((await listForUser(p1)).rows.length).toBe(0);
expect((await listForUser(p2)).rows.length).toBe(0);
const a1Rows = (await listForUser(a1)).rows;
expect(a1Rows[0]?.type).toBe("activity_published");
expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk");
});
it("skips fan-out for non-public activities (defense in depth)", async () => {
const owner = await makeUser({ username: `nf_np_o_${Date.now()}` });
const f = await makeUser({ username: `nf_np_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const privateAct = await makeActivity(owner, "private");
const unlistedAct = await makeActivity(owner, "unlisted");
await fanout(privateAct);
await fanout(unlistedAct);
expect((await listForUser(f)).rows.length).toBe(0);
});
it("is idempotent under retry — second fanout doesn't double-insert", async () => {
const owner = await makeUser({ username: `nf_id_o_${Date.now()}` });
const f = await makeUser({ username: `nf_id_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const activityId = await makeActivity(owner, "public");
await fanout(activityId);
await fanout(activityId);
expect((await listForUser(f)).rows.length).toBe(1);
});
});

View file

@ -0,0 +1,99 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq, isNotNull } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { createNotification } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Fan out an `activity_published` notification to every accepted
* follower of the activity's owner. Idempotent at the DB level via the
* `(recipient_user_id, type, subject_id)` unique partial index a
* retry after partial failure won't double-insert.
*/
export const notificationsFanoutJob = defineJournalJob({
name: "notifications-fanout",
retryLimit: 3,
expireInSeconds: 300,
async handler(job) {
// pg-boss v12: `job` may be an array (batch) per its docs; we
// process whichever shape we get.
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
await fanout(item.data.activityId);
}
},
});
export async function fanout(activityId: string): Promise<void> {
const db = getDb();
// Load the activity + owner info needed for the payload snapshot.
const [row] = await db
.select({
id: activities.id,
name: activities.name,
visibility: activities.visibility,
ownerId: activities.ownerId,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(users, eq(activities.ownerId, users.id))
.where(eq(activities.id, activityId));
if (!row) {
logger.warn({ activityId }, "fanout: activity not found, skipping");
return;
}
// Remote-ingested rows have no local owner and never fan out locally
// (the users innerJoin already excludes them; this narrows the type).
if (row.ownerId === null) return;
// Defense in depth — the create-side guard already filters this, but
// recheck here so the job doesn't mistakenly fan out a row that was
// later flipped to private/unlisted before the job ran.
if (row.visibility !== "public") {
logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity");
return;
}
// Find every accepted *local* follower of the owner. Remote
// followers (follower_id NULL, follower_actor_iri set) get the
// activity via federation push delivery, not via notifications.
const recipients = await db
.select({ followerId: follows.followerId })
.from(follows)
.where(
and(
eq(follows.followedUserId, row.ownerId),
isNotNull(follows.acceptedAt),
isNotNull(follows.followerId),
),
);
let inserted = 0;
for (const r of recipients) {
// Don't notify the owner about their own activity if they happen
// to follow themselves (shouldn't happen — followUser refuses
// self-follow — but defense in depth).
if (r.followerId === row.ownerId || r.followerId === null) continue;
const created = await createNotification({
type: "activity_published",
recipientUserId: r.followerId,
actorUserId: row.ownerId,
subjectId: row.id,
payload: {
activityId: row.id,
activityName: row.name,
ownerUsername: row.ownerUsername,
ownerDisplayName: row.ownerDisplayName,
},
});
if (created) inserted += 1;
}
logger.info(
{ activityId, recipients: recipients.length, inserted },
"notifications-fanout completed",
);
}

View file

@ -0,0 +1,21 @@
import { defineJournalJob } from "./payloads.ts";
import { purgeReadOlderThan } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily retention pass. Drops notifications whose `read_at` is older
* than 90 days; unread rows are kept indefinitely so users never miss
* an event.
*/
export const notificationsPurgeJob = defineJournalJob({
name: "notifications-purge",
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const days = 90;
const purged = await purgeReadOlderThan(days);
logger.info({ days, purged }, "notifications-purge");
return { days, purged };
},
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
// Importing every job module pulls in their (transitively heavy)
// server deps; mock the leaf modules with side effects so this stays a
// unit test of the registry wiring.
import { vi } from "vitest";
vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() }));
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
describe("job registry", () => {
it("every job's queue name is unique and a key of JobPayloads", async () => {
const modules = await Promise.all([
import("./backfill-user-keypairs.ts"),
import("./consumed-jti-sweep.ts"),
import("./deliver-activity.ts"),
import("./demo-bot-generate.ts"),
import("./demo-bot-prune.ts"),
import("./federation-kv-sweep.ts"),
import("./garmin-import-activity.ts"),
import("./import-batches-sweep.ts"),
import("./komoot-bulk-import.ts"),
import("./notifications-fanout.ts"),
import("./notifications-purge.ts"),
import("./poll-remote-actor.ts"),
import("./poll-remote-outboxes.ts"),
import("./send-welcome-email.ts"),
]);
const names = modules.flatMap((m) =>
Object.values(m as Record<string, unknown>)
.filter(
(v): v is { name: string; handler: unknown } =>
typeof v === "object" && v !== null && "handler" in v && "name" in v,
)
.map((def) => def.name),
);
expect(names).toHaveLength(14);
expect(new Set(names).size).toBe(names.length);
// pg-boss v11+ queue-name constraint, mirrored from packages/jobs
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
});
});

View file

@ -0,0 +1,43 @@
import { defineJob, type JobDefinition, type TypedJobDefinition } from "@trails-cool/jobs";
import type { DeliveryPayload } from "../lib/federation-delivery.server.ts";
import type { GarminImportData } from "../lib/connected-services/providers/garmin/import.server.ts";
/**
* Every journal job queue and its payload shape, in one place. The
* typed `enqueue` / `enqueueOptional` in boss.server.ts and the
* `defineJournalJob` helper below both key off this map, so an enqueue
* site and its handler cannot drift apart, and a queue-name typo is a
* compile error instead of an orphaned queue.
*
* `void` marks cron-only jobs that are never enqueued with data.
*/
export interface JobPayloads {
"backfill-user-keypairs": Record<string, never>;
"consumed-jti-sweep": void;
"deliver-activity": DeliveryPayload;
"demo-bot-generate": void;
"demo-bot-prune": void;
"federation-dedup-sweep": void;
"federation-kv-sweep": void;
"garmin-import-activity": GarminImportData;
"import-batches-sweep": void;
"komoot-bulk-import": { batchId: string; userId: string; serviceId: string };
"notifications-fanout": { activityId: string };
"notifications-purge": void;
"poll-remote-actor": { actorIri: string };
"poll-remote-outboxes": void;
"send-welcome-email": { email: string; username: string };
"surface-backfill": { kind: "route" | "activity"; id: string };
}
export type JobName = keyof JobPayloads;
/**
* defineJob, constrained to the journal's queue map: the name must be
* a known queue and the handler's payload type follows from it.
*/
export function defineJournalJob<K extends JobName>(
definition: TypedJobDefinition<JobPayloads[K]> & { name: K },
): JobDefinition {
return defineJob<JobPayloads[K]>(definition);
}

View file

@ -0,0 +1,23 @@
import { defineJournalJob } from "./payloads.ts";
import { pollRemoteActor } from "../lib/federation-ingest.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Poll one remote trails actor's outbox (spec §7). Enqueued by the
* inbox Accept(Follow) listener (first poll, 7.5) and fanned out by
* the poll-remote-outboxes cron sweep (7.1).
*/
export const pollRemoteActorJob = defineJournalJob({
name: "poll-remote-actor",
retryLimit: 2,
expireInSeconds: 120,
async handler(jobs) {
for (const job of jobs) {
// Defensive: jobs enqueued before the typed seam may carry no data.
const actorIri = job.data?.actorIri;
if (!actorIri) continue;
const result = await pollRemoteActor(actorIri);
logger.info({ actorIri, result }, "poll-remote-actor");
}
},
});

View file

@ -0,0 +1,25 @@
import { defineJournalJob } from "./payloads.ts";
import { listActorsDuePolling } from "../lib/federation-ingest.server.ts";
import { enqueueOptional } from "../lib/boss.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Cron sweep (spec 7.1): every 5 minutes, find remote trails actors
* that at least one local user follows (accepted) and that haven't
* been polled within the last hour, and fan out one poll-remote-actor
* job each. Per-host pacing lives in the poll itself.
*/
export const pollRemoteOutboxesJob = defineJournalJob({
name: "poll-remote-outboxes",
cron: "*/5 * * * *",
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const due = await listActorsDuePolling();
for (const actorIri of due) {
await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" });
}
if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep");
return { due: due.length };
},
});

View file

@ -0,0 +1,27 @@
import { defineJournalJob } from "./payloads.ts";
import { sendWelcome } from "../lib/email.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Queue-backed welcome email send. The old code did
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
* failures via the dead-letter queue / logs.
*/
export const sendWelcomeEmailJob = defineJournalJob({
name: "send-welcome-email",
retryLimit: 3,
expireInSeconds: 120,
async handler(job) {
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
const { email, username } = item.data;
try {
await sendWelcome(email, username);
} catch (err) {
logger.error({ err, email }, "send-welcome-email job failed");
throw err;
}
}
},
});

View file

@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execute = vi.fn();
vi.mock("../lib/db.ts", () => ({ getDb: () => ({ execute }) }));
const fetchWaysInBbox = vi.fn();
vi.mock("../lib/overpass-ways.server.ts", () => ({ fetchWaysInBbox: (...a: unknown[]) => fetchWaysInBbox(...a) }));
const emitTo = vi.fn();
vi.mock("../lib/events.server.ts", () => ({ emitTo: (...a: unknown[]) => emitTo(...a) }));
vi.mock("../lib/logger.server.ts", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
import { runSurfaceBackfill } from "./surface-backfill.ts";
const lineString = (coords: number[][]) => JSON.stringify({ type: "LineString", coordinates: coords });
beforeEach(() => {
execute.mockReset();
fetchWaysInBbox.mockReset();
emitTo.mockReset();
});
describe("runSurfaceBackfill", () => {
it("matches ways, stores the breakdown, and emits to the owner", async () => {
execute.mockResolvedValueOnce([
{ geojson: lineString([[0, 0], [0.001, 0], [0.002, 0]]), ownerId: "user-1", hasBreakdown: false },
]); // SELECT
execute.mockResolvedValueOnce(undefined); // UPDATE
fetchWaysInBbox.mockResolvedValue([
{ highway: "residential", surface: "asphalt", geometry: [{ lat: 0, lon: 0 }, { lat: 0, lon: 0.01 }] },
]);
await runSurfaceBackfill("activity", "act-1");
expect(fetchWaysInBbox).toHaveBeenCalledOnce();
expect(execute).toHaveBeenCalledTimes(2); // SELECT + UPDATE
expect(emitTo).toHaveBeenCalledWith("user-1", "surface_breakdown", { kind: "activity", id: "act-1" });
});
it("skips a row that already has a breakdown", async () => {
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [1, 0]]), ownerId: "u", hasBreakdown: true }]);
await runSurfaceBackfill("route", "r-1");
expect(fetchWaysInBbox).not.toHaveBeenCalled();
expect(execute).toHaveBeenCalledTimes(1); // only the SELECT
expect(emitTo).not.toHaveBeenCalled();
});
it("does not store or emit when Overpass returns no ways", async () => {
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [0.001, 0]]), ownerId: "u", hasBreakdown: false }]);
fetchWaysInBbox.mockResolvedValue([]);
await runSurfaceBackfill("activity", "a-2");
expect(execute).toHaveBeenCalledTimes(1); // SELECT only
expect(emitTo).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,101 @@
import { sql } from "drizzle-orm";
import { defineJournalJob } from "./payloads.ts";
import { getDb } from "../lib/db.ts";
import { computeSurfaceBreakdown } from "@trails-cool/map-core";
import { fetchWaysInBbox, type Bbox } from "../lib/overpass-ways.server.ts";
import { matchSurfaces } from "../lib/surface-match.server.ts";
import { emitTo } from "../lib/events.server.ts";
import { logger } from "../lib/logger.server.ts";
// Cap coordinates fed to the matcher; the breakdown still weights by the actual
// segment length, so downsampling barely shifts proportions.
const MAX_COORDS = 250;
const BBOX_PAD_DEG = 0.0008; // ~80 m, so ways just off the line are considered
/**
* Derive a surface/waytype breakdown for a route/activity that has geometry but
* no breakdown yet (imports, uploads, pre-existing rows), by map-matching its
* geometry to OSM ways via Overpass. Idempotent (skips if already set),
* best-effort (Overpass failure throws pg-boss retries; an empty/oversized
* bbox is a no-op). On success it stores the breakdown and pushes an SSE event
* to the owner so an open detail page fills the bars in live.
*/
export const surfaceBackfillJob = defineJournalJob({
name: "surface-backfill",
retryLimit: 3,
expireInSeconds: 120,
async handler(job) {
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
await runSurfaceBackfill(item.data.kind, item.data.id);
}
},
});
export async function runSurfaceBackfill(kind: "route" | "activity", id: string): Promise<void> {
const db = getDb();
const tableName = kind === "route" ? sql`journal.routes` : sql`journal.activities`;
const loaded = (await db.execute(sql`
SELECT ST_AsGeoJSON(geom) AS geojson,
owner_id AS "ownerId",
(surface_breakdown IS NOT NULL) AS "hasBreakdown"
FROM ${tableName} WHERE id = ${id} LIMIT 1
`)) as unknown as Array<{ geojson: string | null; ownerId: string | null; hasBreakdown: boolean }>;
const row = loaded[0];
if (!row) {
logger.warn({ kind, id }, "surface-backfill: row not found");
return;
}
if (row.hasBreakdown) return; // idempotent
if (!row.geojson) return; // no geometry to match
let coords: number[][];
try {
coords = (JSON.parse(row.geojson) as { coordinates?: number[][] }).coordinates ?? [];
} catch {
return;
}
if (coords.length < 2) return;
if (coords.length > MAX_COORDS) {
const stride = Math.ceil(coords.length / MAX_COORDS);
const out: number[][] = [];
for (let i = 0; i < coords.length; i += stride) out.push(coords[i]!);
const last = coords[coords.length - 1]!;
if (out[out.length - 1] !== last) out.push(last);
coords = out;
}
let south = coords[0]![1]!, north = south, west = coords[0]![0]!, east = west;
for (const c of coords) {
const lon = c[0]!, lat = c[1]!;
if (lat < south) south = lat;
if (lat > north) north = lat;
if (lon < west) west = lon;
if (lon > east) east = lon;
}
const bbox: Bbox = {
south: south - BBOX_PAD_DEG,
west: west - BBOX_PAD_DEG,
north: north + BBOX_PAD_DEG,
east: east + BBOX_PAD_DEG,
};
const ways = await fetchWaysInBbox(bbox);
if (ways.length === 0) {
logger.info({ kind, id }, "surface-backfill: no ways (bbox empty/oversized) — skipping");
return;
}
const { surfaces, highways } = matchSurfaces(coords, ways);
const breakdown = computeSurfaceBreakdown(coords, surfaces, highways);
if (Object.keys(breakdown.surface).length === 0 && Object.keys(breakdown.highway).length === 0) return;
await db.execute(sql`
UPDATE ${tableName} SET surface_breakdown = ${JSON.stringify(breakdown)}::jsonb WHERE id = ${id}
`);
if (row.ownerId) emitTo(row.ownerId, "surface_breakdown", { kind, id });
logger.info({ kind, id }, "surface-backfill: stored breakdown");
}

View file

@ -1,76 +1,134 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import type { Visibility, SportType } from "@trails-cool/db/schema/journal";
import { processGpx, writeGeom } from "./gpx-save.server.ts";
import type { ProcessedGpx } from "./gpx-save.server.ts";
import { enqueueOptional } from "./boss.server.ts";
import type { OwnedRef } from "./ownership.server.ts";
import {
enqueueActivityDeliveries,
visibilityTransitionAction,
} from "./federation-delivery.server.ts";
export interface ActivityInput {
name: string;
description?: string;
sportType?: SportType | null;
gpx?: string;
routeId?: string;
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
visibility?: Visibility;
synthetic?: boolean;
}
export async function updateActivityVisibility(
id: string,
ownerId: string,
ownedActivity: OwnedRef,
visibility: Visibility,
): Promise<boolean> {
const { id, ownerId } = ownedActivity;
const db = getDb();
const result = await db
// Read the previous visibility first: the federation action depends on
// the *transition*, not the new value alone (a gratuitous Delete
// permanently tombstones the object's URI on Mastodon — see
// visibilityTransitionAction).
const [existing] = await db
.select({ visibility: activities.visibility })
.from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.limit(1);
if (!existing) return false;
await db
.update(activities)
.set({ visibility })
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.returning({ id: activities.id });
return result.length > 0;
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
// Notify followers when an activity becomes public. The unique
// (recipient, type, subject_id) partial index makes the fan-out
// idempotent, so toggling private→public→private→public won't spam
// followers (only the first transition per activity emits).
if (visibility === "public") {
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
}
// Federation: Create on (re-)publish, Delete(Tombstone) only when the
// activity actually was public before — remotes never saw anything
// else, and an unnecessary Delete poisons the URI forever.
const action = visibilityTransitionAction(existing.visibility, visibility);
if (action !== null) {
await enqueueActivityDeliveries(ownerId, id, action);
}
return true;
}
export async function createActivity(ownerId: string, input: ActivityInput) {
const db = getDb();
const id = randomUUID();
let processed: ProcessedGpx | null = null;
let distance: number | null = input.distance ?? null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let startedAt: Date | null = input.startedAt ?? null;
const duration: number | null = input.duration ?? null;
if (input.gpx) {
try {
const gpxData = await parseGpxAsync(input.gpx);
distance = gpxData.distance || distance;
elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss;
if (!startedAt && gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(gpxData.tracks[0][0].time);
}
} catch {
// Continue without stats if GPX parsing fails
}
processed = await processGpx(input.gpx);
// GPX-derived distance wins unless it is zero; caller input is the fallback
distance = processed.stats.distance || distance;
elevationGain = processed.stats.elevationGain;
elevationLoss = processed.stats.elevationLoss;
startedAt = startedAt ?? processed.stats.startTime;
}
await db.insert(activities).values({
await db.transaction(async (tx) => {
await tx.insert(activities).values({
id,
ownerId,
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
sportType: input.sportType ?? null,
gpx: input.gpx,
distance,
duration,
elevationGain,
elevationLoss,
startedAt,
...(input.visibility ? { visibility: input.visibility } : {}),
...(input.synthetic ? { synthetic: true } : {}),
});
if (input.gpx) {
await setGeomFromGpx(id, "activities", input.gpx);
if (input.gpx && processed) {
await writeGeom(tx, id, "activities", processed.coords);
}
});
// Public activities at creation also fan out (matches the
// updateActivityVisibility path for the case where visibility is set
// up-front rather than flipped later).
if (input.visibility === "public") {
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
// Federation push delivery to accepted remote followers (spec 5.3).
await enqueueActivityDeliveries(ownerId, id, "create");
}
// Activities arrive without surface waytags (bare GPX); kick off the async
// Overpass backfill (route-surface-breakdown Path 2). Skipped for synthetic
// demo content. Best-effort — never blocks creation.
if (input.gpx && !input.synthetic) {
await enqueueOptional(
"surface-backfill",
{ kind: "activity", id },
{ source: "createActivity" },
{ singletonKey: `surface:activity:${id}` },
);
}
return id;
@ -85,11 +143,20 @@ export async function getActivity(id: string) {
return { ...activity, geojson, importSource };
}
export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
export async function deleteActivity(ownedActivity: OwnedRef): Promise<boolean> {
const { id, ownerId } = ownedActivity;
const db = getDb();
const [activity] = await db.select({ id: activities.id }).from(activities)
// The WHERE ownerId clause stays as defense in depth even though the
// OwnedRef brand already proves ownership.
const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
if (!activity) return false;
// Enqueue the federation retraction *before* the row disappears —
// the Delete payload carries everything it needs (object IRI +
// owner), so it survives the deletion (spec 5.6).
if (activity.visibility === "public") {
await enqueueActivityDeliveries(ownerId, id, "delete");
}
await db.delete(activities).where(eq(activities.id, id));
return true;
}
@ -103,13 +170,109 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
return row ?? null;
}
export async function listActivities(ownerId: string) {
export interface ActivityStats {
count: number;
/** metres */
distance: number;
/** metres */
elevationGain: number;
/** seconds, elapsed */
duration: number;
/** activities started in the last 28 days */
last4Weeks: number;
}
/**
* Aggregate roll-up for a profile. One indexed aggregate over stored columns
* (no GPX parsing) `owner_id` leads two existing indexes, so this is cheap
* even for power users (see profile-stats design §D1). `publicOnly` scopes the
* roll-up to what a visitor may see; the owner passes `false` for full totals.
*/
export async function getActivityStats(
ownerId: string,
opts: { publicOnly: boolean },
): Promise<ActivityStats> {
const db = getDb();
const conditions = [eq(activities.ownerId, ownerId)];
if (opts.publicOnly) conditions.push(eq(activities.visibility, "public"));
const [row] = await db
.select({
count: sql<string>`count(*)`,
distance: sql<string>`coalesce(sum(${activities.distance}), 0)`,
elevationGain: sql<string>`coalesce(sum(${activities.elevationGain}), 0)`,
duration: sql<string>`coalesce(sum(${activities.duration}), 0)`,
last4Weeks: sql<string>`count(*) filter (where coalesce(${activities.startedAt}, ${activities.createdAt}) >= now() - interval '28 days')`,
})
.from(activities)
.where(and(...conditions));
// Postgres returns count/sum as strings via the driver; coerce to numbers.
return {
count: Number(row?.count ?? 0),
distance: Number(row?.distance ?? 0),
elevationGain: Number(row?.elevationGain ?? 0),
duration: Number(row?.duration ?? 0),
last4Weeks: Number(row?.last4Weeks ?? 0),
};
}
export interface WeeklyDistanceBucket {
/** ISO date (YYYY-MM-DD) of the week's Monday */
weekStart: string;
/** metres */
distance: number;
}
/**
* Distance per week for the last `weeks` weeks (oldest newest), gap-filled so
* every week is present (zero when no activity). The contiguous axis is built
* in SQL via `generate_series` + a LEFT JOIN that guarantees the week
* boundaries match Postgres `date_trunc('week', …)` exactly (no JS/Postgres
* boundary drift) and keeps it one cheap query over the owner_id index
* (profile-weekly-distance design §D1D2). `publicOnly` scopes it like
* `getActivityStats`.
*/
export async function getWeeklyDistance(
ownerId: string,
opts: { publicOnly: boolean; weeks?: number },
): Promise<WeeklyDistanceBucket[]> {
const weeks = opts.weeks ?? 12;
const db = getDb();
// Filter conditions live in the LEFT JOIN's ON clause so empty weeks survive.
const visibility = opts.publicOnly ? sql` AND a.visibility = 'public'` : sql``;
const result = await db.execute(sql`
WITH weeks AS (
SELECT generate_series(
date_trunc('week', now()) - (${weeks - 1} * interval '1 week'),
date_trunc('week', now()),
interval '1 week'
) AS wk
)
SELECT to_char(w.wk, 'YYYY-MM-DD') AS week_start,
coalesce(sum(a.distance), 0) AS distance
FROM weeks w
LEFT JOIN journal.activities a
ON date_trunc('week', coalesce(a.started_at, a.created_at)) = w.wk
AND a.owner_id = ${ownerId}${visibility}
GROUP BY w.wk
ORDER BY w.wk
`);
const rows = result as unknown as Array<{ week_start: string; distance: string | number }>;
return rows.map((r) => ({ weekStart: r.week_start, distance: Number(r.distance) }));
}
export async function listActivities(
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",
) {
const db = getDb();
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
const rows = await db
.select()
.from(activities)
.where(eq(activities.ownerId, ownerId))
.orderBy(desc(activities.createdAt));
.orderBy(order);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -121,13 +284,19 @@ export async function listActivities(ownerId: string) {
* listings (the public profile page); never includes `unlisted` or
* `private` content.
*/
export async function listPublicActivitiesForOwner(ownerId: string) {
export async function listPublicActivitiesForOwner(
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",
limit: number = 100,
) {
const db = getDb();
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
const rows = await db
.select()
.from(activities)
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
.orderBy(desc(activities.createdAt));
.orderBy(order)
.limit(limit);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -135,24 +304,40 @@ export async function listPublicActivitiesForOwner(ownerId: string) {
}
/**
* Social feed: aggregated public activities from users that `followerId`
* follows (accepted only). Reverse-chronological. Joins users for owner
* attribution. Unlisted/private activities never appear, regardless of
* follow state.
* Social feed (spec: social-federation §8): aggregated activities from
* actors that `followerId` follows with an *accepted* follow local
* users and remote trails actors alike. Reverse-chronological on
* COALESCE(remote_published_at, created_at).
*
* Audience rules:
* - local rows: `visibility = 'public'` only (unlisted/private never
* appear regardless of follow state)
* - remote rows: `audience = 'public'` or `followers-only` the
* latter gated structurally by joining the *viewer's own* accepted
* follow against the originating actor (spec: "Followers-only remote
* content reaches only the right viewer")
* Pending follows contribute nothing (accepted_at IS NOT NULL on both
* branches previously missing on the local branch).
*/
export async function listSocialFeed(followerId: string, limit: number = 50) {
const db = getDb();
const rows = await db
const local = db
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`NULL`.as("external_url"),
remote: sql<boolean>`false`.as("remote"),
})
.from(activities)
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
@ -160,14 +345,47 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
eq(activities.visibility, "public"),
),
)
.orderBy(desc(activities.createdAt))
);
const remote = db
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
sortTime: sql<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
remote: sql<boolean>`true`.as("remote"),
})
.from(activities)
.innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri))
.leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri))
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
// public reaches every accepted follower; followers-only is
// already gated by joining the viewer's own accepted follow.
sql`${activities.audience} IN ('public', 'followers-only')`,
),
);
const rows = await unionAll(local, remote)
.orderBy(sql`sort_time DESC`)
.limit(limit);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
@ -184,6 +402,7 @@ export async function listRecentPublicActivities(limit: number = 20) {
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
@ -203,21 +422,28 @@ export async function listRecentPublicActivities(limit: number = 20) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
export async function linkActivityToRoute(ownedActivity: OwnedRef, ownedRoute: OwnedRef) {
const db = getDb();
await db
.update(activities)
.set({ routeId })
.where(eq(activities.id, activityId));
.set({ routeId: ownedRoute.id })
.where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId)));
}
export async function createRouteFromActivity(activityId: string, ownerId: string): Promise<string | null> {
export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise<string | null> {
const { id: activityId, ownerId } = ownedActivity;
const db = getDb();
const [activity] = await db.select().from(activities).where(eq(activities.id, activityId));
const [activity] = await db
.select()
.from(activities)
.where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId)));
if (!activity?.gpx) return null;
const { coords } = await processGpx(activity.gpx);
const routeId = randomUUID();
await db.insert(routes).values({
await db.transaction(async (tx) => {
await tx.insert(routes).values({
id: routeId,
ownerId,
name: `Route from: ${activity.name}`,
@ -228,13 +454,10 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
elevationLoss: activity.elevationLoss,
});
await setGeomFromGpx(routeId, "routes", activity.gpx);
await writeGeom(tx, routeId, "routes", coords);
// Link the activity to the new route
await db
.update(activities)
.set({ routeId })
.where(eq(activities.id, activityId));
await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId));
});
return routeId;
}
@ -257,13 +480,19 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<str
if (ids.length === 0) return map;
try {
const db = getDb();
await Promise.all(ids.map(async (id) => {
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
if (row?.geojson) map.set(id, row.geojson);
}));
// Use the query builder for the id list: a raw `ANY(${ids}::text[])`
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid
// `ANY((...)::text[])` — which throws and silently drops every preview.
const rows = await db
.select({
id: activities.id,
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`,
})
.from(activities)
.where(and(inArray(activities.id, ids), isNotNull(activities.geom)));
for (const row of rows) {
if (row.geojson) map.set(row.id, row.geojson);
}
} catch {
// Fallback: no geojson
}

View file

@ -1,8 +1,8 @@
import { getOrigin } from "./config.server.ts";
// Canonical ActivityPub actor IRI for a local user. Used as the key in
// `follows.followed_actor_iri` so the column shape is identical for local
// and (future) federated follows. Reading from `process.env.ORIGIN` keeps
// us aligned with the rest of the auth/federation stack.
// and (future) federated follows.
export function localActorIri(username: string): string {
const origin = process.env.ORIGIN ?? "http://localhost:3000";
return `${origin}/users/${username}`;
return `${getOrigin()}/users/${username}`;
}

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TERMS_VERSION } from "./legal";
const mockGetAuthenticatedUser = vi.fn();
vi.mock("./oauth.server.ts", () => ({
getAuthenticatedUser: mockGetAuthenticatedUser,
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("requireApiUser", () => {
it("returns 401 when unauthenticated", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(401);
const body = await (err as Response).json();
expect(body.code).toBe("UNAUTHORIZED");
}
});
it("returns the user when termsVersion matches", async () => {
const user = { id: "u1", termsVersion: TERMS_VERSION };
mockGetAuthenticatedUser.mockResolvedValue(user);
const { requireApiUser } = await import("./api-guard.server.ts");
const result = await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect(result).toBe(user);
});
it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null });
const { requireApiUser } = await import("./api-guard.server.ts");
try {
await requireApiUser(new Request("http://localhost/api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(403);
const body = await (err as Response).json();
expect(body.code).toBe("TERMS_OUTDATED");
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
}
});
});

Some files were not shown because too many files have changed in this diff Show more