From 7d466ca865ade14c4ffe9348b8411bb8692312dc Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 1 Jun 2026 09:16:14 +0300 Subject: [PATCH] fix(invite): derive invite-URL origin from the request, not wacrm.tech MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to `https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset. wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site repo); it has no `/join/[token]` route. Any fork that didn't set the env var got invite links pointing into the marketing site, which 404'd on click. Reported by @ArnasDon while testing the flow on production. Post-fix: `getBaseUrl(request)` resolves in this order: 1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins. 2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every reverse proxy in front of the app (Hostinger Managed Node.js, Vercel, Cloudflare). This is what makes invite links Just Work in production without any env-var fiddling. 3. `Host` header + the request's protocol — bare deployments without a proxy in front. 4. `https://wacrm.tech` last-resort fallback with a console.warn so operators spot the misconfig in logs. Effectively unreachable from a real browser; only triggers if there's no Host header at all. Also tightened `.env.local.example` so the variable's purpose matches the new behavior: it's now only needed when the request- derived origin would be wrong (cron jobs, background workers). Day-to-day forks don't have to remember it. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean (after `rm -rf .next` to clear stale [locale]/* type stubs from the i18n revert that landed on main) - `npm test` → 366/366 pass - `npm run build` → succeeds Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.local.example | 8 +++- src/app/api/account/invitations/route.ts | 58 +++++++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.env.local.example b/.env.local.example index 1d78a41..5e59fe1 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,8 +28,12 @@ META_APP_SECRET=your-meta-app-secret # ============================================================ # Canonical public URL of this deployment (scheme + host, no trailing -# slash). Used for the sitemap, OG images, and any email the app -# sends. Defaults to https://wacrm.tech if unset. +# slash). Used for the sitemap and OG images. Routes that need a +# self-referential URL (invite links from /api/account/invitations, +# any future email-bound link) derive the origin from the request +# itself, so this variable is *only* needed when the request-derived +# origin would be wrong — e.g. when generating links from a cron job +# or a background worker that has no incoming request. NEXT_PUBLIC_SITE_URL=https://crm.example.com # ============================================================ diff --git a/src/app/api/account/invitations/route.ts b/src/app/api/account/invitations/route.ts index d7e901e..5ce3de5 100644 --- a/src/app/api/account/invitations/route.ts +++ b/src/app/api/account/invitations/route.ts @@ -28,11 +28,57 @@ import { } from "@/lib/auth/invitations"; import { isAccountRole } from "@/lib/auth/roles"; -// Resolve the base URL we publish invite links under. Mirrors the -// .env.local.example default so dev / preview / forks all behave -// without explicit config. -function getBaseUrl(): string { - return process.env.NEXT_PUBLIC_SITE_URL || "https://wacrm.tech"; +// Resolve the base URL we publish invite links under. +// +// Resolution order, first match wins: +// +// 1. `NEXT_PUBLIC_SITE_URL` — admin's explicit config. Trumps +// everything; if you set this, that's where links point. +// 2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every +// reverse proxy in front of the app: Hostinger Managed +// Node.js, Vercel, Cloudflare, nginx. This is what makes +// invite links Just Work in production without forcing the +// operator to set an env var. +// 3. `Host` header + the protocol the request arrived on — +// bare deployments without a proxy. +// 4. Last-resort marketing-site fallback. Only hit if the +// request has no Host header at all, which is essentially +// impossible from a real browser. Logs a warning so the +// operator can spot the misconfig. +// +// Previous implementation hard-defaulted to `https://wacrm.tech` +// (the docs/marketing site, a different repo). Forks that didn't +// set `NEXT_PUBLIC_SITE_URL` got invite links pointing at the +// marketing site, which 404s on `/join/`. This resolution +// chain removes the foot-gun. +function getBaseUrl(request: Request): string { + const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (explicit) return explicit.replace(/\/+$/, ""); + + const forwardedHost = request.headers + .get("x-forwarded-host") + ?.split(",")[0] + ?.trim(); + const forwardedProto = request.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim(); + if (forwardedHost) { + return `${forwardedProto || "https"}://${forwardedHost}`; + } + + const host = request.headers.get("host")?.trim(); + if (host) { + // The protocol on `request.url` is whatever the framework saw — + // reliable for bare deployments where no proxy is rewriting it. + const reqProto = new URL(request.url).protocol.replace(":", ""); + return `${reqProto}://${host}`; + } + + console.warn( + "[POST /api/account/invitations] could not derive base URL from request; falling back to marketing domain", + ); + return "https://wacrm.tech"; } const MAX_LABEL_LEN = 80; @@ -133,7 +179,7 @@ export async function POST(request: Request) { invitation: data, // Plaintext payload — visible to the admin exactly once. token, - url: inviteUrl(token, getBaseUrl()), + url: inviteUrl(token, getBaseUrl(request)), expiresInDays: expiryDays, }, { status: 201 },