Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions scripts/fix-typescript-detection.sh
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions src/lib/jwt-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
28 changes: 20 additions & 8 deletions src/lib/jwt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions tests/typescript-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading