Route files in React Router only get server-code stripped from a
specific set of exports (loader/action/middleware/headers). The
overpass failover work added a `fetchWithFailover` named export to
`app/routes/api.overpass.ts` for unit tests, but that export pulls
in `metrics.server` (prom-client registry, etc.) — which then
leaks into the client bundle and fails the production build with:
[plugin react-router:dot-server]
Error: Server-only module referenced by client
'app/lib/metrics.server' imported by route 'app/routes/api.overpass.ts'
Move the upstream fetch logic and its config constants into
`app/lib/overpass.server.ts`. The `.server.ts` naming makes the
server-only boundary explicit, and tests now import from the lib
instead of from the route. The route keeps only its `action` export
(plus the per-instance LRU cache, which is internal to the module
and not exported).
Tests: 85 passing (no behavior changes). Build: planner + journal
both succeed. Typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The gauge was maintained by conditional inc on "this sessionId isn't
in the in-memory `docs` Map yet" and dec on "last client for the
sessionId left." Because `docs` caches entries indefinitely — even
after every client disconnects — a reconnect found the doc still
cached, skipped the inc, then decremented again on the next
disconnect. Every reconnect-after-last-disconnect leaked one
decrement. After a day of normal reload / wifi-blip / tab-hibernate
patterns the gauge hit -182.
Replace the transitions with a `set()` derived from the live `conns`
map: count distinct session ids with at least one open WebSocket.
Self-correcting, no drift possible.
Extract `countActiveSessions()` as a pure helper + unit tests
including a regression case that simulates repeated reconnect/
disconnect cycles on one session and asserts the count never goes
negative.
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>
Public Overpass instances go bad without warning — we just lived
through a day where overpass.private.coffee (our single upstream)
returned 504s after 60s while lz4.overpass-api.de served the same
query in 0.6s. Survive that by trying a list of upstreams in order
until one responds usefully.
Server changes:
- OVERPASS_URLS env — comma-separated list, tried in order. Falls
back to OVERPASS_URL for backward compat, then to a built-in
default pair (lz4.overpass-api.de, overpass-api.de).
- Per-upstream timeout of 10s via AbortSignal.timeout, so a saturated
instance fails over in seconds, not minutes.
- Client abort propagation via AbortSignal.any — if the browser
cancels (e.g. user panned the map), the in-flight upstream fetch is
aborted too. Stops wasting upstream capacity on requests nobody
wants.
- Rate-limited-body detection (Overpass returns 200 with a
`rate_limited` marker when throttling) triggers failover.
- Per-upstream labels on overpass_upstream_requests_total and
overpass_upstream_duration_seconds so the dashboard can break out
health + latency by instance. Histogram buckets extended to 30s.
Compose: OVERPASS_URLS passthrough with the default pair hard-wired,
overridable via SOPS.
Tests: 8 cases covering the new fetchWithFailover helper — happy
path, 5xx failover, rate_limited failover, network error failover,
timeout tagging, all-upstreams-fail, client abort stops the loop,
per-attempt latency observation.
Follow-ups left out of scope:
- Client-side debounce (UI concern).
- Moving `.observe()` after body read for accuracy in measurement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-boss v11 renamed every concatenated-lowercase timestamp column
(`createdon`, `completedon`) to snake_case (`created_on`,
`completed_on`). Our service-health dashboard and failed-job alert
still referenced the v10 names, so after the v12 upgrade those
queries error out silently.
Fix the three affected queries:
- alerts.yml: failed-job alert
- service-health.json: "Recent Failed Jobs" table
- service-health.json: "Job Queue — Completed/hour" time series
The `state` / `name` / `output` columns stay the same, and the `state`
enum still accepts `'failed'` / `'completed'` literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.
## Breaking changes in our usage surface
### v12: default export removed → named `PgBoss` export
Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
re-exports `Job` at the package root in v12)
### v11: queue names restricted to `[A-Za-z0-9_.-]`
Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune` → `demo-bot-prune`
The planner's `expire-sessions` was already valid.
### v12: minimum Node 22.12
Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.
## Regression guard
Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.
## Prod rollout note
Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.
## Verified
- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface net for the class of bug we hit in #282 — a POI test silently
relied on a live Overpass server for months because its `page.route`
mock was targeting a URL the browser no longer hit directly. CI stayed
green on coincidence (real OSM data happened to satisfy the "any
marker renders" assertion) until upstream behaviour drifted.
Add a shared Playwright fixture (e2e/fixtures/test.ts) that installs a
catch-all `page.route("**", ...)` before each test. Requests to
localhost + known tile CDNs pass through via `route.continue()`;
anything else is aborted and accumulated. At test teardown, a non-empty
blocked list throws, failing the test with the offending URLs and a
pointer to the fixture.
All seven e2e *.test.ts files updated to import test/expect (+ types)
from ./fixtures/test.
Full suite passes with this fixture in place (50/50 at --workers=2;
catch-all never fires, meaning no test currently relies on an
unallowlisted external service).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test was added in 0ff04aa (Apr 11), back when the browser called
overpass-api.de directly. When a4df5a4 (Apr 18) moved POI queries
behind the planner's `/api/overpass` proxy, the test's
`page.route("**/api/interpreter", ...)` mock became dead code —
Playwright can only intercept browser traffic, and the browser now
only sees `/api/overpass`. The real upstream got hit and didn't
return a drinking_water node at exactly 52.52, 13.405, so zero
markers rendered and the test failed.
Mock `/api/overpass` instead. Response body shape is unchanged
(the proxy is transparent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Commit subject prefixed with "[github-actions]" so the audit trail
is obvious at a glance in `git log`.
- GH_TOKEN exposed as an env var on the commit step so the PAT is
also available to any `gh` invocations the step might grow, and the
token plumbing is visible at the site where the push happens — not
only up at the checkout step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous comment claimed `git push` reads the token via a git
credential helper wired into $RUNNER_TEMP. I don't actually know that's
how it works end to end — only that checkout stashes the token there
and that subsequent git ops in the same workspace end up authenticating
as the PAT. Describe just the observable contract, not an unverified
internal path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Current actions/checkout stores the token in $RUNNER_TEMP and wires a
git credential helper, rather than writing the token into .git/config's
http.extraheader. The mechanism that makes `git push` pick up the PAT
is unchanged at the caller's level; the comment was just wrong about
where the secret lives on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`actions/checkout` has `persist-credentials: true` as its default, but
that default is the load-bearing part of how the later `git push`
authenticates — it's what writes the token into .git/config's
http.extraheader. Making it explicit so a reader of the YAML can see
the mechanism without having to know the action's defaults.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the workflow fell back to GITHUB_TOKEN when the PAT wasn't
set — which was silently broken: the push succeeds but GitHub's
anti-workflow-loop safeguard means no new CI runs on the dedupe commit.
Reviewers see stale green CI and the dedupe itself isn't tested until
the next dependabot rebase.
Make the PAT a hard requirement: preflight check fails with a clear
message when the secret is missing, and the checkout step uses it
(which is what persists auth for the later `git push`). Intent is now
obvious from the YAML.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v11 is a breaking API rework (renamed components, restructured props,
event payloads on nativeEvent) and requires React Native's new
architecture. Doing it as a manual migration instead of taking
dependabot's #268 because the renames need code changes.
Changes in apps/mobile:
- RouteMap.tsx — switch to named imports; rename MapView→Map,
ShapeSource→GeoJSONSource (with shape→data), LineLayer→Layer
(with type="line" and paint instead of style), MarkerView→
ViewAnnotation (with coordinate→lngLat); Camera uses
initialViewState with bounds as [w,s,e,n] and a separate padding
object; onLongPress reads lngLat from event.nativeEvent.
- app.config.ts — set `newArchEnabled: true`. MapLibre RN v11
supports only the new architecture, so Fabric/TurboModules must
be on for the Expo prebuild. Cast the config type since Expo SDK
55's ExpoConfig typings don't yet include the field (the runtime
does).
- package.json — `^10.4.2` → `^11.0.0`.
Supersedes dependabot #268; it'll auto-close once this merges.
Runtime verification requires an EAS dev build (native deps
changed + new arch flipped). JS-only surface typechecks + lints +
unit tests clean locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Created by the Claude Code scheduled-wakeup runtime when a dynamic
loop is active. Pure ephemeral state; no reason to track it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pnpm install after a dependabot bump keeps duplicate versions of
packages that happen to be pinned via overlapping semver ranges. For
singleton-ish libraries (i18next, react-i18next) that's a latent bug:
the server's module instance and the client's module instance each
hold their own state, and SSR output doesn't match CSR.
#272 ran into this with an i18next patch bump — the bumped version
stayed on some dep paths while other paths kept the old version. A
manual `pnpm dedupe` collapsed them and hydration worked again.
This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
pushes the resulting lockfile update back to the PR branch so CI
runs against the deduped tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five journal API route handlers had the same inline lambda:
parsed.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}))
Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.
Public error-response shape is unchanged.
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>
Mechanical bump across the three packages that pull zod directly:
- packages/api (8 schema files, ~300 lines)
- apps/journal (persona schema in demo-bot.server.ts, 5 API route
handlers that introspect parsed.error.issues)
- apps/mobile (declared dep; no active imports yet)
No code changes required. The legacy methods we use (.url(), .uuid(),
.datetime() on strings) still parse identically in v4 via the compat
shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue
introspection (parsed.error.issues.map(i => ...)) keeps the same shape
at the fields we read (path, message, code).
All 147 tests across journal + api + packages pass against zod 4.3.6.
Follow-ups out of scope for this PR:
- Modernise to top-level validator helpers (z.url(), z.iso.datetime()).
The legacy string methods are deprecated but not removed.
- Consider migrating error introspection to the richer v4 shape (.path
is now branded, .input is new) if we ever want structured error
surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine panels at trails-demo-bot covering:
- Current synthetic route/activity counts + time-since-last-walk
- Gauge time series over the configured retention window
- Walks per day + distance distribution
- Loki log stream filtered to `demo-bot` messages
- Recent walks table with name, distance, ascent
Provisioned via the existing grafana/dashboards directory — no extra
wiring needed.
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>
- Skip major bumps on @types/jest + @types/node so the types stay
aligned with the runtime (jest 29 with @types/jest 29, not 30).
- Skip react-native itself since it's pinned by the Expo SDK and
should only move when we bump Expo. Community react-native-*
libraries stay on the normal weekly cadence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
server.ts imports from ./app/jobs/demo-bot-generate.ts and ./app/jobs/
demo-bot-prune.ts, but the Dockerfile only COPYs app/lib. On prod the
worker crashes on boot with ERR_MODULE_NOT_FOUND and neither the
generate nor the prune cron gets scheduled. Add the jobs dir to the
runtime layer.
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>
Sets DEMO_BOT_ENABLED=true plus DEMO_BOT_RETENTION_DAYS and
DEMO_BOT_REGION in the SOPS env file so the journal worker starts
the generate + prune jobs and bootstraps the Bruno demo user on
next deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>