From 77f8096deba43bbd45bb6a2e6d1bd73e95ca13ad Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Fri, 15 May 2026 10:38:44 +0000 Subject: [PATCH] Improve security and deployment flexibility for external access Update README and application configurations to support reverse proxy setups, including TLS termination and proper cookie handling, and add an option to seed demo meetings. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: c15da2be-cee7-4cbb-8d58-f9fcc3c38ed7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z Replit-Helium-Checkpoint-Created: true --- .env.example | 28 ++++++++++++++++++++-- README.md | 41 ++++++++++++++++++++++++++++++++- artifacts/api-server/src/app.ts | 40 +++++++++++++++++++++++++++++++- docker-compose.yml | 2 ++ scripts/src/seed.ts | 25 +++++++++++++++----- 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 545b281d..e5c33532 100644 --- a/.env.example +++ b/.env.example @@ -22,11 +22,30 @@ MOCKUP_PORT=8081 # In compose this is set automatically; required when running natively. PORT=8080 # Public base URL the SPA + API are reachable at. Used to build absolute -# upload URLs for the local-FS storage driver and to validate CORS origins. +# upload URLs for the local-FS storage driver and to derive whether the +# session cookie should be marked `secure`. Match the URL operators +# actually open in the browser (e.g. https://tx.example.com). PUBLIC_BASE_URL=http://localhost:3000 # Comma-separated list of origins allowed by the API server's CORS layer. -# Must include PUBLIC_BASE_URL and any additional reverse-proxy domains. +# MUST include every URL the SPA can be reached at — the public domain, +# any reverse-proxy hostnames, the LAN IP for tablets/phones, and any +# tunneled URL (Tailscale `*.ts.net`, ngrok, Cloudflare Tunnel, etc). +# Examples: +# ALLOWED_ORIGINS=http://localhost:3000 +# ALLOWED_ORIGINS=https://tx.example.com,https://it-demo.tail70b2bc.ts.net +# ALLOWED_ORIGINS=http://localhost:3000,http://192.168.1.42:3000 ALLOWED_ORIGINS=http://localhost:3000 +# Set to `true` when Tx OS sits behind a TLS-terminating proxy that +# does NOT forward `X-Forwarded-Proto: https` (notably `tailscale serve`, +# the ngrok free tier, and some Cloudflare Tunnel setups). Without this, +# the session cookie is silently dropped and login bounces back to the +# login screen even on a successful POST /auth/login. Leave unset for +# direct localhost access or when your proxy forwards the header. +# SECURITY: only enable when the API is not reachable directly over plain +# HTTP from outside — i.e. only your TLS edge proxy can hit it. Otherwise +# anyone on the same network can submit plain-HTTP requests that the app +# trusts as HTTPS. +TRUST_PROXY_HTTPS=false # pino log level: trace | debug | info | warn | error | fatal LOG_LEVEL=info # 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED. @@ -125,6 +144,11 @@ LOCAL_STORAGE_SIGNING_SECRET= # the first-run wizard at /setup. SEED_ADMIN_PASSWORD= SEED_USER_PASSWORD= +# Set to `true` to populate the Executive Meetings module with a full +# day of realistic-looking demo meetings on first boot (useful for +# screenshots, sales demos, and UI walkthroughs). Leave unset on real +# self-hosted installs — the module should start empty. +SEED_DEMO_MEETINGS=false # ----------------------------------------------------------------------------- # Email (optional) diff --git a/README.md b/README.md index 2817b322..ba4ea686 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,43 @@ Front the SPA (port `WEB_PORT`, default `3000`) with Caddy / Nginx / Traefik for TLS and HTTP/2; bind the API port (`API_PORT`, default `8080`) to `127.0.0.1` in production so the edge proxy is the sole external entry. +### Reverse proxy / Tailscale / external URL + +If you front Tx OS with a TLS-terminating proxy that you don't control +(Tailscale `serve`, ngrok, Cloudflare Tunnel, …), two things matter: + +1. **Add the public URL to `ALLOWED_ORIGINS` in `.env`.** If you skip + this, the browser blocks every API call from the SPA with a CORS + error and the app appears frozen on the login screen. + + ```bash + ALLOWED_ORIGINS=http://localhost:3000,https://it-demo.tail70b2bc.ts.net + PUBLIC_BASE_URL=https://it-demo.tail70b2bc.ts.net + ``` + +2. **Set `TRUST_PROXY_HTTPS=true` in `.env`.** Tunnels like Tailscale + serve forward HTTPS traffic to the upstream as plain HTTP without + sending an `X-Forwarded-Proto: https` header. Without this flag, + `req.secure` is false, the session cookie is silently dropped, and + the user is bounced back to the login screen on every refresh. + + > **Security note**: only enable `TRUST_PROXY_HTTPS=true` when the + > API container/port is **not** reachable directly over plain HTTP + > from outside (i.e. only the TLS edge proxy can hit it). The + > default Docker compose only exposes the SPA port (`3000`) to the + > host and keeps the API on the internal `tx-net` network, which + > satisfies this requirement. If you change the compose file to + > expose the API port publicly, do not enable this flag. + +After editing `.env`, restart the stack: + +```bash +docker compose up -d +``` + +You should be able to log in via the public HTTPS URL with no other +changes. + ### Default seeded accounts | Username | Password | Role | @@ -171,7 +208,9 @@ complete list with comments. Highlights: | --------------------------------- | ---------------------------------------------------------------------- | | `DATABASE_URL` | Postgres connection string. **Required.** | | `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** | -| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. Defaults to `*` (dev only). | +| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. MUST list every URL the SPA is reached at (LAN IP, Tailscale name, custom domain, ...). | +| `TRUST_PROXY_HTTPS` | `true` when fronted by a TLS-terminating proxy that doesn't forward `X-Forwarded-Proto` (Tailscale serve, ngrok free, some Cloudflare Tunnels). Required for the session cookie to persist on those setups. | +| `SEED_DEMO_MEETINGS` | `true` to populate Executive Meetings with a day of demo data on first boot. Default off so real installs start empty. | | `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. | | `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. | | `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.| diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index e3b44841..40d0a7f7 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -13,6 +13,25 @@ const app: Express = express(); app.set("trust proxy", 1); +// Self-hosted deployments are commonly fronted by a TLS-terminating +// proxy that does NOT forward `X-Forwarded-Proto: https` to the +// upstream (e.g. `tailscale serve`, ngrok free tier, some Cloudflare +// Tunnel configs). Without that header, `req.secure` stays false and +// the session cookie (when marked `secure: true`) is silently dropped +// — login appears to succeed but the very next request is unauth'd. +// +// Setting `TRUST_PROXY_HTTPS=true` tells us to treat every incoming +// request as if it arrived over HTTPS. Only enable this when the +// edge proxy you control actually terminates TLS in front of Tx OS. +const trustProxyHttps = + (process.env.TRUST_PROXY_HTTPS ?? "").toLowerCase() === "true"; +if (trustProxyHttps) { + app.use((req, _res, next) => { + req.headers["x-forwarded-proto"] = "https"; + next(); + }); +} + app.use( pinoHttp({ logger, @@ -55,11 +74,30 @@ app.use( app.use(express.json()); app.use(express.urlencoded({ extended: true })); +// The session cookie should only be marked `secure` when the user is +// actually reaching us over HTTPS. We derive that from PUBLIC_BASE_URL +// instead of NODE_ENV so a self-hosted production install that's only +// reachable over plain HTTP (e.g. internal LAN, no TLS yet) still +// works without operator surgery. +const publicBaseUrl = process.env.PUBLIC_BASE_URL ?? ""; +const sessionCookieSecure = + publicBaseUrl.startsWith("https://") || trustProxyHttps; + export const sessionMiddleware = session({ store: new PgSession({ pool, tableName: "user_sessions", + // Auto-create the session table on first boot. Drizzle's migrations + // intentionally exclude `user_sessions` (see lib/db/drizzle.config.ts) + // because the schema is owned by connect-pg-simple, not by us. Without + // this flag, the very first login on a fresh install 500s. + createTableIfMissing: true, }), + // Honour `X-Forwarded-Proto` from upstream proxies when deciding + // whether to set the secure cookie. Combined with `app.set("trust + // proxy", 1)` above, this makes the cookie work end-to-end behind + // Caddy / nginx / Cloudflare / Tailscale. + proxy: true, secret: process.env.SESSION_SECRET ?? (() => { if (process.env.NODE_ENV === "production") { throw new Error("SESSION_SECRET must be set in production"); @@ -69,7 +107,7 @@ export const sessionMiddleware = session({ resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === "production", + secure: sessionCookieSecure, httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: "lax", diff --git a/docker-compose.yml b/docker-compose.yml index b8e9e11f..d7638a7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: # the first-run Setup Wizard (/api/setup/complete). SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-} + SEED_DEMO_MEETINGS: ${SEED_DEMO_MEETINGS:-false} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} user: root entrypoint: ["/usr/bin/tini", "--"] @@ -62,6 +63,7 @@ services: DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} + TRUST_PROXY_HTTPS: ${TRUST_PROXY_HTTPS:-false} LOG_LEVEL: ${LOG_LEVEL:-info} HTTPS_MODE: ${HTTPS_MODE:-local} LOCAL_DOMAIN: ${LOCAL_DOMAIN:-} diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index bb533d47..ab3df7a5 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -646,13 +646,26 @@ async function main() { } } - // Executive meetings: sample data for today (idempotent per-date) + // Executive meetings: sample data for today (idempotent per-date). + // + // Opt-in only — a vanilla self-hosted install should start with an + // empty Executive Meetings module. Set `SEED_DEMO_MEETINGS=true` in + // your environment when bootstrapping a demo / staging deploy that + // benefits from realistic-looking sample data. + const seedDemoMeetings = + (process.env.SEED_DEMO_MEETINGS ?? "").toLowerCase() === "true"; const today = new Date().toISOString().slice(0, 10); - const existingForToday = await db - .select({ id: executiveMeetingsTable.id }) - .from(executiveMeetingsTable) - .where(eq(executiveMeetingsTable.meetingDate, today)); - if (existingForToday.length === 0) { + const existingForToday = seedDemoMeetings + ? await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, today)) + : []; + if (!seedDemoMeetings) { + console.log( + "Executive Meetings demo data skipped (set SEED_DEMO_MEETINGS=true to enable)", + ); + } else if (existingForToday.length === 0) { { const sampleMeetings: Array<{ meeting: {