mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +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
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -291,10 +291,13 @@ Schema:
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
|
||||
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
|
||||
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
|
||||
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
|
||||
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
|
||||
|
||||
#### Shape 2 — inline CSP string in `headers()`
|
||||
|
||||
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
|
||||
|
||||
```ts
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then, inside the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
|
||||
- `connect-src 'self'` → `connect-src 'self'${__impeccableLiveDev}`
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Scan a project tree for Content-Security-Policy signals and classify the
|
||||
* shape so the agent knows which patch template to propose.
|
||||
*
|
||||
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
|
||||
* no dev server, no JS evaluation. The classification drives a user-facing
|
||||
* consent prompt; the agent does the actual patch writing.
|
||||
*
|
||||
* Shape taxonomy:
|
||||
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
|
||||
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
|
||||
* arrays. Patch the app's config to append a dev-only
|
||||
* localhost entry to those arrays.
|
||||
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
|
||||
* SvelteKit config's headers() function with a literal
|
||||
* value string. Patch the CSP string in place.
|
||||
* - "middleware": CSP set in middleware.{ts,js}. Detected but not
|
||||
* auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in layout
|
||||
* files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
'.nuxt',
|
||||
'.astro',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.vercel',
|
||||
]);
|
||||
|
||||
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
|
||||
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_READ_BYTES = 64 * 1024;
|
||||
|
||||
const SHARED_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
|
||||
// Shared helper: package exports, config factory
|
||||
if (SCAN_EXTS.has(ext)) {
|
||||
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
|
||||
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
|
||||
if (matched && looksShared) {
|
||||
hits.sharedHelper.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
|
||||
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
|
||||
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
|
||||
if (allInlineMatch) {
|
||||
hits.inlineHeader.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
// Meta tag CSP: layouts / HTML files
|
||||
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
|
||||
hits.metaTag.push(relPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
|
||||
// A monorepo with a shared helper is always that shape, even if an individual
|
||||
// app file also happens to contain a CSP literal.
|
||||
if (hits.sharedHelper.length > 0) {
|
||||
return {
|
||||
shape: 'shared-helper',
|
||||
signals: hits.sharedHelper,
|
||||
};
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
function walk(root, dir, depth, visit) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(root, abs, depth + 1, visit);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
|
||||
let body;
|
||||
try {
|
||||
const fd = fs.openSync(abs, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
|
||||
body = buf.slice(0, n).toString('utf-8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { continue; }
|
||||
visit(abs, path.relative(root, abs), body);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
|
||||
const result = detectCsp(process.cwd());
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
|
||||
@@ -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