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