fix(invite): derive invite-URL origin from the request, not wacrm.tech

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-01 09:16:14 +03:00
parent 8955b482b3
commit 7d466ca865
No known key found for this signature in database
2 changed files with 58 additions and 8 deletions

View File

@ -28,8 +28,12 @@ META_APP_SECRET=your-meta-app-secret
# ============================================================ # ============================================================
# Canonical public URL of this deployment (scheme + host, no trailing # Canonical public URL of this deployment (scheme + host, no trailing
# slash). Used for the sitemap, OG images, and any email the app # slash). Used for the sitemap and OG images. Routes that need a
# sends. Defaults to https://wacrm.tech if unset. # 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 NEXT_PUBLIC_SITE_URL=https://crm.example.com
# ============================================================ # ============================================================

View File

@ -28,11 +28,57 @@ import {
} from "@/lib/auth/invitations"; } from "@/lib/auth/invitations";
import { isAccountRole } from "@/lib/auth/roles"; import { isAccountRole } from "@/lib/auth/roles";
// Resolve the base URL we publish invite links under. Mirrors the // Resolve the base URL we publish invite links under.
// .env.local.example default so dev / preview / forks all behave //
// without explicit config. // Resolution order, first match wins:
function getBaseUrl(): string { //
return process.env.NEXT_PUBLIC_SITE_URL || "https://wacrm.tech"; // 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/<token>`. 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; const MAX_LABEL_LEN = 80;
@ -133,7 +179,7 @@ export async function POST(request: Request) {
invitation: data, invitation: data,
// Plaintext payload — visible to the admin exactly once. // Plaintext payload — visible to the admin exactly once.
token, token,
url: inviteUrl(token, getBaseUrl()), url: inviteUrl(token, getBaseUrl(request)),
expiresInDays: expiryDays, expiresInDays: expiryDays,
}, },
{ status: 201 }, { status: 201 },