Prepare the four landings for production deployment

Fixes that block or weaken a real deployment. Nothing here changes the
rendered pages.

Bind the lead API to loopback. Each server called app.listen() without a
host, so it bound 0.0.0.0. Combined with a blanket `trust proxy: true` —
which makes Express take the leftmost X-Forwarded-For entry as req.ip —
the in-memory lead rate limiter was spoofable by anyone who could reach
the port directly. HOST now defaults to 127.0.0.1 and trust is narrowed
to 'loopback', so a request arriving from anywhere but the local proxy
has its forged header ignored.

Give each landing its own port. All four .env files claimed PORT=3000,
and fitnes/.env.example collided with medcenter/.env.example, so three of
the four could never have started on one host. Now 3000/3001/3002/3003
consistently across the code defaults, the env templates and the vite
dev proxies, so all four also run side by side locally.

Template the JSON-LD url. canonical and og:url already resolved from
%VITE_SITE_URL%, but the JSON-LD block hardcoded an exodevices.ru
sub-path that would not follow the environment. All four now read from
the same variable.

Declare the Node version. Nothing stated it, yet transitive deps impose
a >=22.12 floor (@rolldown/binding, yargs, concurrently). Added engines
and .nvmrc so a too-old runtime fails clearly.

Typechecked and production-built on all four; verified the socket binds
127.0.0.1 only, health reports amo:true, the site still boots with the
CRM unconfigured, and the limiter returns 429 with Retry-After on the
ninth request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 15:57:24 +06:00
co-authored by Claude Opus 5
parent cd51cb5632
commit b673367c6f
26 changed files with 66 additions and 20 deletions
+3
View File
@@ -54,5 +54,8 @@ export function readAmoConfig(): AmoConfig | null {
export const serverConfig = {
port: optionalNumber('PORT') ?? 3001,
// Loopback by default: in production nginx is the only thing that should
// reach this process, so the port is never exposed to the network.
host: optional('HOST') ?? '127.0.0.1',
isProduction: process.env.NODE_ENV === 'production',
}
+5 -2
View File
@@ -20,7 +20,10 @@ if (!leadService) {
}
const app = express()
app.set('trust proxy', true)
// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the
// address nginx actually observed and the rate limiter below cannot be
// side-stepped with a forged X-Forwarded-For header.
app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
@@ -103,7 +106,7 @@ if (serverConfig.isProduction) {
})
}
app.listen(serverConfig.port, () => {
app.listen(serverConfig.port, serverConfig.host, () => {
console.info(
'[server] listening on http://localhost:%d%s',
serverConfig.port,