diff --git a/.gitea/workflows/deploy-galactus.yml b/.gitea/workflows/deploy-galactus.yml index 739d283..2050244 100644 --- a/.gitea/workflows/deploy-galactus.yml +++ b/.gitea/workflows/deploy-galactus.yml @@ -252,6 +252,7 @@ jobs: "S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}", "DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}", "SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}", + "SESSION_COOKIE_SECURE": "false", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7f01ba1..6298d51 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -25,6 +25,26 @@ async function bootstrap() { throw new Error("SESSION_SECRET must be set (see .env.example)"); } + // Whether the session cookie carries the Secure flag. This CANNOT simply + // follow NODE_ENV: express-session silently declines to send a Secure cookie + // over a plain-HTTP connection, so a production image served over http://ial + // issues no cookie at all. Login then returns 200 with a user, no session is + // established, every later request 403s, and the UI loops back to /login — + // which is exactly what happened on the first galactus deploy. + // + // Leave it ON wherever the app is reached over TLS. Turn it OFF only for a + // deployment that is HTTP but reached over an already-encrypted transport + // (the galactus install is Tailscale-only, so WireGuard encrypts the wire). + // Behind a TLS-terminating proxy, set trust proxy instead of turning this off. + // An EMPTY value counts as unset, not as "false". Compose interpolation turns + // an absent `${SESSION_COOKIE_SECURE:-}` into the empty string, so testing + // `!== undefined` here would silently drop the Secure flag on any deployment + // that merely passes the variable through without setting it. + const cookieSecureRaw = process.env.SESSION_COOKIE_SECURE; + const cookieSecure = cookieSecureRaw + ? cookieSecureRaw === "true" + : process.env.NODE_ENV === "production"; + app.use( session({ secret: sessionSecret, @@ -32,7 +52,7 @@ async function bootstrap() { saveUninitialized: false, cookie: { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: cookieSecure, maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday }, }) diff --git a/deploy/galactus/jorgecuadros-app.compose.yml b/deploy/galactus/jorgecuadros-app.compose.yml index e8b1275..4b243f5 100644 --- a/deploy/galactus/jorgecuadros-app.compose.yml +++ b/deploy/galactus/jorgecuadros-app.compose.yml @@ -44,6 +44,13 @@ services: environment: DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} + # This deployment is HTTP, so a Secure session cookie would never be sent + # and login would silently never establish a session (express-session + # declines to emit a Secure cookie over a plain connection). Acceptable + # here ONLY because galactus is reachable exclusively over Tailscale, so + # WireGuard already encrypts the wire. Set this back to "true" the moment + # the app is served over TLS or exposed off-tailnet. + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set} PORT: "3001" INGEST_DIR: /data/ingest diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index 77ebdba..711bce6 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -46,6 +46,14 @@ COPY --from=build /repo/node_modules node_modules COPY --from=build /repo/packages/database packages/database COPY --from=build /repo/apps/api/dist apps/api/dist COPY --from=build /repo/apps/api/package.json apps/api/package.json +# Operational scripts, run on demand — never automatically. seed-user.mjs is the +# only way to create the first sign-in account on a fresh database, and without +# it in the image that had to be done from a developer's machine against a +# production DATABASE_URL. Run it with: +# docker exec node apps/api/scripts/seed-user.mjs +# honouring SEED_EMAIL / SEED_PASSWORD / SEED_NAME. It upserts, so re-running is +# safe — but note it RESETS the password of an existing account. +COPY --from=build /repo/apps/api/scripts apps/api/scripts # node-linker=hoisted flattens EXTERNAL deps into /repo/node_modules, but the # workspace dependency is still linked per-package: # apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database diff --git a/docs/DEPLOY_AND_MIGRATIONS.md b/docs/DEPLOY_AND_MIGRATIONS.md index 50375c1..4706b67 100644 --- a/docs/DEPLOY_AND_MIGRATIONS.md +++ b/docs/DEPLOY_AND_MIGRATIONS.md @@ -192,6 +192,46 @@ Still open, and **not** handled by anything in this repo: - The channel to the VPS crosses the public internet. It needs a tunnel or TLS — do not publish raw 3306. +## Seeding the first sign-in account + +A freshly migrated database has a schema and **no users**, so nobody can log in. +`prisma migrate deploy` creates tables, never rows; nothing in the deploy path +seeds an account, by design — creating an administrator should be a deliberate +act, not a side effect of shipping code. + +`apps/api/scripts/seed-user.mjs` ships inside the API image. On the target host: + +```bash +docker exec -e SEED_PASSWORD='' \ + node apps/api/scripts/seed-user.mjs +``` + +Defaults are `admin@jorgecuadros.local` / `ChangeMe!2026` / role `ADMIN`, +overridable with `SEED_EMAIL`, `SEED_PASSWORD`, `SEED_NAME`. **Do not accept the +default password on anything but a dev database** — it is published in this +repo's README. The script upserts by email, so re-running is safe, but it also +**resets the password of an existing account**. + +## The session cookie and TLS + +`SESSION_COOKIE_SECURE` controls the `Secure` flag on the session cookie. It +defaults to on in production, and it must be explicitly `"false"` for a +deployment served over plain HTTP. + +This is not cosmetic. express-session silently declines to emit a `Secure` +cookie over an unencrypted connection: no `Set-Cookie` header is sent at all, +`POST /auth/login` still answers `200` with the user object, no session is +established, every subsequent request gets `403`, and the UI bounces back to +`/login` in a loop. It looks like an auth bug and is really a transport +mismatch. + +galactus runs with `SESSION_COOKIE_SECURE=false`, which is acceptable **only** +because it is reachable exclusively over Tailscale — WireGuard already encrypts +the wire, so the cookie never crosses an untrusted network. Turn it back on the +moment the app is served over TLS or reachable off-tailnet. Behind a +TLS-terminating reverse proxy, set `trust proxy` on the Nest app instead of +disabling the flag. + ## Known caveats in the deploy path - The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because