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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
## 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>
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>
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>
- 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.
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>
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\`).
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).
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.
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>
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>
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>
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>
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>
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>
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.
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).
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
- 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>
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>
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.
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.
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>
- 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>
- 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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
- 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>
- `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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Stream A from docs/information-architecture.md: signed-in users now
have a single /feed destination with two views — Followed (default,
people they accepted-follow) and Public (instance-wide). Logging in no
longer hides the public instance feed; switching is a query-param flip
that's bookmarkable and SSR-rendered.
- apps/journal/app/routes/feed.tsx — loader reads ?view=, branches
fetch (listSocialFeed vs listRecentPublicActivities, both already
exist), passes view to the component. Component renders a tab strip
at the top using plain <Link>s so the toggle works without JS. Per-
view <meta> title and empty state. The "see public feed" escape
from the empty Followed view now points at ?view=public instead of
/, keeping the user on /feed.
- packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ },
social.feed.public.{heading,empty}, and social.feed.seePublic
keys; old social.feed.publicFeedLink renamed to seePublic.
- openspec/specs/social-follows/spec.md — Social activity feed
requirement extended with the two-view structure, including the
Public view, the toggle, and the unrecognized-value fallback.
- openspec/specs/activity-feed/spec.md — Instance-wide public
activity feed requirement notes the Public view of /feed is now a
consumer alongside the visitor home.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The navbar already has a "Feed" entry that signed-in users see on every
page, including /. The page-header button next to "New Activity"
duplicated that path without adding anything — they pointed at the same
URL, with the navbar entry being the more discoverable of the two.
Stream D from docs/information-architecture.md.
- apps/journal/app/routes/home.tsx — remove the Feed anchor; "New
Activity" stays as the only header CTA on the personal dashboard.
- openspec/specs/journal-landing/spec.md — retire the "Social feed
link for signed-in users" requirement that prescribed the now-deleted
button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.
Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.
Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.
Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
`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>
Three places assumed the old default-public model:
1. e2e/public-content.test.ts: "profile 404 when user has no public
content" → updated to assert the new locked-account stub renders
200 with "This profile is private" and the private route doesn't
appear. The two follow-on tests (public-route profile, unlisted
route) now flip the owner to public via a setProfileVisibilityPublic
helper before the anon-visit assertions, since new users default to
private.
2. e2e/demo-bot.test.ts: bruno is seeded with profile_visibility =
'public' on insert (timestamped seed) and the existing-bruno path
gets a follow-up UPDATE so the test's anon-visitor assertion
(profile renders, public route is listed) holds regardless of which
default he was first created under.
3. apps/journal/app/lib/demo-bot.server.ts ensureDemoUser: also pins
profile_visibility = 'public' on insert. The demo persona is
discoverable by design — that's the whole point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.
Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
remain 'public' (backfill on first migration handled them).
Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
approveFollowRequest, rejectFollowRequest. Approve/reject are
owner-bound: only the followed user can act on their own incoming
requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
filter to accepted-only relations.
Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
copy + Request-to-follow / sign-in CTA. Routes/activities sections
are not rendered.
UI:
- FollowButton gains a "Request to follow" / "Requested" state for
private targets via a new isPrivateTarget prop. Cancel-request reuses
the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
pending > 0.
Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).
Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
(public, private+anon, private+pending, private+accepted) with
scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
manual accept, approve/reject endpoints, pending request management,
Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
default-private; non-goal "locked-local-accounts as a follow-up" is
removed since this change ships it.
Tests:
- follow.integration.test.ts: pending-against-private, approve flips
to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
flow, plus stub-for-anonymous and /follows/requests auth gate.
Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.
Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
federation forward-compat. Local IRIs look like
`${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
'public'). Existing users land 'public' via the default; current
effective behavior is unchanged.
Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
countFollowers / countFollowing / listFollowers / listFollowing.
Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
follows → activities WHERE visibility='public', reverse-chrono.
Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.
UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
"New Activity".
Privacy manifest updated to document the new follows relation and
profile_visibility setting.
Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.
Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to social-feed. Adds real ActivityPub: per-user actor
objects, WebFinger, signed inbox/outbox, push delivery on local
activity create, signed outbox-poll for remote trails actors.
Asymmetric scope keeps v1 small:
- Inbound: anyone (Mastodon, Pleroma, …) can follow a trails user
and receive their public activities via push delivery.
- Outbound: trails users can follow other *trails* instances only.
Following Mastodon/Pleroma/Misskey is refused at the API layer
with a clear "v1 limitation" message.
This avoids the engineering tar pit of robustly parsing arbitrary AP
vocabulary (Notes with attachments, polls, mentions, content warnings,
threads) while still delivering the core federation pitch.
Capabilities:
- New: social-federation
- Modified: social-follows (remote follows + Pending lifecycle)
- Modified: public-profiles (Pending button state, actor object gate)
- Modified: infrastructure (Fedify dep, key management runbook)
- Modified: security-hardening (HTTP Signatures, signed-fetch policy)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposal + design + specs + tasks for the social layer. Adds a
`follows` relation (local + federated via ActivityPub), Follow buttons
on public profiles, and a `/feed` route showing public activities from
followed users.
Capabilities:
- New: social-follows (the graph + /feed)
- Modified: public-profiles (Follow button + counts)
- Modified: journal-landing (Feed link on signed-in dashboard)
Design picks:
- Pull-based feed (no fan-out-on-write) — fine at trails.cool scale
- follows keyed by actor IRI, local denorm FK for join perf
- Fedify wires Follow/Accept/Undo; we handle the DB side
- Remote activity ingestion via polling (not push) — tolerates our
downtime without losing activities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final tasks ticked post-merge:
- 10.2: verified on prod that journal.routes + journal.activities still
default to 'private' NOT NULL, with the only public rows being the
15-each demo-bot seeded content
- 10.3: demo-activity-bot already inserts with visibility='public'
directly in demo-bot.server.ts
Syncs the three delta specs into main:
+ activity-feed: 2 added, 1 modified
+ public-profiles: new spec (1 added)
+ route-management: 2 added, 1 modified
Moves change to openspec/changes/archive/2026-04-24-public-content-visibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a signed-in user landing on `/` saw the same page as an
anonymous visitor: the visitor hero, the flagship marketing cards, and
the instance-wide public feed. "Home" should mean "your stuff."
The home route now branches on session: signed-out visitors keep the
marketing + public-feed layout from #303; signed-in users get a
personal dashboard showing their own activities reverse-chronologically
(all visibilities), a welcome line linking to their profile, and a
"New Activity" CTA. The public instance feed and marketing blurbs are
suppressed for signed-in users — they'd be redundant on a personal
landing surface.
Loader picks `listActivities(user.id)` vs `listRecentPublicActivities`
based on session, so only one feed query runs per request.
Updates the `journal-landing` spec with the new session-based split and
a "Personal dashboard for signed-in users" requirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.
The new home is one layout that serves both audiences:
- Hero: a product-describing h1 ("Federated outdoor journal") +
tagline. The site name already lives in the top banner and nav brand,
so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
Planner demotes to a small "Or try the Planner without an account →"
link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
for Planner / Journal / Federation / Ownership, matching the Planner
home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
a "Powered by trails.cool — about the project" link back to the
flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
activities on the instance, with owner + distance + date. Empty state
for fresh installs.
Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
so self-hosters (who deploy without these workflows) default to off.
Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
so the visibility contract is pinned (public in feed, unlisted +
private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
feed layout contract and the `IS_FLAGSHIP` toggle, so future
instance-branding or empty-state changes have a stable anchor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The notification policy and contact points live in
`infrastructure/grafana/provisioning/alerting/alerts.yml`, so
UI-created contact points can't be attached to provisioned alerts.
Provision the Pushover integration instead.
Rather than add a second contact point and do the fan-out in the
notification tree (Grafana's tree only fires one receiver per match
without duplication workarounds), put both email + pushover under a
single `default` contact point. Every alert now fans out to both.
Secrets go to SOPS `secrets.infra.env`. The Grafana container reads
`PUSHOVER_API_TOKEN` and `PUSHOVER_USER_KEY_ULLRICH` from env; the
provisioning YAML references them via `$VAR` and Grafana substitutes
at startup, so no secret lands in git.
Priority 1 on firing (bypass quiet hours), 0 on resolve. User can
tweak in the YAML later.
Post-deploy: delete the UI-created "Pushover Ullrich" contact point
in Grafana to avoid a duplicate. It's unused (no policy references it)
but shows up in the contact-point list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The postgres init script grants read permissions to `grafana_reader` on
`public`, `planner`, `journal`, and `pgboss` schemas. It's mounted into
the postgres container via bind mount from
`/opt/trails-cool/postgres/init-grafana-user.sql`, and cd-infra re-runs
it with `psql -f` after postgres comes up.
But `cd-infra.yml` only scp's `infrastructure/postgres/queries.yml` to
the flagship — not `init-grafana-user.sql`. The file on the server was
the pre-pg-boss-upgrade March version with no pgboss grants. After
pg-boss v12 landed and (re)created the `pgboss` schema, the
grafana_reader lost schema-level USAGE, the "Background job failures"
alert query started erroring with `permission denied for schema pgboss`,
and the alert fired as "Alerting (Error)" for hours.
Fix: add the init SQL to the scp source list so changes to it actually
reach the flagship. I also manually re-granted on the live postgres to
clear the firing alert; the next cd-infra deploy will now pick up future
edits to the init file too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final step (task 9.4) of the BRouter relocation. The change is moved to
openspec/changes/archive/2026-04-24-relocate-brouter-to-dedicated-host/
and the four delta specs (brouter-integration, infrastructure,
observability, security-hardening) are merged into the main specs:
+ 6 added requirements
~ 4 modified requirements
The two intentionally-unchecked tasks are 2.2 (obsoleted, see crossed-out
note) and 9.4 itself (this archive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compose healthcheck for both apps was `curl -sf http://localhost:.../health`,
but node:25-slim (Debian trixie-slim) ships neither curl nor wget, so
the check always reported the containers as `unhealthy` even while they
were happily serving 200s. Install curl in both Dockerfiles.
Separately, today's flagship BRouter cleanup (PR #297) left an orphan
`brouter` container behind because cd-infra's `docker compose up -d <list>`
doesn't pass `--remove-orphans`. I had to remove it by hand. Pass the
flag in both cd-infra and cd-apps so future service deletions clean up
on the next deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users land via GitHub OAuth gated by the trails-cool org allowlist, so
Viewer-only is more restrictive than we actually want — no Explore
access, no ad-hoc Loki/Prometheus queries. Promote the default role to
Editor so new sign-ins get it automatically.
Note: GF_USERS_AUTO_ASSIGN_ORG_ROLE only applies to new users on first
sign-in. Existing Viewers (including the single current user) need a
one-off promotion via the Grafana admin UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PR #293 flipped BROUTER_URL to the dedicated host and Grafana +
manual smoke tests confirmed clean routing (US route worked, request
rate/latency/memory/logs all green), there's no reason to keep the
in-tree flagship brouter container warm any longer.
- Remove the `brouter:` service and its `./segments` bind mount from
`infrastructure/docker-compose.yml`.
- Remove `depends_on: brouter` from the planner service.
- Tighten the BROUTER_URL and BROUTER_AUTH_TOKEN env wiring from
`${…:-default}` to `${…:?message}` so a missing SOPS value fails the
compose up loudly instead of silently pointing at the removed service
(or missing auth).
- Tick tasks 5.5, 7.5, 8.4, 9.3 in the OpenSpec change; 9.4 (archive)
is the last remaining step once this merges.
Post-merge operator step: `docker image prune -f` on the flagship to
reclaim the brouter image.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moving one waypoint previously re-fetched all N-1 segments of the route
through /api/route every time, because the server-side computeRoute
recomputes the whole route from scratch. Under rapid edits this piles up
on BRouter's thread pool and wastes BRouter+Planner cycles on segments
the caller already has.
This change keeps a host-local LRU segment cache in the Planner tab,
keyed by (from, to, profile, noGoHash). On each computeRoute:
- build the pair list from the current waypoints
- split into cached vs. missing by looking up each pair key
- if any missing, POST { pairs: missing, … } to the new
/api/route-segments endpoint and populate the cache with its ordered
raw BRouter responses
- merge cache-ordered segments into an EnrichedRoute client-side via
mergeGeoJsonSegments (moved to a pure, isomorphic route-merge.ts)
- write to yjs.routeData exactly as before
Typical drag of one waypoint in a 10-waypoint route drops from 9 BRouter
fetches to 2. A session re-entering a cached arrangement (undo/redo)
short-circuits the server entirely. Profile/no-go changes invalidate all
keys and trigger a full refetch, matching prior behavior.
Yjs stays out of the cache: segments are bulky and CRDT sync of large
blobs hurts more than it helps. The elected host owns the cache; on host
handover the new host pays one full recompute.
Backwards-compat: /api/route is unchanged for external callers (e2e
integration tests, the Journal demo-bot's format=gpx path). Server-side
it now delegates to the shared fetchSegments helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user rapidly moves waypoints in the Planner, BRouter's
thread-priority watchdog kills older in-flight requests to free a thread
for newer ones, surfacing to the user as "operation killed by
thread-priority-watchdog" errors.
Two fixes:
1. Bump the BRouter thread pool on the dedicated host from 4 to 16.
`BROUTER_THREADS` is now a Dockerfile ENV (default 4 for
flagship/CI/dev) overridden to 16 in `infrastructure/brouter-host/`.
The host has 32 GB of RAM and idle cores; 4 threads was a flagship-era
constraint that no longer applies.
2. Cancel the previous in-flight `/api/route` request when a new one
starts. `use-routing.ts` now keeps an `AbortController` in a ref,
aborts it at the top of each `computeRoute`, and bails quietly on
`AbortError` so the newer call drives state.
Together these eliminate both the "real" contention (at the BRouter
level) and the wasted work (BRouter computing a route the client no
longer cares about).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds BROUTER_URL=http://10.0.1.10:17777 (vSwitch) to SOPS so the Planner
starts routing through the dedicated host's Caddy sidecar instead of the
in-tree flagship BRouter. Flagship BRouter stays warm for the 48h
soak/rollback window.
Pre-flight from the flagship confirmed the new host answers 200 with the
auth header and 403 without.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end verified on the dedicated host:
- All 4 containers up (brouter 1.7.9, caddy, cadvisor, promtail)
- Planet segments seeded (~10 GB in 1 min, not hours)
- Auth enforced: 403 without header, 200 with header from flagship
- Planner live with BROUTER_AUTH_TOKEN wired (module-level guard passed)
Remaining:
- 7.4 Flip BROUTER_URL in SOPS (the actual cutover)
- 7.5 Remove flagship brouter after 48h soak
- 7.6 Document rollback in final PR description
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes after the first cd-brouter run on the dedicated host:
1. **BRouter 1.7.8 → 1.7.9** (`docker/brouter/Dockerfile`). Planet RD5
segments on brouter.de are now version 11; 1.7.8's `lookups.dat`
is v10, causing `lookup version mismatch (old rd5?)
lookups.dat=10 E10_N45.rd5=11` on every route request.
2. **cd-brouter.yml: docker login to ghcr.io before pull**.
ghcr.io/trails-cool/brouter is private, and the dedicated host's
Docker daemon isn't logged in by default. Extract DEPLOY_GHCR_TOKEN
from SOPS at runner side, pass to the SSH step via envs, and
`docker login` before `docker compose pull`. Credential is
`::add-mask::`-ed so it doesn't show in logs.
3. **Drop custom healthcheck** on the brouter service. The image
strips wget/curl post-build, and /bin/sh in the base doesn't
support /dev/tcp, so there's no in-image way to do an HTTP probe.
Real health is observed via Caddy's upstream 502 behavior on
outage and the Planner-side `brouter_request_duration_seconds`
metric. caddy's `depends_on` drops from service_healthy to
service_started.
End-to-end verified on the dedicated host after applying the compose
fix manually:
- Caddy enforces auth: 403 without header, proxies with.
- BRouter 1.7.9 will resolve the segment-version error once the image
is rebuilt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands sections 6 and 8 of relocate-brouter-to-dedicated-host on top
of the host compose in #291.
## Observability (section 6)
- **Prometheus**: new `brouter-cadvisor` job scraping
`10.0.1.10:8080` over the vSwitch, labeled `host="brouter"`.
- **cAdvisor sidecar** on the dedicated host's compose
(`--docker_only --whitelisted_container_labels=trails.cool.service`)
so metrics only cover trails containers, never the operator's
unrelated workloads on the shared box.
- **Promtail sidecar** on the dedicated host, Docker SD with relabel-
drop on missing `trails.cool.service` label, pushing to flagship
Loki at `http://10.0.0.2:3100/loki/api/v1/push`.
- **Flagship compose**: Loki now publishes port 3100 on the vSwitch
IP only (10.0.0.2:3100) — Hetzner Cloud firewall blocks it from
the public internet.
- **Grafana dashboard**: `brouter.json` — scrape up/down, request
rate (from Planner-side `brouter_request_duration_seconds`),
p50/p95/p99, container memory/CPU, Loki logs panel.
- **Alert**: `brouter-scrape-down` fires on
`up{job="brouter-cadvisor"} < 1 for 2m`; `noDataState: Alerting`
so a total scrape failure still pages.
Operator needs one UFW rule on the dedicated host for the cAdvisor
port — documented in `infrastructure/brouter-host/README.md`.
## Documentation (section 8)
- `CLAUDE.md` — hosts table + updated deployment table with SSH
targets per workflow; BRouter host SSH is `-p 2232 trails@...`,
different key.
- `docs/architecture.md` — Hosting section rewritten to cover both
hosts, vSwitch boundary, and the observability-scoping rationale
for the shared dedicated host.
- `docs/deployment.md` (new) — full operator runbook: host layout,
first-time BRouter provisioning, SOPS rotation (including the
macOS `SOPS_AGE_KEY_FILE` gotcha), cutover procedure with
rollback, manual workflow triggers.
Task 8.4 (infrastructure/README.md) skipped: that file doesn't
exist and the ground is covered by brouter-host/README.md +
docs/deployment.md.
## Validation
- `docker compose config` on both the flagship and brouter-host
compose files — both validate.
- `pnpm typecheck`, `pnpm lint`, `pnpm test` — all clean (full
turbo cache hits; no code changes in this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands sections 3-5 of the relocate-brouter-to-dedicated-host change:
everything needed to run BRouter on the dedicated Hetzner Robot host
and have the Planner talk to it with the shared-secret header. Does
NOT flip the cutover — the flagship BRouter stays warm during soak.
## BRouter host compose (section 3)
New `infrastructure/brouter-host/` — a standalone compose project that
runs as the `trails` user on `ullrich.is`:
- `docker-compose.yml` — brouter + caddy sidecar. BRouter has no host
port; caddy binds only to `10.0.1.10:17777` (vSwitch IP). Every
service explicitly overrides the host's default Loki logging driver
to `json-file` so logs don't leak to the operator's personal Loki.
- `Caddyfile` — single-purpose reverse proxy that requires
`X-BRouter-Auth: ${BROUTER_AUTH_TOKEN}` on every request. `auto_https
off` (vSwitch-only); default access log format omits request
headers, so the token is never written to disk.
- `download-segments.sh` — crawls brouter.de, pulls planet-wide RD5
tiles via `wget -N` (incremental). Idempotent, safe to cron.
- `README.md` — one-shot provisioning + token rotation + rollback
notes.
`docker/brouter/Dockerfile` is patched to honor `JAVA_OPTS` (was
hardcoded `-Xmx1024M` in CMD). Default keeps the flagship's current
heap; compose on the dedicated host overrides to `-Xmx8g` for planet
scale on a 32 GB box.
## Planner shared-secret header (section 4)
`apps/planner/app/lib/brouter.ts`:
- Module-level guard: throws at startup in production if
`BROUTER_AUTH_TOKEN` is unset.
- `authHeaders()` helper (reads env at call time, so tests can
`vi.stubEnv` without module reset).
- Header attached on both `computeRoute` (per-segment) and
`computeSegmentGpx`.
3 new unit tests cover header attachment + the no-token path.
`infrastructure/docker-compose.yml` passes `BROUTER_AUTH_TOKEN` to
the Planner service, and makes `BROUTER_URL` overridable via SOPS so
the cutover is a one-variable flip.
## cd-brouter workflow (section 5)
Rewritten to deploy to the dedicated host:
- SSH as `trails@${BROUTER_DEPLOY_HOST}` on port
`${BROUTER_DEPLOY_SSH_PORT}` (2232) using
`${BROUTER_DEPLOY_SSH_KEY}`.
- Decrypts SOPS, extracts ONLY `BROUTER_AUTH_TOKEN` into a `.env`
file, scp'd alongside the compose project.
- `paths:` trigger now includes `infrastructure/brouter-host/**`.
- Segment download is NOT run here — first-time seed is a manual
operator step (multi-hour). Routine re-runs are cron-able on the
dedicated host.
- Grafana annotation step preserved (reaches flagship Grafana as
before).
## What's NOT here
- `brouter:` service on the flagship is intentionally left in place
(removed in section 7.5 after the 48 h soak window post-cutover).
- Observability (section 6) — Prometheus scrape + Loki shipping from
the dedicated host — comes in a follow-up PR.
- Cutover itself (section 7) — flip `BROUTER_URL`, verify, remove the
flagship brouter — is an operator action gated on first-time
provisioning + smoke testing.
## Verification
`pnpm typecheck && pnpm lint && pnpm test` all clean; planner build
passes (the CI regression from #286 was fixed in #290).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the shared-secret header value consumed by the Planner
(as X-BRouter-Auth outbound) and by the BRouter host's Caddy
sidecar (enforced inbound). Lives in secrets.app.env only;
cd-brouter.yml will also read from this file, making it the
single source of truth. secrets.infra.env is intentionally NOT
updated — cd-infra no longer touches BRouter after the move.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an OpenSpec change scoping the relocation of BRouter from the
co-located flagship (cx23, 4 GB RAM, 40 GB SSD, Europe segments only) to
a dedicated Hetzner Robot host in the same datacenter, with private
connectivity over Hetzner vSwitch #80672 (VLAN 4000).
This first PR only lays the network prerequisite:
- Terraform: a Hetzner Cloud Network (10.0.0.0/16) with a cloud subnet
(10.0.0.0/24) hosting the flagship at 10.0.0.2, and a vSwitch subnet
(10.0.1.0/24) bridged to Robot VLAN 4000. The dedicated host's VLAN
sub-interface (10.0.1.10 on enp4s0.4000) is configured out-of-band via
netplan and is not Terraform-managed.
- lifecycle { ignore_changes = [user_data] } on the flagship server to
prevent the Hetzner provider's post-1.45 user_data hash drift from
triggering a spurious full-server replacement on unrelated applies.
- OpenSpec change with proposal, design, specs (delta for
brouter-integration / infrastructure / observability /
security-hardening), and tasks; Section 1 (pre-flight) is checked off
with operator notes.
Verification: ping both directions across the vSwitch is 0% loss,
sub-ms latency; dedicated host's VLAN config persists across reboot
(verified ~60 s to restore private reachability).
Follow-up PRs will land the BRouter host compose project, Planner
shared-secret header, CD workflow retarget, observability wiring, and
the cutover.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route files in React Router only get server-code stripped from a
specific set of exports (loader/action/middleware/headers). The
overpass failover work added a `fetchWithFailover` named export to
`app/routes/api.overpass.ts` for unit tests, but that export pulls
in `metrics.server` (prom-client registry, etc.) — which then
leaks into the client bundle and fails the production build with:
[plugin react-router:dot-server]
Error: Server-only module referenced by client
'app/lib/metrics.server' imported by route 'app/routes/api.overpass.ts'
Move the upstream fetch logic and its config constants into
`app/lib/overpass.server.ts`. The `.server.ts` naming makes the
server-only boundary explicit, and tests now import from the lib
instead of from the route. The route keeps only its `action` export
(plus the per-instance LRU cache, which is internal to the module
and not exported).
Tests: 85 passing (no behavior changes). Build: planner + journal
both succeed. Typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The gauge was maintained by conditional inc on "this sessionId isn't
in the in-memory `docs` Map yet" and dec on "last client for the
sessionId left." Because `docs` caches entries indefinitely — even
after every client disconnects — a reconnect found the doc still
cached, skipped the inc, then decremented again on the next
disconnect. Every reconnect-after-last-disconnect leaked one
decrement. After a day of normal reload / wifi-blip / tab-hibernate
patterns the gauge hit -182.
Replace the transitions with a `set()` derived from the live `conns`
map: count distinct session ids with at least one open WebSocket.
Self-correcting, no drift possible.
Extract `countActiveSessions()` as a pure helper + unit tests
including a regression case that simulates repeated reconnect/
disconnect cycles on one session and asserts the count never goes
negative.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Today both proxies are effectively open to anyone who can set an
Origin header for trails.cool — a third party can use us as a free
BRouter/Overpass relay. Require a live planner session on every call
so abuse traffic costs the scraper a session row (observable,
revocable) before they can issue a single query.
Server:
- New `requireSession(id)` helper — returns the session row or a 401
Response. Reused by both route handlers.
- `/api/route`: `sessionId` in body is now required and verified;
rate-limit key always falls back to the session id.
- `/api/overpass`: new `X-Trails-Session` header, verified. Header
keeps the session out of the request body so the body-keyed cache
is unaffected.
Client plumbing:
- `useRouting(yjs, sessionId)` — sessionId goes into the /api/route
body.
- `usePois(sessionId)` → `queryPois(..., sessionId)` → `X-Trails-Session`
on the proxy call.
- `PlannerMap` + `YjsDebugPanel` gain a `sessionId` prop from
`SessionView`.
Journal server-to-server:
- Demo-bot and `/api/v1/routes/compute` now POST `/api/sessions` to
mint a throwaway planner session, then cite it on the forwarded
`/api/route` call. Planner's `expire-sessions` cron cleans these up
(7d window) so nothing needs explicit teardown.
Tests:
- 5 unit tests for `requireSession` covering missing / empty /
non-string / unknown-session / valid-session cases.
- Two integration E2E tests document the 401 for missing session on
each proxy.
- Pre-existing `/api/route` integration tests updated to mint a
session first.
Caveat: existing browser tabs lose their /api/route ability until
reload (the old JS doesn't know to send sessionId). Acceptable for
an anonymous planner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public Overpass instances go bad without warning — we just lived
through a day where overpass.private.coffee (our single upstream)
returned 504s after 60s while lz4.overpass-api.de served the same
query in 0.6s. Survive that by trying a list of upstreams in order
until one responds usefully.
Server changes:
- OVERPASS_URLS env — comma-separated list, tried in order. Falls
back to OVERPASS_URL for backward compat, then to a built-in
default pair (lz4.overpass-api.de, overpass-api.de).
- Per-upstream timeout of 10s via AbortSignal.timeout, so a saturated
instance fails over in seconds, not minutes.
- Client abort propagation via AbortSignal.any — if the browser
cancels (e.g. user panned the map), the in-flight upstream fetch is
aborted too. Stops wasting upstream capacity on requests nobody
wants.
- Rate-limited-body detection (Overpass returns 200 with a
`rate_limited` marker when throttling) triggers failover.
- Per-upstream labels on overpass_upstream_requests_total and
overpass_upstream_duration_seconds so the dashboard can break out
health + latency by instance. Histogram buckets extended to 30s.
Compose: OVERPASS_URLS passthrough with the default pair hard-wired,
overridable via SOPS.
Tests: 8 cases covering the new fetchWithFailover helper — happy
path, 5xx failover, rate_limited failover, network error failover,
timeout tagging, all-upstreams-fail, client abort stops the loop,
per-attempt latency observation.
Follow-ups left out of scope:
- Client-side debounce (UI concern).
- Moving `.observe()` after body read for accuracy in measurement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-boss v11 renamed every concatenated-lowercase timestamp column
(`createdon`, `completedon`) to snake_case (`created_on`,
`completed_on`). Our service-health dashboard and failed-job alert
still referenced the v10 names, so after the v12 upgrade those
queries error out silently.
Fix the three affected queries:
- alerts.yml: failed-job alert
- service-health.json: "Recent Failed Jobs" table
- service-health.json: "Job Queue — Completed/hour" time series
The `state` / `name` / `output` columns stay the same, and the `state`
enum still accepts `'failed'` / `'completed'` literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.
## Breaking changes in our usage surface
### v12: default export removed → named `PgBoss` export
Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
re-exports `Job` at the package root in v12)
### v11: queue names restricted to `[A-Za-z0-9_.-]`
Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune` → `demo-bot-prune`
The planner's `expire-sessions` was already valid.
### v12: minimum Node 22.12
Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.
## Regression guard
Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.
## Prod rollout note
Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.
## Verified
- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface net for the class of bug we hit in #282 — a POI test silently
relied on a live Overpass server for months because its `page.route`
mock was targeting a URL the browser no longer hit directly. CI stayed
green on coincidence (real OSM data happened to satisfy the "any
marker renders" assertion) until upstream behaviour drifted.
Add a shared Playwright fixture (e2e/fixtures/test.ts) that installs a
catch-all `page.route("**", ...)` before each test. Requests to
localhost + known tile CDNs pass through via `route.continue()`;
anything else is aborted and accumulated. At test teardown, a non-empty
blocked list throws, failing the test with the offending URLs and a
pointer to the fixture.
All seven e2e *.test.ts files updated to import test/expect (+ types)
from ./fixtures/test.
Full suite passes with this fixture in place (50/50 at --workers=2;
catch-all never fires, meaning no test currently relies on an
unallowlisted external service).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test was added in 0ff04aa (Apr 11), back when the browser called
overpass-api.de directly. When a4df5a4 (Apr 18) moved POI queries
behind the planner's `/api/overpass` proxy, the test's
`page.route("**/api/interpreter", ...)` mock became dead code —
Playwright can only intercept browser traffic, and the browser now
only sees `/api/overpass`. The real upstream got hit and didn't
return a drinking_water node at exactly 52.52, 13.405, so zero
markers rendered and the test failed.
Mock `/api/overpass` instead. Response body shape is unchanged
(the proxy is transparent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Commit subject prefixed with "[github-actions]" so the audit trail
is obvious at a glance in `git log`.
- GH_TOKEN exposed as an env var on the commit step so the PAT is
also available to any `gh` invocations the step might grow, and the
token plumbing is visible at the site where the push happens — not
only up at the checkout step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous comment claimed `git push` reads the token via a git
credential helper wired into $RUNNER_TEMP. I don't actually know that's
how it works end to end — only that checkout stashes the token there
and that subsequent git ops in the same workspace end up authenticating
as the PAT. Describe just the observable contract, not an unverified
internal path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Current actions/checkout stores the token in $RUNNER_TEMP and wires a
git credential helper, rather than writing the token into .git/config's
http.extraheader. The mechanism that makes `git push` pick up the PAT
is unchanged at the caller's level; the comment was just wrong about
where the secret lives on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`actions/checkout` has `persist-credentials: true` as its default, but
that default is the load-bearing part of how the later `git push`
authenticates — it's what writes the token into .git/config's
http.extraheader. Making it explicit so a reader of the YAML can see
the mechanism without having to know the action's defaults.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the workflow fell back to GITHUB_TOKEN when the PAT wasn't
set — which was silently broken: the push succeeds but GitHub's
anti-workflow-loop safeguard means no new CI runs on the dedupe commit.
Reviewers see stale green CI and the dedupe itself isn't tested until
the next dependabot rebase.
Make the PAT a hard requirement: preflight check fails with a clear
message when the secret is missing, and the checkout step uses it
(which is what persists auth for the later `git push`). Intent is now
obvious from the YAML.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v11 is a breaking API rework (renamed components, restructured props,
event payloads on nativeEvent) and requires React Native's new
architecture. Doing it as a manual migration instead of taking
dependabot's #268 because the renames need code changes.
Changes in apps/mobile:
- RouteMap.tsx — switch to named imports; rename MapView→Map,
ShapeSource→GeoJSONSource (with shape→data), LineLayer→Layer
(with type="line" and paint instead of style), MarkerView→
ViewAnnotation (with coordinate→lngLat); Camera uses
initialViewState with bounds as [w,s,e,n] and a separate padding
object; onLongPress reads lngLat from event.nativeEvent.
- app.config.ts — set `newArchEnabled: true`. MapLibre RN v11
supports only the new architecture, so Fabric/TurboModules must
be on for the Expo prebuild. Cast the config type since Expo SDK
55's ExpoConfig typings don't yet include the field (the runtime
does).
- package.json — `^10.4.2` → `^11.0.0`.
Supersedes dependabot #268; it'll auto-close once this merges.
Runtime verification requires an EAS dev build (native deps
changed + new arch flipped). JS-only surface typechecks + lints +
unit tests clean locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Created by the Claude Code scheduled-wakeup runtime when a dynamic
loop is active. Pure ephemeral state; no reason to track it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pnpm install after a dependabot bump keeps duplicate versions of
packages that happen to be pinned via overlapping semver ranges. For
singleton-ish libraries (i18next, react-i18next) that's a latent bug:
the server's module instance and the client's module instance each
hold their own state, and SSR output doesn't match CSR.
#272 ran into this with an i18next patch bump — the bumped version
stayed on some dep paths while other paths kept the old version. A
manual `pnpm dedupe` collapsed them and hydration worked again.
This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
pushes the resulting lockfile update back to the PR branch so CI
runs against the deduped tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five journal API route handlers had the same inline lambda:
parsed.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}))
Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.
Public error-response shape is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:
- z.string().url() → z.url() (3 sites)
- z.string().uuid() → z.uuid() (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)
Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.
All 147 tests still pass.
The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical bump across the three packages that pull zod directly:
- packages/api (8 schema files, ~300 lines)
- apps/journal (persona schema in demo-bot.server.ts, 5 API route
handlers that introspect parsed.error.issues)
- apps/mobile (declared dep; no active imports yet)
No code changes required. The legacy methods we use (.url(), .uuid(),
.datetime() on strings) still parse identically in v4 via the compat
shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue
introspection (parsed.error.issues.map(i => ...)) keeps the same shape
at the fields we read (path, message, code).
All 147 tests across journal + api + packages pass against zod 4.3.6.
Follow-ups out of scope for this PR:
- Modernise to top-level validator helpers (z.url(), z.iso.datetime()).
The legacy string methods are deprecated but not removed.
- Consider migrating error introspection to the richer v4 shape (.path
is now branded, .input is new) if we ever want structured error
surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine panels at trails-demo-bot covering:
- Current synthetic route/activity counts + time-since-last-walk
- Gauge time series over the configured retention window
- Walks per day + distance distribution
- Loki log stream filtered to `demo-bot` messages
- Recent walks table with name, distance, ascent
Provisioned via the existing grafana/dashboards directory — no extra
wiring needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planner is the BRouter client in this architecture — route
compute, rate limiting, and the eventual move of BRouter off-box all
live there. The journal had two places that called BRouter directly
(demo-bot and /api/v1/routes/compute) and both were broken on prod
because the journal service has no BROUTER_URL.
Fix:
- Extend the planner's /api/route with an optional `format: "gpx"`
that returns BRouter's raw GPX (for server-to-server callers that
don't need the way-tag enriched GeoJSON).
- Point the journal demo-bot at `PLANNER_URL/api/route` with
`format: "gpx"` instead of calling BRouter directly.
- Point /api/v1/routes/compute at `PLANNER_URL/api/route` instead of
BRouter directly.
Journal no longer needs BROUTER_URL at all. When BRouter moves to a
dedicated host later, only the planner changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Skip major bumps on @types/jest + @types/node so the types stay
aligned with the runtime (jest 29 with @types/jest 29, not 30).
- Skip react-native itself since it's pinned by the Expo SDK and
should only move when we bump Expo. Community react-native-*
libraries stay on the normal weekly cadence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
server.ts imports from ./app/jobs/demo-bot-generate.ts and ./app/jobs/
demo-bot-prune.ts, but the Dockerfile only COPYs app/lib. On prod the
worker crashes on boot with ERR_MODULE_NOT_FOUND and neither the
generate nor the prune cron gets scheduled. Add the jobs dir to the
runtime layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.
Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets DEMO_BOT_ENABLED=true plus DEMO_BOT_RETENTION_DAYS and
DEMO_BOT_REGION in the SOPS env file so the journal worker starts
the generate + prune jobs and bootstraps the Bruno demo user on
next deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.
- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
persona username is already a real user; server declines to
schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.
- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit only added a bullet to the English-only Privacy
Manifest appendix. The formal, binding German sections (which the
policy explicitly declares authoritative) didn't mention visibility
at all — and the paired English summary under each section likewise
didn't.
Add a sentence under §2 "Erhobene Daten und Zwecke → Nutzerinhalte"
explaining the visibility setting, defaults, and world-visibility
consequences. Mirror in the section's English summary paragraph. Keep
the Privacy Manifest bullet as an additional plain-language mention.
Re-rendered docs/legal-archive/privacy-2026-04-20.md; both languages
present (verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.
Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.
Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
centralises the rule: public → anyone; unlisted → anyone on direct
link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
content only.
Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.
Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
for cross-user queries. Existing owner-scoped listRoutes/listActivities
stay — owners see their own content regardless of visibility.
Public profile:
- /users/:username is now truly public. Renders the user's public
routes + activities, 404s when no public content exists AND viewer
isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.
Privacy manifest:
- Added a bullet noting public content is world-visible on profile
and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
snapshot.
Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
- Private route → 404 for logged-out visitor
- Public route → reachable + OG tags present (og:title, og:type,
og:site_name)
- Owner still sees own private content
- Profile 404 when no public content
- Profile renders when at least one public route exists
- Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
WebAuthn virtual-authenticator races under Playwright's default
parallel workers.
- New public-content Playwright project added to config.
Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stacked OpenSpec change proposals for a demoable social layer,
with scope deliberately minimal ("enough to send a URL and have it
open to real-looking content without signup"):
1. public-content-visibility
- Adds visibility enum {private, unlisted, public} on routes +
activities, defaulting to private for every existing row.
- Detail pages (/routes/:id, /activities/:id) become accessible to
logged-out visitors when content is public or unlisted; private
→ 404 (not 403) to avoid existence leaks.
- Broadens /users/:username into a public profile listing only
public routes + activities; 404s when there's no public content
to prevent account enumeration.
- Open Graph / Twitter Card meta on public detail + profile pages.
- Visibility selector in the owner's edit flow.
- Out of scope: follow/follower, cross-user feed, reactions,
federation.
2. demo-activity-bot (depends on #1)
- A single bot user, Bruno the trail dog, seeded on worker startup
when DEMO_BOT_ENABLED=true. Reserved username, sentinel email,
no credentials.
- pg-boss recurring job fires every 90 min, decides-to-walk with
p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at
organic times.
- Each walk: random start + end within inner-Berlin bbox, trekking
only (dogs don't ride bikes), 2-12 km crow. BRouter plans the
route; the route GPX is also attached as the activity's trace.
- Everything inserted with visibility=public, synthetic=true.
- Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old
(default 14). Hard cap of 40 items/14d protects against runaway
growth.
- Small "🐕 demo account" badge on /users/bruno for honesty.
Both changes are artifacts only — no code lands with this PR. Apply
in order after merge: public-content-visibility first, then
demo-activity-bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both are blocked on the mobile-app foundation and on a user base that
actually records activities or needs offline peer sync. With 3 users
and 0 activities on prod today, these are engineering for a
hypothetical population.
Move the artifact sets out of openspec/changes/ (so openspec list
stays clean) and under docs/ideas/ where self-host-overpass already
lives. Each gets a short README documenting:
- current parked status
- revive triggers (when should we look at this again?)
- key constraints not to rediscover (background-GPS privacy review;
BLE dev-build requirement; QR-only subset as a cheaper path)
Reviving is `git mv docs/ideas/<name> openspec/changes/<name>` and
running /opsx:apply, same pattern as self-host-overpass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.
Changes:
- Detect "empty" as no geometry AND no distance (the state every new
route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
/ Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
viewer-only copy), primary "Open in Planner" button (same action as
the existing top-row button, just visually prominent), and a
secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.
No non-empty route loses any UI — those paths stay identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.
Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
request is already on an allow-listed path
(/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
acceptance and bounces to a sanitised returnTo). Same-origin check
on returnTo to avoid open-redirect abuse. Logout button is provided
as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
successful re-accept, missing consent, returnTo sanitisation).
No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
render-legal.ts needs @types/node available to the TS language server
so editors (VS Code, cursor, etc.) don't complain about missing Node
globals. Without a tsconfig in this folder VS Code falls back to a
default config with no types and shows red squiggles on every
process/fs/path reference.
Solution: scripts/ becomes its own pnpm workspace (@trails-cool/scripts),
carrying @types/node as a devDep from the catalog, and a tiny
tsconfig.json with types: [node] and include *.ts. This also wires the
folder into `pnpm typecheck` as task #14, so CI type-checks scripts
alongside every other package. No runtime impact — the workspace is
never bundled or deployed.
README documents the layout, current contents (render-legal.ts +
check-dockerfiles.sh), and how to add new scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The repo is a Node/TS stack, so a Python renderer is out of place.
Port the script to TypeScript, runnable via
`node --experimental-strip-types scripts/render-legal.ts <doc>`, and
update the README to match.
Verified byte-identical output against the Python version before
removing it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a simple legal-archive folder that preserves the full text of the
Terms, Privacy Policy, and Impressum at each version, plus a small
renderer script so future snapshots are mechanical:
python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md
Why:
- Terms are per-user contract. users.terms_version maps each user to a
file in this folder so we can always tell what text they agreed to.
- Privacy is informational but GDPR Art. 13/14 still expects us to be
able to say what the policy disclosed on a given date.
- Impressum archived for symmetry; cheap.
How it works:
- scripts/render-legal.py reads the TSX source, resolves operator.*
placeholders from operator.ts and TERMS_VERSION / PRIVACY_LAST_UPDATED
from legal.ts, strips JSX tags, and prints clean markdown to stdout.
- A README in the folder explains when to bump and when to add a
snapshot (material change only, not typo fixes).
Seeded entries:
- terms-2026-04-19.md
- privacy-2026-04-19.md
- imprint-2026-04-19.md
No automation / CI check yet — trust-based process documented in the
folder's README. Revisit if discipline slips.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer's follow-up: it's not enough to record when a user accepted
the Terms; we also need to record which version of the text they saw.
Changes:
- journal.users gains a nullable `terms_version` text column (nullable
so the three pre-existing users without a version are kept as-is).
- New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single
source of truth, reused by the legal pages' "Last updated" header
and by the registration flow as the value to send/store.
- Registration form posts `termsVersion` alongside `termsAccepted` on
all three relevant steps (start, finish, register-magic-link).
- API route validates that `termsVersion` is a non-empty string on
any step that requires terms, and forwards it to the auth server.
- auth.server finishRegistration and registerWithMagicLink now take
`termsVersion` and persist it on the users row.
- journal-auth spec gets a new scenario for version storage and a
rejection scenario for missing version.
PRIVACY_LAST_UPDATED is also exported from the same module and used
by the Privacy page header, keeping both pages on a single legal.ts
source of truth for "last updated" labels. Privacy is not per-user
stored — it's informational, not contract.
Existing users have NULL terms_version; if we ever prompt them to
re-accept updated Terms, we can backfill with the version they
re-accept at that point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The interim proxy (#239/#240/#242/#243) covers the day-one needs for
Overpass — User-Agent compliance via server-side proxy, same-origin +
rate limit + cache with coalescing, bbox quantization, observability.
Further work on self-hosting the upstream is no longer urgent.
Move the full OpenSpec artifact set out of openspec/changes/ so it
doesn't clutter the active change list, and park it under
docs/ideas/self-host-overpass/ as a reference for when we revive it.
Adds a short README at the new location capturing:
- current interim solution
- triggers that would justify reviving (rate limits, >1 req/s, etc.)
- key decisions already made (Hetzner-to-Hetzner firewall model,
DOCKER-USER chain, capacity ceiling at DACH on current box)
- how to switch when the time comes (flip OVERPASS_URL, no client
changes)
The files were never committed in the first place (WIP in the working
tree through the proposal session), so no git history to preserve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes one contradiction and closes five gaps where the code (after the
legal-review rounds in #235, #246, #247) now exceeds what the spec
required.
Contradiction fixed:
- Alpha banner was specified as dismissible via sessionStorage, with
"reappears in a new session". In reality the dismiss control and
sessionStorage were removed during privacy hardening — the banner
is now always visible. Updated the requirement to match.
New scenarios documenting what's already shipped:
- Impressum: EU ODR link + non-participation notice; English
translation noted as informational.
- Terms of Service: service availability, minimum age 16 for Journal
accounts, content usage-rights licence, acceptable-use rules
(including no mass automated data extraction), German-law-tiered
liability, bilingual structure.
- Datenschutzerklärung: explicit data-categories-and-purposes list;
Terms acceptance classified under Art. 6(1)(b) and explicitly NOT
(1)(a); dedicated server-logs section (≤14 days, Art. 6(1)(f));
storage-durations list; alpha-reset caveat; per-third-party
disclosures with transfer details (Sentry, OSM tile servers,
Overpass proxy, BRouter, SMTP); Art. 44 ff. DSGVO third-country
transfer note for Sentry with SCC basis; Berlin supervisory
authority; Privacy Manifest appendix.
- Footer: now includes a link to the source repository on both apps.
No functional changes — only spec text catching up with what is live
in production.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Privacy:
- Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry
is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen
werden nicht aktiv gespeichert" per reviewer's preferred wording.
Drop the unverified "Frankfurt" hosting claim; keep
Auftragsverarbeiter framing.
- SMTP → "externer SMTP-Dienst" per reviewer's phrasing.
- Storage-durations section: add explicit alpha-reset caveat so it
aligns with Terms §4 (database resets).
Terms:
- § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser
Dienst" (leaves pricing model open without promising free-forever).
- § 7 acceptable use: scraping clause softened to "kein massenhaftes
automatisiertes Auslesen von Daten" (drops the specific "to build
competing services" qualifier).
Imprint:
- Normalise address formatting: multi-line across both DE blocks and
the EN § 5 TMG block (§ 18 MStV and EN block previously used a
comma-separated single line). One style only, everywhere.
- Add whitespace-nowrap to the mailto link so the email address
never breaks mid-word on narrow viewports.
No changes to legal bases, server-logs section, storage durations
themselves, or any third-party other than Sentry + SMTP wording.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
required statement that we don't participate in consumer dispute
resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.
Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
purposes → Legal bases → Server logs → Storage durations → Third
parties → Rights → Complaint authority → (plain-language) Privacy
Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
/ status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
browser sends IP+UA directly to OSM tile servers, with OSMF privacy
policy link), Overpass (via our proxy, upstream only sees our
server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
aligned with current reality (no cookies/localStorage/sessionStorage
in Planner; no replays; no PII via Sentry).
Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
modify/limit/interrupt/discontinue the service or individual
accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
operator a limited, non-transferable licence to store/process/
display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
(standard German DACH tiering: unlimited for intent/gross neg./life-
body-health; limited to foreseeable damages on cardinal-duty breach
for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original formulas used clamp_min(denominator, 1) to avoid divide-
by-zero, but that inflates the denominator when request rate is < 1/s.
Real example from prod: ~0.04 req/s, all successful, displayed as
1.02% health (red) instead of ~100%.
Switch to (numerator or vector(0)) / denominator:
- Missing "hit" series (no cache hits yet) → numerator resolves to 0
instead of empty set, so the stat shows 0% instead of "No data".
- Zero traffic (empty denominator) → whole expr is empty, Grafana
renders "No data", which is the correct behaviour for a health
stat when nothing's happening.
- Non-zero low traffic → ratio is true ratio, not artificially
depressed by the clamp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three sections drifted from the actual behaviour of the deployed apps:
Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.
Sentry section:
- Drops the "session replays on error" claim — replay integration is
not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
are both 0.
- Documents the actual scope: Journal server-side always; Journal
client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
and that only the user ID is attached to logged-in Journal errors.
Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
through our own /api/overpass so the upstream host sees our server,
not end users.
Also bumps "Last updated" to today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The same-origin check compared the browser's Origin header against
new URL(request.url), but inside the Planner container request.url
is http://planner:3001/... while the browser sends Origin
https://planner.trails.cool — so production always 403'd.
Trust Caddy's X-Forwarded-Host and X-Forwarded-Proto headers when
present (both are set by Caddy's reverse_proxy directive by default)
to reconstruct the external origin the browser actually connected to.
Falls back to request.url for dev and any direct (non-proxied) access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Near-identical viewports from different collaborators (slightly
different pan/zoom because Yjs awareness doesn't enforce pixel-exact
alignment) previously produced byte-different Overpass queries and
therefore different server-side cache keys. Each client missed cache
and triggered its own upstream fetch.
buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding
outward (south/west floor, north/east ceil) so the viewport is always
covered, and formats coords with 3 decimals for a stable string.
Trade-offs:
- Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on
each side. For a zoom-12 viewport (~11 km wide) that's <10% extra
data; bounded by the existing `[maxsize:1048576]` + `out 100` limits.
- Coordinates in the query are now 3-decimal strings regardless of the
caller's precision — existing buildQuery test updated to match.
Two new tests cover the cache-alignment property and the outward-
expansion invariant. Hit ratio impact will be visible on the Grafana
"Overpass Cache Hit Ratio" stat added in the prior PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move completed legal-disclaimers change to archive, apply deltas to
main specs: append "Terms acknowledgement at signup" requirement to
journal-auth and create the legal-disclaimers capability spec
covering Impressum, Terms, Datenschutzerklärung, alpha banner, and
footer links.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds four metrics surfaced via the existing /metrics endpoint:
- overpass_cache_events_total{result=hit|miss|coalesced}
- overpass_cache_size (gauge)
- overpass_upstream_duration_seconds (histogram)
- overpass_upstream_requests_total{status} — covers 2xx/4xx/5xx and
an explicit "error" label for network-level failures
Grafana dashboards/planner.json gets a new row:
- Upstream health (5m 2xx success ratio, red <90%, green >99%)
- Cache hit ratio
- Cache size
- Upstream p95 latency
Plus timeseries panels for cache event breakdown, upstream status
over time, and p50/p95/p99 upstream latency.
The success-rate formula uses clamp_min(..., 1) on the denominator so
it doesn't NaN when traffic is zero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds apps/planner/app/routes/api.overpass.ts — a same-origin, rate-limited
server-side proxy that forwards Overpass QL to the upstream endpoint
configured via OVERPASS_URL (default: overpass.private.coffee).
Motivation:
- private.coffee's best-practices require a meaningful User-Agent
identifying the project. Browsers cannot set User-Agent on fetch()
(forbidden header), so the request has to originate server-side.
- Collaborative sessions commonly have N clients pan/zoom the same map,
so the same bbox query arrives multiple times within seconds.
- Setting up the proxy now lets us swap OVERPASS_URL to a self-hosted
Overpass later without client changes.
What the proxy does:
- Sets User-Agent "trails.cool Planner (https://trails.cool; legal@trails.cool)"
- Enforces same-origin via the Origin header
- Rate-limits per client IP (120 req/min)
- In-memory LRU cache of upstream responses keyed on the form-encoded body
(TTL 10 min, max 200 entries)
- Coalesces concurrent misses for the same key so N simultaneous clients
in one session incur exactly one upstream call
Client change: apps/planner/app/lib/overpass.ts POSTs to /api/overpass
instead of iterating over public Overpass endpoints. Fallback list
removed; resilience is now the proxy's responsibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the Planner's primary Overpass endpoint from
overpass.kumi.systems to overpass.private.coffee. private.coffee is a
privacy-focused public Overpass instance that doesn't log queries,
which fits the Planner's privacy-first principle better than the
previous default. Keep overpass-api.de as the silent fallback for
resilience while the self-host-overpass change is in flight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@types/node is a devDependency of the package but tsc only auto-picks
it up locally due to root node_modules hoisting. CI runs with pnpm's
isolated resolution so the dep isn't visible to tsc without an
explicit types field — matching what packages/jobs/tsconfig.json does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sentry.client.ts: add stopSentryClient() that awaits Sentry.close() so
the SDK tears down when a user logs out. Hub methods become no-ops
until initSentryClient() is called again on next login.
- root.tsx: call stopSentryClient() from the user-effect when user === null.
- Add sentry-config to journal + planner Dockerfiles (was missing after
#237 introduced the new workspace package, breaking the Dockerfile
package check).
- Footer: replace inner <nav> with <div> — nested nav landmarks broke
the journal "nav bar shows on all pages" e2e test (two navigation
roles on the page).
- e2e auth: tick the required ToS checkbox in the shared registerUser
helper so passkey registration tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces @trails-cool/sentry-config with three helpers that encode
the common Sentry.init options:
- nodeSentryConfig(appContext) for the two HTTP servers
- browserSentryConfig(appContext, env) for the Journal client
- mobileSentryConfig(__DEV__) for the React Native app
- drop404s beforeSend for servers
Also fixes three inconsistencies found in the audit:
- Planner server was missing sendDefaultPii: false (IPs could leak)
- Planner entry.server.tsx didn't call Sentry.captureException on SSR
render errors; Journal's SSR did
- Sentry.init location now matches across apps (both in server.ts;
Journal previously had it in app/entry.server.tsx, which meant init
ran lazily on first request rather than at server startup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Sentry init runs but is disabled (local dev / CI), log a debug
message so developers can see Sentry *would* send in production.
Also explicitly set replaysSessionSampleRate/replaysOnErrorSampleRate
to 0 in the client and mobile configs. Replay integration isn't
installed, so this is documentation — it prevents future accidental
enablement if an integration is ever added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- RouteMap.tsx referenced undefined MapLibreRN namespace, causing
typecheck to fail on main and in every downstream PR
- metro.config.js is CommonJS, exclude from ESLint flat config
Changes that were pushed directly to main earlier bypassed CI and
broke both typecheck and lint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Proposal, design, specs, and tasks for German legal compliance (Impressum,
Datenschutzerklärung, ToS) and alpha-stage disclaimers at signup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.
- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Metro couldn't resolve hoisted packages (@maplibre, @gorhom, etc.)
because it only looked in apps/mobile/node_modules. Configure
watchFolders and nodeModulesPaths to include the monorepo root.
Also revert MapLibre fallback — direct import is correct, the issue
was Metro resolution, not a missing native module.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use try/catch require() for MapLibre so the app works without it
(shows fallback UI). Needed when running on builds that don't include
MapLibre native module (old EAS builds, Expo Go).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The route editor called fetch() directly for /api/v1/routes/compute
without the bearer token. Now uses the API client which injects auth
headers and handles 401 auto-refresh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Waypoints (3)" assertion timed out at 5s in CI. The waypoints
load from URL params but the sidebar needs BRouter mock response +
Yjs sync before updating. Increase to 15s to match other assertions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Android 9+ blocks cleartext HTTP by default. Enable usesCleartextTraffic
so the emulator can reach the Journal dev server at http://10.0.2.2:3000.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Android emulator has its own network stack — localhost refers to the
emulator, not the host. Use 10.0.2.2 (host loopback alias) on Android.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- prebuild: generate native ios/ and android/ projects
- prebuild:clean: regenerate from scratch
- run:ios / run:android: build and run locally with Xcode/Gradle
- Add ios/ and android/ to .gitignore (CNG-generated)
Allows building locally without EAS credits:
pnpm --filter @trails-cool/mobile prebuild
pnpm --filter @trails-cool/mobile run:ios
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace custom View-based bottom sheet with @gorhom/bottom-sheet:
- Native gesture-driven drag, snap points, pan-down-to-close
- Proper safe area handling built in
- Wrap app with GestureHandlerRootView
- Add react-native-reanimated plugin to Expo config
Adds react-native-reanimated and react-native-gesture-handler as
native dependencies (requires new EAS build).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Close button was overlapping the home indicator. Now uses
useSafeAreaInsets() for dynamic bottom padding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The route polyline wasn't visible because segments were initialized
empty. Now extractSegmentsFromGpx() parses trkpt coordinates from
the GPX string via regex on init, so the route shows immediately
in both view and edit modes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The check now also verifies that source paths referenced by
COPY --from=build lines actually exist in the repo. This would have
caught the premature app/jobs COPY that broke the journal build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The journal doesn't have an app/jobs directory yet — that will arrive
with the Komoot import PR. The COPY line was added prematurely and
breaks the Docker build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a script that verifies every packages/*/package.json is listed in
all app Dockerfiles. Catches the recurring issue where adding a new
workspace package breaks production because the Dockerfile deps stage
doesn't know about it.
Also fixes two pre-existing missing packages in the planner Dockerfile
(api, map-core) caught by the new check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both Dockerfiles were missing packages/jobs/package.json in the deps stage,
causing pnpm install to skip pg-boss. Also copy app/jobs directories into
the runtime stage so job handlers are available at runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pg-boss v10 requires explicit queue creation via `createQueue()` before
`schedule()` or `work()` can reference a queue. Without this, the planner
crashes on startup in fresh databases (like CI) with "Queue not found".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Proposal, design, specs, and tasks for persistent staging instance and
ephemeral PR preview environments on the existing Hetzner server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add @trails-cool/jobs package wrapping pg-boss for durable background job
execution using the existing PostgreSQL database. Wire up planner session
expiry as the first scheduled job (hourly, 7-day TTL) — this was previously
dead code that never ran. Add placeholder worker to journal for future
Komoot import and federation jobs.
Infrastructure: grant grafana_reader access to pgboss schema, add job queue
health panels to Service Health dashboard, add failed job alert.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The smoke test failed in CI with version mismatch between react
(19.2.5 from catalog) and react-test-renderer (19.2.0 transitive).
Adding it explicitly ensures they stay in sync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The journal now depends on @trails-cool/api which depends on zod.
The Dockerfile was missing these package.json files, causing pnpm
install to skip them and the build to fail.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The journal now depends on @trails-cool/api (added in PR #217) which
depends on zod. The Dockerfile was missing the package.json for api
and map-core, causing pnpm install to skip them and the build to fail
with "Failed to resolve import zod".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove SENTRY_DISABLE_AUTO_UPLOAD from all build profiles
- Add @sentry/cli as explicit dependency to fix pnpm module
resolution in EAS cloud builds
- SENTRY_AUTH_TOKEN configured as EAS environment secret
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Initialize @sentry/react-native with dedicated mobile project DSN
- Wrap root layout with Sentry.wrap() for unhandled error capture
- Configure Sentry plugin with org (trails-qq) and project (mobile)
for source map uploads during EAS builds
- Disabled in dev, full tracing in production
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add eas.json with development, preview, and production profiles
- Link project to EAS (projectId: 93c75cae-fecf-4ce5-8cd8-c823760b12e2)
- Add expo-dev-client, expo-constants, expo-linking dependencies
- Remove eas-cli from project deps (use globally)
- Add ITSAppUsesNonExemptEncryption to iOS infoPlist
- Use catalog react with expo.install.exclude to skip version check
- Fix slug to match EAS project registration
- Run pnpm dedupe to resolve duplicate react across workspaces
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Route detail at /routes/:id shows:
- Header with back navigation and route name
- Map placeholder (MapLibre requires EAS dev build)
- Stats row: distance, elevation gain/loss, day count
- Description text
- Version count
- "Edit in Planner" opens Journal web UI
- "Download Offline" button (handler pending Phase 5)
Safe area insets applied to header and scroll content.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Routes tab (Phase 3.1):
- Paginated FlatList fetching from Journal REST API
- Route cards with name, distance (km), elevation gain, day count
- Pull-to-refresh, loading spinner, error state with retry, empty state
- Safe area insets for notch and tab bar
API client:
- Import types from @trails-cool/api instead of hand-written interfaces
- Parse list/detail responses through Zod schemas at runtime
- Add zod as mobile app dependency
i18n:
- Add mobile namespace (en + de) for routes, activities, profile, login
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests the full handler flow with mocked DB: auth guard (401), Zod
validation (400), successful responses, and 404s for missing resources.
Also adds ~ alias resolution to Journal vitest config.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests Zod schema validation for all API endpoints (routes, activities,
compute, uploads), error codes, and cursor pagination logic. 21 new
tests covering the validation layer without requiring a running DB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add all REST API endpoints for mobile app consumption:
Routes:
- GET /api/v1/routes — paginated list with cursor, Zod validation
- GET /api/v1/routes/:id — full detail with GPX, versions
- POST /api/v1/routes — create with Zod-validated body
- PUT /api/v1/routes/:id — update, creates new version
- DELETE /api/v1/routes/:id — returns 204
Activities:
- GET /api/v1/activities — paginated list with cursor
- GET /api/v1/activities/:id — full detail
- POST /api/v1/activities — create with GPX stat extraction
- DELETE /api/v1/activities/:id — returns 204
Supporting:
- POST /api/v1/routes/compute — BRouter proxy
- POST /api/v1/uploads — presigned upload URL generation
Device management:
- GET /api/v1/auth/devices — list connected devices with isCurrent
- DELETE /api/v1/auth/devices/:id — revoke device token
- Store device_name on token exchange
Infrastructure:
- requireApiUser() guard — returns 401 with structured error
- apiError() helper for consistent error responses
- All endpoints use Zod schemas from @trails-cool/api for validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal
Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking
Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TS 5.9 enables noUncheckedSideEffectImports under strict mode, which
errors on CSS side-effect imports (e.g. leaflet/dist/leaflet.css).
These imports are resolved by bundlers (Vite/Metro), not tsc. Set
noUncheckedSideEffectImports: false in the shared tsconfig base.
Reverts the unnecessary leaflet devDependencies on the map package
since this base config fix handles it properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
leaflet, react-leaflet, and @types/leaflet are peer dependencies of
the map package. In CI, pnpm's strict isolation means they aren't
accessible from the package directory during standalone tsc. Adding
them as devDependencies ensures they're installed for typechecking
while consumers still provide them at runtime via peer deps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The leaflet CSS side-effect import fails standalone tsc (works under
Vite). Enable allowArbitraryExtensions so tsc accepts .css imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously only 2-3 workspaces participated in each turbo task. This
expands test, lint, and typecheck to cover all 11 workspaces with
parallel execution and caching.
Test pipeline:
- Move from single root vitest to per-workspace test scripts via turbo
- Shared vitest config (vitest.shared.ts) with passWithNoTests
- Per-workspace configs re-export the shared base
- Mobile uses Jest (jest-expo + React Native Testing Library)
- Add node-environment GPX tests verifying linkedom fallback
- Add i18n mobile init tests
- Exclude apps/mobile from root vitest (uses Jest separately)
Lint pipeline:
- Add eslint lint script to all 9 previously unlinted workspaces
- Standardize all scripts to "eslint ." with shared root config
- Add .expo/ to global ESLint ignores
- Fix lint errors: unused imports in api/types, export parseGpx
Typecheck pipeline:
- Add "typecheck": "tsc" to all 8 packages
- Add @types/node (catalog) to gpx and db packages
- Fix mobile app.config.ts: remove deprecated experiments.monorepo
and newArchEnabled (both default in Expo SDK 55)
- Add allowImportingTsExtensions to mobile tsconfig
Shared package compatibility (mobile-app Phase 1.3):
- Add linkedom as explicit dependency to @trails-cool/gpx
- Add initI18nMobile() export to @trails-cool/i18n
- Confirm @trails-cool/types is pure interfaces (no DOM deps)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switches from expo-router Tabs to NativeTabs (unstable API) which
renders native tab bars with liquid glass on iOS 26 and Material
bottom navigation on Android. Adds SF Symbols and Material icons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Main specs (7): Converted from delta format (## ADDED/MODIFIED) to
proper main spec format (## Purpose + ## Requirements)
Changes (3): Added missing spec files for e2e-test-reorganization,
journal-poi-details, and mobile-nearby-sync
Mobile testing: Added missing scenario for "No Vitest" requirement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New renderer-agnostic package with zero dependencies:
- Tile configs (base layers, overlay layers)
- Color palettes (surface, highway, smoothness, tracktype, cycleway,
bikeroute, elevation, maxspeed) — 8 color maps + 3 color functions
- POI category definitions (9 categories with Overpass queries, icons,
colors, profile mappings)
- Z-index layering constants
- Snap distance constant
Updated 15 consuming files to import from @trails-cool/map-core.
Deleted poi-categories.ts and z-index.ts from the Planner (fully moved).
packages/map re-exports tile configs from map-core for backwards compat.
All 117 tests pass, no user-facing changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mobile-app: Unified React Native + Expo app combining Planner and
Journal. OAuth2 PKCE auth, MapLibre maps, versioned REST API with
Zod schemas, configurable server URL, offline SQLite, Web Push relay
notifications. TanStack Query + Zustand state management. Jest +
Maestro testing. 76 tasks across 5 phases.
map-core-package: Extract renderer-agnostic map definitions (tiles,
color palettes, POI categories, z-index) into @trails-cool/map-core.
Pure refactor preparing for MapLibre on mobile. 27 tasks.
mobile-activity-recording: GPS recording, live stats, HealthKit/Health
Connect export. Separated from mobile-app for independent scheduling.
mobile-nearby-sync: BLE route sync between nearby devices for offline
group riding. QR waypoint sharing as simpler v1. TXQR noted as future.
journal-rest-api spec: Full API contract — endpoints, auth, pagination,
errors, discovery, versioning, BRouter proxy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Synced new spec (chart ↔ map interaction, mobile touch, iOS safe area,
responsive header) to openspec/specs/elevation-map-interaction/.
All 4 artifacts complete. All 14 tasks complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Participant list: hidden on mobile (sm:block)
- Undo/redo buttons: hidden on mobile (sm:flex)
- Connection status: hidden on mobile (sm:inline)
- Header: no-wrap, tighter padding on mobile
- Username input: text-base on mobile to prevent iOS auto-zoom
Keeps profile selector and export button visible on all sizes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
maximum-scale=1 + user-scalable=no prevents the browser UI from
zooming when pinching. Map zoom (Leaflet) still works since it
handles touch events independently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- viewport-fit=cover enables safe area insets in Safari
- Header: pt-[max(0.5rem,env(safe-area-inset-top))] clears the notch
- Elevation chart: pb-[max(0.5rem,env(safe-area-inset-bottom))] clears
the home indicator bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Single touch + drag: scrub the highlight point along the chart,
map dot follows in real-time
- Tap: pan map to that point (10px threshold for movement)
- Two-finger touch: range select — area between fingers highlighted,
on release zooms map to fit that route section
- touch-none CSS + preventDefault prevents page scrolling on chart
Spec updated with mobile touch interaction scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three new interactions:
1. Route hover → chart: Invisible interactive polyline (weight 20,
opacity 0) detects mouse hover on the route, computes cumulative
distance, and highlights the corresponding position on the chart.
2. Chart click → map pan: Clicking the elevation chart pans the map
to center on that point along the route. Uses 5px threshold to
distinguish from drag.
3. Chart drag-select → map zoom: Dragging a range on the chart shows
a blue selection overlay, then zooms the map to fit the route
coordinates in that range. "Reset zoom" button appears to restore
the full route view.
State flow avoids feedback loops: external highlights (from map hover)
set the chart crosshair without re-emitting onHover back to the map.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix blind spot where Caddy 502 errors were invisible: the dashboards and
alerts queried caddy_http_response_duration_seconds (upstream responses only),
missing 502s that Caddy generates itself. Switch to
caddy_http_request_duration_seconds (server-level, all responses).
Add Journal Grafana dashboard with: 502 rate, response codes, request rate
by route, latency percentiles, container restarts/memory/CPU, Node.js event
loop lag and heap, and Loki log panels for errors and Caddy 5xx entries.
Add color coding to Caddy status code panel (green=2xx, blue=3xx, yellow=4xx,
red=5xx). Add log-based error rate panel to the overview dashboard.
New alerts: container restart loop, PostgreSQL connections > 80, application
crash log detection (Loki), and Caddy 502 rate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch journal Docker health check from `node -e "fetch(...)"` to `curl -sf`,
matching the planner. The Node.js process spawn was heavy and prone to timeout
under memory pressure, causing false health check failures and 502s.
Upgrade Promtail from static file scraping to Docker service discovery so logs
get `service` and `container` labels. Parse Pino JSON logs to extract the
`level` label, enabling Loki queries filtered by log level.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
oauth.server.ts used a TypeScript parameter property (`public code: string`
in constructor) which is not supported by Node.js v25's strip-only TypeScript
mode. This caused the journal to crash-loop on startup after seeding OAuth
clients.
Also enable `erasableSyntaxOnly` in tsconfig.base.json so `pnpm typecheck`
catches any future use of non-erasable TypeScript syntax.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
'cycleway' now matches both the highway legend and the Cycleway
dropdown option, causing a strict mode violation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clicking the chart title (e.g. "Road Type Profile") opens the
corresponding OSM wiki page. Plain, elevation, and grade modes
remain as plain text since they're not OSM tag-based.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dummyUsage trick needs a separate assign per tag:
assign dummyUsage2 = maxspeed=
(not appended to the existing smoothness= line)
- Dockerfile: Patch all BRouter profiles to include maxspeed in
WayTags output via separate assign dummyUsage2
- brouter.ts: Extract maxspeed with direction handling
(maxspeed:forward/backward + reversedirection awareness)
- ColoredRoute: maxspeed color mode (green ≤30, yellow ≤50,
orange ≤70, red ≤100, dark red 100+)
- ElevationChart: maxspeed chart rendering, legend, hover "X km/h"
- PlannerMap: maxspeeds state + Yjs read + prop passing
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures: maxspeed data for E2E tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
E2E fixes:
- Color mode select locator uses option[value='highway'] filter
instead of fragile page.locator("select").last()
Removed maxspeed color mode:
- BRouter does not expose maxspeed in WayTags output despite the tag
existing in its lookup table. The dummyUsage profile trick only
affects cost calculation, not tiledesc output.
- Can revisit if BRouter adds maxspeed to WayTags in a future version
or if we fork/patch the BRouter profiles more deeply.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
E2E fixes:
- Color mode select locator now uses option[value='highway'] filter
instead of fragile page.locator("select").last()
Speed limit color mode:
- Extract maxspeed tags from BRouter tiledesc (with forward/backward
direction handling following brouter-web's pattern)
- Color route and chart by speed limit: green ≤30, yellow ≤50,
orange ≤70, red ≤100, dark red 100+ km/h
- Legend with speed thresholds, hover shows "X km/h"
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures include maxspeed data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract highway=* tags from BRouter tiledesc messages alongside surface
data and add a new "Road Type" color mode that colors the route polyline
and elevation chart by OSM highway classification (cycleway, residential,
path, etc.). Includes color palette, legend, hover labels, i18n (EN+DE),
unit tests for tag extraction, and E2E tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full notes lifecycle:
- GPX: description field in GpxData, <metadata><desc> in generate/parse
- Export: Plan GPX and Save to Journal include notes as description
- Journal: updateRoute extracts description from GPX, stores on route
- Reimport: Edit in Planner passes notes via URL params → Yjs Y.Text
- Drop import: GPX with <desc> restores notes in session
Spec updated: session-notes gains GPX export, Journal sync, and
reimport requirements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The color mode selector moved from the header to the elevation chart,
which only renders when a route exists. Updated test to use mocked
BRouter with waypoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Legend shows actual surface types present in the route (up to 6)
with colored swatches instead of generic text
- Hover label shows surface type name (e.g. "34m · 12.3km · asphalt")
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The chart was falling through to plain blue when surface mode was
selected. Now reads surface data from Yjs routeData and draws
segments colored by road surface type, matching the route colors
on the map.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Legends sit between the title and the mode selector dropdown:
- Grade: colored swatches with percentage thresholds
- Elevation: gradient bar from low (green) to high (red)
- Surface: text hint about surface coloring
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shows grade thresholds with color swatches: <3% green, <6% yellow,
<10% orange, <15% red, 15%+ dark red. Only visible in grade mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Moved color mode dropdown from header bar to inline with the
elevation chart title — closer to the visualization it controls
- Removed ColorModeToggle component from SessionView header
- Added "Grade" color mode: colors route and elevation chart by
steepness (green <3%, yellow <6%, orange <10%, red <15%, dark red 15%+)
- Grade hover label shows percentage (e.g. "+4.2%")
- Fixed useTranslation namespace in ElevationChart ("planner")
- i18n: Added grade keys for en + de
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CodeMirror renders placeholders as .cm-placeholder elements, not
HTML placeholder attributes. Updated the sidebar tabs test to look
for the CodeMirror content area instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Initialize CodeMirror with existing Y.Text content (doc: yjs.notes.toString())
so text is visible immediately, not just after the next remote change
- Use dedicated Y.UndoManager for notes instead of sharing with waypoints
undo manager (different tracked origins caused conflicts)
- Use yUndoManagerKeymap for proper Yjs-aware undo/redo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
y-codemirror.next uses the awareness 'cursor' field for text editor
cursor positions. Our map cursor tracker was using the same field name,
causing Invalid LatLng errors when the receiving participant tried to
render a CodeMirror cursor object as map coordinates.
Renamed to 'mapCursor' to avoid the collision.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added scenarios for character-level notes sync and cursor awareness
between participants.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a route has overnight waypoints, the export dropdown shows a
third option: "Export Day Segments" which downloads one GPX file per
day. Each file is named day-N-start-name.gpx with the day's track
segment. Only visible when the route has multiple days.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Right-clicking a waypoint now shows a context menu with:
- "Mark as overnight stop" / "Remove overnight stop" (middle waypoints only)
- "Delete" (all waypoints)
Previously first/last waypoints were deleted on right-click and
middle waypoints toggled overnight, with no visual indication of
which action would happen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When clicking near the existing route to add a waypoint (including POI
snap), find the closest route segment and insert after it. Falls back
to appending at the end if the click is far from the route (~200m
threshold in degrees).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- leaflet.markercluster: Dynamic import with fallback to plain layer
group. Clusters POI markers at low zoom, ungroups at zoom 15+.
- Base layer sync: baselayerchange event writes to Yjs, new
participants load the selected base layer on connect.
- Both tile overlays and base layer now persist across participants
and crash recovery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OverlaySync component listens to Leaflet overlayadd/overlayremove
events and writes enabled overlay IDs to Yjs routeData. On connect,
loads initial overlay state from Yjs. LayersControl.Overlay uses
checked prop to reflect the synced state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
useYjsPoiSync: Bidirectional sync between local POI state and Yjs
routeData. When a participant enables/disables POI categories, the
change propagates to all connected clients. Initial state loaded from
Yjs on connect. Crash recovery included via existing Yjs localStorage
snapshot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POI markers should render below waypoint markers so waypoints are
always easy to click and drag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test dropped GPX with far-apart waypoints (Berlin/Dessau/Erfurt)
which timed out waiting for BRouter on CI. Switched to nearby Berlin
waypoints and added mockBRouter for instant deterministic routing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
useProfileDefaults hook observes profile changes in Yjs and merges
relevant POI categories into the enabled set. Skips initial load to
respect existing state. Cycling profiles → bike infra, hiking →
shelter + viewpoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The app is primarily for cycling. Default profile is now "Cycling (fast)"
instead of "Hiking". Profile order: fastbike, safety, shortest, car,
trekking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hillshading: mock tile endpoint, enable via DOM evaluate, verify
tile requests fired
- POI markers: mock Overpass API response, zoom to threshold, enable
category, verify marker rendered in marker pane
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When clicking on the route to insert a waypoint, the new point now
snaps to a nearby POI (within 50m) just like click-to-add and drag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With multi-waypoint routes (6 BRouter requests per computation) and
debounced re-routing on waypoint changes, 60/hour was too easy to hit
during active planning sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a waypoint is created from or snapped to a POI, the Yjs Y.Map
now stores osmId (OSM node ID) and poiTags (phone, website, address,
opening hours, etc.). Dragging away clears this data.
This persists through the Yjs document and will be available when the
route is saved to the Journal, enabling future display of campsite
contact details, opening hours, etc. on route detail pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When placing or dragging a waypoint within 50m of a visible POI, the
waypoint snaps to the POI's exact coordinates and adopts its name.
Snapping only applies to visible POIs (enabled categories, current
viewport). Explicit name (from "Add as waypoint" button) is preserved
without snapping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clicking the button in a POI popup adds the POI's location and name as
a new waypoint at the end of the route. Uses event delegation on the
map container to handle clicks on dynamically created popup buttons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Try overpass.kumi.systems first, fall back to overpass-api.de
- Reduced max results from 200 to 100, added maxsize:1MB limit
- Shortened timeout from 15s to 10s, added qt (quiet) output mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Highlighted waypoints now render above normal waypoints (1000) but
below POI markers (1500) and the elevation highlight dot (2000).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract all Leaflet zIndexOffset values into z-index.ts constants:
POI markers (-1000) < cursors (-1000) < ghost waypoint (-100)
< waypoint markers (1000) < highlight dot (2000)
Replaced CircleMarker highlight with a Marker+DivIcon so it
participates in z-index ordering above waypoint markers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fixed sort() mutating enabledCategories array in place (used spread copy)
- Show loading status immediately when a fetch will happen, not after
the debounce delay
- Reduced debounce to 800ms and min interval to 2s for snappier response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Picnic sites (a table in a park) are not useful for route planning
alongside actual camp sites and caravan sites. Removed from the
camping query to reduce noise.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
overpass.kumi.systems has higher rate limits than overpass-api.de.
This is the same endpoint brouter-web uses as its default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Overpass API sometimes returns HTTP 200 with a plain-text error body
containing "rate_limited" instead of a proper 429 status. Now checks
response body for this pattern before attempting JSON parse.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PoiRefresher: Use ref for refresh callback to avoid re-running effect
on every category change. Only trigger refresh on category changes,
not on mount.
- usePois: Bump debounce from 500ms to 1000ms, add 3s minimum interval
between actual Overpass requests, increase backoff base to 10s.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PoiPanel: Collapsible panel with category checkboxes, count badges,
loading/error/zoom-too-low states
- PoiMarkers: L.Marker with L.DivIcon per POI, click popup with name,
hours, website, OSM link. z-index below route/waypoints.
- PoiRefresher: Listens to map moveend, refreshes POIs via usePois hook
- Wired into PlannerMap alongside existing controls
Skipped markercluster (7.3) — can add later if density is an issue.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- brouter-mock.ts: Canned route responses for 2 and 3 waypoint sessions,
plus latLngToPixel helper for pixel-precise map interactions
- planner.test.ts: Route split, map zoom, and overnight tests now use
mockBRouter() for instant deterministic responses instead of real
BRouter — fixes all 3 flaky tests
- Used .first() for sidebar km locator to avoid strict mode violations
when both header summary and stats footer match
All 38 E2E tests pass in 9.7s (was 34s+ with real BRouter).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test used waypoints 300km apart causing BRouter timeouts. Switched
to nearby Berlin waypoints for fast route computation. Scoped all
locators to the sidebar to avoid strict mode violations from map
elements matching the same text.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "km" locator matched both header summary and stats footer — narrowed
to match "km ·" pattern unique to the header
- "Day 1" locator matched both map label pill and sidebar header —
scoped to sidebar with page.locator("aside")
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Synced delta spec (6 requirements) to openspec/specs/multi-day-routes/.
Archived change to openspec/changes/archive/2026-04-11-multi-day-routes/.
All 4 artifacts complete. All 35 tasks complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When hovering a day row in the route detail breakdown, the map smoothly
flies to fit that segment's bounds (0.5s animation). On mouse leave it
flies back to the full route bounds. Refactored geometry splitting into
shared helpers used by both DayColoredRoute and FlyToSegment.
Rotation not implemented — Leaflet doesn't support native bearing
rotation. Would require leaflet-rotate plugin or MapLibre GL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hovering a day row dims other segments (opacity 0.3) and thickens
the hovered day's segment (weight 5) for clear visual focus
- Each day row has a GPX download button that exports just that day's
track segment via /api/routes/:id/gpx?day=N
- GPX endpoint uses computeDays to extract the correct track slice
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The subtle size bump (24→28px) is sufficient visual feedback without
the outline ring.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace position-based CircleMarker highlight with index-based marker
highlighting. Hovered waypoint marker grows from 24px to 30px with an
outline ring in its color. Uses CSS transition for smooth animation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hovering a waypoint row in the sidebar highlights it on the map with
the same red CircleMarker used by the elevation chart hover. Reuses
the existing highlightPosition state — both sidebar and chart feed
into the same mechanism.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use absolute positioning with left:50% + translate(-50%) for proper
horizontal centering of auto-width content. Reduced font to 10px,
padding to 1px 6px, border-radius to 8px.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use iconSize + iconAnchor instead of CSS transform to position the
day label pill above the waypoint marker. The label was overlapping
the marker because translate(-50%, -30px) didn't clear the 24px icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ExportButton.getWaypoints() wasn't reading the overnight flag from
Yjs Y.Maps, so exported plan GPX was missing <type>overnight</type>.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three gaps in the Planner → Journal → Planner roundtrip:
1. SaveToJournalButton: wasn't including isDayBreak when building
waypoints for GPX generation
2. PlannerMap GPX import: wasn't setting overnight on Y.Maps when
importing waypoints with isDayBreak
3. use-yjs initial waypoints: wasn't setting overnight from isDayBreak
Also widened the waypoint type throughout the chain (api.sessions,
session.$id, SessionView, extractWaypoints) to carry isDayBreak.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- planner.test.ts: E2E test for overnight toggle → day breakdown in sidebar,
and GPX import with overnight metadata
- integration.test.ts: E2E test for isDayBreak preservation through GPX import
- RouteMapThumbnail: Per-day route coloring with alternating colors
- overnight.test.ts: Y.Doc-aware tests for isOvernight/setOvernight
- daybreaks-extraction.test.ts: GPX → dayBreaks index extraction tests
All 28 tasks complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- RouteMapThumbnail: Color route segments per day with alternating colors
when dayBreaks are present, pass through from route detail page
- overnight.test.ts: 6 tests for isOvernight/setOvernight with Y.Doc
- daybreaks-extraction.test.ts: 3 tests for GPX → dayBreaks index extraction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- compute-days.test.ts: 8 tests covering single day, two days, three days,
ascent/descent, edge cases (first/last overnight, single waypoint, empty)
- generate.test.ts: 4 new tests for isDayBreak emission, GPX roundtrip with
isDayBreak preserved, and splitByDay multi-track output
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
generateGpx() gains a splitByDay option that splits the track into
separate <trk> elements per day, each named "Day N: Start - End".
Track points are split at the closest points to overnight waypoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Overnight waypoints render amber-brown (#8B6D3A) with moon icon
- Day boundary labels (white pill "Day N · X km") at overnight waypoints
- Right-click middle waypoints toggles overnight (first/last still delete)
- Wire days prop through SessionView → PlannerMap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WaypointSidebar and DayBreakdown were calling useTranslation() without
the "planner" namespace, then using fully qualified keys like
"planner.multiDay.overnight". Fixed to use useTranslation("planner")
and drop the prefix from all t() calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- routes.server.ts: Extract dayBreaks from parsed GPX waypoints on save
- routes.$id.tsx: Compute per-day stats in loader, render day breakdown
section with distance/ascent/descent per day
- ElevationChart: Dashed vertical lines at day boundaries
- i18n: Add Journal day breakdown keys (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ElevationChart: Dashed vertical lines at day boundaries with distance labels
- SessionView: Lift useDays() to top level, pass to both ElevationChart and
SidebarTabs to avoid duplicate hook calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- DayBreakdown: Collapsible day sections with per-day stats
- WaypointSidebar: Overnight toggle button (moon icon), day-grouped view
when any waypoint is marked overnight, route summary in header
- SessionView: Wire useDays() hook into sidebar via SidebarTabs
- i18n: Add multiDay keys for en + de (day labels, overnight, stats)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- overnight.ts: Yjs helpers to set/clear/check overnight flag on waypoints
- compute-days.ts: Pure function splitting routes into DayStage[] at day breaks
- use-days.ts: Reactive hook mapping Yjs state into computeDays() input
- GPX generate: emit <type>overnight</type> for isDayBreak waypoints
- GPX parse: recognize <type>overnight</type> and set isDayBreak on waypoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root package.json had hardcoded versions for react, react-dom, @types/react,
@types/react-dom, tailwindcss, @tailwindcss/vite, drizzle-orm, drizzle-kit,
and drizzle-postgis that drifted from catalog entries. Switched them all to
catalog: and bumped catalog versions to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vite 8 ships Rolldown as its Rust-based bundler, replacing esbuild+Rollup.
Removed the esbuild override from pnpm-workspace.yaml since Vite 8 no longer
uses esbuild for bundling. All plugins (@react-router/dev, @tailwindcss/vite,
@sentry/vite-plugin, @vitejs/plugin-basic-ssl) support Vite 8.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The spec was Planner-only — day structure would be lost when saving to the
Journal. Added GPX roundtrip (D9), Journal storage (D10), route detail day
breakdown (D11), and shared computeDays in @trails-cool/gpx (D12).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Vite 6.4.1 → 6.4.2: fixes path traversal in optimized deps .map
handling (medium) and arbitrary file read via dev server WebSocket
(high)
- lodash 4.17.23 → 4.18.1 via pnpm override: fixes code injection
via _.template (CVE-2026-4800, high) and prototype pollution via
_.unset/_.omit (CVE-2026-2950, medium). Transitive dep from
@react-router/dev — override needed until they bump it upstream.
Skipped 4.18.0 which was marked as a bad release.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous attempt left duplicate i18next versions in the lockfile
(26.0.1 for @trails-cool/i18n peer dep, 26.0.3 for root). This caused
two different i18next instances at runtime, breaking i18n initialization
and leaving pages blank in E2E tests.
Fix: update specifiers and run pnpm dedupe to ensure a single version.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
.client.tsx modules return undefined on the server, but lazy() still
tries to resolve them during SSR causing "Element type is invalid".
Replace lazy()+Suspense with a ClientMap component that dynamically
imports the map only in useEffect (client-side only).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Leaflet accesses `window` on import, crashing during SSR. Renamed
RouteMapThumbnail to .client.tsx so React Router excludes it from
the server bundle entirely. Updated all lazy() imports to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
api.health.ts was never registered in routes.ts — the server.ts
handler intercepts /api/health before React Router sees it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both server.ts files called createDb() on every /health request,
creating a new postgres.js connection pool (~10 connections) that was
never closed. Docker healthchecks hit this every 15s, exhausting
max_connections within minutes and causing "too many clients" errors.
Fix: use a fresh postgres.js client (max: 1) per health check that is
properly closed in a finally block. This truly tests whether the DB is
accepting new connections without leaking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add ## Purpose sections and convert delta headers to ## Requirements
on all 25 specs
- Add SHALL keywords to requirements missing them (gpx-import,
planner-session, planner-journal-handoff)
- Convert prose GPX format section to proper scenarios (no-go-areas)
- Create specs/ delta files for 7 changes that were missing them
(activity-photos, local-dev-stack, multi-day-routes, route-discovery,
route-sharing, visual-redesign, waypoint-notes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ActivityInput now accepts optional distance, duration, and startedAt.
GPX-derived stats take precedence when available, but provider stats
are used as fallback for workouts without GPS data.
The import form and Import All now pass these fields from the Wahoo
workout data so activities show correct metadata even without a FIT file.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove Garage from service list (commented out, not deployed)
- Replace S3_ENDPOINT/S3_BUCKET with actual Journal env vars
(JWT_SECRET, SESSION_SECRET, WAHOO_*)
- Add monitoring exporters (postgres-exporter, node-exporter, cAdvisor)
to monitoring stack scenario
- Add metrics collection requirement documenting Prometheus scrape
targets and pg_stat_statements custom queries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bugs fixed:
- FIT parser returns timestamps as Date objects, not strings — convert
to ISO 8601 before passing to generateGpx (caused str.replace crash)
- FIT parser already converts semicircles to degrees — remove redundant
conversion that produced near-zero coordinates
- Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header
from download requests (caused 400 "Unsupported Authorization Type")
- Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo
does not share their data via the API
UX improvements:
- Individual imports use fetcher (no page refresh), button shows
"Importing..." inline
- "Import all" button imports all unimported workouts on the page
sequentially with progress indicator
- Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1)
Also adds unit test for FIT-to-GPX conversion with real fixture file,
and updates wahoo-import spec to reflect all behaviors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Activity detail: show "Imported from wahoo" badge when activity was
imported from an external provider
- Activity detail: add delete button with confirmation, cleans up
sync_imports so the workout can be reimported
- Import page: show "No GPS" badge with tooltip on workouts that have
no file URL from the provider
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The custom server.ts imports logger and metrics from app/lib/ which
are TypeScript source files run via --experimental-strip-types. The
runtime image needs these source files copied (same as planner).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dashboard: switch error rate panel from app-level http_request_duration
(never observed) to Caddy's caddy_http_response_duration_seconds_count.
Journal: add custom server.ts (matching planner pattern) that observes
http_request_duration_seconds on every request. Replaces react-router-serve
with a custom Node HTTP server that handles /api/health, /api/metrics,
static assets, and request timing. Removes redundant React Router routes
for health and metrics.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Caddy Response Status Codes: caddy_http_responses_total doesn't exist,
use caddy_http_response_duration_seconds_count which has the code label
- pg_stat_statements: enable extension in init script, add custom queries
config for postgres-exporter, remove invalid datname filter from query
- Add postgres/queries.yml to cd-infra SCP sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous PR added Promtail to docker-compose but the CD workflow
didn't copy the config file or include promtail in the service list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Loki was running but had no log shipper — no labels or logs were
visible in Grafana. Adds Promtail to scrape all Docker container
logs and push them to Loki.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Some Wahoo workouts (indoor, manual entries) don't have a FIT file URL.
Both the manual import action and webhook handler called downloadFile
unconditionally, throwing "No file URL for workout". Now skips the
download and creates the activity without GPX when no file is available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WAHOO_CLIENT_ID, WAHOO_CLIENT_SECRET, and WAHOO_WEBHOOK_TOKEN were in
SOPS secrets but never passed through to the journal service in
docker-compose.yml, causing the OAuth authorize URL to have an empty
client_id.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Archive completed changes and sync their delta specs to main:
- wahoo-import: new wahoo-import spec, updated journal-auth and account-settings
- journal-route-previews: new route-preview spec, updated map-display and route-management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yjs UndoManager tracks waypoints, no-go areas, and notes. Only
local mutations (origin "local") are undoable — remote changes
from other users are not.
- Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo)
- Shortcuts suppressed in text inputs (browser-native undo there)
- Undo/redo buttons in header with disabled state
- stopCapturing on drag to isolate drag as one undo step
- All mutation sites wrapped with "local" origin
- 5 unit tests for UndoManager behavior
- Archived undo-redo change
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Route/activity list pages: map thumbnails with route drawn on OSM tiles
- Route/activity detail pages: interactive Leaflet map with zoom controls
- Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists)
- RouteMapThumbnail component: shared, supports thumbnail and interactive modes
- Placeholder shown for routes/activities without geometry
- Archive journal-route-previews change, sync specs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents:
- Right-click to delete
- Save to Journal preserves no-go areas in GPX extensions
- Export Plan includes full planning state
- Export Route is clean track only
- GPX extensions format with trails:planning namespace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In dev mode, the sessions API and the Yjs WebSocket server are
separate processes (Vite plugin vs React Router action), so
server-side Yjs doc initialization doesn't reach the client.
Now no-go areas flow the same way as waypoints:
API response → URL params → client-side Yjs initialization.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Save to Journal now includes waypoints and no-go areas alongside
the track — the full planning state needed for round-tripping.
Export Route remains the clean track-only option for other apps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Save to Journal now includes no-go areas in GPX extensions so they
round-trip correctly: Planner → Journal → Edit in Planner preserves
the no-go polygons.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
querySelectorAll('nogo') doesn't match <trails:nogo>. Now matches
both unprefixed and prefixed element names.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add description text below each export option explaining the difference
- Fix z-index (z-[1001]) so dropdown renders above the map
- Align dropdown left instead of right to avoid overflow
- Widen dropdown (w-56) to fit descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The document click handler registered on open could catch the same
click event that opened the dropdown, immediately closing it.
Using mousedown avoids the race since it fires before click.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The chevron was too narrow (px-1.5 → px-2.5) and clicks were
caught by the outside-click handler before toggling the dropdown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split button with default "Export GPX" (track only) and dropdown:
- Export Route: clean track for use in any app
- Export Plan: track + waypoints + no-go areas in GPX extensions
(trails:planning namespace) for reimporting into the planner
Also:
- Add no-go area serialization to generateGpx (GPX extensions)
- Parse no-go areas from GPX extensions on import
- Initialize no-go areas in Yjs session from imported GPX
- Save to Journal now exports track only (consistent with Export Route)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The button was rendered as a React component, not a Leaflet control,
so clicks propagated to the map. Use L.DomEvent.disableClickPropagation
on the container — same approach Leaflet's built-in controls use.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BRouter supports a `polygons` parameter with full vertex coordinates.
We were approximating polygons as circles (centroid + max radius),
which poorly represented elongated or concave shapes.
Now passes polygon vertices directly — no approximation, exact
no-go boundaries.
Also removes unused haversineMeters helper and updates the spec.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vite's dev server can re-evaluate modules, causing prom-client
"already registered" errors for all metrics, not just default ones.
Use getOrCreate pattern to reuse existing metrics from the registry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Importing metrics.server.ts from yjs-server.ts caused
collectDefaultMetrics() to re-register during HMR, crashing
the planner dev server. Check if metrics already exist first.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
plannerActiveSessions and plannerConnectedClients were registered
but never updated — they always reported 0. Now incremented on
WebSocket connect and decremented on close.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For GPX files with one long track segment, extracting only start/end
gives just 2 waypoints. Now uses Douglas-Peucker line simplification
(epsilon=0.05° ≈ 5km) to find significant turning points.
Result: berlin-dresden-radweg (249km, 5660 points) → 10 waypoints
that capture the route's key direction changes.
Multi-segment GPX files still use segment endpoints as before.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both new.tsx and api.sessions.ts had duplicated track-segment
waypoint extraction logic. Moved to packages/gpx as
extractWaypoints(gpxData) — uses <wpt> elements when present,
falls back to start of each track segment + end of last.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as new.tsx — the journal→planner handoff also only used
<wpt> elements from GPX. Now both code paths extract waypoints
from track segments when no explicit waypoints exist.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The planner's GPX import only used <wpt> elements, which many GPX
files don't have. Now falls back to the first and last track point,
giving the planner two endpoints to route between.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
parseGpx (sync) needs browser DOMParser which doesn't exist on the
server — it silently failed in every server-side caller. Removed the
export and migrated all callers to parseGpxAsync. Updated tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Distance was only stored in the elevation profile array, which was
empty when track points had no <ele> elements. Now computed via
haversine independently and exposed as gpxData.distance.
Tested: berlin-dresden-radweg-2021.gpx (5660 points, no elevation)
now correctly reports 248.8 km.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The initial approach (#150) didn't work because:
1. routes.server.ts used parseGpx (sync, needs browser DOMParser)
instead of parseGpxAsync (uses linkedom on Node.js) — GPX parsing
silently failed, so stats AND geom were never populated
2. Drizzle's customType doesn't pass SQL expressions through toDriver
Fixes:
- Switch to parseGpxAsync for all server-side GPX parsing
- Use db.execute() with raw ST_GeomFromGeoJSON SQL after insert
- Remove unused lineStringFromCoords helper from schema
- Share setGeomFromGpx between routes and activities
- Add unit tests for GPX→GeoJSON coordinate extraction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract coordinates from parsed GPX tracks and store as PostGIS
LineString geometry via ST_GeomFromGeoJSON. Applied to:
- createRoute / updateRoute (routes.server.ts)
- createActivity / createRouteFromActivity (activities.server.ts)
Adds lineStringFromCoords() helper to the journal schema that
builds the SQL expression from [lon, lat] coordinate pairs.
Unblocks spatial queries (e.g. route discovery via ST_Intersects).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without this, GitHub OAuth creates a new Grafana user on every login
instead of linking to the existing account by email.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add ~67 segment tiles covering W10-E40, N35-N70 (was 4 Germany-only
tiles). Total disk usage ~2.9 GB. Tiles that don't exist on the
BRouter CDN are silently skipped.
Already downloaded to the server and BRouter restarted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SSH hardening to user_data for future server rebuilds:
- PasswordAuthentication no (key-only access)
- X11Forwarding no (headless server)
- AllowTcpForwarding no (no SSH tunnels needed)
- AllowAgentForwarding no (no agent forwarding needed)
- MaxAuthTries 3 (reduced from 6)
- ClientAliveInterval 300 + ClientAliveCountMax 2 (clean up dead sessions)
Already applied manually to the existing server via
/etc/ssh/sshd_config.d/99-hardening.conf.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The alert was firing with DatasourceNoData because zero 5xx responses
means the numerator returns empty (not 0). Fixed by:
- Adding `or vector(0)` so zero 5xx returns 0% instead of empty
- Setting `noDataState: OK` as a safety net
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Grafana file-based provisioning expects flat dashboard JSON, not the
API format with a "dashboard" wrapper. The 4 wrapped dashboards were
silently failing to load — only business.json (already flat) worked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The type:dashboard field was causing Grafana to filter annotations
by dashboard scope. Our deploy annotations are global (no dashboardUID),
so the target.type:tags is the only filter needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The annotation config was missing the target block that tells
Grafana to filter by tags. Without it, the annotation query
was silently ignored.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BusyBox wget in the Grafana Alpine container doesn't support
--post-data/--header. Grafana's image includes curl.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GRAFANA_SERVICE_TOKEN is in secrets.infra.env, which the infra
workflow merges into .env on the server. The apps workflow was
reading from app.env (which only has app secrets).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Posts an annotation to Grafana after each successful deploy, tagged
with "deploy" and the service name. Annotations appear as vertical
markers on dashboards, making it easy to correlate deploys with
metric changes.
Uses GRAFANA_SERVICE_TOKEN from SOPS secrets — must be added via
sops infrastructure/secrets.infra.env. Gracefully skips if the
token is not configured.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
invalidateSize() + requestAnimationFrame ensures Leaflet knows
the map's actual dimensions after the elevation chart renders.
Also removes unnecessary filter on already-typed coordinates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show error toasts when route calculation fails, with distinct messages:
- "No route found" for BRouter 4xx (unroutable waypoints, missing tiles)
- "Route calculation failed" for server errors / network failures
- "Too many requests" for rate limiting
- Rollback all waypoints to last known good state on route failure
- Guard against restore→recompute loop via restoringRef flag
- Extract reusable useToasts hook from awareness toast logic
- Add BRouterError class to distinguish client vs server errors
- API returns 422 for unroutable requests instead of blanket 502
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ghost marker (route split indicator) was interfering with waypoint
dragging because it sat above waypoints (zIndexOffset 1000) and stayed
active during drag operations.
Adopt brouter-web's suspension pattern:
- Suspend route interaction on waypoint mouseover (not just dragstart),
unsuspend on mouseout — ghost marker hides before any click/drag
- Lower ghost marker zIndexOffset to -100 so waypoints always receive
clicks first
- Keep drag guards as a safety net
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Now that both server and client use the same locale from context,
the post-hydration useEffect that re-formatted with the browser's
OS locale was causing a visible flicker. Just render with the
context locale directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add detectLocale() that extracts the full locale tag (e.g. "de-DE")
from the request, not just the language. The journal root loader passes
it via LocaleContext so ClientDate renders with the correct locale on
both server and client — no more hardcoded "en-US" fallback.
Also fixes settings page hydration mismatch by using ClientDate for
passkey creation dates instead of inline toLocaleDateString().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split initI18n() into separate server/client initialization paths
inspired by remix-i18next's architecture:
- initI18nServer(lng): no LanguageDetector, per-request language from
Accept-Language header
- initI18nClient(): LanguageDetector reads <html lang> first to match
server, then localStorage and navigator
- detectLanguage(request): parses Accept-Language for best match
Fixes rehydration errors caused by v26 removing initImmediate option
(replaced with initAsync) and LanguageDetector running on the server
where browser APIs don't exist.
Also makes <html lang> dynamic instead of hardcoded "en".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The prompt now only appears when coming from signup/login with
?add-passkey=1 AND the user has zero passkeys. Passkey count display
removed from home — that info lives in /settings now.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gitleaks requires a license key for org repos. Skip the step (not the
job) for dependabot[bot] so the Security Scan job still passes as a
required status check. pnpm audit still runs for all PRs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gitleaks requires a license key for org repos. Skip the step (not the
job) for dependabot[bot] so the Security Scan job still passes as a
required status check. pnpm audit still runs for all PRs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add `purpose` column (default: 'login') to distinguish login tokens
from email-change tokens
- verifyMagicToken only matches purpose='login'
- verifyEmailChange only matches purpose='email-change'
- Re-check email availability at verification time, not just initiation
— prevents race where someone registers with the new email between
request and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /settings with profile, security, and account sections
- Profile: edit display name and bio
- Security: list passkeys with device labels, add/delete with
last-passkey warning
- Account: email change with magic link verification, account
deletion with username confirmation
- Settings link in nav for authenticated users
- E2E tests: auth guard, profile editing, passkey add/delete,
last-passkey warning, account deletion, nav visibility
- i18n: full en + de translations for all settings UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Settings page at /settings with profile editing, passkey management
(list, add, delete), email change with verification, and account
deletion. Includes comprehensive E2E test plan using virtual WebAuthn.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Login: hide passkey button when browser lacks WebAuthn, default to
magic link mode, hide "back to passkey" link
- Register: detect passkey support and offer magic link registration as
fallback. New server-side registerWithMagicLink() creates account
without credential and sends verification email.
- Home: show passkey count for logged-in users, show amber unsupported
message instead of hiding the add-passkey prompt entirely
- i18n: add passkeyNotSupported, passkeyNotSupportedRegister,
registerWithMagicLink, passkeyStatus keys (en + de)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Archive the monolithic route-features spec and replace with 9 focused
OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping),
undo-redo, local-dev-stack, route-sharing, route-discovery,
activity-photos, osm-overlays, plus the existing changelog and
komoot-import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Design direction: D1 warmth + D3 lightness (warm off-whites, sage green,
Outfit + Geist Mono). Includes desktop + mobile HTML mockup, 5 logo
concepts, and 33 implementation tasks across 7 phases.
Logo decision: Waypoint Dot mark (3 connected dots) + wordmark combo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The error rate formula (errors/total*100) produces NaN when there's
zero traffic (0/0). Grafana treats NaN as a firing condition.
Use clamp_min on the denominator to return 0% instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Planner's save-to-journal callback makes a cross-origin request to
trails.cool from planner.trails.cool. Add the Journal's origin to the
Planner's connect-src CSP directive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses Playwright's virtual WebAuthn authenticator (CDP) to test:
- Register with passkey + sign in (full round-trip)
- Login fails with no credential (friendly error message)
- Duplicate email rejection
- Duplicate username rejection
Would have caught the credential encoding bug.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".
Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.
Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.
Existing passkeys must be re-registered after deploy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@trails-cool/db doesn't export a db instance — only createDb and withDb.
The health check was importing a non-existent export, always failing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes replayIntegration and replay sample rates from both apps.
Quota was already exhausted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Returns SENTRY_RELEASE (set at build time) as the version field,
making it easy to verify which commit is deployed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The route files existed but weren't registered in the explicit routes.ts,
so React Router never compiled them into the build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The threshold expressions were missing the 'expression' field referencing
the query refId, causing 'no variable specified for refId C' errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Panels:
- Stat cards: total users, routes, activities, active planner sessions
- Time series: cumulative user growth, route growth
- Bar charts: signups/day, routes/day, planner sessions/day
- Table: top 20 routes by distance
All queries use the grafana_reader PostgreSQL datasource.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create grafana_reader role with SELECT-only access on all schemas
- Init script runs on postgres first boot (docker-entrypoint-initdb.d)
- Grafana PostgreSQL datasource provisioned with read-only credentials
- Enables SQL queries in dashboards (user count, routes, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove commit message [restart-all] detection — doesn't work with
GitHub merge commits. Keep only the explicit workflow_dispatch input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exporters:
- postgres_exporter: DB connections, transactions, cache hit ratio, query stats
- node_exporter: host CPU, memory, disk, network
- cAdvisor: per-container CPU and memory usage
PostgreSQL:
- Enable pg_stat_statements for query-level performance tracking
- Track index scans vs sequential scans, cache hit ratio
Dashboard (service-health.json):
- DB: connections, size, transactions/s, slow queries, cache hit ratio, index usage
- Host: disk gauge, CPU, memory, network I/O, disk I/O
- BRouter: request latency p50/p95/p99, container CPU + memory
- All containers: CPU and memory comparison
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
node:25-slim doesn't include CA certificates, causing sentry-cli
SSL errors: "unable to get local issuer certificate". Also removes
debug logging from previous troubleshooting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The token file may have a trailing newline that causes sentry-cli
API requests to fail with "API request failed".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Disable Sentry Vite plugin telemetry (telemetry: false)
- Pass SENTRY_RELEASE as build-arg (not secret) since it's just the git sha
- Keep only SENTRY_AUTH_TOKEN as Docker secret
The release version was showing as *** because Docker secrets get masked
by GitHub Actions. Git sha is not sensitive — safe as a build arg.
The sourcemap warning from react-router is a known upstream issue (ships
without sourcemaps) and is harmless.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pnpm approve-builds in Dockerfile ran after install, so the approval
wasn't picked up. Use onlyBuiltDependencies in package.json instead —
this is read during install and allows build scripts to run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Approve @sentry/cli and esbuild build scripts so postinstall runs
- Use --mount=type=secret instead of ARG for SENTRY_AUTH_TOKEN
(fixes Docker warning about secrets in build args)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The decrypted secrets.app.env contains values with angle brackets
(SMTP_FROM) that bash interprets as redirects. The build job only
needs SENTRY_AUTH_TOKEN, so extract it with grep instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Write decrypted env files to infrastructure/ instead of /tmp/ so
strip_components: 1 correctly places them on the server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Secrets:
- Add .sops.yaml with age encryption config
- Add encrypted secrets.app.env (app secrets) and secrets.infra.env (Grafana OAuth)
- CD decrypts at deploy time with AGE_SECRET_KEY — all other secrets
move out of GitHub Actions into version-controlled encrypted files
Split CD:
- cd-apps.yml: triggered by apps/packages changes, builds Docker images, deploys apps
- cd-infra.yml: triggered by infrastructure/ changes, copies configs, restarts services
- Remove monolithic cd.yml
Grafana auth:
- GitHub OAuth (trails-cool org), disable login form
- Remove Caddy basic_auth block and all GRAFANA_* env vars/secrets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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
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
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
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
@ -56,10 +59,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -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..."
@ -114,14 +116,14 @@ 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` |
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
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
- **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 1–3 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+codeanchor>
- …
## 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
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
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
@ -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"
@ -59,10 +62,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -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..."
@ -119,14 +121,14 @@ 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` |
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
description: Avoid unnecessary EAS cloud builds — they consume limited credits
type: feedback
---
Don't trigger EAS builds casually. They consume limited cloud build credits.
**Why:** Free tier has a monthly cap; each `eas build` / `eas build:dev` costs credits.
**How to apply:** Only suggest or run EAS builds when adding/changing native dependencies. JS-only changes don't need a new build — the dev client hot-reloads via the bundler.
For full project context — including architecture, principles, tech stack, dev commands, testing strategy, code conventions, git workflow, and deployment — see [`CLAUDE.md`](../CLAUDE.md) at the repository root.
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.
@ -81,9 +121,10 @@ pnpm db:studio # Open Drizzle Studio (DB browser)
## Code Conventions
- **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)
@ -140,6 +181,91 @@ before pushing to an existing PR branch.
### Emergency override
Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a hotfix). Document the reason in the commit message.
trails.cool runs on two Hetzner boxes in the same Falkenstein datacenter:
- **Flagship** — Hetzner Cloud `cx23`, public IP + vSwitch IP `10.0.0.2`. Runs Journal, Planner, Postgres, Caddy, Prometheus, Loki, Grafana.
- **BRouter host** — Hetzner Dedicated `ullrich.is`, public IP `176.9.150.227` + vSwitch IP `10.0.1.10`. Shared self-hosted box; trails.cool owns only a non-root `trails` user with docker-group rights, scoped to `~trails/brouter/`. SSH is on port **2232**.
The two hosts are bridged via Hetzner vSwitch #80672 (VLAN 4000). Planner → BRouter traffic crosses it; BRouter → Loki traffic (for log shipping) crosses it back.
### Secrets
All secrets are SOPS-encrypted: `infrastructure/secrets.app.env` (apps + BRouter shared token), `infrastructure/secrets.infra.env` (flagship infra only). Edit with `sops infrastructure/secrets.app.env`. GitHub Actions secrets: `AGE_SECRET_KEY`, `DEPLOY_HOST` / `DEPLOY_SSH_KEY` (flagship), `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` / `BROUTER_DEPLOY_SSH_PORT` (dedicated).
### Full restart
To restart **all** containers on the flagship (not just the ones a workflow normally touches):
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)
**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:
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