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..b0b4f747 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, every authenticated request fails CORS and login appears to + succeed but the next request 500s. + + ```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: {