diff --git a/__tests__/dev-env.test.ts b/__tests__/dev-env.test.ts index 8451396..81f0a6b 100644 --- a/__tests__/dev-env.test.ts +++ b/__tests__/dev-env.test.ts @@ -28,6 +28,46 @@ describe('dev-env / buildDevEnv', () => { expect(env.app.env.NUXT_PUBLIC_STORAGE_PREFIX).toBe('crm'); }); + test('gives the App a session password so logins work on the built server', () => { + // Regression: `lt dev test` serves the *built* Nitro server, which never reads the project's + // .env. Without a password every login answered 500 ("H3Error: Empty password") and roughly + // half of a project's Playwright suite failed on a cause unrelated to its tests. + const env = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, identity: fullIdentity }); + + // h3 rejects anything shorter than 32 characters + expect(env.app.env.NUXT_SESSION_PASSWORD).toHaveLength(32); + }); + + test('derives the session password deterministically per project', () => { + const first = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, identity: fullIdentity }); + const second = buildDevEnv({ apiInternalPort: 4020, appInternalPort: 4021, identity: fullIdentity }); + + // Same slug → same value, so a restart does not invalidate open sessions and every shard + // of `lt dev test --shard N` agrees + expect(second.app.env.NUXT_SESSION_PASSWORD).toBe(first.app.env.NUXT_SESSION_PASSWORD); + + const other = buildDevEnv({ + apiInternalPort: 4010, + appInternalPort: 4011, + identity: { ...fullIdentity, slug: 'shop' }, + }); + + // Different project → different value, so one stack's cookies never validate against another + expect(other.app.env.NUXT_SESSION_PASSWORD).not.toBe(first.app.env.NUXT_SESSION_PASSWORD); + }); + + test('never overrides a session password the project already set', () => { + // A project with real session data must keep its own value — lt dev only fills the gap + const env = buildDevEnv({ + apiInternalPort: 4010, + appInternalPort: 4011, + baseEnv: { NUXT_SESSION_PASSWORD: 'project-owned-value-with-32-chars' }, + identity: fullIdentity, + }); + + expect(env.app.env.NUXT_SESSION_PASSWORD).toBe('project-owned-value-with-32-chars'); + }); + test('pins HOST to 127.0.0.1 for both API and App so Caddy upstream stays unambiguous', () => { // Regression: without HOST=127.0.0.1 Nuxt / Nest may bind to // `[::1]` only on macOS, and Caddy's IPv4 upstream gets a diff --git a/src/lib/dev-env-bridge.ts b/src/lib/dev-env-bridge.ts index 7c05304..d68d337 100644 --- a/src/lib/dev-env-bridge.ts +++ b/src/lib/dev-env-bridge.ts @@ -83,6 +83,9 @@ export function writeEnvBridge(projectRoot: string, devEnv: DevEnv, dbName?: str 'NUXT_PUBLIC_SITE_URL', 'NUXT_PUBLIC_STORAGE_PREFIX', 'NUXT_PUBLIC_API_PROXY', + // Not needed by the runner itself, but external suites check it to tell a stack that + // can log in from one that will 500 on every login (see dev-env.ts). + 'NUXT_SESSION_PASSWORD', 'NSC__MONGOOSE__URI', 'DATABASE_URL', // Legacy aliases — see dev-env.ts for the rationale. diff --git a/src/lib/dev-env.ts b/src/lib/dev-env.ts index 766d757..c0a5bbf 100644 --- a/src/lib/dev-env.ts +++ b/src/lib/dev-env.ts @@ -18,6 +18,8 @@ * subdomains succeed. Without this Nuxt SSR fails with "unable to * get local issuer certificate" when the app calls its own API. */ +import { createHash } from 'node:crypto'; + import { detectCaddyRootCa } from './dev-env-bridge'; import { DevIdentity } from './dev-identity'; @@ -108,6 +110,17 @@ export function buildDevEnv(input: BuildDevEnvInput): DevEnv { // same-origin trickery is no longer required. NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug, + // Nuxt/h3 sessions refuse to start without a password (>= 32 chars): every login + // answers 500 "H3Error: Empty password". `nuxt dev` papers over this by reading the + // project's .env, but `lt dev test` serves the *built* Nitro server, which never does — + // so a project with a perfectly good .env still saw half its E2E suite fail on an error + // that has nothing to do with its tests. + // + // Derived from the slug rather than random so sessions survive a restart and every + // shard of `lt dev test --shard N` agrees. Local-only by construction: it never reaches + // a deployed environment, and a project that sets its own value keeps it (baseEnv wins + // because this key is only added when the inherited env has none). + ...(baseEnv.NUXT_SESSION_PASSWORD ? {} : { NUXT_SESSION_PASSWORD: deriveSessionPassword(identity.slug) }), PORT: String(appInternalPort), // macOS: the default $TMPDIR (/var/folders/…/T/, ~49 chars) pushes Nuxt's // vite-node IPC socket path past the 104-char UNIX sun_path limit, so the @@ -125,3 +138,15 @@ export function buildDevEnv(input: BuildDevEnvInput): DevEnv { function buildPostgresUrl(dbName: string): string { return `postgresql://${dbName}:${dbName}@localhost:5432/${dbName}`; } + +/** + * Stable local session password for a project's app process. + * + * 32 hex chars — h3 rejects anything shorter. Deterministic per slug: the same project always + * gets the same value, so restarting the stack does not invalidate open sessions and parallel + * shards stay consistent. Not a secret in any meaningful sense and not meant to be one; it exists + * so a local stack boots without hand-set environment variables. + */ +function deriveSessionPassword(slug: string): string { + return createHash('sha256').update(`lt-dev:session:${slug}`).digest('hex').slice(0, 32); +} diff --git a/src/lib/dev-patches.ts b/src/lib/dev-patches.ts index 5148058..e67383e 100644 --- a/src/lib/dev-patches.ts +++ b/src/lib/dev-patches.ts @@ -160,7 +160,7 @@ export function patchClaudeMd(file: string, options: { dbName?: string; identity if (dbName) lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``); lines.push(''); lines.push( - 'Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.', + 'Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NUXT_SESSION_PASSWORD`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.', ); lines.push(''); lines.push(endMarker);