From 1b48cd18bded96b769daa5892d10e22d5aecc0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ko=CC=88nig?= Date: Wed, 26 Aug 2026 15:27:08 +0200 Subject: [PATCH 1/2] fix(dev): give the app process a session password `lt dev test` can rely on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuxt/h3 refuses to start a session without a password of at least 32 characters, so every login answers 500. `nuxt dev` never runs into it, because the dev server reads the project's `.env`. `lt dev test` serves the built Nitro server, which reads only `process.env` — so a project with a perfectly good `.env` watched half its E2E suite fail on a cause that has nothing to do with its tests. Found in SWF DNA: 42 of 92 Playwright tests red. Three runs with an identical `.env`, the only difference being the export into the shell: .env present, no export → 42 red .env present, no export → 42 red .env present, exported → 92 green The same project's CI job creates the variable itself and was never affected — which is precisely why this surfaces locally and stays invisible in the pipeline. `lt dev` now supplies the variable to the app process itself, the way the CI job does: src/lib/dev-env.ts app env gets NUXT_SESSION_PASSWORD, derived from the slug src/lib/dev-env-bridge.ts the value reaches `.lt-dev/.env`, so an external suite can tell a stack that can log in from one that 500s per login src/lib/dev-patches.ts the variable is named in the CLAUDE.md block `lt dev` writes Two deliberate decisions: Deterministic rather than random. Derived from the slug, so a session survives a `lt dev down` / `up`. Keyed on the slug rather than the project, so the dev stack and its parallel test stack get different values and one stack's cookies never validate against another's. A value the project set itself wins. The key is only added when the inherited environment carries none — `lt dev` fills a gap, it does not overwrite. Refs DEV-2972 --- __tests__/dev-env.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ src/lib/dev-env-bridge.ts | 3 +++ src/lib/dev-env.ts | 25 ++++++++++++++++++++++++ src/lib/dev-patches.ts | 2 +- 4 files changed, 69 insertions(+), 1 deletion(-) 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); From 250ef916aee91343df5ef7b4ee84171fd4dcd10b Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Wed, 2 Sep 2026 21:20:14 +0200 Subject: [PATCH 2/2] fix(dev): stop deriving a session key from a value the app publishes itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the commit before this one. Three of its properties did not survive checking, and one of them was a security hole. **The derivation had no entropy.** `sha256('lt-dev:session:')` takes only the slug — which the app itself ships to every browser as `NUXT_PUBLIC_STORAGE_PREFIX` in the SSR payload, and which `lt dev tunnel` puts behind a public URL on request. Anyone who loaded a page could recompute the key sealing that stack's session cookies and mint one of their own. "Not a secret" describes the intent, not what the code does the moment a project actually seals sessions with it. It is now `HMAC-SHA256(machine salt, 'lt-dev:session:')`, with the salt at `~/.lenneTech/dev-session-salt` (0600, created with `wx` so parallel shards cannot race to two different salts, `LT_DEV_SESSION_SALT_PATH` overrides for tests). Determinism, restart survival and per-slug distinctness are unchanged. **"A project that sets its own value keeps it" was true only for a shell export.** The guard read `process.env`, but a project keeps this value in `projects/app/.env` — the file the whole commit exists because the built server does not read. dotenv-style loaders do not override an already-set `process.env` key, so `lt dev`'s value silently beat the project's own file, under `nuxt dev` as well. `buildDevEnv` now takes an optional `appDir` and resolves shell export → the app's `.env` → derived fallback. Forwarding the `.env` value is also what fixes the built server for exactly those projects. **Shards do not agree on one password, they deliberately differ.** `testStackNames` suffixes `-test-N`, so every shard runs under its own slug. The claim was in a comment, in a test name and in the commit message; the property it described is real but is the opposite one — different stacks get different keys, which is what makes this a cross-wiring guard. Also from the review: - The ENV bridge carries a sealing key now, so `.lt-dev/.env` is written 0600 and chmod'ed on rewrite (`mode` only applies on create, so a file from an older `lt` kept its 0644). Its header promised "no secrets" — a promise the next reviewer would have relied on. - Root `.dockerignore` learns `**/.output-*` and `**/.nuxt-*`. `**/.output` matches a path component exactly, so it never covered the isolated `lt dev test` build dirs — which Nitro builds with the app's full env and therefore freeze the value. - Two source files changed with no test that could fail. The bridge fixture carried no `NUXT_SESSION_PASSWORD` at all, so the new export was vacuous, and nothing pinned the CLAUDE.md env-var line — the whole sentence could be deleted and stay green. Both are covered now, along with the app-vs-API split, the dev/test/shard triple, the hex alphabet, the empty-string case and both `.env` precedence paths. The override fixture was named `…with-32-chars` and was 33 characters long. - `npm ci` failed with `Missing: @emnapi/core@1.11.3 from lock file` while every pipeline stayed green, because nothing ran `npm ci`. npm >= 11.5 prunes peer+optional nodes from the ideal tree and never writes them; npm <= 11.4 demands them. `npm install` on a current npm cannot repair it — the fix is to declare both as devDependencies, which is the remedy arborist's own source comment names, verified across npm 10 and 11.6 and stable under a later `npm install`. `build.yml` runs `npm ci` now, so the next drift fails CI instead of shipping. `docs/commands.md`, the touchpoint table, the cross-wiring paragraph and the file header learn the new key; CLAUDE.md gets an entry for each of the two traps. npm run check: green (66 suites, 1018 tests, audit 0 findings). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 6 +- CLAUDE.md | 88 ++++++++++++++- __tests__/dev-env-bridge.test.ts | 23 +++- __tests__/dev-env.test.ts | 160 ++++++++++++++++++++++++--- __tests__/dev-patches.test.ts | 22 ++++ docs/commands.md | 21 ++++ package-lock.json | 26 ++++- package.json | 6 ++ src/commands/dev/up.ts | 3 + src/lib/dev-env-bridge.ts | 18 +++- src/lib/dev-env.ts | 162 +++++++++++++++++++++++++--- src/lib/dev-test-session.ts | 3 + src/lib/ensure-root-dockerignore.ts | 7 ++ 13 files changed, 505 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74b23c9..4e55684 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,11 @@ jobs: with: node-version: 20 - name: Install dependencies - run: npm install + # `npm ci` and not `npm install`: it FAILS on a lockfile that disagrees with + # package.json, which is the only guard against the drift that shipped through + # three releases unnoticed (see CLAUDE.md → "An out-of-sync lockfile is invisible + # to `npm install`"). Node 20 here means npm 10 — the strict generation. + run: npm ci - name: Lint run: npm run lint - name: Build diff --git a/CLAUDE.md b/CLAUDE.md index 95ca755..568edb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,7 @@ ports. Developers and Claude Code never see the internal ports. | Concern | File | Notes | |---|---|---| | Identity (slug + subdomains) | `src/lib/dev-identity.ts` | `projectSlug` reads `package.json` "name" (scope stripped, slugified); `buildIdentity` enumerates `projects/api`/`projects/app` (monorepo) or detects `config.env.ts`/`nuxt.config.ts` (standalone). | -| ENV builder | `src/lib/dev-env.ts` | Single source of truth for `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_*`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Always URLs, never bare ports.** `NUXT_PUBLIC_API_PROXY=false` because Caddy + cookie-domain make vite-proxy obsolete. | +| ENV builder | `src/lib/dev-env.ts` | Single source of truth for `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_*`, `NUXT_SESSION_PASSWORD`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Always URLs, never bare ports.** `NUXT_PUBLIC_API_PROXY=false` because Caddy + cookie-domain make vite-proxy obsolete. `NUXT_SESSION_PASSWORD` resolves shell export → the app's own `.env` (via the optional `appDir` input) → a fallback derived as `HMAC-SHA256(machine salt, 'lt-dev:session:')`, 32 hex chars; the salt lives at `~/.lenneTech/dev-session-salt` (0600, override `LT_DEV_SESSION_SALT_PATH`). | | Registry + session state | `src/lib/dev-state.ts` | Central registry `~/.lenneTech/projects.json` (override via `LT_DEV_REGISTRY_PATH`); per-project session at `/.lt-dev/state.json`. Atomic writes; PID validation gate via `isValidPid` / `isPidAlive`. | | Caddy integration | `src/lib/caddy.ts` | One block per project, marked with `# >>> lt-dev: >>>`/`# <<<`. `upsertProjectBlock` is idempotent; `removeProjectBlock` is a no-op when absent. Caddyfile path overridable via `LT_DEV_CADDYFILE`. The daemon is owned by `lt dev install` (see `dev-service.ts`) — **never** rely on `brew services caddy`: its plist hardcodes `--config /opt/homebrew/etc/Caddyfile` and crash-loops against our location, which is the bug that originally blocked the first real `lt dev install`. | | VS Code memory profile | `src/commands/dev/vscode.ts` + `src/lib/vscode-settings.ts` | `lt dev vscode` (alias `vsc`) tunes the USER `settings.json` of VS Code / Insiders / Cursor / VSCodium. Machine-level, not project-level — no registry or Caddy involvement. JSONC-aware via `jsonc-parser` (lazy-required, since gluegun loads every command on every `lt` run) so comments survive; refuses an unparseable file or a symlink; keeps the FIRST `settings.json.bak`. Object-valued exclude maps are MERGED on apply (user entries win) and SUBTRACTED on `--revert`, so an undo never removes a hand-maintained exclusion. `MEMORY_PROFILE` targets the per-root SEMANTIC TS servers and excludes `**/.nuxt*/**` / `**/.output*/**` (segment globs — the bare names miss `.nuxt-test`); `EXCLUDED_FROM_PROFILE` records three commonly recommended keys that were ruled out, surfaced by `--explain`. `--dry-run` uses presence-as-intent (`isPreventingFlagSet`), not `=== true`, because it PREVENTS a write; `--noConfirm` must be an explicit CLI flag — a repo-local `lt.config.json` must not silence a prompt guarding a machine-global write. | @@ -250,7 +250,7 @@ ports. Developers and Claude Code never see the internal ports. 8. **`lt dev test [--api] [--keep] [--debug] [-- args]`** / **`lt dev test down`** — App mode (default) brings up an ISOLATED parallel stack (`-test.localhost` / `api.-test.localhost`, DB `<…>-test`), runs Playwright against it, then tears it down. The dev `lt dev up` session is never touched. `--keep` leaves the test stack up for debugging; `lt dev test down` tears a leftover stack down. `--api` runs the API E2E suite in the api project instead (already DB-isolated, no stack needed). Forwards args after `--` to the test runner. 9. **`lt dev tunnel [--api]`** — Cloudflare Quick Tunnel: foreground `cloudflared tunnel --url https://.localhost --http-host-header .localhost --no-tls-verify`, prints the public `*.trycloudflare.com` URL. The host-header rewrite is mandatory — without it Caddy's vhost match fails for the random tunnel URL. Tunnels only expose ONE subdomain at a time; start a second `lt dev tunnel --api` in another shell for full external usage. -**Cross-wiring protection:** API gets `APP_URL` so Better-Auth `trustedOrigins` only includes its own App; App gets `BASE_URL` so it only talks to its own API; localStorage is namespaced via `NUXT_PUBLIC_STORAGE_PREFIX=`; Mongo URI is namespaced via `NSC__MONGOOSE__URI=mongodb://127.0.0.1/`. The isolated `lt dev test` stack reuses the same protections under a `-test` suffix (slug `<…>-test`, DB `<…>-test`, prefix `<…>-test`, port band 4500+) so it can run literally side-by-side with the dev session. +**Cross-wiring protection:** API gets `APP_URL` so Better-Auth `trustedOrigins` only includes its own App; App gets `BASE_URL` so it only talks to its own API; localStorage is namespaced via `NUXT_PUBLIC_STORAGE_PREFIX=`; Mongo URI is namespaced via `NSC__MONGOOSE__URI=mongodb://127.0.0.1/`; h3 session cookies are namespaced via `NUXT_SESSION_PASSWORD`, keyed per slug so a cookie sealed by one stack cannot be unsealed by another (dev, `-test` and every `-test-N` shard stack differ). The isolated `lt dev test` stack reuses the same protections under a `-test` suffix (slug `<…>-test`, DB `<…>-test`, prefix `<…>-test`, port band 4500+) so it can run literally side-by-side with the dev session. ### Vendor Modification Policy (for CLI-generated content) @@ -774,6 +774,90 @@ projects predating the starter's globs), and `vscode-settings.ts#MEMORY_PROFILE` to exclude). `tearDownTestSession` removes the suffixed dirs — never the bare ones, which belong to the developer's own build. +### An out-of-sync lockfile is invisible to `npm install` — and only an npm you no longer run can write it + +`npm ci` on this repo failed with `Missing: @emnapi/core@1.11.3 from lock file` while every +pipeline stayed green, because nothing here ran `npm ci` — `scripts/check.sh` and both GitHub +workflows used `npm install`, which happily installs around a lockfile that disagrees with +`package.json`. The drift entered at 1.42.0 and survived two more releases. + +**The trap is that the naive fix is a silent no-op.** `@napi-rs/wasm-runtime` 1.x moved +`@emnapi/core` / `@emnapi/runtime` from `dependencies` to `peerDependencies`, and the only path +to them here is both dev-only and optional (`eslint-plugin-import-x` / `jest-resolve` → +`unrs-resolver` → `@unrs/resolver-binding-wasm32-wasi`, `cpu: wasm32` → `@napi-rs/wasm-runtime`). +npm **>= 11.5.0** (arborist 9.1.3) deliberately prunes nodes that are both peer and optional from +the ideal tree, so it never writes them; npm **<= 11.4** still demands them and fails. So +`npm install`, `npm install --package-lock-only`, `--force` and `--cpu=wasm32` all changed +nothing on npm 11.6 — the modern npm cannot repair the file, and regenerating it with an old npm +makes it oscillate by ten lines on every `check`. + +**Rules:** + +- **Declare the peer explicitly** (here: both `@emnapi/*` as devDependencies). That is the remedy + arborist's own source comment names — an optional peer stops being optional once a root + dependency requires it — and it is the only one both npm generations agree on. Verified across + npm 10 → 11.6, and `npm install` no longer strips it. +- **Document it next to the entry.** `//devDependencies` in `package.json` mirrors the existing + `//overrides` convention and carries the removal condition, so an unused-looking dependency is + not deleted by the next maintenance pass. +- **A fix nothing enforces comes back.** `.github/workflows/build.yml` now runs `npm ci`, so a + lockfile that drifts again fails CI instead of shipping. Node 20 there means npm 10 — the strict + generation, which is what makes the guard meaningful. +- **When a lockfile claim disagrees with your machine, check the npm version before the file.** + This one reproduced on npm <= 11.4 and passed on 11.5+; "works for me" was a version, not a + mistake by the reporter. + +### The built Nitro server reads no `.env` — what `nuxt dev` got for free is missing under `lt dev test` + +`lt dev up` runs the App via `nuxt dev`, which loads the project's `.env`. `lt dev test` serves +the **built** Nitro server (`node .output*/server/index.mjs`), which reads only `process.env`. So +every key a project keeps in `.env` and never exports silently vanishes in the test stack. Nitro +*does* apply `NUXT_*` runtime-config overrides from `process.env` at runtime, which is why the fix +is always "inject it into the spawn", never "bake it into the build". + +Worked example: Nuxt/h3 sessions need a password of at least 32 characters — an **absent** one +throws `H3Error: Empty password`, a **short** one `Password string too short (min 32 characters +required)`. Two distinct errors from `iron-webcrypto` (`minPasswordlength: 32`), reached via h3's +`seal`/`unseal`; quoting the first while describing the second sends a debugger hunting for a +string that is never printed. Without a password every login answered 500 and **42 of 92 Playwright +specs failed on assertions unrelated to their subject** — the same expensive mis-signal as the +`.nuxt` build-dir lock above: it reads as a broken suite while being pure infrastructure. + +**Rules for adding such a key to `buildDevEnv`:** + +- **Let the project win, and mean it.** Precedence is shell export → the app's own `.env` (read via + the optional `appDir` input) → the derived fallback. Consulting only `baseEnv` is not enough: a + project keeps this kind of value in `.env`, and dotenv-style loaders do not override an + already-set `process.env` key — so an injected value silently beats the project's own file, in + `nuxt dev` too. Forwarding the `.env` value explicitly is also what fixes the built server for + exactly those projects. +- **Derive it, never randomise it.** A fresh value per run logs everybody out on every restart. +- **Key it on the slug, and do not claim shards agree — they deliberately do not.** `testStackNames` + suffixes `-test-N`, so the dev stack, the test stack and each shard stack get DIFFERENT values. + That is the point: it makes the key a cross-wiring guard, so one stack's cookies can never + validate against another's. +- **Salt anything that seals or signs.** The slug is public — the app publishes it as + `NUXT_PUBLIC_STORAGE_PREFIX` in every SSR payload, and `lt dev tunnel` can put that app on a + public URL. `sha256('lt-dev:session:')` therefore handed any visitor the key sealing its + sessions. The salt (`~/.lenneTech/dev-session-salt`, 0600, created with `wx` so parallel shards + cannot race to two different salts) keeps determinism and removes the guessability. Deriving a + credential from a public identifier is zero entropy however good the hash is. +- **A new key is a new name SIX enumerations must learn:** the `dev-env.ts` file-header + "Cross-wiring protection" list, the `ENV builder` row of the touchpoint table above, the + "Cross-wiring protection" paragraph below it, `dev-env-bridge.ts#writeEnvBridge` (external + runners), `dev-patches.ts#patchClaudeMd` (consumer projects' Claude sessions), and both env-var + tables in `docs/commands.md`. +- **Say who consumes it.** `NUXT_SESSION_PASSWORD` is inert for the standard lt stack — + nuxt-base-starter and nuxt-extensions authenticate via Better Auth, not h3 sessions. It helps + only projects that added h3 `useSession` / `nuxt-auth-utils` on top. A key listed in the + injected-CLAUDE.md block next to `BASE_URL` reads as load-bearing everywhere unless you say so. +- **The bridge file is now credential-bearing.** `.lt-dev/.env` carries the sealing key, so it is + written 0600 (and chmod'ed on rewrite, since `mode` only applies on create). Its header used to + promise "no secrets" — a promise a later reviewer would have relied on. +- **A build dir built with that env carries it too.** `.output-test/` freezes the value as a Nitro + runtime-config default, so the root `.dockerignore` needs `**/.output-*` and `**/.nuxt-*` — + `**/.output` matches a path component exactly and covers neither. + ### A destructive self-heal must prove the hazard, not fail to recognise a guard Anything the CLI writes ONCE into a generated project has no update path — the core updater only touches `src/core/`. `migrations-utils/migrate.js` is the example: diff --git a/__tests__/dev-env-bridge.test.ts b/__tests__/dev-env-bridge.test.ts index 96c84ba..9fc799c 100644 --- a/__tests__/dev-env-bridge.test.ts +++ b/__tests__/dev-env-bridge.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -20,6 +20,7 @@ const fakeDevEnv: DevEnv = { NUXT_PUBLIC_API_URL: 'https://api.crm.localhost', NUXT_PUBLIC_SITE_URL: 'https://crm.localhost', NUXT_PUBLIC_STORAGE_PREFIX: 'crm', + NUXT_SESSION_PASSWORD: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', PORT: '4011', SITE_URL: 'https://crm.localhost', }, @@ -50,6 +51,26 @@ describe('dev-env-bridge', () => { expect(content).toContain('LT_DEV_DB_NAME=crm-local'); }); + test('exports NUXT_SESSION_PASSWORD and keeps the file readable by its owner only', () => { + // Regression: the key was added to `writeEnvBridge`'s export list while the fixture here + // carried no such key, so deleting the line again left every bridge test green. A suite + // that seals its own session cookie needs the value — and because it IS a sealing key, + // the bridge file must not be world-readable. + const file = writeEnvBridge(project, fakeDevEnv, 'crm-local'); + expect(readFileSync(file, 'utf8')).toContain('NUXT_SESSION_PASSWORD=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'); + expect(statSync(file).mode & 0o777).toBe(0o600); + }); + + test('heals the mode of a bridge written by an older lt version', () => { + // `writeFileSync`'s `mode` only applies on CREATE, so an existing 0644 file from before + // this change would silently keep its permissions. + const file = writeEnvBridge(project, fakeDevEnv, 'crm-local'); + chmodSync(file, 0o644); + // Content-compare short-circuits an identical rewrite, so change the db name to force one + writeEnvBridge(project, fakeDevEnv, 'crm-other'); + expect(statSync(file).mode & 0o777).toBe(0o600); + }); + test('exports legacy aliases API_URL + SITE_URL', () => { // Regression: projects that read `process.env.API_URL` directly // (no `NUXT_PUBLIC_` prefix) need the alias in the bridge too, diff --git a/__tests__/dev-env.test.ts b/__tests__/dev-env.test.ts index 81f0a6b..3f43e14 100644 --- a/__tests__/dev-env.test.ts +++ b/__tests__/dev-env.test.ts @@ -1,5 +1,9 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + import { buildDevEnv } from '../src/lib/dev-env'; -import { DevIdentity } from '../src/lib/dev-identity'; +import { buildTestIdentity, DevIdentity } from '../src/lib/dev-identity'; const fullIdentity: DevIdentity = { root: '/tmp/fake', @@ -11,6 +15,27 @@ const fullIdentity: DevIdentity = { }; describe('dev-env / buildDevEnv', () => { + // `deriveSessionPassword` reads (and on first use creates) a machine-local salt. Redirect it + // into a tmpdir for the whole suite, so running the tests never writes to the developer's + // real `~/.lenneTech/` — and so the derived values stay stable across the cases below. + let saltDir: string; + let previousSaltPath: string | undefined; + + beforeAll(() => { + saltDir = mkdtempSync(join(tmpdir(), 'lt-dev-env-')); + previousSaltPath = process.env.LT_DEV_SESSION_SALT_PATH; + process.env.LT_DEV_SESSION_SALT_PATH = join(saltDir, 'dev-session-salt'); + }); + + afterAll(() => { + if (previousSaltPath === undefined) { + delete process.env.LT_DEV_SESSION_SALT_PATH; + } else { + process.env.LT_DEV_SESSION_SALT_PATH = previousSaltPath; + } + rmSync(saltDir, { force: true, recursive: true }); + }); + test('sets URL-based env for both API and App', () => { const env = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, dbName: 'crm', identity: fullIdentity }); expect(env.api.env.PORT).toBe('4010'); @@ -29,21 +54,31 @@ describe('dev-env / buildDevEnv', () => { }); 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. + // Regression: `lt dev test` serves the *built* Nitro server, which reads only `process.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 }); + + // 32 hex chars: h3's floor is 32 characters, and pinning the ALPHABET too means a change of + // digest or encoding is caught — `toHaveLength(32)` alone would accept base64 just as well. + expect(env.app.env.NUXT_SESSION_PASSWORD).toMatch(/^[0-9a-f]{32}$/); + }); + + test('keeps the session password on the App only — the API has no h3 session', () => { + // The key sits in the app block, not in `sharedKeys`. Moving it is a one-line, plausible + // refactor, and nothing else in the suite would notice. 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); + expect(env.api.env.NUXT_SESSION_PASSWORD).toBeUndefined(); }); - test('derives the session password deterministically per project', () => { + test('derives the session password deterministically per slug', () => { 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 + // Same slug, different ports → same value. That is what lets a stack be stopped and started + // again without logging everybody out. (It does NOT mean all shards of `lt dev test --shard` + // agree — each shard runs under its own `-test-N` slug, see the next case.) expect(second.app.env.NUXT_SESSION_PASSWORD).toBe(first.app.env.NUXT_SESSION_PASSWORD); const other = buildDevEnv({ @@ -56,16 +91,115 @@ describe('dev-env / buildDevEnv', () => { 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 + test('gives the dev stack and its parallel test stacks different session passwords', () => { + // The pair that actually co-exists on one machine is not "two projects" but the dev stack + // and the `lt dev test` stack it runs beside — plus one stack per shard (`testStackNames` + // suffixes `-test-N`). Each must seal its own cookies, or a test run could resurrect a + // session from the developer's parked `lt dev up`. + const dev = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, identity: fullIdentity }); + const test = buildDevEnv({ + apiInternalPort: 4510, + appInternalPort: 4511, + identity: buildTestIdentity(fullIdentity), + }); + const shard = buildDevEnv({ + apiInternalPort: 4520, + appInternalPort: 4521, + identity: buildTestIdentity(fullIdentity, '-test-2'), + }); + + const values = [dev, test, shard].map((e) => e.app.env.NUXT_SESSION_PASSWORD); + expect(new Set(values).size).toBe(3); + }); + + test('never overrides a session password exported in the shell', () => { + // A project with real session data must keep its own value — lt dev only fills the gap. + // The fixture is exactly 32 characters, so it also stands for a value h3 would accept. + const shellValue = 'project-owned-value-with-32-char'; + expect(shellValue).toHaveLength(32); + + const env = buildDevEnv({ + apiInternalPort: 4010, + appInternalPort: 4011, + baseEnv: { NUXT_SESSION_PASSWORD: shellValue }, + identity: fullIdentity, + }); + + expect(env.app.env.NUXT_SESSION_PASSWORD).toBe(shellValue); + }); + + test('forwards a session password the project set in its own .env', () => { + // The case the fix exists for: the project HAS a password, in the file the built Nitro + // server never reads. Forwarding it (rather than deriving a replacement) is what makes + // "lt dev fills a gap, it does not replace a value the project chose" actually true. + const appDir = mkdtempSync(join(tmpdir(), 'lt-app-')); + try { + writeFileSync( + join(appDir, '.env'), + ['# a comment', '', 'NUXT_PUBLIC_FOO=bar', 'NUXT_SESSION_PASSWORD="dotenv-owned-value-32-chars-x"'].join('\n'), + 'utf8', + ); + + const env = buildDevEnv({ apiInternalPort: 4010, appDir, appInternalPort: 4011, identity: fullIdentity }); + + // Quotes stripped, value taken verbatim — not the derived fallback + expect(env.app.env.NUXT_SESSION_PASSWORD).toBe('dotenv-owned-value-32-chars-x'); + } finally { + rmSync(appDir, { force: true, recursive: true }); + } + }); + + test('lets a shell export win over the app .env, and both over the derived fallback', () => { + const appDir = mkdtempSync(join(tmpdir(), 'lt-app-')); + try { + writeFileSync(join(appDir, '.env'), 'NUXT_SESSION_PASSWORD=from-dotenv-value-32-chars-ab\n', 'utf8'); + + const env = buildDevEnv({ + apiInternalPort: 4010, + appDir, + appInternalPort: 4011, + baseEnv: { NUXT_SESSION_PASSWORD: 'from-the-shell-value-32-chars-ab' }, + identity: fullIdentity, + }); + + expect(env.app.env.NUXT_SESSION_PASSWORD).toBe('from-the-shell-value-32-chars-ab'); + } finally { + rmSync(appDir, { force: true, recursive: true }); + } + }); + + test('treats an empty session password as absent — an empty one is what h3 rejects', () => { const env = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, - baseEnv: { NUXT_SESSION_PASSWORD: 'project-owned-value-with-32-chars' }, + baseEnv: { NUXT_SESSION_PASSWORD: '' }, identity: fullIdentity, }); - expect(env.app.env.NUXT_SESSION_PASSWORD).toBe('project-owned-value-with-32-chars'); + expect(env.app.env.NUXT_SESSION_PASSWORD).toMatch(/^[0-9a-f]{32}$/); + }); + + test('salts the derivation per machine, so the slug alone does not yield the password', () => { + // The slug is public: the app publishes it as `NUXT_PUBLIC_STORAGE_PREFIX` in every SSR + // payload, and `lt dev tunnel` can put that app on a public URL. If the slug were the only + // input, any visitor could recompute the key that seals the stack's session cookies. + const withFirstSalt = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, identity: fullIdentity }) + .app.env.NUXT_SESSION_PASSWORD; + + const otherMachine = mkdtempSync(join(tmpdir(), 'lt-salt-')); + const previous = process.env.LT_DEV_SESSION_SALT_PATH; + try { + process.env.LT_DEV_SESSION_SALT_PATH = join(otherMachine, 'dev-session-salt'); + const withSecondSalt = buildDevEnv({ apiInternalPort: 4010, appInternalPort: 4011, identity: fullIdentity }) + .app.env.NUXT_SESSION_PASSWORD; + + // Same slug, different machine → different value + expect(withSecondSalt).not.toBe(withFirstSalt); + expect(withSecondSalt).toMatch(/^[0-9a-f]{32}$/); + } finally { + process.env.LT_DEV_SESSION_SALT_PATH = previous; + rmSync(otherMachine, { force: true, recursive: true }); + } }); test('pins HOST to 127.0.0.1 for both API and App so Caddy upstream stays unambiguous', () => { diff --git a/__tests__/dev-patches.test.ts b/__tests__/dev-patches.test.ts index 4d5c17e..d9bd93f 100644 --- a/__tests__/dev-patches.test.ts +++ b/__tests__/dev-patches.test.ts @@ -472,6 +472,28 @@ describe('dev-patches', () => { expect(out).toContain(''); expect(out).toContain(''); }); + test('names every env var `lt dev up` injects, so a Claude session can rely on the list', () => { + // No test pinned this sentence, so a key could be added to `buildDevEnv` and forgotten + // here (or removed here and not noticed) — and this block is what a consumer project's + // Claude session reads instead of guessing `localhost:3000`. + const f = join(tmp, 'CLAUDE.md'); + writeFileSync(f, '# Project notes\n'); + patchClaudeMd(f, { dbName: 'crm-local', identity: fullIdentity }); + const out = readFileSync(f, 'utf8'); + for (const key of [ + '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', + ]) { + expect(out).toContain(`\`${key}\``); + } + }); test('idempotent: re-applies replace block in-place', () => { const f = join(tmp, 'CLAUDE.md'); writeFileSync(f, '# X\n'); diff --git a/docs/commands.md b/docs/commands.md index cffba34..91e1db1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -451,9 +451,24 @@ lt dev up | `NUXT_PUBLIC_SITE_URL` | Nuxt `useRuntimeConfig().public.siteUrl` + Playwright | `https://crm.localhost` | | `NUXT_PUBLIC_STORAGE_PREFIX` | namespaces sessionStorage/localStorage | `crm` | | `NUXT_PUBLIC_API_PROXY` | always `false` — Caddy + cookie-domain make it obsolete | `false` | +| `NUXT_SESSION_PASSWORD` | Nuxt/h3 `useSession` — only projects that added h3 sessions or `nuxt-auth-utils` read it; the Better-Auth default stack ignores it | `a1b2…` (32 hex chars) | | `NSC__MONGOOSE__URI` | nest-server Mongoose URI | `mongodb://127.0.0.1/crm-local` | | `DATABASE_URL` | Postgres convenience URL (for nest-base-style projects) | `postgresql://crm-local:crm-local@localhost:5432/crm-local` | +`NUXT_SESSION_PASSWORD` exists because `lt dev test` serves the **built** Nitro server, which +reads no `.env` — so a project that keeps its password there watched every login answer 500 +(`H3Error: Empty password`). Resolution order: a value exported in the shell, then the app's own +`.env`, then a fallback derived as `HMAC-SHA256(machine salt, 'lt-dev:session:')` truncated +to 32 hex chars. `lt dev` only fills the gap — it never replaces a value the project chose. + +The salt lives at `~/.lenneTech/dev-session-salt` (created once, mode 0600; override the path with +`LT_DEV_SESSION_SALT_PATH`). It is what makes the value unguessable: the slug itself is public — +the app ships it to every browser as `NUXT_PUBLIC_STORAGE_PREFIX`, and `lt dev tunnel` can put +that app on a public URL — so an unsalted derivation would hand any visitor the key that seals +the stack's session cookies. Keying on the slug also means the dev stack, the `lt dev test` stack +and each `--shard` stack get **different** passwords, so one stack's cookies never validate +against another's. + **Override the binary** for both spawns via `LT_PNPM_BIN` (e.g. `LT_PNPM_BIN=/usr/local/bin/pnpm lt dev up`). **Pre-flight guards (exit code 1 each):** @@ -685,10 +700,16 @@ lt dev test -- --ui spec.ts # everything after `--` is forwarded to playwri | `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL` | Same URLs for Nuxt | | `NUXT_PUBLIC_STORAGE_PREFIX` | Project slug | | `NUXT_PUBLIC_API_PROXY` | Always `false` under `lt dev` | +| `NUXT_SESSION_PASSWORD` | The app process's h3 session password — lets a suite tell a stack that can log in from one that 500s on every login, and seal its own cookie to skip the login form | | `NSC__MONGOOSE__URI`, `DATABASE_URL` | Project-namespaced DB URI (when `dbName` known) | | `LT_DEV_ACTIVE`, `LT_DEV_DB_NAME` | Marker keys for consumers | | `NODE_EXTRA_CA_CERTS` | Path to Caddy's root CA cert (auto-detected) | +Because `NUXT_SESSION_PASSWORD` is a session-sealing key, the bridge file is written with mode +`0600` (and chmod'ed on every rewrite, since a file created by an older `lt` would otherwise keep +its `0644`). It is gitignored via `.lt-dev/`. Treat it as credential-bearing when deciding what +else may be written there. + Additionally, `lt dev test` exports two build-directory keys into the app process it spawns. They are **not** written to the bridge file — they scope one run, not the project: diff --git a/package-lock.json b/package-lock.json index d94085b..5cd5a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "lt": "bin/lt" }, "devDependencies": { + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", "@lenne.tech/eslint-config-ts": "2.3.0", "@lenne.tech/npm-package-helper": "0.0.12", "@types/ejs": "3.1.5", @@ -1073,13 +1075,35 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.4.0" } diff --git a/package.json b/package.json index 8291e47..b20f137 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,13 @@ "turndown-plugin-gfm": "1.0.2", "typescript": "6.0.3" }, + "//devDependencies": { + "@emnapi/core": "Not imported by this CLI. @napi-rs/wasm-runtime 1.x moved @emnapi/core and @emnapi/runtime from dependencies to peerDependencies (napi-rs#3174), and the only path to them here is dev-only AND optional: eslint-plugin-import-x / jest-resolve > unrs-resolver > @unrs/resolver-binding-wasm32-wasi (cpu: wasm32) > @napi-rs/wasm-runtime. npm >= 11.5.0 (arborist 9.1.3) prunes nodes that are both peer and optional from the ideal tree, so it never writes them to the lockfile — while npm <= 11.4 still demands them and fails npm ci with \"Missing: @emnapi/core@1.11.3 from lock file\". Declaring them here is the remedy arborist's own source comment names: an optional peer stops being optional once a root dependency requires it, so both npm generations then agree. Verified: npm ci passes on npm 10 and 11.6, and npm install no longer strips the entries. Remove once @napi-rs/wasm-runtime declares them in peerDependenciesMeta as optional, or once npm >= 11.5 is the floor everywhere (GitHub Actions still runs Node 20 = npm 10).", + "@emnapi/runtime": "Same as @emnapi/core." + }, "devDependencies": { + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", "@lenne.tech/eslint-config-ts": "2.3.0", "@lenne.tech/npm-package-helper": "0.0.12", "@types/ejs": "3.1.5", diff --git a/src/commands/dev/up.ts b/src/commands/dev/up.ts index 940bb80..591c141 100644 --- a/src/commands/dev/up.ts +++ b/src/commands/dev/up.ts @@ -380,6 +380,9 @@ const UpCommand: GluegunCommand = { // Build env per process. const devEnv = buildDevEnv({ apiInternalPort: apiPort ?? 0, + // Lets `buildDevEnv` consult the app's own `.env` for keys a project may have set + // itself, so `lt dev` never replaces one of its values with a derived fallback. + appDir: layout.appDir, appInternalPort: appPort ?? 0, baseEnv: process.env, dbName, diff --git a/src/lib/dev-env-bridge.ts b/src/lib/dev-env-bridge.ts index d68d337..acb38c3 100644 --- a/src/lib/dev-env-bridge.ts +++ b/src/lib/dev-env-bridge.ts @@ -10,10 +10,13 @@ * Shell B does not inherit shell A's exports. Reading a file solves * this without polluting global state. * - * The file is gitignored via `.lt-dev/`. It contains only public URLs + - * the local CA path — no secrets. Format: standard dotenv KEY=VALUE. + * The file is gitignored via `.lt-dev/` and written 0600, because it is no longer + * URLs only: `NUXT_SESSION_PASSWORD` is a session-sealing key. Usually that is the + * value `lt dev` derived itself, but when the project exported its own it is THAT + * one being copied here — so treat the file as credential-bearing, and think twice + * before adding a key to it. Format: standard dotenv KEY=VALUE. */ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { homedir, platform } from 'os'; import { dirname, join } from 'path'; @@ -84,7 +87,9 @@ export function writeEnvBridge(projectRoot: string, devEnv: DevEnv, dbName?: str '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). + // can log in from one that will 500 on every login, and a suite that seals its own + // session cookie to skip the login form needs the value (see dev-env.ts). This is the + // one credential-shaped key in the bridge — it is why the file is written 0600. 'NUXT_SESSION_PASSWORD', 'NSC__MONGOOSE__URI', 'DATABASE_URL', @@ -108,6 +113,9 @@ export function writeEnvBridge(projectRoot: string, devEnv: DevEnv, dbName?: str const content = `${HEADER}${lines.join('\n')}\n`; if (existsSync(file) && readFileSync(file, 'utf8') === content) return file; mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, content, 'utf8'); + writeFileSync(file, content, { encoding: 'utf8', mode: 0o600 }); + // `mode` only applies when the file is CREATED, so an existing bridge from an older + // lt version would keep its 0644. chmod unconditionally to heal those. + chmodSync(file, 0o600); return file; } diff --git a/src/lib/dev-env.ts b/src/lib/dev-env.ts index c0a5bbf..47e7974 100644 --- a/src/lib/dev-env.ts +++ b/src/lib/dev-env.ts @@ -11,6 +11,8 @@ * - `NUXT_PUBLIC_*` lock the App to its own API * - `NUXT_PUBLIC_STORAGE_PREFIX` namespaces localStorage/sessionStorage * - `NSC__MONGOOSE__URI` / `DATABASE_URL` namespace the database per project + * - `NUXT_SESSION_PASSWORD` is keyed per slug, so a session cookie sealed by one + * stack cannot be unsealed by another (dev vs. `-test` vs. `-test-N` shard) * * CA trust for SSR fetches: * - Both API and App receive `NODE_EXTRA_CA_CERTS` pointing at the @@ -18,7 +20,10 @@ * 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 { createHmac, randomBytes } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import { detectCaddyRootCa } from './dev-env-bridge'; import { DevIdentity } from './dev-identity'; @@ -26,6 +31,11 @@ import { DevIdentity } from './dev-identity'; export interface BuildDevEnvInput { /** Internal API port (assigned by `dev-state.allocateInternalPort`). */ apiInternalPort: number; + /** + * App project directory (e.g. `/projects/app`). Optional — when given, the app's + * own `.env` is consulted for keys the built Nitro server would otherwise never see. + */ + appDir?: string; /** Internal App port. */ appInternalPort: number; /** Inherited shell env (defaults to {}, callers usually pass `process.env`). */ @@ -51,7 +61,7 @@ export interface DevEnv { * vars survive. `lt dev`-managed keys win on top. */ export function buildDevEnv(input: BuildDevEnvInput): DevEnv { - const { apiInternalPort, appInternalPort, baseEnv = {}, dbName, identity } = input; + const { apiInternalPort, appDir, appInternalPort, baseEnv = {}, dbName, identity } = input; const apiSub = identity.subdomains.api; const appSub = identity.subdomains.app; @@ -110,17 +120,36 @@ 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. + // Nuxt/h3 sessions need a password of at least 32 characters. An ABSENT one makes + // every login answer 500 `H3Error: Empty password`; a SHORT one answers `Password + // string too short (min 32 characters required)` — two distinct errors from + // iron-webcrypto (`minPasswordlength: 32`), reached via h3's seal/unseal. `nuxt dev` + // gets a password for free because it reads the project's `.env`; `lt dev test` + // serves the *built* Nitro server, which reads only `process.env`. So a project with + // a perfectly good `.env` watched half its E2E suite fail on a cause unrelated to it. // - // 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) }), + // Precedence, highest first: a value exported in the SHELL, then the app's own + // `.env` file, then the derived fallback. `lt dev` fills a gap; it never replaces a + // value the project chose. Forwarding the `.env` value explicitly is the point — the + // built server would not read that file itself. + // + // Derived rather than random so a restarted stack does not invalidate open sessions. + // Keyed on the SLUG, so the dev stack, the `-test` stack and every `-test-N` shard + // stack get DIFFERENT values (see `testStackNames`) — that is what stops one stack's + // cookies from validating against another's. Salted per machine, because the slug is + // public: it is the app's own `NUXT_PUBLIC_STORAGE_PREFIX` and ships in every SSR + // payload, and `lt dev tunnel` can put that app on a public URL. An unsalted + // derivation would hand any visitor the key that seals its sessions. + // + // Inert for the standard lt stack — nuxt-base-starter and nuxt-extensions + // authenticate via Better Auth, not h3 sessions. This exists for projects that added + // h3 `useSession` / nuxt-auth-utils on top; for everyone else it is an unused key. + ...(baseEnv.NUXT_SESSION_PASSWORD + ? {} + : { + NUXT_SESSION_PASSWORD: + readEnvFileValue(appDir, '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 @@ -142,11 +171,110 @@ function buildPostgresUrl(dbName: string): string { /** * 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. + * 32 hex chars — h3's floor. Deterministic per slug AND per machine: the same stack on the + * same machine always gets the same value, so a restart does not invalidate open sessions, + * while a different slug (or a different developer's machine) gets a different one. + * + * The machine salt is what makes it unguessable. Without it the only input is the slug, which + * the app publishes itself via `NUXT_PUBLIC_STORAGE_PREFIX`, so anyone who loaded a page — + * including any visitor of a `lt dev tunnel` URL — could recompute the key and forge a sealed + * session cookie. With it, an attacker would have to read the developer's home directory. */ function deriveSessionPassword(slug: string): string { - return createHash('sha256').update(`lt-dev:session:${slug}`).digest('hex').slice(0, 32); + // No salt means the home directory is unwritable. Fall back to a constant key rather than a + // random one: determinism is the property every caller depends on, and a fresh value per run + // would log everybody out on every restart. The fallback is guessable — hence last resort. + return createHmac('sha256', machineSessionSalt() ?? 'lt-dev') + .update(`lt-dev:session:${slug}`) + .digest('hex') + .slice(0, 32); +} + +/** + * Read (or create) the machine-local salt at `~/.lenneTech/dev-session-salt`, mode 0600. + * + * Returns `null` when it can be neither read nor created — see {@link deriveSessionPassword} + * for what happens then. + */ +function machineSessionSalt(): null | string { + const file = sessionSaltPath(); + try { + const existing = readFileSync(file, 'utf8').trim(); + if (existing) { + return existing; + } + } catch { + // Not created yet — fall through and create it. + } + + try { + mkdirSync(dirname(file), { recursive: true }); + // `wx` fails when the file already exists, so two shards racing on a cold machine can + // never end up with two different salts: the loser falls into the catch and re-reads. + const salt = randomBytes(32).toString('hex'); + writeFileSync(file, `${salt}\n`, { flag: 'wx', mode: 0o600 }); + return salt; + } catch { + try { + return readFileSync(file, 'utf8').trim() || null; + } catch { + return null; + } + } +} + +/** + * Read one key out of `/.env` without pulling in a dotenv dependency. + * + * Deliberately minimal: no interpolation, no multi-line values, no `.env.local` cascade. It + * exists to answer one question — did the project set this key itself? — for a value the built + * Nitro server would otherwise never see. + */ +function readEnvFileValue(dir: string | undefined, key: string): string | undefined { + if (!dir) { + return undefined; + } + + let content: string; + try { + content = readFileSync(join(dir, '.env'), 'utf8'); + } catch { + return undefined; + } + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const eq = trimmed.indexOf('='); + if (eq === -1) { + continue; + } + if ( + trimmed + .slice(0, eq) + .replace(/^export\s+/, '') + .trim() !== key + ) { + continue; + } + + const raw = trimmed.slice(eq + 1).trim(); + const quoted = + raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))); + return (quoted ? raw.slice(1, -1) : raw) || undefined; + } + + return undefined; +} + +/** + * Path of the machine-local salt. `LT_DEV_SESSION_SALT_PATH` overrides it, and `HOME` is read + * before `os.homedir()` — both so tests can redirect the write to a tmpdir, the same reason + * `dev-service.ts#userHome` does it. + */ +function sessionSaltPath(): string { + return process.env.LT_DEV_SESSION_SALT_PATH || join(process.env.HOME || homedir(), '.lenneTech', 'dev-session-salt'); } diff --git a/src/lib/dev-test-session.ts b/src/lib/dev-test-session.ts index 515c2a3..371837c 100644 --- a/src/lib/dev-test-session.ts +++ b/src/lib/dev-test-session.ts @@ -316,6 +316,9 @@ export async function bringUpTestSession( const devEnv = buildDevEnv({ apiInternalPort: apiPort ?? 0, + // The built server this stack runs reads no `.env` at all, so a key the project set + // there has to be forwarded explicitly — that is the whole reason this argument exists. + appDir: layout.appDir, appInternalPort: appPort ?? 0, baseEnv: process.env, dbName, diff --git a/src/lib/ensure-root-dockerignore.ts b/src/lib/ensure-root-dockerignore.ts index be9ce28..bd9b9d5 100644 --- a/src/lib/ensure-root-dockerignore.ts +++ b/src/lib/ensure-root-dockerignore.ts @@ -15,8 +15,15 @@ import type { GluegunFilesystem } from 'gluegun'; // bundle itself changes. const REQUIRED_PATTERNS = [ '**/node_modules', + // `**/.output` matches a path component EXACTLY, so the isolated build dirs of + // `lt dev test` (`.output-test`, `.nuxt-test`, `.nuxt-test-2`, …) need their own + // globs. They are built with the app's full dev env, so Nitro freezes values like + // `NUXT_SESSION_PASSWORD` into them as runtime-config defaults — and a `--keep` + // run or a crashed teardown leaves the tree on disk for the next `docker build`. '**/.output', + '**/.output-*', '**/.nuxt', + '**/.nuxt-*', '**/dist', // `**/.env` matches a path component EXACTLY, so it does NOT cover // `.env.production` / `.env.staging` / `.env.test` — all of which routinely