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 8451396..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'); @@ -28,6 +53,155 @@ 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 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 }); + + expect(env.api.env.NUXT_SESSION_PASSWORD).toBeUndefined(); + }); + + 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, 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({ + 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('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: '' }, + identity: fullIdentity, + }); + + 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', () => { // 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/__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 7c05304..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'; @@ -83,6 +86,11 @@ 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, 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', // Legacy aliases — see dev-env.ts for the rationale. @@ -105,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 766d757..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,12 +20,22 @@ * subdomains succeed. Without this Nuxt SSR fails with "unable to * get local issuer certificate" when the app calls its own API. */ +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'; 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`). */ @@ -49,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; @@ -108,6 +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 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. + // + // 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 @@ -125,3 +167,114 @@ 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'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 { + // 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-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); 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