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
This commit is contained in:
riyadhafraa
2026-05-15 10:38:44 +00:00
parent fa31fb6374
commit 77f8096deb
5 changed files with 126 additions and 10 deletions
+26 -2
View File
@@ -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)
+40 -1
View File
@@ -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`.|
+39 -1
View File
@@ -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",
+2
View File
@@ -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:-}
+19 -6
View File
@@ -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: {