- 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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>