diff --git a/README.md b/README.md index ccd21e3..e9f3b03 100644 --- a/README.md +++ b/README.md @@ -124,23 +124,26 @@ DEV_EMAIL=your.email@mail.utoronto.ca DEV_ROLE=PROFESSOR # or STUDENT ``` -> **The `DEV_*` variables must never be set in production.** The auth route falls back to `DEV_UTORID` whenever the Shibboleth header is missing, so setting them on the server would allow unauthenticated logins. - -| Variable | Required | Description | -| ----------------------------------- | :------: | --------------------------------------------------------------------------- | -| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres credentials | -| `REDIS_PASSWORD` | Yes | Passed to the Redis container as `--requirepass` | -| `SESSION_SECRET` | Yes | Key for iron-session cookie encryption. Changing it logs everyone out. | -| `PROFESSOR_WHITELIST` | Yes | UTORids granted the PROFESSOR role on login. Everyone else is a STUDENT. | -| `ADMIN_WHITELIST` | Yes | UTORids granted `/dashboard` access. Empty means nobody can administer. | -| `CRON_SECRET` | Yes | Bearer token for the cleanup-sessions cron endpoint | -| `DATABASE_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | -| `REDIS_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | -| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | -| `DEV_NAME` | Dev | Display name for the fake dev user | -| `DEV_EMAIL` | Dev | Email for the fake dev user; defaults to `@mail.utoronto.ca` | -| `DEV_ROLE` | Dev | `PROFESSOR` or `STUDENT` — overrides whitelist lookup | -| `SOCKET_IO_USE_REDIS` | No | Set to `"false"` to disable the Socket.IO Redis adapter. Enabled otherwise. | +> **The `DEV_*` variables are ignored in production.** The auth route only reads them when `NODE_ENV !== "production"`; a production request arriving without a Shibboleth header is rejected with a 401 rather than falling back to `DEV_UTORID`. Keep them out of production environments regardless — `docker-compose.override.yml` is the only place they belong. + +| Variable | Required | Description | +| ------------------------------------------- | :------: | ------------------------------------------------------------------------------------------------------------------------- | +| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres credentials | +| `REDIS_PASSWORD` | Yes | Passed to the Redis container as `--requirepass` | +| `SESSION_SECRET` | Yes | Key for iron-session cookie encryption. Changing it logs everyone out. | +| `PROFESSOR_WHITELIST` | Yes | UTORids granted the PROFESSOR role on login. Everyone else is a STUDENT. | +| `ADMIN_WHITELIST` | Yes | UTORids granted `/dashboard` access. Empty means nobody can administer. | +| `CRON_SECRET` | Yes | Bearer token for the cleanup-sessions cron endpoint | +| `DATABASE_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `REDIS_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | +| `DEV_NAME` | Dev | Display name for the fake dev user | +| `DEV_EMAIL` | Dev | Email for the fake dev user; defaults to `@mail.utoronto.ca` | +| `DEV_ROLE` | Dev | `PROFESSOR` or `STUDENT` — overrides whitelist lookup | +| `DEV_PROF_*` / `DEV_TA_*` / `DEV_STUDENT_*` | Dev | Per-persona `UTORID` / `NAME` / `ROLE` / `EMAIL` for `pnpm dev:all`. Each falls back to a default with a startup warning. | +| `SESSION_COOKIE_NAME` | No | Set per instance by `pnpm dev:all`. Ignored in production. | +| `NEXT_DIST_DIR` | No | Set per instance by `pnpm dev:all` so concurrent dev servers don't share `.next`. | +| `SOCKET_IO_USE_REDIS` | No | Set to `"false"` to disable the Socket.IO Redis adapter. Enabled otherwise. | Whitelists are read once at startup and cached, so restart the app after changing them. @@ -191,6 +194,62 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000). The app auto-reloads on changes. +### Testing multi-user flows: `pnpm dev:all` + +A single dev server can only ever be one user — identity comes from `DEV_UTORID` / +`DEV_NAME` / `DEV_ROLE`, which are process-global. To test a student asking a question +while a professor answers it, run three instances at once: + +```bash +pnpm dev:all +``` + +| Persona | URL | Cookie | +| --------- | --------------------- | --------------------- | +| `PROF` | http://localhost:3000 | `askeasy-dev-prof` | +| `TA` | http://localhost:3001 | `askeasy-dev-ta` | +| `STUDENT` | http://localhost:3002 | `askeasy-dev-student` | + +Open all three in tabs of the same window. Each instance gets its own session cookie +name, so they don't clobber each other — browser cookies are keyed by host and ignore +the port, meaning a single shared name would make every tab become whoever logged in +last. All three share one Postgres and one Redis, which is what lets events broadcast +between them. + +Configure each persona in `.env` (all optional — anything missing falls back to a +default and prints a warning at startup): + +```bash +DEV_PROF_UTORID=devprof +DEV_PROF_NAME=Dev Professor +DEV_PROF_ROLE=PROFESSOR + +DEV_TA_UTORID=devta +DEV_TA_NAME=Dev TA +DEV_TA_ROLE=TA + +DEV_STUDENT_UTORID=devstudent +DEV_STUDENT_NAME=Dev Student +DEV_STUDENT_ROLE=STUDENT +``` + +Notes: + +- `DEV_ROLE` overrides the whitelist lookup, so the PROF persona does **not** need to be + in `PROFESSOR_WHITELIST`. It **does** need to be in `ADMIN_WHITELIST` for `/dashboard` + access, and whitelists are cached at startup — restart after editing. +- Leave `SOCKET_IO_USE_REDIS` unset. With the adapter disabled, events don't cross + instances: a question asked in one tab won't appear in the others until you refresh. +- Each instance runs its own Next compiler against its own build dir (`.next-prof`, + `.next-ta`, `.next-student`), so expect roughly 3× the memory and CPU of `pnpm dev`. +- Instances start one at a time rather than all at once. Next rewrites `next-env.d.ts` + during startup and each instance wants its own build dir in it, so concurrent + startups interleave those writes and corrupt the file — which then breaks + `pnpm typecheck` and the pre-commit hook. Startup therefore takes about three + times as long as `pnpm dev`. +- `pnpm dev` is unaffected and still uses the `ask_easy_session` cookie, so switching + between the two modes won't log you out of either. + ### Switching branches `git checkout` only updates tracked files. The Prisma client (`src/generated/`) and `node_modules/` are gitignored, so they keep whatever the previous branch left behind. Run this after switching to any branch that touches `prisma/schema.prisma` or `package.json`: diff --git a/docker-compose.yml b/docker-compose.yml index 9bd0809..0f1c3d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ask_easy} - REDIS_URL=redis://:${REDIS_PASSWORD:-changeme}@redis:6379 - - SESSION_SECRET=${SESSION_SECRET} + - SESSION_SECRET=${SESSION_SECRET:?required in prod.env} - PROFESSOR_WHITELIST=${PROFESSOR_WHITELIST} - ADMIN_WHITELIST=${ADMIN_WHITELIST} # DEV_* live in docker-compose.override.yml so production never sees them. diff --git a/eslint.config.mjs b/eslint.config.mjs index bb1390f..494e52f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,8 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + // Per-instance build dirs created by `pnpm dev:all` + ".next-*/**", "out/**", "build/**", "next-env.d.ts", diff --git a/next.config.ts b/next.config.ts index 5a86b0a..1c0ac07 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,9 @@ const scriptSrc = isProd const nextConfig: NextConfig = { output: "standalone", + // `pnpm dev:all` gives each instance its own build dir so three concurrent + // dev servers from one checkout don't fight over `.next`. Unset otherwise. + distDir: process.env.NEXT_DIST_DIR || ".next", serverExternalPackages: ["socket.io", "ioredis", "@socket.io/redis-adapter"], async headers() { return [ diff --git a/package.json b/package.json index 249d904..e8458c9 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "prisma:generate": "prisma generate", "typecheck": "tsc --noEmit", "dev": "cross-env NODE_ENV=development tsx watch src/server.ts", + "dev:all": "tsx scripts/dev-all.ts", "build": "next build", "start": "tsx src/server.ts", "lint": "eslint . --report-unused-disable-directives --max-warnings 3", diff --git a/scripts/dev-all.ts b/scripts/dev-all.ts new file mode 100644 index 0000000..b825bba --- /dev/null +++ b/scripts/dev-all.ts @@ -0,0 +1,371 @@ +/** + * Multi-instance dev launcher — `pnpm dev:all` + * + * Spins up three dev servers, each permanently logged in as a different + * persona, so multi-user flows (a student asking, a TA resolving, a professor + * answering) can be tested in three tabs of one browser window. + * + * PROF -> http://localhost:3000 askeasy-dev-prof + * TA -> http://localhost:3001 askeasy-dev-ta + * STUDENT -> http://localhost:3002 askeasy-dev-student + * + * Identity is process-global (src/app/api/auth/session/route.ts reads + * DEV_UTORID / DEV_NAME / DEV_ROLE from the environment), so one identity + * requires one process. This script resolves each persona from the DEV_

