After Phase A (#442) moved the journal callback token off the browser,
the token was still replayable on the wire until \`exp\` (7 days). This
PR makes each token strictly single-use.
Changes:
- **\`journal.consumed_jwt_jti\` table** — \`jti TEXT PRIMARY KEY,
consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ\`. Picked up by
drizzle-kit push on deploy.
- **\`createRouteToken\` now sets a \`jti\` claim** (\`randomUUID()\`).
- **\`verifyRouteToken\` atomically consumes the jti** via
\`INSERT … ON CONFLICT DO NOTHING RETURNING jti\`. Postgres
serializes the insert, so exactly one concurrent caller wins; the
rest see an empty result and throw \`TokenAlreadyConsumedError\`.
Tokens without a \`jti\` claim (i.e. minted before this PR) are
also rejected — the right call: any in-flight legacy token sitting
in a planner session is replayable, and we'd rather fail-loud than
silently grandfather them in.
- **\`consumed-jti-sweep\` job** — daily 03:45 UTC cron that
\`DELETE WHERE expires_at < now()\`. Keeps the table tiny; offset
from the other purge jobs to spread load.
- **e2e replay test** — \`integration.test.ts\` now exercises a
same-token double-submit and asserts the second returns 401 with
\`/consumed|already/i\`.
UX implication worth flagging: a user who clicks \"Save\" twice (or whose
network retries a failed POST) sees an error on the second attempt.
They go back to the journal for a fresh \"Edit in Planner\" link.
Full repo: pnpm typecheck / lint / test all green (177 + 31 integration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The five \`*.integration.test.ts\` files in apps/journal (explore, follow,
demo-bot, notifications, notifications-fanout — 31 tests total) were
gated behind \`EXPLORE_INTEGRATION=1\` / \`FOLLOW_INTEGRATION=1\` /
\`DEMO_BOT_INTEGRATION=1\` / \`NOTIFICATIONS_INTEGRATION=1\`. The unit-test
job doesn't have Postgres, so they correctly skipped there — but no
CI job set the env vars, so they were effectively dead code.
Added a step in the E2E job (which already has Postgres + schema
pushed) that flips all four gates and runs the integration files
serially (\`--no-file-parallelism\` — they share the schema and trip FK
constraints if run in parallel; ~2.5s sequential anyway).
Wiring them up surfaced two real issues, both fixed here:
1. **\`createRoute\` silently dropped \`input.visibility\`** — every
caller passing \`visibility: \"public\"\` (including the demo-bot)
was getting the column's \`private\` default. Spread now mirrors
\`createActivity\`'s pattern. Demo-bot routes have actually been
private in production all this time — they were rendering on the
home feed only via the \`activity_published\` fan-out from the
*activity*, not as visible *routes*.
2. **The demo-bot test's fetch stub was incomplete** — it stubbed
\`.text()\` but the planner-session preflight calls \`.json()\`. The
stub now branches on URL: \`/api/sessions\` returns
\`{ sessionId: 'test-session' }\` JSON, the BRouter call returns the
stub GPX text.
Full repo: pnpm typecheck, pnpm lint, pnpm test all green
(177 unit-test pass + 31 integration pass = 208 total, no skips).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-hosted instances no longer inherit the trails.cool flagship Sentry
DSNs. Code paths now read DSN strictly from env — unset = no Sentry
init, no events sent.
Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml
inject the public DSNs as workflow env vars, which feed into:
- runtime via \`infrastructure/app.env\` / \`staging.env\`
(\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service)
- build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only;
planner has no client Sentry init).
Sentry DSNs are public-by-design (transmitted unencrypted from the
client JS bundle), so embedding them as plaintext workflow env vars
is no worse than the runtime exposure. Forks should replace these with
their own DSNs or remove the workflow env lines to ship Sentry-free.
Files changed:
- apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback
- apps/planner/server.ts: same
- apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\`
only; falsy = skip init
- apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build
- infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service
- infrastructure/docker-compose.staging.yml: same
- .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo
- .github/workflows/cd-staging.yml: same
Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build 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.
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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'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>
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>
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>
Roughly tripled the demo persona's name + description pools so the
demo bot's daily output doesn't feel canned within a few weeks of
watching it. Same Bruno: dog-park-inspector deadpan, Berlin-set,
absurd bureaucratic tone. New entries grouped into clear categories
inside the file (more Berlin neighborhoods, time-of-day variants,
bureaucratic-deadpan one-liners, specific events, wildlife
encounters, stick + tennis-ball lore, self-aware moments, Berlin
weather) so it's easy to add to one bucket without rewriting the
whole list.
Counts before → after:
- en names: 12 → 35
- de names: 12 → 35
- en descriptions: 10 → 30
- de descriptions: 10 → 30
No schema or behavior change. Tests unchanged — the persona schema
still parses; the existing pool-shape-agnostic tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The directory was filtering out the demo persona on the rationale
that "the demo bot is not a real user and should not appear in real
discovery." That's exactly backwards — the whole point of having a
demo persona is to give new users a follow target so the platform
doesn't feel empty when they arrive. Hiding the bot from the
discovery surface defeats its purpose.
Concretely on flagship: only one local user (ullrich) was visible
on /explore today, even though Bruno (the demo persona) is
public-by-default and posting public activities. After this change
both appear; Bruno carries a small "🐕 Demo account" badge next to
his display name so viewers know what they're following.
- apps/journal/app/lib/explore.server.ts — drop the
ne(users.username, persona.username) clause from exclusionFilters.
The demo persona is now treated like any other public user. Banned/
suspended scaffolding stays for forward-compat.
- apps/journal/app/routes/explore.tsx — loader computes isDemoUser
per row (cheap, just username comparison against
loadPersona().username). DirectoryRow renders the demo badge inline
with the display name, matching the existing pattern on
/users/:username.
- openspec/specs/explore/spec.md — updated the "Excluded users"
requirement to remove the demo persona, replaced the "demo
excluded" scenario with "demo appears with badge", and updated
the "Active recently" requirement + scenarios accordingly.
- apps/journal/app/lib/explore.integration.test.ts — flipped the
demo-persona test from "is excluded" to "is included".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.
- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
ordered by MAX(public-activity created_at) DESC NULLS LAST,
tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
days), countDirectory, batched countFollowersBatch and
getFollowStateBatch helpers so the page issues two extra queries
regardless of page size. Excludes private profiles and the demo
persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
queries in parallel; component renders an "Active recently" strip
(hidden when empty) above the main directory; FollowButton inlines
per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
"Browse who's here →" link to /explore alongside the Planner
escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
(EXPLORE_INTEGRATION=1) integration tests covering inclusion,
exclusion, ordering, NULLS LAST behavior, "Active recently"
30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
private profile is excluded from the directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.
Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.
Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.
Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.
Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
paste-friendly), with `[Register Magic Link]` console line in dev so
the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
profile route (owner + accepted followers see the list; others 404).
Profile page renders the count chips as plain spans for viewers who
can't see the lists, so private profiles don't surface dead links.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three places assumed the old default-public model:
1. e2e/public-content.test.ts: "profile 404 when user has no public
content" → updated to assert the new locked-account stub renders
200 with "This profile is private" and the private route doesn't
appear. The two follow-on tests (public-route profile, unlisted
route) now flip the owner to public via a setProfileVisibilityPublic
helper before the anon-visit assertions, since new users default to
private.
2. e2e/demo-bot.test.ts: bruno is seeded with profile_visibility =
'public' on insert (timestamped seed) and the existing-bruno path
gets a follow-up UPDATE so the test's anon-visitor assertion
(profile renders, public route is listed) holds regardless of which
default he was first created under.
3. apps/journal/app/lib/demo-bot.server.ts ensureDemoUser: also pins
profile_visibility = 'public' on insert. The demo persona is
discoverable by design — that's the whole point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.
Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
remain 'public' (backfill on first migration handled them).
Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
approveFollowRequest, rejectFollowRequest. Approve/reject are
owner-bound: only the followed user can act on their own incoming
requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
filter to accepted-only relations.
Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
copy + Request-to-follow / sign-in CTA. Routes/activities sections
are not rendered.
UI:
- FollowButton gains a "Request to follow" / "Requested" state for
private targets via a new isPrivateTarget prop. Cancel-request reuses
the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
pending > 0.
Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).
Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
(public, private+anon, private+pending, private+accepted) with
scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
manual accept, approve/reject endpoints, pending request management,
Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
default-private; non-goal "locked-local-accounts as a follow-up" is
removed since this change ships it.
Tests:
- follow.integration.test.ts: pending-against-private, approve flips
to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
flow, plus stub-for-anonymous and /follows/requests auth gate.
Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.
Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
federation forward-compat. Local IRIs look like
`${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
'public'). Existing users land 'public' via the default; current
effective behavior is unchanged.
Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
countFollowers / countFollowing / listFollowers / listFollowing.
Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
follows → activities WHERE visibility='public', reverse-chrono.
Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.
UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
"New Activity".
Privacy manifest updated to document the new follows relation and
profile_visibility setting.
Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.
Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.
The new home is one layout that serves both audiences:
- Hero: a product-describing h1 ("Federated outdoor journal") +
tagline. The site name already lives in the top banner and nav brand,
so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
Planner demotes to a small "Or try the Planner without an account →"
link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
for Planner / Journal / Federation / Ownership, matching the Planner
home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
a "Powered by trails.cool — about the project" link back to the
flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
activities on the instance, with owner + distance + date. Empty state
for fresh installs.
Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
so self-hosters (who deploy without these workflows) default to off.
Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
so the visibility contract is pinned (public in feed, unlisted +
private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
feed layout contract and the `IS_FLAGSHIP` toggle, so future
instance-branding or empty-state changes have a stable anchor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Today both proxies are effectively open to anyone who can set an
Origin header for trails.cool — a third party can use us as a free
BRouter/Overpass relay. Require a live planner session on every call
so abuse traffic costs the scraper a session row (observable,
revocable) before they can issue a single query.
Server:
- New `requireSession(id)` helper — returns the session row or a 401
Response. Reused by both route handlers.
- `/api/route`: `sessionId` in body is now required and verified;
rate-limit key always falls back to the session id.
- `/api/overpass`: new `X-Trails-Session` header, verified. Header
keeps the session out of the request body so the body-keyed cache
is unaffected.
Client plumbing:
- `useRouting(yjs, sessionId)` — sessionId goes into the /api/route
body.
- `usePois(sessionId)` → `queryPois(..., sessionId)` → `X-Trails-Session`
on the proxy call.
- `PlannerMap` + `YjsDebugPanel` gain a `sessionId` prop from
`SessionView`.
Journal server-to-server:
- Demo-bot and `/api/v1/routes/compute` now POST `/api/sessions` to
mint a throwaway planner session, then cite it on the forwarded
`/api/route` call. Planner's `expire-sessions` cron cleans these up
(7d window) so nothing needs explicit teardown.
Tests:
- 5 unit tests for `requireSession` covering missing / empty /
non-string / unknown-session / valid-session cases.
- Two integration E2E tests document the 401 for missing session on
each proxy.
- Pre-existing `/api/route` integration tests updated to mint a
session first.
Caveat: existing browser tabs lose their /api/route ability until
reload (the old JS doesn't know to send sessionId). Acceptable for
an anonymous planner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:
- z.string().url() → z.url() (3 sites)
- z.string().uuid() → z.uuid() (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)
Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.
All 147 tests still pass.
The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planner is the BRouter client in this architecture — route
compute, rate limiting, and the eventual move of BRouter off-box all
live there. The journal had two places that called BRouter directly
(demo-bot and /api/v1/routes/compute) and both were broken on prod
because the journal service has no BROUTER_URL.
Fix:
- Extend the planner's /api/route with an optional `format: "gpx"`
that returns BRouter's raw GPX (for server-to-server callers that
don't need the way-tag enriched GeoJSON).
- Point the journal demo-bot at `PLANNER_URL/api/route` with
`format: "gpx"` instead of calling BRouter directly.
- Point /api/v1/routes/compute at `PLANNER_URL/api/route` instead of
BRouter directly.
Journal no longer needs BROUTER_URL at all. When BRouter moves to a
dedicated host later, only the planner changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.
Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>