Skip to content

Commit 641a3fe

Browse files
protect: keep the runtime edge-safe (load Node fs lazily) (#117)
* protect: keep the runtime edge-safe (load Node fs lazily) The protect runtime is what an EDGE guard imports — the Next edge-middleware template, Cloudflare Workers, Deno, Bun, Supabase Functions. But runtime.js and rules/store.js imported `node:fs`/`node:path` STATICALLY, and the bundler rewrites those to bare `fs`/`path`, which don't resolve on an edge runtime at all: the built dist/protect.js could fail the build or leave the guard unavailable on exactly the platforms we advertise support for. Both filesystem uses are Node-only conveniences and were already fail-open, so load them with a dynamic `await import(…)` instead: - rules/store.js: the disk cache tier (memoized loader; on a filesystem-less runtime it reports "no cache" and the memory / pluggable `ruleCache` tiers carry last-known-good). - runtime.js: the `.patchstackrc.json` api-key fallback (resolveApiKey is now async; createProtection already awaited). Adds tests/protect/edge-safe.test.ts to pin the invariant — no static Node-builtin import anywhere in the runtime graph, and none in the built bundle. The Node disk cache / last-known-good tests still pass unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: only assert on dist when the build is fresh The dist assertion could fail spuriously against a STALE bundle left by a build on another branch (CI tests before building, so it skips there and never ran). Gate it on dist being newer than the newest source file; the source-graph assertion is the real invariant and always runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 81e590d commit 641a3fe

3 files changed

Lines changed: 97 additions & 17 deletions

File tree

‎src/protect/rules/store.js‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,29 @@
55
// a KV store for filesystem-less runtimes). Survives process restarts.
66
// read: memory → durable → null. write: memory + best-effort durable. Everything is fail-open —
77
// a read/write error yields "no cache" rather than throwing.
8-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9-
import { join } from 'node:path';
8+
//
9+
// Node's fs/path are loaded LAZILY (dynamic import), never as a static top-level import: this module
10+
// is part of the WinterCG/edge-safe graph (Next edge middleware, Workers, Deno, Supabase Functions),
11+
// where a static `node:fs` import fails to resolve at build/load time and would take the whole guard
12+
// down. On those runtimes the disk tier simply reports "no cache" and the memory tier (or a pluggable
13+
// `ruleCache` adapter) carries last-known-good.
14+
15+
let fsMod; // memoized { readFileSync, writeFileSync, mkdirSync, join } | null (unavailable)
16+
async function loadFs() {
17+
if (fsMod !== undefined) return fsMod;
18+
try {
19+
const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]);
20+
fsMod = {
21+
readFileSync: fs.readFileSync,
22+
writeFileSync: fs.writeFileSync,
23+
mkdirSync: fs.mkdirSync,
24+
join: path.join,
25+
};
26+
} catch {
27+
fsMod = null; // no filesystem here (edge runtime) — memory/adapter tiers still work
28+
}
29+
return fsMod;
30+
}
1031

1132
export function makeStore(options = {}) {
1233
let mem = null;
@@ -54,24 +75,24 @@ function durableTier(options) {
5475
};
5576
}
5677

57-
function cachePath(dir) {
58-
return join(dir, 'patchstack-rules.json');
59-
}
60-
61-
function cacheWrite(dir, env) {
78+
async function cacheWrite(dir, env) {
6279
if (!dir) return;
80+
const fs = await loadFs();
81+
if (!fs) return; // filesystem-less runtime — memory tier only
6382
try {
64-
mkdirSync(dir, { recursive: true });
65-
writeFileSync(cachePath(dir), JSON.stringify(env));
83+
fs.mkdirSync(dir, { recursive: true });
84+
fs.writeFileSync(fs.join(dir, 'patchstack-rules.json'), JSON.stringify(env));
6685
} catch {
6786
/* cache is best-effort — the memory tier still holds last-known-good */
6887
}
6988
}
7089

71-
function cacheRead(dir) {
90+
async function cacheRead(dir) {
7291
if (!dir) return null;
92+
const fs = await loadFs();
93+
if (!fs) return null;
7394
try {
74-
return toEnvelope(JSON.parse(readFileSync(cachePath(dir), 'utf8')));
95+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, 'patchstack-rules.json'), 'utf8')));
7596
} catch {
7697
return null;
7798
}

‎src/protect/runtime.js‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ import { makeStore } from './rules/store.js';
2424
import { resolveRules } from './rules/source.js';
2525
import { startRefresh, makeRefreshHandler } from './rules/refresh.js';
2626
import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js';
27-
import { readFileSync } from 'node:fs';
28-
import { join } from 'node:path';
2927