_* + * vars in .env, falls back to a documented default with a warning, and spawns + * a child with those values injected. + * + * Each child also gets its own SESSION_COOKIE_NAME. Browser cookies are keyed + * by host and ignore the port, so without distinct names all three instances + * would share one cookie and every tab would become whoever logged in last. + * + * All three share one Postgres and one Redis — that is the point. The + * Socket.IO Redis adapter is what carries events between the processes. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import type { Readable } from "node:stream"; + +import dotenv from "dotenv"; + +// Resolve personas from the same values the servers will see (src/server.ts:5-6). +dotenv.config(); +dotenv.config({ path: ".env.local", override: true }); + +// --------------------------------------------------------------------------- +// Personas +// --------------------------------------------------------------------------- + +const ROLES = ["STUDENT", "TA", "PROFESSOR"] as const; +type Role = (typeof ROLES)[number]; + +interface PersonaSpec { + key: string; + port: number; + cookieName: string; + distDir: string; + color: string; + defaults: { utorid: string; name: string; role: Role }; +} + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const YELLOW = "\x1b[33m"; +const RED = "\x1b[31m"; + +const PERSONAS: PersonaSpec[] = [ + { + key: "PROF", + port: 3000, + cookieName: "askeasy-dev-prof", + distDir: ".next-prof", + color: "\x1b[35m", // magenta + defaults: { utorid: "devprof", name: "Dev Professor", role: "PROFESSOR" }, + }, + { + key: "TA", + port: 3001, + cookieName: "askeasy-dev-ta", + distDir: ".next-ta", + color: "\x1b[36m", // cyan + defaults: { utorid: "devta", name: "Dev TA", role: "TA" }, + }, + { + key: "STUDENT", + port: 3002, + cookieName: "askeasy-dev-student", + distDir: ".next-student", + color: "\x1b[32m", // green + defaults: { utorid: "devstudent", name: "Dev Student", role: "STUDENT" }, + }, +]; + +interface ResolvedPersona { + spec: PersonaSpec; + utorid: string; + name: string; + email: string; + role: Role; +} + +const warnings: string[] = []; +const errors: string[] = []; + +function resolvePersona(spec: PersonaSpec): ResolvedPersona { + const read = (suffix: string, fallback: string): string => { + const varName = `DEV_${spec.key}_${suffix}`; + const value = process.env[varName]?.trim(); + if (value) return value; + warnings.push(`${varName} is not set — using default "${fallback}".`); + return fallback; + }; + + const utorid = read("UTORID", spec.defaults.utorid); + const name = read("NAME", spec.defaults.name); + const role = read("ROLE", spec.defaults.role) as Role; + + if (!ROLES.includes(role)) { + // route.ts casts DEV_ROLE straight to the Prisma enum, so a typo would + // otherwise surface as an opaque database error on first login. + errors.push(`DEV_${spec.key}_ROLE is "${role}" — must be one of ${ROLES.join(", ")}.`); + } + + // Always set explicitly: a single global DEV_EMAIL shared by all three + // personas would collide on the User table's unique email. + const email = process.env[`DEV_${spec.key}_EMAIL`]?.trim() || `${utorid}@mail.utoronto.ca`; + + return { spec, utorid, name, email, role }; +} + +// --------------------------------------------------------------------------- +// Pre-flight +// --------------------------------------------------------------------------- + +function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.once("error", () => resolve(false)); + probe.once("listening", () => probe.close(() => resolve(true))); + probe.listen(port, "0.0.0.0"); + }); +} + +function resolveTsxBin(): string { + const local = path.join(process.cwd(), "node_modules", ".bin", "tsx"); + return existsSync(local) ? local : "tsx"; +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/** Tags every line of a child stream so interleaved output stays readable. */ +function pipePrefixed(stream: Readable, prefix: string, onLine?: (line: string) => void): void { + let pending = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + process.stdout.write(`${prefix} ${line}\n`); + onLine?.(line); + } + }); + stream.on("end", () => { + if (pending.length > 0) { + process.stdout.write(`${prefix} ${pending}\n`); + onLine?.(pending); + } + }); +} + +/** + * Next rewrites next-env.d.ts during startup, and each instance wants its own + * distDir in the import line. A previous concurrent run may have interleaved + * those writes and left garbage behind, which breaks `pnpm typecheck` and so + * the pre-commit hook. The file is gitignored and regenerated, so drop it if it + * contains anything other than the references, imports and comments Next emits. + */ +function repairNextEnv(): void { + const file = path.join(process.cwd(), "next-env.d.ts"); + if (!existsSync(file)) return; + + const corrupted = readFileSync(file, "utf8") + .split("\n") + .some((raw) => { + const line = raw.trim(); + if (line === "") return false; + return !line.startsWith("//") && !line.startsWith("import "); + }); + + if (corrupted) { + unlinkSync(file); + console.log(`${YELLOW}⚠${RESET} Removed a corrupted next-env.d.ts — Next will regenerate it.`); + } +} + +function printSummary(resolved: ResolvedPersona[]): void { + if (warnings.length > 0) { + console.log(""); + for (const warning of warnings) { + console.log(`${YELLOW}⚠${RESET} ${warning}`); + } + console.log(`${DIM} Set these explicitly in your .env to avoid surprises.${RESET}`); + } + + if (process.env.SOCKET_IO_USE_REDIS === "false") { + console.log(""); + console.log( + `${YELLOW}⚠${RESET} SOCKET_IO_USE_REDIS is "false", so the Socket.IO Redis adapter is off.` + ); + console.log( + `${DIM} Events will not cross instances: a question asked in one tab will not${RESET}` + ); + console.log( + `${DIM} appear in the others until you refresh. Unset it for realtime testing.${RESET}` + ); + } + + console.log(""); + for (const { spec, utorid, name, role } of resolved) { + const label = `${spec.color}${BOLD}${spec.key.padEnd(8)}${RESET}`; + const url = `http://localhost:${spec.port}`; + console.log(` ${label} ${url} ${DIM}${name} (${utorid}, ${role})${RESET}`); + } + console.log(""); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const children: ChildProcess[] = []; +let shuttingDown = false; + +function shutdown(code: number): void { + if (shuttingDown) return; + shuttingDown = true; + + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + } + + // Give children a moment to exit cleanly, then leave regardless. + setTimeout(() => process.exit(code), 2000).unref(); + + void Promise.all( + children.map( + (child) => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) return resolve(); + child.once("exit", () => resolve()); + }) + ) + ).then(() => process.exit(code)); +} + +const READY_PATTERN = /Ready on/; +const READY_TIMEOUT_MS = 120_000; + +/** Spawns one instance and resolves once it reports ready (or gives up waiting). */ +function launch(persona: ResolvedPersona, tsx: string): Promise { + const { spec, utorid, name, email, role } = persona; + + const child = spawn(tsx, ["watch", "src/server.ts"], { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + NODE_ENV: "development", + PORT: String(spec.port), + DEV_UTORID: utorid, + DEV_NAME: name, + DEV_EMAIL: email, + DEV_ROLE: role, + SESSION_COOKIE_NAME: spec.cookieName, + NEXT_DIST_DIR: spec.distDir, + }, + }); + + children.push(child); + + return new Promise((resolve) => { + let settled = false; + const settle = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + + const timer = setTimeout(() => { + console.warn( + `${YELLOW}⚠${RESET} ${spec.key} did not report ready within ${READY_TIMEOUT_MS / 1000}s — continuing anyway.` + ); + settle(); + }, READY_TIMEOUT_MS); + + const prefix = `${spec.color}[${spec.key}]${RESET}`; + const onLine = (line: string): void => { + if (READY_PATTERN.test(line)) settle(); + }; + if (child.stdout) pipePrefixed(child.stdout, prefix, onLine); + if (child.stderr) pipePrefixed(child.stderr, prefix, onLine); + + child.on("error", (err) => { + console.error(`${RED}\u2716${RESET} ${spec.key} failed to start: ${err.message}`); + settle(); + shutdown(1); + }); + + child.on("exit", (code, signal) => { + settle(); + if (shuttingDown) return; + // A half-running set is more confusing than none — tear the rest down. + console.error( + `${RED}\u2716${RESET} ${spec.key} exited unexpectedly (${signal ?? `code ${code}`}). Stopping the others.` + ); + shutdown(code ?? 1); + }); + }); +} + +async function main(): Promise { + const resolved = PERSONAS.map(resolvePersona); + + // Two personas sharing a utorid are the same database user, which defeats + // the entire purpose of running three instances. + const seen = new Map(); + for (const { spec, utorid } of resolved) { + const previous = seen.get(utorid.toLowerCase()); + if (previous) { + errors.push( + `DEV_${spec.key}_UTORID and DEV_${previous}_UTORID are both "${utorid}" — each persona needs a distinct UTORid.` + ); + } + seen.set(utorid.toLowerCase(), spec.key); + } + + const portChecks = await Promise.all( + resolved.map(async ({ spec }) => ({ spec, free: await isPortFree(spec.port) })) + ); + for (const { spec, free } of portChecks) { + if (!free) errors.push(`Port ${spec.port} (${spec.key}) is already in use.`); + } + + if (errors.length > 0) { + console.error(""); + for (const error of errors) console.error(`${RED}✖${RESET} ${error}`); + console.error(""); + process.exit(1); + } + + printSummary(resolved); + + repairNextEnv(); + + const tsx = resolveTsxBin(); + + // Start one instance at a time, waiting for each to report ready. + // + // Next rewrites next-env.d.ts (and can rewrite tsconfig.json) during + // app.prepare(), and each instance wants its own distDir in the import line. + // Launching all three at once interleaves those writes and corrupts the file, + // which then breaks `pnpm typecheck` and the pre-commit hook. + for (const persona of resolved) { + await launch(persona, tsx); + if (shuttingDown) return; + } + + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); +} + +main().catch((err) => { + console.error("Failed to launch dev instances:", err); + process.exit(1); +}); diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 9f4d6b1..5f807da 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -46,8 +46,10 @@ export async function GET(request: NextRequest) { email = request.headers.get("mail") ?? request.headers.get("email"); } - if (!isProd || !utorid) { - // Dev mode or local Docker without Shibboleth — fall back to DEV_UTORID. + if (!isProd) { + // Dev only — no Shibboleth available, so fall back to DEV_UTORID. + // This must never run in production: a missing `utorid` header there is an + // authentication failure, not an invitation to log in as DEV_UTORID. utorid = utorid ?? process.env.DEV_UTORID ?? null; name = name ?? process.env.DEV_NAME ?? utorid; email = email ?? process.env.DEV_EMAIL ?? `${utorid}@mail.utoronto.ca`; diff --git a/src/lib/devCookie.ts b/src/lib/devCookie.ts new file mode 100644 index 0000000..a13b355 --- /dev/null +++ b/src/lib/devCookie.ts @@ -0,0 +1,27 @@ +// --------------------------------------------------------------------------- +// Session cookie name resolution +// +// `pnpm dev:all` runs three dev servers on localhost:3000-3002, each logged in +// as a different persona. Browser cookies are keyed by host and ignore the +// port, so all three ports share one cookie jar — with a single cookie name +// they would clobber each other's session and every tab would silently become +// whoever logged in last. +// +// The launcher gives each instance its own SESSION_COOKIE_NAME. Each process +// only ever looks up its own name and is blind to the other two. +// +// This module is deliberately dependency-free: src/middleware.ts imports it and +// runs in the Edge runtime. +// --------------------------------------------------------------------------- + +export const DEFAULT_COOKIE_NAME = "ask_easy_session"; + +/** + * Returns the iron-session cookie name for this process. + * + * Safety condition added to ensure dev cookies aren't read in production. + */ +export function resolveCookieName(): string { + if (process.env.NODE_ENV === "production") return DEFAULT_COOKIE_NAME; + return process.env.SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME; +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 52ea9ec..58a77e8 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,5 +1,7 @@ import type { SessionOptions } from "iron-session"; +import { resolveCookieName } from "@/lib/devCookie"; + // --------------------------------------------------------------------------- // Session data shape stored inside the encrypted cookie // --------------------------------------------------------------------------- @@ -16,24 +18,26 @@ export interface SessionData { // iron-session configuration (lazy so build can run without SESSION_SECRET) // --------------------------------------------------------------------------- -const KNOWN_WEAK_SECRET = "replace-me-with-a-32-plus-char-random-string"; - export function getSessionOptions(): SessionOptions { const secret = process.env.SESSION_SECRET; + if (!secret) { throw new Error( - "SESSION_SECRET environment variable is not set. Generate one with: openssl rand -hex 32" + "SESSION_SECRET environment variable is not set. Generate a strong secret with: openssl rand -hex 32" ); } - if (secret === KNOWN_WEAK_SECRET || secret.length < 32) { + + if (secret.length < 32) { throw new Error( - "SESSION_SECRET is insecure: it is either the example placeholder or shorter than 32 characters. " + - "Generate a strong secret with: openssl rand -hex 32" + "SESSION_SECRET is insecure: it is shorter than 32 characters. Generate a strong secret with: openssl rand -hex 32" ); } + return { password: secret, - cookieName: "ask_easy_session", + // Normally "ask_easy_session". `pnpm dev:all` overrides it per instance so + // three dev servers on localhost don't clobber each other's cookie. + cookieName: resolveCookieName(), cookieOptions: { secure: process.env.NODE_ENV === "production", httpOnly: true, diff --git a/src/middleware.ts b/src/middleware.ts index 5734a97..0776614 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { unsealData } from "iron-session"; +import { resolveCookieName } from "@/lib/devCookie"; import type { SessionData } from "@/lib/session"; // --------------------------------------------------------------------------- @@ -34,8 +35,9 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } - // Check for a valid iron-session cookie - const cookieValue = request.cookies.get("ask_easy_session")?.value; + // Check for a valid iron-session cookie. The name must match the one + // /api/auth/session writes — under `pnpm dev:all` that is per-instance. + const cookieValue = request.cookies.get(resolveCookieName())?.value; if (cookieValue) { try { const sessionSecret = process.env.SESSION_SECRET; diff --git a/src/server.ts b/src/server.ts index da22662..03de8fc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,30 @@ import dotenv from "dotenv"; +// Vars injected per-instance by scripts/dev-all.ts. The `override: true` below +// would otherwise let .env.local clobber them and silently collapse all three +// dev instances into a single identity. Empty under plain `pnpm dev`. +const INJECTED_KEYS = [ + "PORT", + "DEV_UTORID", + "DEV_NAME", + "DEV_EMAIL", + "DEV_ROLE", + "SESSION_COOKIE_NAME", + "NEXT_DIST_DIR", +] as const; + +const injected = Object.fromEntries( + INJECTED_KEYS.filter((key) => process.env[key] !== undefined).map((key) => [ + key, + process.env[key] as string, + ]) +); + // Load .env, then let .env.local override (same as prisma.config.ts). // Required for `pnpm dev` outside Docker: hosts must be localhost, not postgres/redis. dotenv.config(); dotenv.config({ path: ".env.local", override: true }); +Object.assign(process.env, injected); import { createServer, type IncomingMessage } from "node:http"; import next from "next"; diff --git a/src/socket/middleware/auth.ts b/src/socket/middleware/auth.ts index 5fb647f..f2c9eed 100644 --- a/src/socket/middleware/auth.ts +++ b/src/socket/middleware/auth.ts @@ -14,7 +14,7 @@ import type { SocketData } from "../types"; // // Flow: // 1. Parse the `cookie` header from the Socket.IO handshake. -// 2. Find the `ask_easy_session` cookie value. +// 2. Find the session cookie by name (resolveCookieName in lib/devCookie). // 3. Unseal it with iron-session to recover { userId, utorid, role, … }. // 4. Populate socket.data and call next(). // --------------------------------------------------------------------------- diff --git a/stress-tests/auth.ts b/stress-tests/auth.ts index 75f057c..f707ea4 100644 --- a/stress-tests/auth.ts +++ b/stress-tests/auth.ts @@ -19,7 +19,9 @@ interface SessionData { role: string; } -const COOKIE_NAME = "ask_easy_session"; +// Matches resolveCookieName() in src/lib/devCookie.ts — `pnpm dev:all` runs +// each instance with its own cookie name. +const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "ask_easy_session"; export async function mintCookie(user: StressUser, role: string): Promise { const secret = process.env.SESSION_SECRET; diff --git a/tsconfig.json b/tsconfig.json index efbbb10..a2d4d34 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,19 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.mts"], - "exclude": ["node_modules", ".next", ".next-prof", ".next-ta"] + "include": [ + "next-env.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + ".next-prof/types/**/*.ts", + ".next-prof/dev/types/**/*.ts", + ".next-ta/types/**/*.ts", + ".next-ta/dev/types/**/*.ts", + ".next-student/types/**/*.ts", + ".next-student/dev/types/**/*.ts" + ], + "exclude": ["node_modules", ".next", ".next-prof", ".next-ta", ".next-student"] }