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>