mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
feat(live): CSP detection + consent-gated patch flow at first-time setup
Real-world tests (EAC Next turborepo) confirmed that CSP is the common
blocker for live mode. Adds setup-time detection with a one-time user
consent flow — the patch becomes a permanent, dev-guarded entry in the
user's own config, not a transient add/remove.
## Changes
- New detect-csp.mjs helper: grep-based classifier returning
{ shape, signals }. Shape is one of:
- "shared-helper" (monorepo CSP helper with additional*Src arrays)
- "inline-headers" (literal CSP string in headers())
- "middleware" (response.headers.set in middleware.ts; detect-only v1)
- "meta-tag" (<meta http-equiv>; detect-only v1)
- null (no CSP)
Max depth 6, skips node_modules / build / cache dirs, 64KB per file.
- cspChecked boolean on config.json. First-run setup runs detection;
subsequent runs skip. Users re-trigger by deleting the flag.
Validator accepts it.
- Skill live.md gains:
- CSP detection step in first-time setup (gated by cspChecked)
- Consent-prompt template (so every agent phrases it the same way)
- Shape 1 patch template: append `...__impeccableLiveDev` to
additionalScriptSrc/additionalConnectSrc in the app's config
- Shape 2 patch template: two-point edit — declare a dev-only
variable, interpolate into script-src and connect-src in the
CSP literal string
- Troubleshooting note for "said no but now live doesn't work"
## Fixtures
- nextjs-turborepo/: Turborepo shape (shared CSP helper with
additionalScriptSrc options). Sanitized from a real monorepo so the
patch mechanics get tested against realistic layering. Includes
expected-after-patch.ts for human/agent review.
- nextjs-inline-csp/: app-level next.config.js with a literal CSP
string. Includes expected-after-patch.js showing the Shape 2 edit.
## Tests
Framework-fixture harness extended with a detect-csp shape-classification
assertion per fixture. 42 tests across 7 fixtures pass. Clean fixtures
(vite-react, nextjs-app, astro, sveltekit, multipage-with-generator)
correctly return shape: null.
## Deliberately not doing
- No patches[] array, no marker-based rollback, no add/remove lifecycle.
The patch is a permanent dev-guarded config line — the same kind of
edit a user would make themselves.
- No base URL rewriting or proxy mechanism. Script tag still points at
localhost:8400; CSP permits it once patched. No browser-side changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
444f881295
commit
d5480caee3
@@ -18,6 +18,7 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isGeneratedFile } from '../source/skills/impeccable/scripts/is-generated.mjs';
|
||||
import { detectCsp } from '../source/skills/impeccable/scripts/detect-csp.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPTS_DIR = join(__dirname, '..', 'source', 'skills', 'impeccable', 'scripts');
|
||||
@@ -149,6 +150,21 @@ for (const name of listFixtures()) {
|
||||
}
|
||||
});
|
||||
|
||||
it('detect-csp classifies CSP shape correctly', () => {
|
||||
const { tmp, fixture } = stageFixture(name);
|
||||
try {
|
||||
const expected = fixture.csp?.shape ?? null;
|
||||
const result = detectCsp(tmp);
|
||||
assert.equal(
|
||||
result.shape,
|
||||
expected,
|
||||
`expected CSP shape ${expected}, got ${result.shape}; signals: ${JSON.stringify(result.signals)}`
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('live-wrap routes to the expected source (or emits the expected fallback)', () => {
|
||||
const { tmp, fixture } = stageFixture(name);
|
||||
try {
|
||||
|
||||
@@ -26,10 +26,18 @@ Representative project shapes for exercising live mode against different framewo
|
||||
"expectedFile": "where wrap should land (relative to fixture root)",
|
||||
"expectsError": "optional error code, e.g. element_not_in_source"
|
||||
}
|
||||
]
|
||||
],
|
||||
"csp": {
|
||||
"shape": "shared-helper | inline-headers | middleware | meta-tag | null",
|
||||
"signals": ["diagnostic hints — paths where CSP was detected"],
|
||||
"patchTarget": "which file the agent should modify",
|
||||
"expectedAfter": "filename of the reference post-patch output inside this fixture"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `expectedAfter` file lives alongside `fixture.json` (not inside `files/`) and is a human/agent-review reference — tests don't auto-apply the patch.
|
||||
|
||||
## Current fixtures
|
||||
|
||||
| Fixture | Shape |
|
||||
@@ -39,5 +47,7 @@ Representative project shapes for exercising live mode against different framewo
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). Exercises CSP shape 1 (shared-helper). |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. Exercises CSP shape 2 (inline-headers). |
|
||||
|
||||
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Reference output for agent/human review — not executed by tests.
|
||||
// After the Shape 2 (inline-headers) CSP patch is applied, next.config.js
|
||||
// should look like this.
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// Dev-only allowance so impeccable live mode can load. Empty string in any
|
||||
// non-development environment.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
|
||||
module.exports = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value:
|
||||
"default-src 'self'; " +
|
||||
`script-src 'self' 'unsafe-inline' 'unsafe-eval'${__impeccableLiveDev}; ` +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
`connect-src 'self'${__impeccableLiveDev}; ` +
|
||||
"frame-ancestors 'self';",
|
||||
},
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Inline CSP Fixture",
|
||||
description: "Minimal app with a literal CSP header for live-mode tests.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value:
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'self';",
|
||||
},
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Next.js (inline CSP headers)",
|
||||
"config": {
|
||||
"files": ["app/layout.tsx"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "jsx"
|
||||
},
|
||||
"sourceFiles": ["next.config.js", "app/layout.tsx"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [],
|
||||
"csp": {
|
||||
"shape": "inline-headers",
|
||||
"signals": [
|
||||
"next.config.js:Content-Security-Policy",
|
||||
"next.config.js:script-src",
|
||||
"next.config.js:connect-src"
|
||||
],
|
||||
"patchTarget": "next.config.js",
|
||||
"expectedAfter": "expected-after-patch.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
@@ -0,0 +1,48 @@
|
||||
// Reference output for agent/human review — not executed by tests.
|
||||
// After the Shape 1 (shared-helper) CSP patch is applied, apps/web/next.config.ts
|
||||
// should look like this.
|
||||
|
||||
import {
|
||||
buildSupabaseRemotePatterns,
|
||||
createBaseNextConfig,
|
||||
} from "@app/shared/next-config";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const posthogHost =
|
||||
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV;
|
||||
// empty array in any non-development environment.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
|
||||
const baseConfig = createBaseNextConfig({
|
||||
appName: "web",
|
||||
enableMapbox: true,
|
||||
additionalImgSrc: ["https:", "https://*.googleusercontent.com"],
|
||||
additionalScriptSrc: [posthogHost, ...__impeccableLiveDev],
|
||||
additionalConnectSrc: [posthogHost, ...__impeccableLiveDev],
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...baseConfig,
|
||||
|
||||
devIndicators: {
|
||||
position: "bottom-right",
|
||||
},
|
||||
|
||||
experimental: {
|
||||
...baseConfig.experimental,
|
||||
},
|
||||
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: buildSupabaseRemotePatterns(),
|
||||
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Turborepo Fixture",
|
||||
description: "Minimal monorepo app layout for live-mode tests.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
buildSupabaseRemotePatterns,
|
||||
createBaseNextConfig,
|
||||
} from "@app/shared/next-config";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const posthogHost =
|
||||
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
|
||||
const baseConfig = createBaseNextConfig({
|
||||
appName: "web",
|
||||
enableMapbox: true,
|
||||
additionalImgSrc: ["https:", "https://*.googleusercontent.com"],
|
||||
additionalScriptSrc: [posthogHost],
|
||||
additionalConnectSrc: [posthogHost],
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...baseConfig,
|
||||
|
||||
devIndicators: {
|
||||
position: "bottom-right",
|
||||
},
|
||||
|
||||
experimental: {
|
||||
...baseConfig.experimental,
|
||||
},
|
||||
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: buildSupabaseRemotePatterns(),
|
||||
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Shared Next.js configuration utilities
|
||||
*
|
||||
* Reusable configuration for Next.js apps in a monorepo: CSP headers,
|
||||
* webpack tweaks, remote image patterns, rewrites.
|
||||
*
|
||||
* Derived from a real monorepo shape — sanitized of company-specific identifiers
|
||||
* but structurally identical so patch mechanics get tested against realistic
|
||||
* CSP/rewrite layering.
|
||||
*/
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import {
|
||||
buildConnectSrc,
|
||||
getSupabaseOrigin,
|
||||
HSTS_VALUE,
|
||||
PERMISSIONS_POLICY_VALUE,
|
||||
} from "../security/origins";
|
||||
|
||||
export interface SharedNextConfigOptions {
|
||||
appName?: string;
|
||||
additionalImgSrc?: string[];
|
||||
additionalConnectSrc?: string[];
|
||||
additionalScriptSrc?: string[];
|
||||
enableMapbox?: boolean;
|
||||
serverExternalPackages?: string[];
|
||||
optimizePackageImports?: string[];
|
||||
transpilePackages?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_TRANSPILE_PACKAGES = [
|
||||
"@app/backend",
|
||||
"@app/database",
|
||||
"@app/mcp-ui",
|
||||
"@app/shared",
|
||||
"@app/supabase-client",
|
||||
"@app/ui",
|
||||
];
|
||||
|
||||
const KNOWN_SUPABASE_CUSTOM_DOMAINS = [
|
||||
"db.example.com",
|
||||
"db-staging.example.com",
|
||||
] as const;
|
||||
|
||||
const KNOWN_SUPABASE_CUSTOM_ORIGINS = KNOWN_SUPABASE_CUSTOM_DOMAINS.map(
|
||||
(h) => `https://${h}`
|
||||
);
|
||||
|
||||
interface CSPConfig {
|
||||
scriptSrc: string;
|
||||
styleSrc: string;
|
||||
imgSrc: string;
|
||||
fontSrc: string;
|
||||
connectSrc: string;
|
||||
frameSrc: string;
|
||||
workerSrc: string;
|
||||
childSrc: string;
|
||||
}
|
||||
|
||||
function buildCSPConfig(options: SharedNextConfigOptions = {}): CSPConfig {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const supabaseOrigin = getSupabaseOrigin();
|
||||
const connectSrcBase = buildConnectSrc(apiUrl);
|
||||
|
||||
const isPreview = true;
|
||||
|
||||
const scriptSrc = [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"https://va.vercel-scripts.com",
|
||||
...(isPreview ? ["https://vercel.live"] : []),
|
||||
...(options.additionalScriptSrc || []),
|
||||
].join(" ");
|
||||
|
||||
const styleSrc = [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
...(isPreview ? ["https://fonts.googleapis.com"] : []),
|
||||
].join(" ");
|
||||
|
||||
const imgSrc = [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:",
|
||||
"http://localhost:54321",
|
||||
"http://127.0.0.1:54321",
|
||||
"https://*.supabase.co",
|
||||
...KNOWN_SUPABASE_CUSTOM_ORIGINS,
|
||||
...(supabaseOrigin &&
|
||||
!KNOWN_SUPABASE_CUSTOM_ORIGINS.includes(
|
||||
supabaseOrigin as (typeof KNOWN_SUPABASE_CUSTOM_ORIGINS)[number]
|
||||
)
|
||||
? [supabaseOrigin]
|
||||
: []),
|
||||
...(options.enableMapbox
|
||||
? ["https://api.mapbox.com", "https://*.tiles.mapbox.com"]
|
||||
: []),
|
||||
...(isPreview ? ["https://vercel.com", "https://vercel.live"] : []),
|
||||
...(options.additionalImgSrc || []),
|
||||
].join(" ");
|
||||
|
||||
const fontSrc = [
|
||||
"'self'",
|
||||
"data:",
|
||||
...(options.enableMapbox ? ["https://api.mapbox.com"] : []),
|
||||
...(isPreview ? ["https://fonts.gstatic.com", "https://vercel.live"] : []),
|
||||
].join(" ");
|
||||
|
||||
const connectExtras = [
|
||||
...(options.enableMapbox
|
||||
? [
|
||||
"https://api.mapbox.com",
|
||||
"https://*.tiles.mapbox.com",
|
||||
"https://events.mapbox.com",
|
||||
]
|
||||
: []),
|
||||
...(isPreview
|
||||
? ["https://vercel.live", "wss://*.pusher.com", "https://*.pusher.com"]
|
||||
: []),
|
||||
...(options.additionalConnectSrc || []),
|
||||
];
|
||||
|
||||
const connectSrc = [...connectSrcBase, ...connectExtras].join(" ");
|
||||
const frameSrc = isPreview ? "'self' https://vercel.live" : "'self'";
|
||||
const workerSrc = "'self' blob:";
|
||||
const childSrc = "'self' blob:";
|
||||
|
||||
return {
|
||||
scriptSrc,
|
||||
styleSrc,
|
||||
imgSrc,
|
||||
fontSrc,
|
||||
connectSrc,
|
||||
frameSrc,
|
||||
workerSrc,
|
||||
childSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSecurityHeaders(
|
||||
options: SharedNextConfigOptions = {}
|
||||
): NextConfig["headers"] {
|
||||
return () => {
|
||||
const csp = buildCSPConfig(options);
|
||||
const isPreview = true;
|
||||
|
||||
return Promise.resolve([
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
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';`,
|
||||
},
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Strict-Transport-Security", value: HSTS_VALUE },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: PERMISSIONS_POLICY_VALUE },
|
||||
{ key: "Cross-Origin-Embedder-Policy", value: "unsafe-none" },
|
||||
{ key: "Cross-Origin-Resource-Policy", value: "cross-origin" },
|
||||
{
|
||||
key: "Cross-Origin-Opener-Policy",
|
||||
value: "same-origin-allow-popups",
|
||||
},
|
||||
...(isPreview
|
||||
? [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://vercel.live",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Headers", value: "*" },
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Vary", value: "Origin" },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApiProxyRewrites(): NextConfig["rewrites"] {
|
||||
return () => {
|
||||
const rewrites: Array<{ source: string; destination: string }> = [];
|
||||
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_API_PROXY === "1" &&
|
||||
process.env.NEXT_PUBLIC_API_URL
|
||||
) {
|
||||
const proxyPath = process.env.NEXT_PUBLIC_API_PROXY_PATH || "/backend";
|
||||
rewrites.push({
|
||||
source: `${proxyPath}/:path*`,
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
beforeFiles: rewrites,
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type WebpackConfig = Parameters<NonNullable<NextConfig["webpack"]>>[0];
|
||||
type WebpackContext = Parameters<NonNullable<NextConfig["webpack"]>>[1];
|
||||
|
||||
export function buildWebpackConfig(
|
||||
options: SharedNextConfigOptions = {}
|
||||
): NextConfig["webpack"] {
|
||||
return (config: WebpackConfig, context: WebpackContext) => {
|
||||
if (context.isServer && options.serverExternalPackages?.length) {
|
||||
config.externals = config.externals || [];
|
||||
if (Array.isArray(config.externals)) {
|
||||
for (const pkg of options.serverExternalPackages) {
|
||||
config.externals.push({ [pkg]: `commonjs ${pkg}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupabaseRemotePatterns(): Array<{
|
||||
protocol: "http" | "https";
|
||||
hostname: string;
|
||||
port?: string;
|
||||
pathname: string;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "54321",
|
||||
pathname: "/storage/v1/object/public/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.supabase.co",
|
||||
pathname: "/storage/v1/object/public/**",
|
||||
},
|
||||
...KNOWN_SUPABASE_CUSTOM_DOMAINS.map((hostname) => ({
|
||||
protocol: "https" as const,
|
||||
hostname,
|
||||
pathname: "/storage/v1/object/public/**",
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared Next.js configuration base. Apps extend this via spread.
|
||||
*/
|
||||
export function createBaseNextConfig(
|
||||
options: SharedNextConfigOptions = {}
|
||||
): NextConfig {
|
||||
const transpilePackages = Array.from(
|
||||
new Set([
|
||||
...DEFAULT_WORKSPACE_TRANSPILE_PACKAGES,
|
||||
...(options.transpilePackages || []),
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
experimental: {
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
...(options.optimizePackageImports && {
|
||||
optimizePackageImports: options.optimizePackageImports,
|
||||
}),
|
||||
},
|
||||
env: {
|
||||
VERCEL_RELATED_PROJECTS: process.env.VERCEL_RELATED_PROJECTS || "",
|
||||
VERCEL_ENV: process.env.VERCEL_ENV || "",
|
||||
},
|
||||
...(options.serverExternalPackages && {
|
||||
serverExternalPackages: options.serverExternalPackages,
|
||||
}),
|
||||
transpilePackages,
|
||||
webpack: buildWebpackConfig(options),
|
||||
headers: buildSecurityHeaders(options),
|
||||
rewrites: buildApiProxyRewrites(),
|
||||
};
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Shared security helpers for CORS/CSP across apps.
|
||||
Configure once via env: ALLOWED_ORIGINS (CSV), ALLOW_VERCEL_PREVIEWS (1/0),
|
||||
VERCEL_TEAM_SLUG, ALLOW_LOCALHOST_ORIGINS (1/0).
|
||||
|
||||
Derived from a real monorepo shape — sanitized of company-specific identifiers
|
||||
but structurally identical so patch mechanics get tested against realistic
|
||||
CSP/rewrite layering.
|
||||
*/
|
||||
|
||||
export interface CorsPolicyOptions {
|
||||
allowVercelPreviews?: boolean;
|
||||
vercelTeamSlug?: string;
|
||||
allowLocalhost?: boolean;
|
||||
}
|
||||
|
||||
export function parseCsvEnv(value?: string | null): string[] {
|
||||
return (value || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getBaseAllowedOrigins(): string[] {
|
||||
const fromCsv = parseCsvEnv(process.env.ALLOWED_ORIGINS);
|
||||
return Array.from(new Set([...fromCsv]));
|
||||
}
|
||||
|
||||
const LOCALHOST_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
||||
|
||||
export function localhostRegex(): RegExp {
|
||||
return LOCALHOST_REGEX;
|
||||
}
|
||||
|
||||
export function vercelPreviewRegex(teamSlug: string): RegExp {
|
||||
const safeSlug = teamSlug.replace(/[^a-z0-9-]/gi, "");
|
||||
return new RegExp(`^https:\\/\\/.*-${safeSlug}\\.vercel\\.app$`, "i");
|
||||
}
|
||||
|
||||
export const PERMISSIONS_POLICY_VALUE =
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(self), payment=(), usb=()";
|
||||
|
||||
export const HSTS_VALUE = "max-age=31536000; includeSubDomains; preload";
|
||||
|
||||
export function getSupabaseOrigin(): string {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
if (!url) return "";
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConnectSrc(apiUrl?: string): string[] {
|
||||
const apiOrigin = apiUrl ? new URL(apiUrl).origin : "";
|
||||
const base: string[] = ["'self'", "https://*.supabase.co"];
|
||||
|
||||
// Only include localhost allowances outside production
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
base.push(
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"ws://localhost:*",
|
||||
"ws://127.0.0.1:*"
|
||||
);
|
||||
}
|
||||
|
||||
const allowPreviews = (process.env.ALLOW_VERCEL_PREVIEWS || "1") !== "0";
|
||||
if (allowPreviews) {
|
||||
base.push("https://*.vercel.app");
|
||||
}
|
||||
|
||||
if (apiOrigin) base.push(apiOrigin);
|
||||
|
||||
const supabaseOrigin = getSupabaseOrigin();
|
||||
if (supabaseOrigin && !base.includes(supabaseOrigin)) {
|
||||
base.push(supabaseOrigin);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Next.js (Turborepo, shared CSP helper)",
|
||||
"config": {
|
||||
"files": ["apps/web/app/layout.tsx"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "jsx"
|
||||
},
|
||||
"sourceFiles": [
|
||||
"apps/web/next.config.ts",
|
||||
"apps/web/app/layout.tsx",
|
||||
"packages/shared/src/next-config/index.ts",
|
||||
"packages/shared/src/security/origins.ts"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps element in app source",
|
||||
"args": { "classes": "page", "tag": "main" },
|
||||
"expectsError": "element_not_found"
|
||||
}
|
||||
],
|
||||
"csp": {
|
||||
"shape": "shared-helper",
|
||||
"signals": [
|
||||
"packages/shared/src/next-config/index.ts:buildCSPConfig",
|
||||
"packages/shared/src/next-config/index.ts:additionalScriptSrc",
|
||||
"apps/web/next.config.ts:additionalScriptSrc"
|
||||
],
|
||||
"patchTarget": "apps/web/next.config.ts",
|
||||
"expectedAfter": "expected-after-patch.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.turbo/
|
||||
out/
|
||||
Reference in New Issue
Block a user