Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions src/protect/rules/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 10 additions & 6 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -580,21 +578,27 @@ 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;
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
}
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;
}
Expand Down
55 changes: 55 additions & 0 deletions tests/protect/edge-safe.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading