From 217a3bd163aa9b0ff0c8a5a000cb1aaf25f2b9ef Mon Sep 17 00:00:00 2001 From: Joker Date: Wed, 26 Aug 2026 03:10:20 +0000 Subject: [PATCH 1/4] fix(build): add typescript detection shim for next@16 with typescript@7 next@16 verifies the TS toolchain by probing typescript/lib/typescript.js (verify-typescript-setup -> has-necessary-dependencies). typescript@7 is the Go-native port and no longer ships that file, so next build treated typescript as missing and attempted an auto-install, dying with ERR_PNPM_ADDING_TO_ROOT under pnpm workspaces (Docker) or throwing missingDepsError in CI. The postinstall now creates a tiny ESM shim at the probed path that re-exports the package version metadata. Build-time type checking stays disabled via typescript.ignoreBuildErrors, so the classic compiler API is never loaded. Guarded by tests/typescript-detection.test.ts. --- package.json | 2 +- scripts/fix-typescript-detection.sh | 44 +++++++++++++++++++++++++++++ tests/typescript-detection.test.ts | 35 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100755 scripts/fix-typescript-detection.sh create mode 100644 tests/typescript-detection.test.ts 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/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); + }); +}); From 440ceebfef3a84eb6b811ac7089e50ae21243a13 Mon Sep 17 00:00:00 2001 From: Joker Date: Wed, 26 Aug 2026 03:10:49 +0000 Subject: [PATCH 2/4] fix(auth): lazy-init JWT key cache for build-time page data collection loadKeys() ran at module scope, so importing jwt-utils without runtime secrets threw during next build page-data collection (Docker/CI builds have no .env.local): 'Failed to collect page data for /api/wiki/sync'. This was hidden behind the CI build masking workaround. The cache now initializes lazily on first sign/verify call. Fail-fast is preserved: the first token operation still validates JWT_SECRET/JWT_SECRETS. --- src/lib/jwt-utils.test.ts | 24 ++++++++++++++++++++++++ src/lib/jwt-utils.ts | 28 ++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 8 deletions(-) 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 { From 4bed31efc0480b2876a354a5e5b69290428cf8f7 Mon Sep 17 00:00:00 2001 From: Joker Date: Wed, 26 Aug 2026 03:11:12 +0000 Subject: [PATCH 3/4] fix(docker): run next start via real JS entrypoint, not pnpm .bin shim node_modules/.bin/next is a POSIX shell shim in the pnpm layout; running it with 'node' died with SyntaxError on container start. Invoke next/dist/bin/next (the actual JS entrypoint) instead. This CMD path was never exercisable before because the image build never succeeded. --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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"] From 9ce9a64808e3a2b09b2a8637232e64f42b80098e Mon Sep 17 00:00:00 2001 From: Joker Date: Wed, 26 Aug 2026 03:11:29 +0000 Subject: [PATCH 4/4] fix(ci): remove build failure masking workaround The '|| exit 0' in the Build job (b3a99e2) converted every build exit 1 into a warning, hiding both the TypeScript detection failure and the JWT module-scope failure. With both root causes fixed, pnpm build exits 0 on its own; the job now fails loudly again on real regressions. --- .github/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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