diff --git a/src/protect/rules/store.js b/src/protect/rules/store.js index 01b3d1d..fde4c53 100644 --- a/src/protect/rules/store.js +++ b/src/protect/rules/store.js @@ -5,8 +5,29 @@ // a KV store for filesystem-less runtimes). Survives process restarts. // read: memory → durable → null. write: memory + best-effort durable. Everything is fail-open — // a read/write error yields "no cache" rather than throwing. -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; +// +// Node's fs/path are loaded LAZILY (dynamic import), never as a static top-level import: this module +// is part of the WinterCG/edge-safe graph (Next edge middleware, Workers, Deno, Supabase Functions), +// where a static `node:fs` import fails to resolve at build/load time and would take the whole guard +// down. On those runtimes the disk tier simply reports "no cache" and the memory tier (or a pluggable +// `ruleCache` adapter) carries last-known-good. + +let fsMod; // memoized { readFileSync, writeFileSync, mkdirSync, join } | null (unavailable) +async function loadFs() { + if (fsMod !== undefined) return fsMod; + try { + const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); + fsMod = { + readFileSync: fs.readFileSync, + writeFileSync: fs.writeFileSync, + mkdirSync: fs.mkdirSync, + join: path.join, + }; + } catch { + fsMod = null; // no filesystem here (edge runtime) — memory/adapter tiers still work + } + return fsMod; +} export function makeStore(options = {}) { let mem = null; @@ -54,24 +75,24 @@ function durableTier(options) { }; } -function cachePath(dir) { - return join(dir, 'patchstack-rules.json'); -} - -function cacheWrite(dir, env) { +async function cacheWrite(dir, env) { if (!dir) return; + const fs = await loadFs(); + if (!fs) return; // filesystem-less runtime — memory tier only try { - mkdirSync(dir, { recursive: true }); - writeFileSync(cachePath(dir), JSON.stringify(env)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fs.join(dir, 'patchstack-rules.json'), JSON.stringify(env)); } catch { /* cache is best-effort — the memory tier still holds last-known-good */ } } -function cacheRead(dir) { +async function cacheRead(dir) { if (!dir) return null; + const fs = await loadFs(); + if (!fs) return null; try { - return toEnvelope(JSON.parse(readFileSync(cachePath(dir), 'utf8'))); + return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, 'patchstack-rules.json'), 'utf8'))); } catch { return null; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 4e294ae..c2764c8 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -24,8 +24,6 @@ import { makeStore } from './rules/store.js'; import { resolveRules } from './rules/source.js'; import { startRefresh, makeRefreshHandler } from './rules/refresh.js'; import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; // Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase). export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js'; @@ -72,7 +70,7 @@ export async function createProtection(options = {}) { // Report enforced blocks via existing connector POST /api/logs/log (WP path). // Needs api_key from provision / PATCHSTACK_API_KEY / .patchstackrc.json. // Opt out: PATCHSTACK_TELEMETRY=off. Never embed api_key in the public widget. - const apiKey = resolveApiKey(options); + const apiKey = await resolveApiKey(options); const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({ @@ -580,8 +578,13 @@ function resolveMode(options, bundle) { return 'dry-run'; } -/** WP-format api_key for connector /api/logs/log. Never use the public site UUID. */ -function resolveApiKey(options) { +/** + * WP-format api_key for connector /api/logs/log. Never use the public site UUID. + * The `.patchstackrc.json` fallback reads the filesystem, so fs/path are imported LAZILY — this + * module must stay loadable on edge runtimes (Next edge middleware, Workers, Deno, Supabase + * Functions), where a static `node:fs` import fails to resolve and would take the guard down. + */ +async function resolveApiKey(options) { if (typeof options?.apiKey === 'string' && options.apiKey.length > 0) return options.apiKey; if (typeof process !== 'undefined') { const fromEnv = process.env?.PATCHSTACK_API_KEY; @@ -589,12 +592,13 @@ function resolveApiKey(options) { } try { if (typeof process === 'undefined' || typeof process.cwd !== 'function') return undefined; + const [{ readFileSync }, { join }] = await Promise.all([import('node:fs'), import('node:path')]); const cwd = options?.cwd ?? process.cwd(); const raw = readFileSync(join(cwd, '.patchstackrc.json'), 'utf8'); const key = JSON.parse(raw)?.apiKey; if (typeof key === 'string' && key.length > 0) return key; } catch { - /* missing — reporting stays off */ + /* missing, or no filesystem on this runtime — reporting stays off */ } return undefined; } diff --git a/tests/protect/edge-safe.test.ts b/tests/protect/edge-safe.test.ts new file mode 100644 index 0000000..30a5d3d --- /dev/null +++ b/tests/protect/edge-safe.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The protect runtime is the module an EDGE guard imports (Next edge middleware, Cloudflare Workers, +// Deno, Bun, Supabase Functions). A STATIC top-level import of a Node builtin breaks those runtimes at +// build/load time — a bare `fs` specifier (what the bundler emits from `node:fs`) doesn't resolve at +// all — taking the whole guard down. Node-only capabilities (disk rule cache, .patchstackrc.json, +// node:http egress patching, DNS screening) must therefore be loaded with a DYNAMIC `await import(…)` +// so they're absent-but-harmless off Node. This test pins that invariant. + +const PROTECT_DIR = fileURLToPath(new URL('../../src/protect/', import.meta.url)); + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'templates' || e.name === 'install') continue; // scaffolded/CLI-side, not the runtime graph + const full = join(dir, e.name); + if (e.isDirectory()) sourceFiles(full, out); + else if (/\.(js|ts)$/.test(e.name) && !e.name.endsWith('.d.ts')) out.push(full); + } + return out; +} + +// `import … from 'node:x'` / `'fs'` / `require('node:x')` at module scope. Dynamic `await import(…)` is fine. +const STATIC_NODE_IMPORT = + /^\s*(?:import\s[^;]*?\sfrom\s*|import\s*)['"](?:node:[a-z_/]+|fs|path|os|crypto|dns|net|http|https|child_process|worker_threads)['"]/m; + +describe('protect runtime stays edge-safe', () => { + const files = sourceFiles(PROTECT_DIR); + + it('has source files to check', () => { + expect(files.length).toBeGreaterThan(5); + }); + + it('never statically imports a Node builtin in the runtime graph', () => { + const offenders = files.filter((f) => STATIC_NODE_IMPORT.test(readFileSync(f, 'utf8'))); + expect(offenders.map((f) => f.replace(PROTECT_DIR, ''))).toEqual([]); + }); + + it('the built protect bundle has no static Node-builtin import (when dist is fresh)', () => { + const dist = fileURLToPath(new URL('../../dist/protect.js', import.meta.url)); + if (!existsSync(dist)) return; // dist is gitignored and CI tests before building + // Only assert against a build that reflects the current sources: a STALE dist (left by a build on + // another branch) would otherwise fail this spuriously. The source-graph assertion above is the + // real invariant and always runs. + const distMtime = statSync(dist).mtimeMs; + const newestSrc = Math.max(...files.map((f) => statSync(f).mtimeMs)); + if (distMtime < newestSrc) return; + const built = readFileSync(dist, 'utf8'); + // The bundler rewrites `node:fs` → `fs`; either form at top level would break an edge build. + const bad = built.match(/^import\s[^;]*?\sfrom\s*["'](?:node:)?(?:fs|path|child_process|dns|net|os)["']/gm); + expect(bad ?? []).toEqual([]); + }); +});