From dccf0587c9c846c1b20a916fe14eef1ea4edcebd Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 10:03:03 +0000 Subject: [PATCH 1/2] Wire protection into Lovable's TanStack server wrapper Lovable's current TanStack template points TanStack at its own wrapper in src/server.ts (tanstackStart: { server: { entry: "server" } }), a plain `export default { fetch }` object. protect refused both the config option and that entry shape and asked for manual integration, so agents hand-wrote a guard that had no rules credential and blocked nothing. - The config check accepts server.entry "server" or "./server", which name src/server.ts, the default entry. - The entry rewriter wraps the fetch of a plain `export default { fetch }` object with protectFetch, as it does for createServerEntry({ fetch }). - An entry that builds its own protection is left untouched, with a message saying what to remove so the managed guard can replace it. - siteUuid, apiKey, pulseAuth and supabaseUrl accept undefined, so the TanStack templates compile under exactOptionalPropertyTypes, which that template turns on. The template typecheck now checks them with it. - AGENT-INSTALL.md describes the wrapper and says not to hand-write a guard. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 10 ++++- scripts/typecheck-templates.mjs | 13 ++++++ src/protect/install/adapters/tanstack.ts | 45 ++++++++++++++++--- src/protect/protect.d.ts | 8 ++-- tests/protect/framework-entries.test.ts | 55 ++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 13 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index e79ad267..301cbdd6 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -456,9 +456,15 @@ It is server-only. Never put it in the widget tag, client bundles, or public env or commit them. Source checks do not verify rule delivery or blocking in the running deployment. **TanStack Start:** the documented `src/server.ts` Fetch entry can be scaffolded without Supabase - or `src/start.ts`. Supported literal `createServerEntry({ fetch: ... })` configurations are wrapped - without changing host arguments or application error handling. The installed framework must expose + or `src/start.ts`. Supported literal `createServerEntry({ fetch: ... })` configurations, and a plain + `export default { fetch }` wrapper in `src/server.ts` (which a config can name with + `tanstackStart: { server: { entry: "server" } }`, as Lovable's template does), are wrapped without + changing host arguments or application error handling. The installed framework must expose `server-entry`; custom entry paths, spreads, getters and competing files need manual integration. + An entry that builds its own protection (`createProtection` imported from `@patchstack/connect`) is + left untouched: remove that code and its rules import, then run `protect` again. Do not hand-write a + guard in its place. The managed one carries the site's read-only rules credential in its rules file, + and a hand-written one cannot fetch rule updates or report detections without it. An app that deploys as a Cloudflare Worker (`@cloudflare/vite-plugin` with a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`) runs the module its wrangler `main` names, so the Vite config's entry setting does not decide it. When `main` is TanStack's own `@tanstack/react-start/server-entry`, diff --git a/scripts/typecheck-templates.mjs b/scripts/typecheck-templates.mjs index 1def8abf..069a783e 100644 --- a/scripts/typecheck-templates.mjs +++ b/scripts/typecheck-templates.mjs @@ -85,9 +85,22 @@ writeFileSync( ), ); +// The TanStack Start templates also go into Lovable apps, whose tsconfig turns on +// exactOptionalPropertyTypes, so they are checked a second time with it on. +const STRICT_OPTIONAL = ['fetch-guard.ts', 'guard.ts']; +writeFileSync( + join(OUT, 'tsconfig.strict-optional.json'), + JSON.stringify( + { extends: './tsconfig.json', compilerOptions: { exactOptionalPropertyTypes: true }, include: [...STRICT_OPTIONAL, 'shims.d.ts'] }, + null, + 2, + ), +); + const tsc = join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc'); try { execFileSync(tsc, ['--noEmit', '-p', join(OUT, 'tsconfig.json')], { stdio: 'inherit' }); + execFileSync(tsc, ['--noEmit', '-p', join(OUT, 'tsconfig.strict-optional.json')], { stdio: 'inherit' }); console.log(`template typecheck: OK (${templates.length} templates)`); rmSync(OUT, { recursive: true, force: true }); } catch { diff --git a/src/protect/install/adapters/tanstack.ts b/src/protect/install/adapters/tanstack.ts index 6dde04a7..2b752cbf 100644 --- a/src/protect/install/adapters/tanstack.ts +++ b/src/protect/install/adapters/tanstack.ts @@ -72,6 +72,16 @@ function defaultEntryConfig(ts: Compiler, file: string, source: string): boolean } if (!ts.isObjectLiteralExpression(config)) return false; let unsafe = false; + // `server: { entry: "server" }` names src/server.ts, which is the default entry anyway (Lovable's template + // sets it to point TanStack at its own wrapper there). Any other value moves the entry. + const namesDefaultEntry = (value: import('typescript').Expression): boolean => { + const server = unwrap(value); + if (!ts.isObjectLiteralExpression(server) || server.properties.length !== 1) return false; + const entry = server.properties[0]!; + return ts.isPropertyAssignment(entry) && (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) + && entry.name.text === 'entry' && ts.isStringLiteral(unwrap(entry.initializer)) + && ['server', './server'].includes((unwrap(entry.initializer) as import('typescript').StringLiteral).text); + }; const visit = (node: import('typescript').Node) => { if (ts.isSpreadAssignment(node) || ts.isSpreadElement(node) || ts.isComputedPropertyName(node)) unsafe = true; if (ts.isObjectLiteralExpression(node)) { @@ -79,9 +89,13 @@ function defaultEntryConfig(ts: Compiler, file: string, source: string): boolean for (const property of node.properties) { if (!ts.isPropertyAssignment(property) || (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name))) { unsafe = true; continue; } const name = property.name.text; - if (names.has(name) || ['srcDirectory', 'serverEntry', 'entry', 'server', 'root'].includes(name)) unsafe = true; + if (names.has(name)) unsafe = true; names.add(name); + if (name === 'server' && namesDefaultEntry(property.initializer)) continue; + if (['srcDirectory', 'serverEntry', 'entry', 'server', 'root'].includes(name)) unsafe = true; + visit(property.initializer); } + return; } ts.forEachChild(node, visit); }; @@ -134,20 +148,28 @@ function entryProblem(cwd: string): string | null { return null; } -/** Compose only the documented literal createServerEntry shape; never evaluate app config. */ +/** + * Compose only literal entry shapes; never evaluate app config. The entry is `createServerEntry({ fetch })` + * or a plain `export default { fetch }` object, the shape of a server wrapper the app keeps at src/server.ts + * (Lovable's template wraps TanStack's handler there for error pages). + */ export function composeTanstackEntry(ts: Compiler, source: string): string | null { const tree = parsedSource(ts, ENTRY, source); - if (!tree || /\bprotectFetch\b/.test(source)) return null; + if (!tree || /\bprotectFetch\b/.test(source) || ownProtection(source)) return null; const imported = tree.statements.some(node => ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@tanstack/react-start/server-entry' && !node.importClause?.isTypeOnly && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && node.importClause.namedBindings.elements.some(e => !e.propertyName && !e.isTypeOnly && e.name.text === 'createServerEntry')); - if (!imported) return null; const exports = tree.statements.filter(ts.isExportAssignment); if (exports.length !== 1 || exports[0]!.isExportEquals) return null; - const call = exports[0]!.expression; - if (!ts.isCallExpression(call) || call.expression.getText(tree) !== 'createServerEntry' || call.arguments.length !== 1) return null; - const object = call.arguments[0]!; + const exported = exports[0]!.expression; + let object: import('typescript').Expression; + if (ts.isObjectLiteralExpression(exported)) object = exported; + else { + if (!imported) return null; + if (!ts.isCallExpression(exported) || exported.expression.getText(tree) !== 'createServerEntry' || exported.arguments.length !== 1) return null; + object = exported.arguments[0]!; + } if (!ts.isObjectLiteralExpression(object) || object.properties.some(p => ts.isSpreadAssignment(p) || !p.name || ts.isComputedPropertyName(p.name))) return null; const properties = object.properties.filter(p => p.name?.getText(tree) === 'fetch'); if (properties.length !== 1) return null; @@ -165,6 +187,11 @@ export function composeTanstackEntry(ts: Compiler, source: string): string | nul return parsedSource(ts, ENTRY, result) ? result : null; } +/** Whether the entry builds its own Patchstack protection rather than importing the managed guard. */ +function ownProtection(source: string): boolean { + return /['"]@patchstack\/connect(?:\/protect)?['"]/.test(source); +} + function wiredEntry(ts: Compiler, source: string): boolean { const tree = parsedSource(ts, ENTRY, source); if (!tree) return false; @@ -199,6 +226,10 @@ export const tanstackAdapter: Adapter = { const ts = sourceCompiler(cwd); if (problem || !ts) { log(`TanStack server entry left untouched: ${problem ?? 'parser unavailable'}.`); return {ok:false,changed:[]}; } const previous = existsSync(join(cwd, ENTRY)) ? read(join(cwd, ENTRY)) : DEFAULT; + if (ownProtection(previous) && !wiredEntry(ts, previous)) { + log(`${ENTRY} creates its own Patchstack protection, so it was left untouched. Remove that code (the createProtection call and the rules file it imports) and run protect again: the managed guard carries this site's read-only rules credential, which a hand-written one does not.`); + return {ok:false,changed:[]}; + } const next = wiredEntry(ts, previous) ? previous : composeTanstackEntry(ts, previous); if (!next) { log('Custom TanStack server entry left untouched; wrap its final Fetch boundary manually.'); return {ok:false,changed:[]}; } const changed: string[] = []; diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 47ffd2a2..c8403158 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -214,7 +214,7 @@ export interface CreateProtectionOptions { token?: string; baseUrl?: string; /** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */ - siteUuid?: string; + siteUuid?: string | undefined; /** * WP-format site API key (`{secret}-{oauth.id}`) for authenticated block logs * via connector `POST /api/logs/log`. Falls back to `PATCHSTACK_API_KEY`, then @@ -222,14 +222,14 @@ export interface CreateProtectionOptions { * `.patchstackrc.json` (where installs that predate the split still hold it). * Never put this in the public widget. */ - apiKey?: string; + apiKey?: string | undefined; /** * Credential for the authenticated rules lookup. Falls back to * `PATCHSTACK_PULSE_AUTH`, then `pulseAuth` in `.patchstackrc.local.json` and * then in `.patchstackrc.json`, then to `apiKey`. Exchanged for a short-lived * token; never sent directly. Never put this in the public widget. */ - pulseAuth?: string; + pulseAuth?: string | undefined; /** Override the Pulse rules API base URL. */ pulseRulesUrl?: string; /** @@ -506,7 +506,7 @@ export const GUARD_PATH: string; /** Browser-tunnel guard: evaluate then forward to the app's own Supabase project (SSRF-pinned). */ export function createSupabaseGuard(opts: { protection: Protection; - supabaseUrl?: string; + supabaseUrl?: string | undefined; fetchImpl?: typeof fetch; /** Maximum tunneled request body size. Default 1 MiB. */ maxBodyBytes?: number; diff --git a/tests/protect/framework-entries.test.ts b/tests/protect/framework-entries.test.ts index 65ca9f23..34b2ff3a 100644 --- a/tests/protect/framework-entries.test.ts +++ b/tests/protect/framework-entries.test.ts @@ -140,6 +140,61 @@ describe('native TanStack server boundary',()=>{ expect(existsSync(join(cwd, 'src/server.ts'))).toBe(false); }); + // A config wrapper pointing TanStack at its own server wrapper in src/server.ts (Lovable's template), with + // a plain `export default { fetch }` entry that calls TanStack's handler itself. + const ownWrapper = `import { renderFailure } from "./lib/failure"; +async function loadEntry() { return (await import("@tanstack/react-start/server-entry")).default; } +export default { + async fetch(request: Request, env: unknown, ctx: unknown) { + try { + const entry = await loadEntry(); + return await entry.fetch(request, env, ctx); + } catch { + return renderFailure(); + } + }, +}; +`; + + it.each(['"server"', '"./server"'])('wires a server wrapper the config names as the default entry (entry %s)', entry => { + const cwd = tanstack(); + put(cwd, 'vite.config.ts', `import {defineConfig} from '@lovable.dev/vite-tanstack-config';\nexport default defineConfig({tanstackStart:{server:{entry:${entry}}}});`); + put(cwd, 'src/server.ts', ownWrapper); + expect(runProtect(cwd).status).toBe('wired'); + const wired = read(cwd, 'src/server.ts'); + expect(wired).toContain('import { protectFetch } from "./patchstack/guard";'); + expect(wired).toContain('fetch: protectFetch(async function (request: Request, env: unknown, ctx: unknown)'); + expect(wired).toContain('return renderFailure();'); + expect((ts.createSourceFile('server.ts', wired, ts.ScriptTarget.Latest, true) as any).parseDiagnostics).toEqual([]); + expect(runVerify(cwd).wired).toBe(true); + runProtect(cwd); + expect(read(cwd, 'src/server.ts')).toBe(wired); + }); + + it.each([ + 'defineConfig({tanstackStart:{server:{entry:"server", preset:"node"}}})', + 'defineConfig({tanstackStart:{server:{entry:"src/server"}}})', + 'defineConfig({tanstackStart:{srcDirectory:"web", server:{entry:"server"}}})', + ])('still refuses a server option that may move the entry: %s', call => { + const cwd = tanstack(); + put(cwd, 'vite.config.ts', `import {defineConfig} from '@lovable.dev/vite-tanstack-config';\nexport default ${call};`); + put(cwd, 'src/server.ts', ownWrapper); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, 'src/server.ts')).toBe(ownWrapper); + }); + + it('leaves an entry that builds its own Patchstack protection alone, and says what to remove', () => { + const cwd = tanstack(); + put(cwd, 'vite.config.ts', `import {defineConfig} from '@lovable.dev/vite-tanstack-config';\nexport default defineConfig({tanstackStart:{server:{entry:"server"}}});`); + const own = `import { createProtection } from "@patchstack/connect/protect";\n` + ownWrapper; + put(cwd, 'src/server.ts', own); + const logged: string[] = []; + vi.mocked(console.log).mockImplementation((line: unknown) => { logged.push(String(line)); }); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, 'src/server.ts')).toBe(own); + expect(logged.join('\n')).toContain('creates its own Patchstack protection'); + }); + it.each(['vite.config.cjs','vite.config.cts','app.config.ts','rsbuild.config.ts'])('preserves unsupported configuration in %s', file => { const cwd = tanstack(); put(cwd, file, 'module.exports = require("./options");'); From b65cba6116ca3952bf543d5d79d70c2ea4f1248e Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 10:09:07 +0000 Subject: [PATCH 2/2] Give any guard, hand-wired too, the read-only rules credential A guard written by hand passed its rules from a file setup did not know about, so setup never wrote the read-only rules credential into it. The guard then could not fetch rule updates without the site key, and its warning sent agents to the key. - Setup and a keyed scan now also maintain the JSON file a source that imports @patchstack/connect/protect passes in (static import, dynamic import, require or new URL), under any name and in any folder, with the site's current rules, the read-only credential and the map stamp. - A copy of the guard's runtime cache entry ({ bundle, etag, ... }) is read as the bundle it holds: the runtime runs those rules, and setup rewrites the file as a plain rules file. - The no-credential warning names the read-only credential first. - The TanStack message and AGENT-INSTALL.md say a hand-wired guard works once setup has run, and to run setup rather than ask for the key. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 14 ++- src/build-stamp.ts | 104 +++++++++++++++++++++-- src/bundled-rules.ts | 7 +- src/protect/install/adapters/tanstack.ts | 2 +- src/protect/runtime.js | 13 ++- tests/build-stamp.test.ts | 18 ++++ tests/bundled-rules.test.ts | 53 ++++++++++++ tests/protect/cache-shaped-rules.test.ts | 28 ++++++ 8 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 tests/protect/cache-shaped-rules.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 301cbdd6..fd8db0d3 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -462,9 +462,17 @@ It is server-only. Never put it in the widget tag, client bundles, or public env changing host arguments or application error handling. The installed framework must expose `server-entry`; custom entry paths, spreads, getters and competing files need manual integration. An entry that builds its own protection (`createProtection` imported from `@patchstack/connect`) is - left untouched: remove that code and its rules import, then run `protect` again. Do not hand-write a - guard in its place. The managed one carries the site's read-only rules credential in its rules file, - and a hand-written one cannot fetch rule updates or report detections without it. + left untouched. Prefer the managed guard: remove that code and its rules import, then run `protect` + again. Do not hand-write a guard in its place. + + **Any guard, managed or hand-written, fetches rule updates without the site key** when the rules file + it passes as `rules` carries the read-only credential (`_patchstack.rules_reader`). `setup` and a + keyed `scan` write it, with the site's current rules, into the one JSON file a source importing + `@patchstack/connect/protect` passes in, wherever that file is and whatever it is called. A copy of + the guard's runtime cache (`{ "bundle": … }`) is read as the bundle it holds and rewritten as a plain + rules file. So when a guard reports that it has no credential, run `setup` again; do not ask the + person for the key. Setting `PATCHSTACK_API_KEY` in the deployment also works, and makes reports + verified. An app that deploys as a Cloudflare Worker (`@cloudflare/vite-plugin` with a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`) runs the module its wrangler `main` names, so the Vite config's entry setting does not decide it. When `main` is TanStack's own `@tanstack/react-start/server-entry`, diff --git a/src/build-stamp.ts b/src/build-stamp.ts index e0a92bc6..b5ea253f 100644 --- a/src/build-stamp.ts +++ b/src/build-stamp.ts @@ -15,7 +15,7 @@ // A retained value would let a later build present an identity its coordinates did not earn. import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; -import { join, relative, sep } from 'node:path'; +import { join, relative, resolve, sep } from 'node:path'; import { BUILD_STAMP_KEY, hasRawBuildStamp, readBuildStamp } from './build-id.js'; import { stripComments } from './protect/install/source-scope.js'; @@ -85,6 +85,85 @@ function isRuleBundle(value: unknown): value is Record { ); } +/** + * The rule bundle a guard rules file holds, as a plain bundle. + * + * Usually the file is the bundle. A copy of the guard's runtime cache entry (`{ "bundle": …, "etag": … }`) + * holds it one level down; that is read as the bundle it carries, with its `_patchstack` namespace if any, + * so writing the file back turns it into an ordinary rules file. Null when neither shape is there. + */ +export function ruleBundleOf(value: unknown): Record | null { + if (isRuleBundle(value)) return value; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (!isRuleBundle(record.bundle)) return null; + const namespace = record[BUILD_STAMP_KEY] ?? record.bundle[BUILD_STAMP_KEY]; + + return { ...record.bundle, ...(namespace === undefined ? {} : { [BUILD_STAMP_KEY]: namespace }) }; +} + +/** Relative `.json` specifiers a source file imports, statically, dynamically, by require or by URL. */ +const JSON_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bnew\s+URL\s*\(\s*)["'](\.{1,2}\/[^"'\n]+?\.json)["']/g; + +/** + * JSON files a hand-wired guard passes to the protection runtime. + * + * A guard written by hand (or by an assistant) rather than scaffolded can keep its rules anywhere, under + * any name. The same ownership test as for a scaffolded guard applies: a source file that imports the + * public protection runtime and imports that exact file. Only files holding a rule bundle are returned. + */ +function importedRulesFiles(root: string, contained: (path: string) => boolean): string[] { + const found: string[] = []; + const walk = (dir: string, depth: number): void => { + if (depth > MAX_DEPTH || found.length > 8) return; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + if (name.startsWith('.')) continue; + const path = join(dir, name); + let entry: ReturnType; + try { + entry = lstatSync(path); + } catch { + continue; + } + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (!SKIP_DIRECTORIES.has(name)) walk(path, depth + 1); + continue; + } + if (!entry.isFile() || !GUARD_SOURCE.test(name) || entry.size > MAX_GUARD_SOURCE_BYTES) continue; + let source: string; + try { + source = readFileSync(path, 'utf8'); + } catch { + continue; + } + if (!source.includes('@patchstack/connect/protect')) continue; + source = stripComments(source); + if (!source.includes('@patchstack/connect/protect')) continue; + for (const match of source.matchAll(JSON_IMPORT)) { + const target = resolve(dir, match[1]!); + if (!contained(target)) continue; + try { + const file = lstatSync(target); + if (!file.isFile() || file.isSymbolicLink()) continue; + if (ruleBundleOf(JSON.parse(readFileSync(target, 'utf8'))) !== null) found.push(target); + } catch { + // Not a readable bundle: not something this hook writes into. + } + } + } + }; + walk(root, 0); + + return found; +} + export type RulesFileLocation = | { kind: 'one'; path: string } /** No guard is installed here, so there is nothing to stamp and nothing to create. */ @@ -165,7 +244,7 @@ export function findRulesFile(cwd: string): RulesFileLocation { if (!contained(path)) continue; if (!guardImportsRulesFile(dir, name)) continue; try { - if (isRuleBundle(JSON.parse(readFileSync(path, 'utf8')))) found.push(path); + if (ruleBundleOf(JSON.parse(readFileSync(path, 'utf8'))) !== null) found.push(path); } catch { // Named like ours and not parseable. Recorded, so the caller can say that rather than saying // this project has no guard. @@ -174,6 +253,20 @@ export function findRulesFile(cwd: string): RulesFileLocation { } }; walk(cwd, 0); + const real = (path: string): string => { + try { + return realpathSync(path); + } catch { + return path; + } + }; + const seen = new Set(found.map(real)); + for (const path of importedRulesFiles(cwd, contained)) { + if (!seen.has(real(path))) { + seen.add(real(path)); + found.push(path); + } + } if (found.length > 1) return { kind: 'ambiguous', paths: found.map((p) => relative(cwd, p)) }; if (found.length === 1) return { kind: 'one', path: found[0]! }; @@ -243,14 +336,13 @@ export function applyBuildStamp(cwd: string, id: string | null): StampOutcome { } catch { return { kind: 'skipped', reason: `${file} is not valid JSON — left untouched` }; } - if (!isRuleBundle(parsed)) return { kind: 'skipped', reason: `${file} is not a guard rules bundle` }; - - const bundle = parsed as Record; + const bundle = ruleBundleOf(parsed); + if (bundle === null) return { kind: 'skipped', reason: `${file} is not a guard rules bundle` }; const current = readBuildStamp(bundle); // Clearing keys off the RAW property, not the canonical reading of it. A malformed `build_id` reads as // null, so comparing canonical values would call a file that still carries `"deadbee"` unchanged and // leave it there — a stamp this build did not earn, surviving into the artifact. - if (id === null ? !hasRawBuildStamp(bundle) : current === id) return { kind: 'unchanged', file }; + if (bundle === parsed && (id === null ? !hasRawBuildStamp(bundle) : current === id)) return { kind: 'unchanged', file }; const namespace = { ...((bundle[BUILD_STAMP_KEY] ?? {}) as Record) }; if (id === null) delete namespace.build_id; diff --git a/src/bundled-rules.ts b/src/bundled-rules.ts index 1e1a7466..9bf1c04c 100644 --- a/src/bundled-rules.ts +++ b/src/bundled-rules.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { findRulesFile } from './build-stamp.js'; +import { findRulesFile, ruleBundleOf } from './build-stamp.js'; import { writeProjectFileSync } from './safe-file.js'; /** Starter signatures shipped with every guard. They stay in the file whatever the site's rules are. */ @@ -52,7 +52,10 @@ export function writeBundledRules( let current: Record; try { raw = readFileSync(location.path, 'utf8'); - current = JSON.parse(raw) as Record; + // A copied runtime cache entry is read as the bundle it carries, so this write leaves a plain rules file. + const bundle = ruleBundleOf(JSON.parse(raw)); + if (bundle === null) return 'skipped'; + current = bundle; } catch { return 'skipped'; } diff --git a/src/protect/install/adapters/tanstack.ts b/src/protect/install/adapters/tanstack.ts index 2b752cbf..5bfcefa8 100644 --- a/src/protect/install/adapters/tanstack.ts +++ b/src/protect/install/adapters/tanstack.ts @@ -227,7 +227,7 @@ export const tanstackAdapter: Adapter = { if (problem || !ts) { log(`TanStack server entry left untouched: ${problem ?? 'parser unavailable'}.`); return {ok:false,changed:[]}; } const previous = existsSync(join(cwd, ENTRY)) ? read(join(cwd, ENTRY)) : DEFAULT; if (ownProtection(previous) && !wiredEntry(ts, previous)) { - log(`${ENTRY} creates its own Patchstack protection, so it was left untouched. Remove that code (the createProtection call and the rules file it imports) and run protect again: the managed guard carries this site's read-only rules credential, which a hand-written one does not.`); + log(`${ENTRY} creates its own Patchstack protection, so it was left untouched. Setup keeps the rules file it passes current, read-only rules credential included, so it fetches rule updates without a key. To use the managed guard instead, remove that code (the createProtection call and that rules file) and run protect again.`); return {ok:false,changed:[]}; } const next = wiredEntry(ts, previous) ? previous : composeTanstackEntry(ts, previous); diff --git a/src/protect/runtime.js b/src/protect/runtime.js index e81f0694..d2c6da78 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -131,6 +131,11 @@ const DETECTION_REQUEST_DELIVERY_MS = 2_000; const DETECTION_STALE_MS = 10_000; export async function createProtection(options = {}) { + // A copy of the runtime cache entry (`{ bundle, etag, … }`) passed as `rules` is read as the bundle it holds. + if (options.rules && !Array.isArray(options.rules) && !Array.isArray(options.rules.firewall) + && options.rules.bundle && Array.isArray(options.rules.bundle.firewall)) { + options = { ...options, rules: { ...options.rules.bundle, ...(options.rules._patchstack ? { _patchstack: options.rules._patchstack } : {}) } }; + } const localBuildId = options.buildId != null ? canonicalBuildId(options.buildId) : readBuildStamp(options.rules); // A refresh cannot acquire the identity of source files changed underneath this loaded guard. options = { ...options, ...(localBuildId ? {buildId:localBuildId} : {}), @@ -263,10 +268,12 @@ export async function createProtection(options = {}) { // trade a stale rule set for no rule set at all. if (options.siteUuid && !rulesAuth) { const message = - 'Patchstack: no API credential resolved for site ' + + 'Patchstack: this guard has no credential for site ' + options.siteUuid + - '. Rule updates will be rejected and this guard would keep running on its cached rules. ' + - 'Set PATCHSTACK_API_KEY (or pass { pulseAuth }) — required on runtimes without a filesystem.'; + ', so it cannot fetch rule updates or report attacks, and keeps the rules it was given. ' + + 'No key is needed for this: the guard rules file that `npx @patchstack/connect setup` keeps current carries a ' + + 'read-only rules credential (`_patchstack.rules_reader`). Pass that file as `rules` and run setup again. ' + + 'Alternatively set PATCHSTACK_API_KEY in the deployment (or pass { pulseAuth }).'; notify(onError, new Error(message), 'onError'); console.warn(message); } diff --git a/tests/build-stamp.test.ts b/tests/build-stamp.test.ts index c7c36779..dc7de28b 100644 --- a/tests/build-stamp.test.ts +++ b/tests/build-stamp.test.ts @@ -322,3 +322,21 @@ describe('when a build may write to the project', () => { expect(isPreBundleBuildHook({})).toBe(false); }); }); + +describe('stamping a hand-wired guard', () => { + it('finds the file a hand-written guard imports and stamps a copied cache entry as a plain bundle', () => { + const dir = mkdtempSync(join(tmpdir(), 'ps-stamp-handwired-')); + mkdirSync(join(dir, 'src/lib'), { recursive: true }); + writeFileSync(join(dir, 'src/server.ts'), "import { createProtection } from '@patchstack/connect/protect';\nconst rules = (await import('./lib/site-rules.json')).default;\nexport default createProtection({ rules });\n"); + writeFileSync(join(dir, 'src/lib/site-rules.json'), JSON.stringify({ bundle: { firewall: [], whitelists: [], whitelist_keys: {} }, etag: 'W/"1"' })); + try { + expect(findRulesFile(dir)).toEqual({ kind: 'one', path: join(dir, 'src/lib/site-rules.json') }); + expect(applyBuildStamp(dir, 'b'.repeat(64)).kind).toBe('stamped'); + const written = JSON.parse(readFileSync(join(dir, 'src/lib/site-rules.json'), 'utf8')); + expect(Object.keys(written).sort()).toEqual(['_patchstack', 'firewall', 'whitelist_keys', 'whitelists']); + expect(written._patchstack.build_id).toBe('b'.repeat(64)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/bundled-rules.test.ts b/tests/bundled-rules.test.ts index 492ceede..b0b9197c 100644 --- a/tests/bundled-rules.test.ts +++ b/tests/bundled-rules.test.ts @@ -68,3 +68,56 @@ describe('writeBundledRules', () => { expect(writeBundledRules(dir, { firewall: [{ id: 'site-1' }] })).toBe('skipped'); }); }); + +/** A project whose guard was written by hand: it keeps its rules elsewhere, under its own name. */ +function handWired(rules: unknown, importer = "const rules = (await import('./lib/site-rules.json')).default;"): string { + const dir = mkdtempSync(join(tmpdir(), 'ps-handwired-')); + dirs.push(dir); + mkdirSync(join(dir, 'src/lib'), { recursive: true }); + writeFileSync(join(dir, 'src/server.ts'), `import { createProtection } from '@patchstack/connect/protect';\n${importer}\nexport const guard = createProtection({ siteUuid: 'x', rules });\n`); + writeFileSync(join(dir, 'src/lib/site-rules.json'), `${JSON.stringify(rules)}\n`); + return dir; +} + +const readHandWired = (dir: string) => JSON.parse(readFileSync(join(dir, 'src/lib/site-rules.json'), 'utf8')); +const reader = `rr_${'c'.repeat(48)}-7`; + +describe('a hand-wired guard', () => { + it('gets the site rules and the read-only credential in the file it imports, under any name and folder', () => { + const dir = handWired({ firewall: [], whitelists: [], whitelist_keys: {} }); + + expect(writeBundledRules(dir, { firewall: [{ id: 'site-1' }], whitelists: [], whitelist_keys: {} }, reader)).toBe('written'); + + const written = readHandWired(dir); + expect(written.firewall).toEqual([{ id: 'site-1' }]); + expect(written._patchstack.rules_reader).toBe(reader); + }); + + it('turns a copied runtime cache entry into a plain rules file that carries the credential', () => { + const dir = handWired({ bundle: { firewall: [{ id: 'old' }], whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }, etag: 'W/"1"', buildId: null, source: 'site:x@' }); + + expect(writeBundledRules(dir, { firewall: [{ id: 'site-1' }], whitelists: [], whitelist_keys: {}, enforcement: 'block' }, reader)).toBe('written'); + + const written = readHandWired(dir); + expect(Object.keys(written)).not.toContain('bundle'); + expect(Object.keys(written)).not.toContain('etag'); + expect(written.firewall).toEqual([{ id: 'site-1' }]); + expect(written.enforcement).toBe('block'); + expect(written._patchstack.rules_reader).toBe(reader); + }); + + it('is found through a static import or require as well', () => { + for (const importer of ["import rules from './lib/site-rules.json';", "const rules = require('./lib/site-rules.json');"]) { + const dir = handWired({ firewall: [], whitelists: [], whitelist_keys: {} }, importer); + expect(writeBundledRules(dir, { firewall: [{ id: 'site-1' }] }, reader)).toBe('written'); + } + }); + + it('leaves alone a JSON file that only code without the protection runtime imports', () => { + const dir = handWired({ firewall: [], whitelists: [], whitelist_keys: {} }); + writeFileSync(join(dir, 'src/server.ts'), "const rules = (await import('./lib/site-rules.json')).default;\nexport default rules;\n"); + + expect(writeBundledRules(dir, { firewall: [{ id: 'site-1' }] }, reader)).toBe('skipped'); + expect(readHandWired(dir).firewall).toEqual([]); + }); +}); diff --git a/tests/protect/cache-shaped-rules.test.ts b/tests/protect/cache-shaped-rules.test.ts new file mode 100644 index 00000000..6976631e --- /dev/null +++ b/tests/protect/cache-shaped-rules.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// A guard handed a copy of its runtime cache entry, rather than a rules file, still runs those rules. +describe('rules passed in the runtime cache shape', () => { + it('are read as the bundle they hold', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const protection: any = await createProtection({ + mode: 'block', + reportDetections: false, + rules: { + bundle: { + firewall: [{ id: 'cached-traversal', rule_v2: [{ parameter: 'get.f', mutations: ['urldecode'], match: { type: 'contains', value: '../' } }] }], + whitelists: [], whitelist_keys: {}, enforcement: 'block', + }, + etag: 'W/"1"', buildId: null, source: 'site:x@', + } as never, + }); + try { + const blocked = await protection.fetchGuard()(new Request('https://app.test/?f=../../etc/passwd')); + expect(blocked?.status).toBe(403); + expect(await protection.fetchGuard()(new Request('https://app.test/'))).toBeNull(); + } finally { + await protection.stop(); + vi.restoreAllMocks(); + } + }); +});