3028
// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase).
3129
export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js';
@@ -72,7 +70,7 @@ export async function createProtection(options = {}) {
7270
// Report enforced blocks via existing connector POST /api/logs/log (WP path).
7371
// Needs api_key from provision / PATCHSTACK_API_KEY / .patchstackrc.json.
7472
// Opt out: PATCHSTACK_TELEMETRY=off. Never embed api_key in the public widget.
75-
const apiKey = resolveApiKey(options);
73+
const apiKey = await resolveApiKey(options);
7674
const firewallLog =
7775
apiKey && telemetryEnabled() && options.reportFirewallLog !== false
7876
? createFirewallLogReporter({
@@ -580,21 +578,27 @@ function resolveMode(options, bundle) {
580578
return 'dry-run';
581579
}
582580

583-
/** WP-format api_key for connector /api/logs/log. Never use the public site UUID. */
584-
function resolveApiKey(options) {
581+
/**
582+
* WP-format api_key for connector /api/logs/log. Never use the public site UUID.
583+
* The `.patchstackrc.json` fallback reads the filesystem, so fs/path are imported LAZILY — this
584+
* module must stay loadable on edge runtimes (Next edge middleware, Workers, Deno, Supabase
585+
* Functions), where a static `node:fs` import fails to resolve and would take the guard down.
586+
*/
587+
async function resolveApiKey(options) {
585588
if (typeof options?.apiKey === 'string' && options.apiKey.length > 0) return options.apiKey;
586589
if (typeof process !== 'undefined') {
587590
const fromEnv = process.env?.PATCHSTACK_API_KEY;
588591
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
589592
}
590593
try {
591594
if (typeof process === 'undefined' || typeof process.cwd !== 'function') return undefined;
595+
const [{ readFileSync }, { join }] = await Promise.all([import('node:fs'), import('node:path')]);
592596
const cwd = options?.cwd ?? process.cwd();
593597
const raw = readFileSync(join(cwd, '.patchstackrc.json'), 'utf8');
594598
const key = JSON.parse(raw)?.apiKey;
595599
if (typeof key === 'string' && key.length > 0) return key;
596600
} catch {
597-
/* missing — reporting stays off */
601+
/* missing, or no filesystem on this runtime — reporting stays off */
598602
}
599603
return undefined;
600604
}

‎tests/protect/edge-safe.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
// The protect runtime is the module an EDGE guard imports (Next edge middleware, Cloudflare Workers,
7+
// Deno, Bun, Supabase Functions). A STATIC top-level import of a Node builtin breaks those runtimes at
8+
// build/load time — a bare `fs` specifier (what the bundler emits from `node:fs`) doesn't resolve at
9+
// all — taking the whole guard down. Node-only capabilities (disk rule cache, .patchstackrc.json,
10+
// node:http egress patching, DNS screening) must therefore be loaded with a DYNAMIC `await import(…)`
11+
// so they're absent-but-harmless off Node. This test pins that invariant.
12+
13+
const PROTECT_DIR = fileURLToPath(new URL('../../src/protect/', import.meta.url));
14+
15+
function sourceFiles(dir: string, out: string[] = []): string[] {
16+
for (const e of readdirSync(dir, { withFileTypes: true })) {
17+
if (e.name === 'templates' || e.name === 'install') continue; // scaffolded/CLI-side, not the runtime graph
18+
const full = join(dir, e.name);
19+
if (e.isDirectory()) sourceFiles(full, out);
20+
else if (/\.(js|ts)$/.test(e.name) && !e.name.endsWith('.d.ts')) out.push(full);
21+
}
22+
return out;
23+
}
24+
25+
// `import … from 'node:x'` / `'fs'` / `require('node:x')` at module scope. Dynamic `await import(…)` is fine.
26+
const STATIC_NODE_IMPORT =
27+
/^\s*(?:import\s[^;]*?\sfrom\s*|import\s*)['"](?:node:[a-z_/]+|fs|path|os|crypto|dns|net|http|https|child_process|worker_threads)['"]/m;
28+
29+
describe('protect runtime stays edge-safe', () => {
30+
const files = sourceFiles(PROTECT_DIR);
31+
32+
it('has source files to check', () => {
33+
expect(files.length).toBeGreaterThan(5);
34+
});
35+
36+
it('never statically imports a Node builtin in the runtime graph', () => {
37+
const offenders = files.filter((f) => STATIC_NODE_IMPORT.test(readFileSync(f, 'utf8')));
38+
expect(offenders.map((f) => f.replace(PROTECT_DIR, ''))).toEqual([]);
39+
});
40+
41+
it('the built protect bundle has no static Node-builtin import (when dist is fresh)', () => {
42+
const dist = fileURLToPath(new URL('../../dist/protect.js', import.meta.url));
43+
if (!existsSync(dist)) return; // dist is gitignored and CI tests before building
44+
// Only assert against a build that reflects the current sources: a STALE dist (left by a build on
45+
// another branch) would otherwise fail this spuriously. The source-graph assertion above is the
46+
// real invariant and always runs.
47+
const distMtime = statSync(dist).mtimeMs;
48+
const newestSrc = Math.max(...files.map((f) => statSync(f).mtimeMs));
49+
if (distMtime < newestSrc) return;
50+
const built = readFileSync(dist, 'utf8');
51+
// The bundler rewrites `node:fs` → `fs`; either form at top level would break an edge build.
52+
const bad = built.match(/^import\s[^;]*?\sfrom\s*["'](?:node:)?(?:fs|path|child_process|dns|net|os)["']/gm);
53+
expect(bad ?? []).toEqual([]);
54+
});
55+
});

0 commit comments

Comments
 (0)