Prod came up with nobody able to log in, in two separate ways.
1. No sign-in account exists. `prisma migrate deploy` creates tables, never
rows, and nothing in the deploy path seeds one — deliberately, since making
an administrator should not be a side effect of shipping code. But
apps/api/scripts was not in the runtime image either, so the only way to
create the first account was to run the script from a developer machine
against a production DATABASE_URL. Ship scripts/ in the image so it can be
run on the host with docker exec. Still never run automatically.
2. Login could not establish a session at all. cookie.secure followed NODE_ENV,
the image sets NODE_ENV=production, and the app is served over plain HTTP —
express-session then silently emits NO Set-Cookie header. POST /auth/login
still answered 200 with the full user object, no session was created, every
later request 403'd, and the UI would have looped back to /login. It reads
as an auth bug and is really a transport mismatch.
The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to
NODE_ENV. An EMPTY value counts as unset rather than false, because compose
turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the
naive check would have quietly dropped Secure on any deployment that merely
passed the variable through.
galactus sets it to "false". That is acceptable ONLY because the host is
reachable exclusively over Tailscale, so WireGuard already encrypts the
wire. It must go back to "true" when the app is served over TLS or exposed
off-tailnet; behind a TLS-terminating proxy, set trust proxy instead.
Verified against live prod: seeded an admin, POST /auth/login returns 200 with
full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie
was present before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
import "reflect-metadata";
|
|
import { NestFactory } from "@nestjs/core";
|
|
import { ValidationPipe } from "@nestjs/common";
|
|
import * as session from "express-session";
|
|
import * as passport from "passport";
|
|
import { AppModule } from "./app.module";
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule);
|
|
|
|
// Every request body is validated and stripped of unknown fields before it
|
|
// reaches a controller — this is the structural replacement for the old
|
|
// app's complete lack of input validation (src/core/db.php took every
|
|
// $_POST field straight into a SQL string).
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
})
|
|
);
|
|
|
|
const sessionSecret = process.env.SESSION_SECRET;
|
|
if (!sessionSecret) {
|
|
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,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
httpOnly: true,
|
|
secure: cookieSecure,
|
|
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
|
},
|
|
})
|
|
);
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
app.enableCors({ credentials: true, origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
|
|
|
|
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
|
await app.listen(port);
|
|
}
|
|
|
|
bootstrap();
|