1/**
2 * Shared Next.js configuration utilities
3 *
4 * Reusable configuration for Next.js apps in a monorepo: CSP headers,
5 * webpack tweaks, remote image patterns, rewrites.
6 *
7 * Derived from a real monorepo shape — sanitized of company-specific identifiers
8 * but structurally identical so patch mechanics get tested against realistic
9 * CSP/rewrite layering.
10 */
11
12import type { NextConfig } from "next";
13import {
14 buildConnectSrc,
15 getSupabaseOrigin,
16 HSTS_VALUE,
17 PERMISSIONS_POLICY_VALUE,
18} from "../security/origins";
19
20export interface SharedNextConfigOptions {
21 appName?: string;
22 additionalImgSrc?: string[];
23 additionalConnectSrc?: string[];
24 additionalScriptSrc?: string[];
25 enableMapbox?: boolean;
26 serverExternalPackages?: string[];
27 optimizePackageImports?: string[];
28 transpilePackages?: string[];
29}
30
31const DEFAULT_WORKSPACE_TRANSPILE_PACKAGES = [
32 "@app/backend",
33 "@app/database",
34 "@app/mcp-ui",
35 "@app/shared",
36 "@app/supabase-client",
37 "@app/ui",
38];
39
40const KNOWN_SUPABASE_CUSTOM_DOMAINS = [
41 "db.example.com",
42 "db-staging.example.com",
43] as const;
44
45const KNOWN_SUPABASE_CUSTOM_ORIGINS = KNOWN_SUPABASE_CUSTOM_DOMAINS.map(
46 (h) => `https://${h}`
47);
48
49interface CSPConfig {
50 scriptSrc: string;
51 styleSrc: string;
52 imgSrc: string;
53 fontSrc: string;
54 connectSrc: string;
55 frameSrc: string;
56 workerSrc: string;
57 childSrc: string;
58}
59
60function buildCSPConfig(options: SharedNextConfigOptions = {}): CSPConfig {
61 const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
62 const supabaseOrigin = getSupabaseOrigin();
63 const connectSrcBase = buildConnectSrc(apiUrl);
64
65 const isPreview = true;
66
67 const scriptSrc = [
68 "'self'",
69 "'unsafe-eval'",
70 "'unsafe-inline'",
71 "https://va.vercel-scripts.com",
72 ...(isPreview ? ["https://vercel.live"] : []),
73 ...(options.additionalScriptSrc || []),
74 ].join(" ");
75
76 const styleSrc = [
77 "'self'",
78 "'unsafe-inline'",
79 ...(isPreview ? ["https://fonts.googleapis.com"] : []),
80 ].join(" ");
81
82 const imgSrc = [
83 "'self'",
84 "data:",
85 "blob:",
86 "http://localhost:54321",
87 "http://127.0.0.1:54321",
88 "https://*.supabase.co",
89 ...KNOWN_SUPABASE_CUSTOM_ORIGINS,
90 ...(supabaseOrigin &&
91 !KNOWN_SUPABASE_CUSTOM_ORIGINS.includes(
92 supabaseOrigin as (typeof KNOWN_SUPABASE_CUSTOM_ORIGINS)[number]
93 )
94 ? [supabaseOrigin]
95 : []),
96 ...(options.enableMapbox
97 ? ["https://api.mapbox.com", "https://*.tiles.mapbox.com"]
98 : []),
99 ...(isPreview ? ["https://vercel.com", "https://vercel.live"] : []),
100 ...(options.additionalImgSrc || []),
101 ].join(" ");
102
103 const fontSrc = [
104 "'self'",
105 "data:",
106 ...(options.enableMapbox ? ["https://api.mapbox.com"] : []),
107 ...(isPreview ? ["https://fonts.gstatic.com", "https://vercel.live"] : []),
108 ].join(" ");
109
110 const connectExtras = [
111 ...(options.enableMapbox
112 ? [
113 "https://api.mapbox.com",
114 "https://*.tiles.mapbox.com",
115 "https://events.mapbox.com",
116 ]
117 : []),
118 ...(isPreview
119 ? ["https://vercel.live", "wss://*.pusher.com", "https://*.pusher.com"]
120 : []),
121 ...(options.additionalConnectSrc || []),
122 ];
123
124 const connectSrc = [...connectSrcBase, ...connectExtras].join(" ");
125 const frameSrc = isPreview ? "'self' https://vercel.live" : "'self'";
126 const workerSrc = "'self' blob:";
127 const childSrc = "'self' blob:";
128
129 return {
130 scriptSrc,
131 styleSrc,
132 imgSrc,
133 fontSrc,
134 connectSrc,
135 frameSrc,
136 workerSrc,
137 childSrc,
138 };
139}
140
141export function buildSecurityHeaders(
142 options: SharedNextConfigOptions = {}
143): NextConfig["headers"] {
144 return () => {
145 const csp = buildCSPConfig(options);
146 const isPreview = true;
147
148 return Promise.resolve([
149 {
150 source: "/(.*)",
151 headers: [
152 {
153 key: "Content-Security-Policy",
154 value: `default-src 'self'; script-src ${csp.scriptSrc}; style-src ${csp.styleSrc}; img-src ${csp.imgSrc}; font-src ${csp.fontSrc}; connect-src ${csp.connectSrc}; frame-src ${csp.frameSrc}; worker-src ${csp.workerSrc}; child-src ${csp.childSrc}; frame-ancestors 'self';`,
155 },
156 { key: "X-Frame-Options", value: "SAMEORIGIN" },
157 { key: "Strict-Transport-Security", value: HSTS_VALUE },
158 { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
159 { key: "Permissions-Policy", value: PERMISSIONS_POLICY_VALUE },
160 { key: "Cross-Origin-Embedder-Policy", value: "unsafe-none" },
161 { key: "Cross-Origin-Resource-Policy", value: "cross-origin" },
162 {
163 key: "Cross-Origin-Opener-Policy",
164 value: "same-origin-allow-popups",
165 },
166 ...(isPreview
167 ? [
168 {
169 key: "Access-Control-Allow-Origin",
170 value: "https://vercel.live",
171 },
172 {
173 key: "Access-Control-Allow-Methods",
174 value: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
175 },
176 { key: "Access-Control-Allow-Headers", value: "*" },
177 { key: "Access-Control-Allow-Credentials", value: "true" },
178 { key: "Vary", value: "Origin" },
179 ]
180 : []),
181 ],
182 },
183 ]);
184 };
185}
186
187export function buildApiProxyRewrites(): NextConfig["rewrites"] {
188 return () => {
189 const rewrites: Array<{ source: string; destination: string }> = [];
190
191 if (
192 process.env.NEXT_PUBLIC_API_PROXY === "1" &&
193 process.env.NEXT_PUBLIC_API_URL
194 ) {
195 const proxyPath = process.env.NEXT_PUBLIC_API_PROXY_PATH || "/backend";
196 rewrites.push({
197 source: `${proxyPath}/:path*`,
198 destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
199 });
200 }
201
202 return {
203 beforeFiles: rewrites,
204 afterFiles: [],
205 fallback: [],
206 };
207 };
208}
209
210type WebpackConfig = Parameters<NonNullable<NextConfig["webpack"]>>[0];
211type WebpackContext = Parameters<NonNullable<NextConfig["webpack"]>>[1];
212
213export function buildWebpackConfig(
214 options: SharedNextConfigOptions = {}
215): NextConfig["webpack"] {
216 return (config: WebpackConfig, context: WebpackContext) => {
217 if (context.isServer && options.serverExternalPackages?.length) {
218 config.externals = config.externals || [];
219 if (Array.isArray(config.externals)) {
220 for (const pkg of options.serverExternalPackages) {
221 config.externals.push({ [pkg]: `commonjs ${pkg}` });
222 }
223 }
224 }
225 return config;
226 };
227}
228
229export function buildSupabaseRemotePatterns(): Array<{
230 protocol: "http" | "https";
231 hostname: string;
232 port?: string;
233 pathname: string;
234}> {
235 return [
236 {
237 protocol: "http",
238 hostname: "localhost",
239 port: "54321",
240 pathname: "/storage/v1/object/public/**",
241 },
242 {
243 protocol: "https",
244 hostname: "*.supabase.co",
245 pathname: "/storage/v1/object/public/**",
246 },
247 ...KNOWN_SUPABASE_CUSTOM_DOMAINS.map((hostname) => ({
248 protocol: "https" as const,
249 hostname,
250 pathname: "/storage/v1/object/public/**",
251 })),
252 ];
253}
254
255/**
256 * Create a shared Next.js configuration base. Apps extend this via spread.
257 */
258export function createBaseNextConfig(
259 options: SharedNextConfigOptions = {}
260): NextConfig {
261 const transpilePackages = Array.from(
262 new Set([
263 ...DEFAULT_WORKSPACE_TRANSPILE_PACKAGES,
264 ...(options.transpilePackages || []),
265 ])
266 );
267
268 return {
269 experimental: {
270 turbopackFileSystemCacheForDev: true,
271 ...(options.optimizePackageImports && {
272 optimizePackageImports: options.optimizePackageImports,
273 }),
274 },
275 env: {
276 VERCEL_RELATED_PROJECTS: process.env.VERCEL_RELATED_PROJECTS || "",
277 VERCEL_ENV: process.env.VERCEL_ENV || "",
278 },
279 ...(options.serverExternalPackages && {
280 serverExternalPackages: options.serverExternalPackages,
281 }),
282 transpilePackages,
283 webpack: buildWebpackConfig(options),
284 headers: buildSecurityHeaders(options),
285 rewrites: buildApiProxyRewrites(),
286 };
287}