diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 608293324..d24e959cc 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -67,7 +67,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build - run: pnpm build || ([ $? -eq 1 ] && echo '::warning::Build exited 1 (known Turbopack NFT warning)' && exit 0) + run: pnpm build test: name: Unit Tests diff --git a/Dockerfile b/Dockerfile index cdf027655..a3a60f14b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,6 @@ ENV PORT=3001 EXPOSE 3001 -CMD ["node", "node_modules/.bin/next", "start", "-H", "0.0.0.0", "-p", "3001"] +# node_modules/.bin/next is a POSIX shell shim (pnpm layout) and cannot be run +# with `node`; invoke Next's real JS entrypoint instead. +CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3001"] diff --git a/package.json b/package.json index a75356e1e..2f51449bc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "docs:translate": "npx tsx scripts/translate-docs.ts", "i18n:check": "npx tsx scripts/check-i18n-parity.ts", "prepare": "husky", - "postinstall": "bash scripts/fix-react-exports.sh" + "postinstall": "bash scripts/fix-react-exports.sh && bash scripts/fix-typescript-detection.sh" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/scripts/fix-typescript-detection.sh b/scripts/fix-typescript-detection.sh new file mode 100755 index 000000000..20c50f5ec --- /dev/null +++ b/scripts/fix-typescript-detection.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Fix Next.js 16 TypeScript detection with typescript@7 (native port) under pnpm. +# +# Root cause: `next build` verifies the TypeScript toolchain by probing the +# file `typescript/lib/typescript.js` (next/dist/lib/verify-typescript-setup.js +# -> has-necessary-dependencies.js performs fs.existsSync on that exact path). +# typescript@7 is the Go-native rewrite: its lib/ ships only tsc.js, +# version.cjs and getExePath.js — lib/typescript.js no longer exists. +# The failed probe makes Next treat typescript as missing and attempt an +# auto-install, which: +# - in Docker/local builds: dies with ERR_PNPM_ADDING_TO_ROOT +# (pnpm workspace root guard) +# - in CI (isCI=true): throws missingDepsError -> `next build` exits 1 +# +# Fix: create a tiny compatibility shim at the probed path. Build-time type +# checking stays disabled via typescript.ignoreBuildErrors (next.config.mjs), +# so the classic compiler API is never loaded; the shim re-exports the +# package version metadata as a safe loadable surface. +# +# Idempotent: skips when the file already exists (e.g. typescript@5 layout, +# which ships a real lib/typescript.js). + +TS_LIB_DIR="node_modules/typescript/lib" +TS_SHIM="$TS_LIB_DIR/typescript.js" + +if [ -d "$TS_LIB_DIR" ] && [ ! -f "$TS_SHIM" ]; then + cat > "$TS_SHIM" <<'EOF' +// Compatibility shim for Next.js TypeScript detection. +// Created by scripts/fix-typescript-detection.sh — see root cause notes there. +// typescript@7 (native port) does not ship lib/typescript.js; next@16 probes +// this exact path to decide whether TypeScript is installed. +// +// The typescript@7 package.json declares "type": "module", so this file must +// use ESM syntax. Node >= 22 require(ESM) returns the module namespace, so +// consumers using require() still see `version` / `versionMajorMinor`. +// Build-time type checking is disabled (typescript.ignoreBuildErrors), so the +// classic compiler API is never required: re-export version metadata only. +import { version, versionMajorMinor } from "./version.cjs"; + +export { version, versionMajorMinor }; +export default { version, versionMajorMinor }; +EOF + echo "[postinstall] Created $TS_SHIM (next@16 + typescript@7 detection compat)" +fi diff --git a/src/lib/jwt-utils.test.ts b/src/lib/jwt-utils.test.ts index 33ebda0f5..49de61398 100644 --- a/src/lib/jwt-utils.test.ts +++ b/src/lib/jwt-utils.test.ts @@ -135,4 +135,28 @@ describe("jwt-utils (JWT rotation)", () => { expect(await jwtUtils.isValidToken(token)).toBe(true); }); }); + + describe("build-time import safety (Next.js page-data collection)", () => { + // `next build` imports route modules to collect page data while runtime + // secrets are absent (Docker/CI builds have no .env.local). A module-level + // loadKeys() used to throw during import and kill the whole build + // ("Failed to collect page data for /api/..."). + it("does not throw at import time when no JWT env is set", async () => { + await expect(import("./jwt-utils")).resolves.toBeTruthy(); + }); + + it("fails fast on first token operation when no JWT env is set", async () => { + const { jwtUtils } = await import("./jwt-utils"); + await expect(jwtUtils.createSessionToken()).rejects.toThrow(/JWT_SECRET/); + }); + + it("still validates eagerly-enough: secret configured after import is used", async () => { + // Lazy cache must initialize on first use, not at import, so setting + // the env after import (e.g. Next loading .env.local) still works. + const mod = await import("./jwt-utils"); + process.env.JWT_SECRET = "lazy-loaded-secret-thats-long-enough!!"; + const token = await mod.jwtUtils.createSessionToken(3600000, { role: "admin" }); + expect(await mod.jwtUtils.isValidToken(token)).toBe(true); + }); + }); }); diff --git a/src/lib/jwt-utils.ts b/src/lib/jwt-utils.ts index 0222b6eb4..7aaf3ad0b 100644 --- a/src/lib/jwt-utils.ts +++ b/src/lib/jwt-utils.ts @@ -58,15 +58,26 @@ function loadKeys(): { entries: KeyEntry[]; currentKid: string | null } { return { entries: [{ kid: "legacy", secret }], currentKid: "legacy" }; } -// Module-level key cache (re-initialized on each import = per-request in dev, once in prod) -const keyCache = loadKeys(); +// Module-level key cache, lazily initialized on first use. +// Lazy because `next build` imports route modules during page-data +// collection while runtime secrets are absent (Docker/CI builds) — a +// module-level loadKeys() would throw at import time and kill the build. +// Fail-fast is preserved: the first sign/verify call still validates. +let keyCache: { entries: KeyEntry[]; currentKid: string | null } | null = null; + +function getKeyCache(): { entries: KeyEntry[]; currentKid: string | null } { + if (keyCache === null) { + keyCache = loadKeys(); + } + return keyCache; +} function encodeSecret(secret: string): Uint8Array { return new TextEncoder().encode(secret); } function findKey(kid: string): KeyEntry | undefined { - return keyCache.entries.find((k) => k.kid === kid); + return getKeyCache().entries.find((k) => k.kid === kid); } export const jwtUtils = { @@ -93,9 +104,9 @@ export const jwtUtils = { const now = Math.floor(Date.now() / 1000); const exp = now + Math.floor(validatedTtlMs / 1000); - const currentKey = findKey(keyCache.currentKid!); + const currentKey = findKey(getKeyCache().currentKid!); if (!currentKey) { - throw new Error(`Current signing key "${keyCache.currentKid}" not found`); + throw new Error(`Current signing key "${getKeyCache().currentKid}" not found`); } const jwt = await new SignJWT({ @@ -137,12 +148,13 @@ export const jwtUtils = { } // Try keys in order: matching kid first, then all others + const { entries } = getKeyCache(); const keysToTry = preferredKid ? [ - ...keyCache.entries.filter((k) => k.kid === preferredKid), - ...keyCache.entries.filter((k) => k.kid !== preferredKid), + ...entries.filter((k) => k.kid === preferredKid), + ...entries.filter((k) => k.kid !== preferredKid), ] - : keyCache.entries; + : entries; for (const keyEntry of keysToTry) { try { diff --git a/tests/typescript-detection.test.ts b/tests/typescript-detection.test.ts new file mode 100644 index 000000000..59506c6af --- /dev/null +++ b/tests/typescript-detection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const nodeRequire = createRequire(import.meta.url); + +/** + * next@16 verifies the TypeScript toolchain by probing the file + * `typescript/lib/typescript.js` (next/dist/lib/verify-typescript-setup.js -> + * has-necessary-dependencies.js performs fs.existsSync on it). + * + * typescript@7 (Go-native port) no longer ships that file, so + * scripts/fix-typescript-detection.sh creates a compatibility shim during + * postinstall. Without it, `next build` treats typescript as missing and + * tries to auto-install it, which fails with ERR_PNPM_ADDING_TO_ROOT under + * pnpm workspaces (or throws directly in CI). + * + * This test guards that contract for both Docker and CI environments. + */ +describe("Next.js TypeScript detection contract", () => { + const pkgJsonPath = nodeRequire.resolve("typescript/package.json"); + const pkgDir = path.dirname(realpathSync(pkgJsonPath)); + const probePath = path.join(pkgDir, "lib", "typescript.js"); + + it("provides the lib/typescript.js file that next build probes", () => { + expect(existsSync(probePath)).toBe(true); + }); + + it("shim is loadable and reports the installed TypeScript version", () => { + const shim = nodeRequire(probePath) as { version: string }; + const installed = nodeRequire("typescript/package.json") as { version: string }; + expect(shim.version).toBe(installed.version); + }); +});