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
18 changes: 16 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,23 @@ 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. 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`,
Expand Down
13 changes: 13 additions & 0 deletions scripts/typecheck-templates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 98 additions & 6 deletions src/build-stamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +85,85 @@ function isRuleBundle(value: unknown): value is Record<string, unknown> {
);
}

/**
* 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<string, unknown> | null {
if (isRuleBundle(value)) return value;
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
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<typeof lstatSync>;
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. */
Expand Down Expand Up @@ -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.
Expand All @@ -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]! };
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>) };
if (id === null) delete namespace.build_id;
Expand Down
7 changes: 5 additions & 2 deletions src/bundled-rules.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -52,7 +52,10 @@ export function writeBundledRules(
let current: Record<string, unknown>;
try {
raw = readFileSync(location.path, 'utf8');
current = JSON.parse(raw) as Record<string, unknown>;
// 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';
}
Expand Down
45 changes: 38 additions & 7 deletions src/protect/install/adapters/tanstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,30 @@ 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)) {
const names = new Set<string>();
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);
};
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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. 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);
if (!next) { log('Custom TanStack server entry left untouched; wrap its final Fetch boundary manually.'); return {ok:false,changed:[]}; }
const changed: string[] = [];
Expand Down
8 changes: 4 additions & 4 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,22 +214,22 @@ export interface CreateProtectionOptions {
token?: string;
baseUrl?: string;
/** Patchstack for JavaScript site UUID — pull live per-site rules from the Patchstack for JavaScript 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
* `apiKey` in `.patchstackrc.local.json` (where setup writes it) and then in
* `.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 Patchstack for JavaScript rules API base URL. */
pulseRulesUrl?: string;
/**
Expand Down Expand Up @@ -507,7 +507,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;
Expand Down
13 changes: 10 additions & 3 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ const DETECTION_STALE_MS = 10_000;
const BACKGROUND_CHECK_MS = 30_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} : {}),
Expand Down Expand Up @@ -271,10 +276,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);
}
Expand Down
Loading
Loading