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