Files
TREK/wiki/Troubleshooting.md
T
jubnl ebc98f0a3f v3.4.0 (#1527)
* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.
2026-07-13 12:40:00 +02:00

24 KiB

Troubleshooting

"Access token required" when changing password on first login

Cause: The session cookie has the Secure flag set, which means the browser will only send it over HTTPS. When accessing TREK over plain HTTP (e.g. http://192.168.1.x:3000), the browser silently drops the cookie and the server sees no session — returning "Access token required".

Fix: Choose one of the following options:

Option 1 — Use HTTPS. Access TREK via HTTPS with a valid SSL certificate.

Option 2 — Disable the Secure flag. Set COOKIE_SECURE=false in your Docker environment to allow the session cookie to be sent over plain HTTP:

environment:
  - COOKIE_SECURE=false

Note: Option 2 is only recommended for internal/home-lab deployments that do not use HTTPS. Do not use it on a publicly accessible instance. See Environment Variables.


Can't log in after setup / ADMIN_EMAIL and ADMIN_PASSWORD seem ignored

Cause: The initial admin account is seeded only on the first boot, when the database has no users yet. Three things follow from that, and each trips people up:

  • ADMIN_EMAIL / ADMIN_PASSWORD apply only on that first run. If you first start without them, an admin is created with a random password (it is not changeme) — and adding the variables afterwards has no effect, because a user already exists. The server now logs a reminder when it ignores them.
  • The random first-run password is printed to the log once, in a box titled TREK — First Run: Admin Account Created. It is easy to miss if you read the logs later.
  • Pulling a "fresh image" does not reset anything — your ./data volume still holds the old database, so first-run setup does not run again.

Fix — pick whichever applies:

Read the first-run credentials (only present on the very first start of an empty database):

docker compose logs | grep -A6 "First Run"

Log in with what it shows; you will be asked to set a new password.

Reset the admin without losing data (locked-out, existing install):

docker exec -it trek node server/reset-admin.js

This resets (or creates) admin@trek.local and prints a generated password. Override with -e RESET_ADMIN_EMAIL=you@example.com -e RESET_ADMIN_PASSWORD=yourpass. You will be asked to change it on first login.

Start over with chosen credentials (fresh install, no data to keep):

docker compose down
rm -rf ./data        # deletes ALL TREK data — only on a throwaway/fresh install
docker compose up -d

With ADMIN_EMAIL and ADMIN_PASSWORD set, the admin is created with exactly those credentials.

Note (Docker Desktop on Windows/macOS): SQLite's WAL mode is unreliable on bind mounts backed by the Windows/macOS filesystem and can cause silent write failures. Prefer a Docker named volume for /app/data over a host bind mount. See Install: Docker Compose.


WebSocket not connecting / real-time sync broken

Cause: Your reverse proxy is not forwarding WebSocket upgrade headers on the /ws path.

Fix: Add the following to your proxy config for the /ws location:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Without these headers, the WebSocket handshake fails and real-time sync will not work. See Reverse Proxy for a complete nginx and Caddy configuration. Caddy handles WebSocket upgrades automatically.


HTTPS redirect loop

Cause: FORCE_HTTPS=true is set but your reverse proxy is not forwarding the X-Forwarded-Proto: https header, so every request looks like plain HTTP and gets redirected indefinitely.

Fix: Ensure your proxy passes the X-Forwarded-Proto header to TREK. Also set TRUST_PROXY=1 so that Express uses the forwarded IP for rate limiting and audit logs:

environment:
  - FORCE_HTTPS=true
  - TRUST_PROXY=1

Note: The /api/health endpoint is always exempt from the HTTPS redirect so that Docker health checks continue to work over plain HTTP.

If you are accessing TREK directly on http://<host>:3000 with no proxy, remove FORCE_HTTPS entirely. See Environment Variables.


Encrypted settings lost / API keys not working after migration

Cause: The ENCRYPTION_KEY was changed or lost. All API keys, SMTP passwords, OIDC client secrets, and MFA TOTP secrets are encrypted at rest using this key. Without the original key, decryption fails.

Fix: See Encryption Key Rotation for the migration script that re-encrypts data under a new key. If the original key is gone entirely, the encrypted values are unrecoverable and must be re-entered in the admin panel.

Note: If you upgraded from an older version without setting ENCRYPTION_KEY, the server uses the following resolution order on startup: (1) ENCRYPTION_KEY env var, (2) data/.encryption_key file, (3) one-time fallback to data/.jwt_secret for legacy upgrades — the value is immediately written to data/.encryption_key so JWT rotation cannot break decryption later, (4) auto-generated fresh key for brand-new installs. Check data/.encryption_key for the key currently in use.


Locked out of MFA / lost authenticator

Fix: If you still have access to your account, use one of the 10 backup codes generated during MFA setup to complete login. After signing in, go to Settings > Security to disable or reconfigure MFA.

If you no longer have access to backup codes and cannot log in, an admin must disable MFA for your account directly in the database, or use the reset-admin.js script to regain access to an admin account. There is no per-user MFA reset in the Admin Panel UI — the Admin Panel only controls the global "require MFA for all users" policy. See Admin: Users and Invites.


Demo user cannot edit or create

Cause: The instance is running with DEMO_MODE=true. All write operations are blocked for the demo account by design.

Fix: This is intentional behavior for public demo deployments. If you are self-hosting and want full access, remove the DEMO_MODE variable (or set it to false). See Demo Mode.


Backup restore fails with "file too large"

Cause: Your reverse proxy has a default body size limit (commonly 1 MB or 10 MB) that is smaller than the backup ZIP. Backup archives include the full uploads directory and can be large.

Fix: Raise the body size limit in your proxy config. TREK's own backup upload cap is 500 MB. For nginx:

client_max_body_size 500m;

Add this to the location / block (or the specific backup route). See Reverse Proxy and Backups.


"Cannot find module" on startup

Likely cause: A Docker volume mount is missing or the /app/data and /app/uploads directories are not writable by the container process. TREK automatically creates all required subdirectories on startup (data/logs, data/backups, data/tmp, uploads/files, uploads/covers, uploads/avatars, uploads/photos) — if this fails because the volume is read-only or owned by the wrong user, startup will abort.

Fix: Check your Docker volume configuration. Both ./data:/app/data and ./uploads:/app/uploads must be mounted and writable. Run docker inspect <container> --format '{{json .Mounts}}' to verify the mounts are present and point to valid host paths. If the host directories are owned by root, the container's chown step (which runs as root before dropping to node) should correct permissions automatically — but if your host filesystem is read-only or permissions are locked down, grant write access manually:

sudo chown -R 1000:1000 ./data ./uploads

Container won't start: "exec /usr/bin/dumb-init: operation not permitted"

Symptoms: The container restarts in a loop and the logs show nothing but:

trek  | exec /usr/bin/dumb-init: operation not permitted
trek exited with code 255 (restarting)

Cause: You are running Docker installed from the snap (its config lives under /var/snap/docker/...) and your compose file sets security_opt: [no-new-privileges:true]. The snap-packaged dockerd runs under its own AppArmor profile, and AppArmor refuses the no_new_privs privilege transition for snapped daemons. The container's very first execve is denied with EPERM, so it never starts. Confirm it in the kernel log right after a crash:

sudo dmesg -T | grep -iE 'apparmor|denied'
# apparmor="DENIED" operation="exec" ... info="no new privs"

This affects any image, not just TREK, and is a known snap limitation (snapd bug #1908448). Setting apparmor=unconfined on the container does not help — that only swaps the container's profile, while the denial comes from the daemon's (snap's) confinement, which a container-level option cannot reach.

Fix: Install Docker from the official apt repository instead of the snap. Your data is safe as long as it lives in host bind-mounts (./data, ./uploads):

sudo snap remove docker
curl -fsSL https://get.docker.com | sudo sh   # or follow docs.docker.com/engine/install/ubuntu
docker compose up -d

Note: If you must stay on the snap, the only workaround is removing no-new-privileges from security_opt. The rest of the hardening (read_only, cap_drop: ALL with a minimal cap_add, the noexec,nosuid tmpfs) keeps working and carries most of the weight. See Security Hardening.


Encryption key regenerated on restart — stored secrets stop working

Cause: On every startup, TREK resolves its encryption key in this order: (1) ENCRYPTION_KEY env var, (2) data/.encryption_key file, (3) legacy data/.jwt_secret fallback, (4) auto-generate a fresh key. If neither the env var nor the data/ volume is persisted — for example after recreating a container without a volume mount — a new random key is generated and all stored secrets (SMTP password, OIDC client secret, API keys, MFA TOTP seeds) become unrecoverable.

Fix: Ensure ./data:/app/data is mounted as a persistent volume so data/.encryption_key survives restarts. Alternatively, pin the key explicitly:

environment:
  - ENCRYPTION_KEY=<your-key>

See Encryption Key Rotation for how to retrieve or rotate the key.


OIDC login returns "APP_URL is not configured"

Cause: When OIDC is enabled, TREK needs to know its own public URL to build the redirect URI. It resolves this from (1) APP_URL env var, (2) the first entry in ALLOWED_ORIGINS, (3) http://localhost:<PORT> as a last resort. If none of these are set and the request is not coming from localhost, TREK returns a 500 error.

Fix: Set APP_URL to the public URL of your instance:

environment:
  - APP_URL=https://trek.example.com

OIDC login fails with issuer mismatch

Cause: TREK validates that the issuer field in the provider's discovery document exactly matches the configured OIDC_ISSUER. A trailing-slash difference (e.g. https://auth.example.com vs https://auth.example.com/) is enough to fail.

Fix: Check the exact issuer value your provider advertises and match it:

curl -s https://<your-oidc-issuer>/.well-known/openid-configuration | jq .issuer

Set OIDC_ISSUER to that exact string.


OIDC login fails when provider is on a private/internal network

Cause: TREK's SSRF guard blocks outbound requests to private IP ranges by default. If your OIDC provider (e.g. Keycloak, Authentik) is running on an internal address, the discovery document fetch will be blocked with: Requests to private/internal network addresses are not allowed.

Fix:

environment:
  - ALLOW_INTERNAL_NETWORK=true

Password reset emails are not delivered / SMTP is silent

Cause: SMTP failures are logged but do not surface as errors to the end user — the "reset email sent" message appears regardless. Common causes: wrong SMTP_HOST or SMTP_PORT, bad credentials, firewall blocking outbound on the SMTP port, or a self-signed certificate on the SMTP server.

Fix:

  1. Check server logs for Email send failed:
    docker logs <container> 2>&1 | grep "Email send failed"
    
  2. If the error mentions TLS or certificate, set SMTP_SKIP_TLS_VERIFY=true.
  3. Verify the port: 587 for STARTTLS, 465 for implicit TLS, 25 for plain SMTP.
  4. Test connectivity from the container:
    docker exec <container> nc -zv <SMTP_HOST> <SMTP_PORT>
    

Note: If no SMTP is configured at all, TREK prints the reset link directly to the server logs (===== PASSWORD RESET LINK =====). This is useful for initial setup or self-hosted installs without email.


CORS error — API requests blocked in the browser

Cause: If ALLOWED_ORIGINS is set, only those origins are permitted. Any request from a different origin is rejected with a CORS error visible in the browser console.

Fix: Add your origin to the comma-separated list:

environment:
  - ALLOWED_ORIGINS=https://trek.example.com,https://other.example.com

If ALLOWED_ORIGINS is not set, TREK allows all origins (development default). See Environment Variables.


WebSocket closes immediately after connecting (codes 4001 / 4403)

Cause: The /ws endpoint requires an ephemeral token generated by the client immediately before connecting. If the token is missing, expired, or the user's session state changed, the server closes the connection with a specific code:

Code Reason
4001 No token, expired/invalid token, or user not found — re-login required
4403 MFA is required globally but the user has not enabled it

Fix:

  • Code 4001: Log out and log back in. If it persists, check that your reverse proxy is not stripping the token query parameter from the WebSocket upgrade request.
  • Code 4403: The user must enable MFA in Settings > Security, or an admin can disable the global MFA requirement in Admin > Settings.

Cause: The browser Clipboard API (navigator.clipboard) is only available in a secure context. When accessing TREK over plain HTTP on a non-localhost address, the API is unavailable and clipboard operations silently fail or show an error.

Fix: The only supported options are:

  • Access TREK over HTTPS with a valid SSL certificate.
  • Access TREK directly from http://localhost:<port> — browsers treat localhost as a secure context for the Clipboard API (unlike the session cookie, which always requires HTTPS regardless of hostname).

Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured)

Cause: When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the /place-photo/:id endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are:

  1. HTTP referrer restriction on the API key. Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a Referer header derived from APP_URL. If APP_URL is not set, the fallback is http://localhost:<PORT>, which will not match any domain whitelist in GCP.

  2. Wrong key restriction type. API keys restricted by HTTP referrers are designed for browser-side JavaScript. For a self-hosted server application, use IP address restrictions instead — add the public IP of your TREK server and no APP_URL configuration is needed.

  3. Places API (New) not enabled. The key must have Places API (New) enabled in Google Cloud Console under APIs & Services → Enabled APIs. Enabling only the legacy Places API is not sufficient.

  4. Billing not set up. Google requires a billing account to be linked to the project even within the free tier. Without it, photo and details requests return REQUEST_DENIED.

Fix for HTTP referrer restriction:

Set APP_URL to the public URL of your instance and add that URL (or its domain with a wildcard, e.g. https://trek.example.com/*) to the allowed referrers in GCP:

environment:
  - APP_URL=https://trek.example.com

Fix for wrong restriction type:

Switch the key's "Application restrictions" from HTTP referrers to IP addresses in Google Cloud Console, and add your server's public IP. No APP_URL change needed.

Verifying the issue:

Run the following curl command using your key to check whether Google returns photo references:

curl "https://places.googleapis.com/v1/places/<PLACE_ID>" \
  -H "X-Goog-Api-Key: YOUR_API_KEY" \
  -H "X-Goog-FieldMask: photos"

If the response is {} or {"error": {...}}, the key or its restrictions are blocking the request. If it returns a photos array, the key is valid and the issue is elsewhere.


MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts

Cause: TREK builds the OAuth 2.1 redirect URI from APP_URL. If APP_URL is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.

Fix: Set APP_URL to the public URL of your instance:

environment:
  - APP_URL=https://trek.example.com

Restart the container after adding the variable. Once set, clicking Connect in the MCP client should redirect to your TREK instance and complete the OAuth flow normally.

Note: APP_URL is required for any MCP OAuth integration. Without it, the authorization endpoint resolves to http://localhost:<PORT>, which is unreachable from external MCP clients.


MCP integration: "Too many requests"

Cause: Each user is limited to 300 MCP requests per minute by default. Exceeding the limit returns a 429 response.

Fix: Increase the limits via environment variables:

environment:
  - MCP_RATE_LIMIT=600          # requests per minute per user (default: 300)
  - MCP_MAX_SESSION_PER_USER=50 # concurrent sessions per user (default: 20)

The session limit no longer rejects requests: at the cap, the server closes the user's least-recently-active session to make room for the new one. If you are hitting the cap at all, see the next entry — the usual cause is sessions being created faster than they are reused, not genuinely parallel clients.


MCP: a new session is created on every tool call / sessions pile up

Symptoms: the server log shows a Session <uuid> created line for every tool call, with Active sessions climbing steadily. The idle sweep reports cleaned 0 no matter how many sessions are open. On older versions the connection dies once the count reaches MCP_MAX_SESSION_PER_USER and only a restart brings it back.

Cause: the client never receives — or cannot read — the Mcp-Session-Id response header, so it cannot send it back on the next call. Every request then looks like a brand-new connection and the server opens a fresh session for it. The cleaned 0 sweep is a red herring: the sweep expires sessions after an hour of inactivity (MCP_SESSION_TTL), so sessions created seconds apart are nowhere near expiry.

There are two reasons the header goes missing:

  1. TREK 3.3.0 and earlier did not send Access-Control-Expose-Headers: Mcp-Session-Id. Without it, browser-based clients — Claude.ai, Claude Desktop connectors, MCP Inspector — are forbidden by the browser from reading the session id, no matter how the proxy is configured. Fix: upgrade — no proxy change will help.
  2. A reverse proxy stripping the header. Nginx and Caddy forward it by default, so this only happens if you have a proxy_hide_header directive or a response-header allowlist in front of /mcp. See Reverse-Proxy.

How to tell which: from the machine running the proxy, ask for a session and look at the response headers.

curl -i -X POST https://trek.example.com/mcp \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'

A healthy response contains both:

mcp-session-id: 412d245d-1daa-4dfc-b453-eaf266673696
access-control-expose-headers: Mcp-Session-Id,MCP-Protocol-Version,WWW-Authenticate

If mcp-session-id is missing, the proxy is stripping it. If access-control-expose-headers is missing, you are on an affected version — upgrade.

On current versions the server also warns on every session-less request, which is the same signal:

[MCP] POST without mcp-session-id for user 1 — starting a new session. If this
repeats on every tool call, the Mcp-Session-Id response header is not reaching
the client (check that your reverse proxy forwards it).

MCP requests blocked by Cloudflare WAF (Bot Fight Mode)

Cause: When TREK is proxied through Cloudflare, Bot Fight Mode and Super Bot Fight Mode classify requests from ChatGPT as bots and block them at the WAF level — before the request ever reaches TREK. This is specific to ChatGPT; Claude.ai is not affected. ChatGPT's exit node IPs have low reputation scores in Cloudflare's threat intelligence and the User-Agent matches Cloudflare's automated-traffic heuristics. TREK itself never receives the request, so there is nothing in TREK's logs; the block is silent from TREK's perspective.

Symptoms:

  • ChatGPT shows a connection error or times out immediately after OAuth completes.
  • Cloudflare's Security → Events log shows blocked requests to /mcp with action block and source bfm (Bot Fight Mode) or managed_rule.

Fix — Option 1: Disable Bot Fight Mode (free plan and paid plan)

In the Cloudflare dashboard for your zone: Security → Bots → Bot Fight Mode → Off (or Super Bot Fight Mode → Off).

This is the only option available on the free plan. It disables bot blocking for the entire zone — all probe bots, scrapers, and crawlers that Cloudflare would otherwise block will reach your server. Only use this if you have no alternative.

Fix — Option 2: WAF skip rule for MCP paths (paid plan only)

WAF custom rules require a paid Cloudflare plan (Pro or above). This option is not available on the free plan.

Create a WAF skip rule that bypasses bot management only for the MCP and OAuth paths, leaving protection in place for the rest of the site:

  1. Go to Security → WAF → Custom rules and click Create rule.

  2. Enter the following expression (replace trek.example.com with your domain):

    (http.host eq "trek.example.com") and (
      http.request.uri.path eq "/mcp" or
      http.request.uri.path starts_with "/oauth/" or
      http.request.uri.path starts_with "/.well-known/"
    )
    

    This covers all paths that ChatGPT's servers hit during discovery, OAuth, and MCP calls:

    Path Purpose
    /mcp MCP endpoint (GET, POST, DELETE)
    /oauth/authorize OAuth authorization handler
    /oauth/register Dynamic client registration
    /oauth/token Token issuance
    /oauth/userinfo User info (for domain claiming)
    /oauth/revoke Token revocation
    /.well-known/oauth-authorization-server RFC 8414 AS metadata
    /.well-known/oauth-protected-resource RFC 9728 flat resource metadata
    /.well-known/openid-configuration OIDC discovery
  3. Set the action to Skip and check Bot Fight Mode (and/or Super Bot Fight Mode) under the skip options.

  4. Save and deploy.

This allows MCP and OAuth traffic through while keeping Cloudflare bot protection active for all other paths.