import { execSync } from "node:child_process"; import { PROXY_BODY_LIMIT } from "./scripts/server-body-policy.mjs"; // Build id baked into the client bundle so a redeploy can be detected. const BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID || process.env.NEXT_DEPLOYMENT_ID || String(Date.now()); // WHICH COMMIT THIS BUILD IS, baked in at build time — the only way the running app // can know it, since `git rev-parse HEAD` at runtime answers for the CHECKOUT, which // moves whenever someone pulls. Settings → Account → About compares the two: equal means the // running build is current, different means a build is pending and the update panel // offers the rebuild. Empty is fine and expected — scripts/verify-build.sh compiles // a `git archive` export with no .git in it. const BUILD_CPUS_RAW = Number(process.env.MSO_BUILD_CPUS || ""); const BUILD_CPUS = Number.isInteger(BUILD_CPUS_RAW) && BUILD_CPUS_RAW > 0 ? BUILD_CPUS_RAW : undefined; const LOW_MEMORY_BUILD = process.env.MSO_LOW_MEMORY_BUILD === "1"; const COMMIT_SHA = process.env.NEXT_PUBLIC_COMMIT_SHA || (() => { try { return execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; } })(); /** @type {import('next').NextConfig} */ const nextConfig = { // Don't advertise the framework/version — the shell is now public; minimize // the fingerprint an unauthenticated visitor can use for CVE matching. poweredByHeader: false, // Cache Components off: mso is a fully dynamic app (auth-gated OS shell, // no SSG marketing pages), so static prerendering adds no value and conflicts // with the auth provider's cookie reads. Re-enable only if static routes land. cacheComponents: false, // The Browser app renders site favicons via Google's s2 service (fixed host) // through next/Image, so the optimizer caches/resizes them. Host filesystem // images + the live Playwright screenshot stream stay raw on purpose — // they're dynamic/auth'd bytes the optimizer can't help with. images: { remotePatterns: [ { protocol: "https", hostname: "www.google.com", pathname: "/s2/favicons/**" }, ], }, // node-pty is a native addon (.node binary) — it must be require()'d from // node_modules at runtime, never bundled, or the binding fails to load. serverExternalPackages: ["node-pty"], // Only ever an EXPLICIT deployment id — never BUILD_ID. This config is evaluated // twice (once by `next build`, once when `next start` boots), so a Date.now() // fallback produced two different ?dpl= values for the same chunk: the HTML // referenced both variants and the browser downloaded, parsed and executed ~160 KB // gzip of entry chunks twice on every cold load. Chunks are content-hashed, so no // ?dpl= at all is correct. // // The key must be ABSENT, not undefined: `deploymentId: undefined` still counts as // set, and Next then appends a literal empty `?dpl=` — which leaves `chunk.js` and // `chunk.js?dpl=` as two distinct URLs and the double download intact. Hence the // conditional spread. BUILD_ID below is unaffected: it is inlined at build time and // the SW cache name + /api/health still need it. ...(process.env.NEXT_DEPLOYMENT_ID ? { deploymentId: process.env.NEXT_DEPLOYMENT_ID } : {}), env: { NEXT_PUBLIC_BUILD_ID: BUILD_ID, NEXT_PUBLIC_COMMIT_SHA: COMMIT_SHA }, experimental: { // Constrained Android/PRoot builders can opt into bounded worker fan-out. // Normal VPS/CI builds are unchanged unless these environment variables exist. ...(BUILD_CPUS ? { cpus: BUILD_CPUS, staticGenerationMaxConcurrency: BUILD_CPUS, } : {}), ...(LOW_MEMORY_BUILD ? { parallelServerCompiles: false, parallelServerBuildTraces: false, turbopackMemoryEviction: "auto", } : {}), // proxy.ts clones request bodies; the default clone cap is 10MB, which // The authenticated upload route bypasses cloning and streams its own 200 MiB cap. // Public middleware requests keep a small aggregate buffer ceiling. proxyClientMaxBodySize: PROXY_BODY_LIMIT, // Tree-shake heavy icon/radix barrels — keeps the OS shell bundle lean. optimizePackageImports: [ "lucide-react", "@phosphor-icons/react", "@radix-ui/react-dialog", "@radix-ui/react-dropdown-menu", "@radix-ui/react-tooltip", "@radix-ui/react-scroll-area", "@radix-ui/react-select", "@radix-ui/react-popover", "@radix-ui/react-alert-dialog", "@radix-ui/react-slider", "@radix-ui/react-tabs", ], }, // Serve the service worker at /sw.js (stable scope) from the /api/sw route // handler. beforeFiles runs BEFORE page routing, so the optional catch-all // [[...slug]] never swallows /sw.js (a route under /api is never caught by it). async rewrites() { return { beforeFiles: [ { source: "/sw.js", destination: "/api/sw" }, // NO camoufox rewrite here, deliberately. It used to live here and published // websockify (127.0.0.1:6080, x11vnc behind it) to the public origin with no // real gate. A config rewrite bypasses route handlers, so nothing downstream // can authenticate it — proxy.ts owns that hop now and rewrites it itself, // behind hasApprovedSession(). Do not add it back. ], afterFiles: [], fallback: [], }; }, async headers() { return [ { source: "/(.*)", headers: [ { key: "X-Content-Type-Options", value: "nosniff" }, // Authenticated remote shell over HTTPS — refuse any plaintext leg so // a MITM can't strip TLS and capture the session cookie before the // redirect fires. 1 year. includeSubDomains omitted on purpose: not // every configured cookie-domain subdomain is HTTPS-only. { key: "Strict-Transport-Security", value: "max-age=31536000" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, // MSO is a private control plane even when reached through a temporary HTTPS tunnel. { key: "X-Robots-Tag", value: "noindex, nofollow, noarchive" }, // Public remote shell — never allow it to be framed (clickjacking on // exec/fs buttons). The full Content-Security-Policy (incl. a nonce'd // script-src) is set PER-REQUEST in proxy.ts so a fresh nonce can gate // inline scripts; a static CSP here can't nonce, so it's intentionally // omitted (proxy owns it). { key: "X-Frame-Options", value: "DENY" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", }, ], }, { source: "/camoufox-vnc/:path*", headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], }, // Private API responses must never be cached by any intermediary — they // carry host bytes (fs/read), auth state, and per-session data. { source: "/api/:path*", headers: [{ key: "Cache-Control", value: "no-store" }] }, // Only the internal manager is same-origin embeddable; its nonce CSP also // restricts frame-ancestors to self. The standalone page/shell retain DENY. { source: "/integrations", has: [{ type: "query", key: "embed", value: "shell" }], headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }] }, { source: "/integrations/manager", headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Cache-Control", value: "no-store" }, { key: "Referrer-Policy", value: "no-referrer" }] }, { source: "/integrations", headers: [{ key: "Cache-Control", value: "no-store" }, { key: "Referrer-Policy", value: "no-referrer" }] }, // Dedicated shell-only embed path. The global DENY remains authoritative for // every ordinary page; this one path is still same-origin only and also has // a route-level CSP `frame-ancestors 'self'`. { source: "/integrations/embed", headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Cache-Control", value: "no-store" }, { key: "Referrer-Policy", value: "no-referrer" }] }, { source: "/api/integrations/:path*", headers: [{ key: "Referrer-Policy", value: "no-referrer" }] }, // Named brand/wallpaper assets are effectively immutable but, unlike // /_next chunks, are NOT content-hashed — rename or add ?v= if ever redrawn. { source: "/wallpapers/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }] }, { source: "/icon.svg", headers: [{ key: "Cache-Control", value: "public, max-age=604800" }] }, { source: "/icon-maskable.svg", headers: [{ key: "Cache-Control", value: "public, max-age=604800" }] }, ]; }, }; export default nextConfig;