Task 7.3: applies the MODIFIED + ADDED requirements from
changes/deepen-connected-services/specs/ into openspec/specs/:
- connected-services/spec.md:
- MODIFIED: OAuth token storage (renamed sync_connections → connected_services
with credential_kind discriminator + JSONB credentials shape).
- ADDED: Capability seams for providers (Importer / RoutePusher /
WebhookReceiver per ADR-0002, no unified SyncProvider).
- ADDED: Centralized credential lifecycle via ConnectedServiceManager.
- wahoo-import/spec.md: Provider-agnostic framework rewritten to reference
capability seams + per-provider manifest. Token refresh now goes through
withFreshCredentials. Renames sync_connections → connected_services
throughout.
- wahoo-route-push/spec.md: Renames sync_connections → connected_services.
ADDED: RoutePusher capability seam — shape (service, route) →
{remoteId, version} per ADR-0003; Wahoo workarounds stay inside the
adapter.
Change directory moved to openspec/changes/archive/2026-05-08-deepen-connected-services/.
29/29 tasks complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Purpose
Push planned routes from the Journal to a connected Wahoo account as FIT Course files, so users can ride routes they planned in trails.cool on their Wahoo head unit.
Requirements
Requirement: Send to Wahoo action on route detail page
The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (routes.geom is non-null and routes.gpx is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.
Scenario: Owner with connected Wahoo and a route with geometry
- WHEN the route owner loads a route detail page for a route that has geometry
- AND the owner has a
connected_servicesrow withprovider = 'wahoo' - THEN a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
Scenario: Owner without a connected Wahoo account
- WHEN the route owner loads a route detail page
- AND the owner has no Wahoo
connected_servicesrow - THEN the "Send to Wahoo" button is not rendered
Scenario: Non-owner viewing the route
- WHEN any visitor who is not the route owner loads the route detail page
- THEN the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection
Scenario: Route without geometry
- WHEN the route owner loads a route detail page for a route whose
geomis null or whosegpxis empty - THEN the "Send to Wahoo" button is not rendered
Requirement: Server-side route push pipeline
The Journal SHALL convert the current route version to a FIT Course file and send it to Wahoo, recording the outcome in sync_pushes. When no successful push exists for the (user_id, route_id, provider='wahoo') tuple, the request is POST https://api.wahooligan.com/v1/routes. When a successful push exists with a non-null remote_id, the request is PUT https://api.wahooligan.com/v1/routes/<remote_id> so subsequent pushes update the existing Wahoo route in place rather than creating duplicates. If a PUT returns 404 (the user deleted the Wahoo route on their side), the server SHALL fall back to POST and overwrite remote_id with the response. The body shape is identical for POST and PUT and matches the existing field set (external_id, provider_updated_at, name, workout_type_family_id, start_lat, start_lng, distance, ascent, route[file] data URI).
Scenario: First push of a route
- WHEN the route owner triggers the push action and no
sync_pushesrow exists for(user, route, wahoo) - THEN the server reads the route's GPX, runs
gpxToFitCourse, encodes the result asdata:application/vnd.fit;base64,<base64>, and POSTs it to/v1/routes - AND on a 2xx response, a
sync_pushesrow is inserted withpushed_at = now(),remote_idset from Wahoo's response,last_pushed_versionset to the local route version, anderror = null - AND the user is shown a "Sent to Wahoo" confirmation
Scenario: Re-push after editing the route
- WHEN the route owner edits a previously-pushed route, then triggers the push action
- AND a
sync_pushesrow exists for(user, route, wahoo)with non-nullremote_id - THEN the server PUTs the new FIT and metadata to
/v1/routes/<remote_id>(not POST) - AND on a 2xx response, the existing
sync_pushesrow is updated in place:pushed_at = now(),last_pushed_versionadvanced to the new local version,error = null - AND the Wahoo route id remains the same (no duplicate route created on Wahoo's side)
Scenario: PUT against a deleted Wahoo route falls back to POST
- WHEN the server PUTs to
/v1/routes/<remote_id>and Wahoo returns 404 - THEN the server retries the request as POST
/v1/routes - AND the existing
sync_pushesrow is updated with the newremote_idfrom the POST response
Scenario: Wahoo returns an error
- WHEN Wahoo responds with a non-2xx status that is not the 404 fallback
- THEN the
sync_pushesrow is created or updated in place witherrorpopulated andpushed_atunchanged - AND the user is shown a generic "Sending to Wahoo failed — try again later" message
- AND no exception leaks to the browser
Scenario: Route file omits records
- WHEN the route's GPX has no track points (zero-segment route)
- THEN the server returns HTTP 422 to the client and does not call Wahoo
- AND no
sync_pushesrow is created or modified
Requirement: Push idempotency per route
Each (user_id, route_id, provider) SHALL map to at most one sync_pushes row. Re-clicking the button when the local version equals last_pushed_version and the row's pushed_at is non-null SHALL be a no-op surfacing the existing remote id; clicking after editing (local version > last_pushed_version) SHALL trigger an update via PUT.
Scenario: Re-push of an already-pushed unchanged route
- WHEN the route owner clicks "Send to Wahoo" and the existing
sync_pushesrow haslast_pushed_versionequal to the current route version and a non-nullpushed_at - THEN the server skips the Wahoo call and returns the existing
remote_id - AND the UI shows "Already on Wahoo" with the timestamp from the row
Scenario: Push after editing the route (new version)
- WHEN the route owner edits the route (incrementing the local version) and clicks "Send to Wahoo"
- THEN the server takes the PUT path against the existing
remote_id - AND exactly one
sync_pushesrow exists for(user, route, wahoo)after the operation completes
Scenario: Retry after a failed push
- WHEN the existing
sync_pushesrow haspushed_at = nullanderrorpopulated - THEN clicking "Send to Wahoo" retries — POST if
remote_idis null, PUT ifremote_idis non-null - AND the existing row is updated in place (no duplicate row)
Requirement: Stable external_id per route
The external_id field sent to Wahoo SHALL be deterministic per (route_id) so that the value identifies the logical route, not a specific edit of it. This makes the source-of-truth identifier stable across re-pushes.
Scenario: External id is deterministic
- WHEN the server constructs the Wahoo payload for any version of a route
- THEN
external_idis set toroute:<route_id>(no version suffix) - AND subsequent pushes of the same route — including PUTs after edits — send the same
external_id
Requirement: Re-auth flow when routes_write scope is missing
The Journal SHALL detect when a connected Wahoo account lacks the routes_write scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.
Scenario: Existing connection lacks routes_write
- WHEN the route owner clicks "Send to Wahoo"
- AND the user's
connected_services.granted_scopesdoes not includeroutes_write - THEN the server redirects the user to Wahoo's authorization URL with the full updated scope list and a
statethat encodes{ return_to: <route_url>, push_after: true } - AND no Wahoo
/v1/routescall is attempted
Scenario: Push completes after re-auth
- WHEN the user returns from Wahoo's OAuth callback with
push_after = truein the state - THEN the connection is updated with the new scopes
- AND the push action runs automatically against the route encoded in
return_to - AND the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible
Scenario: User declines the new scope
- WHEN the user reaches Wahoo's authorization page and clicks "Deny"
- THEN the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
- AND no
sync_pushesrow is created
Requirement: GPX to FIT Course conversion
The system SHALL provide a gpxToFitCourse function in the @trails-cool/fit package that converts a GPX string to a FIT Course binary suitable for POST /v1/routes.
Scenario: GPX with track points produces a valid FIT Course
- WHEN
gpxToFitCourse({ gpx, name })is called with a GPX string containing one or more track points - THEN the returned
Uint8Arrayis a valid FIT file that decodes viafit-file-parserto a Course with the same number of records as the input had track points - AND each record's lat/lon round-trips to within 1e-5 degrees of the source
Scenario: GPX with elevation data preserves elevation
- WHEN the input GPX track points include
<ele>values - THEN the encoded FIT records carry
altitudevalues that round-trip to within 0.5 m of the source
Scenario: GPX without track points is rejected
- WHEN the input GPX has no track points
- THEN
gpxToFitCoursethrows an error and produces no output
Requirement: Push status surfaced on the route detail page
The route detail page SHALL show whether the route has been pushed to Wahoo and whether the local version is newer than what Wahoo currently has.
Scenario: Local version matches Wahoo's
- WHEN the route owner views a route whose
sync_pushesrow haslast_pushed_versionequal to the current local version and a non-nullpushed_at - THEN the page shows "Sent to Wahoo on " near the action buttons
- AND the "Send to Wahoo" button is replaced with disabled "Already on Wahoo" text
Scenario: Local version is newer than Wahoo's
- WHEN the route owner views a route whose
last_pushed_versionis less than the current local version - THEN the page shows "On Wahoo (v<last_pushed_version>) — local version is newer"
- AND the action button reads "Send updated version" and triggers the same push action (which will PUT)
Scenario: Push failed previously shows retry affordance
- WHEN the route owner views a route whose
sync_pushesrow haserrorset andpushed_atnull - THEN the page shows "Last attempt failed: " with a "Send to Wahoo" button still active
Requirement: RoutePusher capability seam
The system SHALL expose RoutePusher as the capability seam through which any provider pushes routes. The seam shape is pushRoute(service, route) → {remoteId, version}. Provider-specific concerns — file format conversion (e.g. FIT Course), external_id conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the RoutePusher interface.
Scenario: Wahoo pusher implements the seam without leaking workarounds
- WHEN the route push action invokes the Wahoo
RoutePusher - THEN the seam is called as
pushRoute(connectedService, route)and returns{remoteId, version} - AND the FIT-Course conversion, the
external_id = route:<route_id>convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter - AND callers do not pass FIT data or Wahoo-specific fields across the seam
Scenario: Future pusher reuses the same seam
- WHEN a developer adds a second
RoutePusher(e.g. Garmin) - THEN they implement
pushRoute(service, route) → {remoteId, version}with the same shape - AND the route push action invokes it identically to the Wahoo pusher
- AND their format conversion and HTTP recovery live inside the adapter