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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ field-test/results/

# test-build throwaway fixture (recreated on every run)
test-build/.work/

# template typecheck scratch dir
.template-typecheck/
2 changes: 1 addition & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
- **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `<script>` tag in the project's root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists. It touches no other file, never edits on `--dry-run` or after a failed post, leaves any pre-existing manual widget tag untouched, and is disabled entirely by `"widget": false` in `.patchstackrc.json`. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`.
- **`setup` runs `scan`, then edits only `package.json` build scripts:** it preserves existing commands, adds `scan` before builds and `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build or `protect`. If the widget needs a framework-specific source edit, it prints the exact remaining step instead of rewriting framework code.
- The package also bundles an **opt-in** `protect` command (runtime exploit guard; its templates live under `dist/protect/`). It runs **only** when explicitly invoked; `scan`, `setup`, `guide`, `status`, and `mark-build` never invoke it, and it writes only local files. It auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds `middleware.ts`), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Fastify** (`app.register(patchstackFastify)`), and **Express** (`app.use(patchstackMiddleware)`). On **any other stack** it scaffolds a framework-agnostic guard under `src/patchstack/` and prints a wiring plan — then you finish the install by importing that guard into your server entry (`protectFetch(handler)` for a Web-Fetch server, or `app.use(patchstackMiddleware)` for Node/Express) and running `patchstack-connect protect --check` to confirm it is wired (exit 1 until it is). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production).
- The package also bundles an **opt-in** `protect` command (runtime exploit guard; its templates live under `dist/protect/`). It runs **only** when explicitly invoked; `scan`, `setup`, `guide`, `status`, and `mark-build` never invoke it, and it writes only local files. It auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds `middleware.ts`), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **NestJS** (`app.use(patchstackMiddleware)` in the bootstrap), **Fastify** (`app.register(patchstackFastify)`), and **Express** (`app.use(patchstackMiddleware)`). On **any other stack** it scaffolds a framework-agnostic guard under `src/patchstack/` and prints a wiring plan — then you finish the install by importing that guard into your server entry (`protectFetch(handler)` for a Web-Fetch server, or `app.use(patchstackMiddleware)` for Node/Express) and running `patchstack-connect protect --check` to confirm it is wired (exit 1 until it is). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production).
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.

## Before you start — never install twice
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"test": "vitest run",
"test:manifest": "bun scripts/test-manifest.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && node scripts/typecheck-templates.mjs",
"typecheck:templates": "node scripts/typecheck-templates.mjs",
"prepare": "npm run build",
"prepublishOnly": "npm run typecheck && npm test && npm run build"
},
Expand Down
90 changes: 90 additions & 0 deletions scripts/typecheck-templates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Typecheck the scaffolded `src/protect/templates/*.ts` the way a target app's `tsc` would.
//
// WHY: templates are deliberately excluded from connect's own tsconfig (they import framework +
// JSON files that only exist AFTER scaffolding), so a template type bug ships silently — a broken
// install has slipped out this way before (a `string`-typed `mode`, a non-generic `screenResponse`
// return that a strict app's tsc then rejected). This assembles each template in a scratch dir with
// the pieces it expects — the REAL `@patchstack/connect/protect` declarations, a stub rules JSON,
// and loose shims for the framework type-only imports — then runs `tsc --noEmit`. It catches
// template-vs-protect-API drift (that class of bug). Framework types are shimmed, not installed, so
// it does NOT verify a handler matches its framework's exact hook signature — a possible follow-up.

import { mkdirSync, rmSync, copyFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';

const ROOT = process.cwd();
const TEMPLATES = join(ROOT, 'src/protect/templates');
const OUT = join(ROOT, '.template-typecheck');

rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });

// Copy every .ts template into the scratch dir.
const templates = readdirSync(TEMPLATES).filter((f) => f.endsWith('.ts'));
for (const f of templates) copyFileSync(join(TEMPLATES, f), join(OUT, f));

// The JSON the templates import: the real starter rules (register-into-app templates import
// ./rules.json) + a stub for the seam templates' co-located ./patchstack.rules.json.
copyFileSync(join(TEMPLATES, 'rules.json'), join(OUT, 'rules.json'));
writeFileSync(join(OUT, 'patchstack.rules.json'), '[]\n');

// The real hand-authored declarations for @patchstack/connect/protect — this is what we check against.
copyFileSync(join(ROOT, 'src/protect/protect.d.ts'), join(OUT, 'protect-types.d.ts'));

// Loose shims for the framework type-only imports (we don't install the frameworks). Signatures
// mirror the real hooks closely enough to catch obvious wiring mistakes without full fidelity.
writeFileSync(
join(OUT, 'shims.d.ts'),
`declare module "astro" {
export type MiddlewareHandler = (
context: { request: Request },
next: () => Promise<Response>,
) => Promise<Response> | Response;
}
declare module "@sveltejs/kit" {
export type Handle = (input: {
event: { request: Request };
resolve: (event: unknown) => Promise<Response>;
}) => Promise<Response> | Response;
}
`,
);

writeFileSync(
join(OUT, 'tsconfig.json'),
JSON.stringify(
{
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'Bundler',
lib: ['ES2022', 'DOM'],
types: ['node'],
strict: true,
noUncheckedIndexedAccess: true,
esModuleInterop: true,
skipLibCheck: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
baseUrl: '.',
paths: { '@patchstack/connect/protect': ['./protect-types'] },
},
include: ['*.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' });
console.log(`template typecheck: OK (${templates.length} templates)`);
rmSync(OUT, { recursive: true, force: true });
} catch {
console.error(`\ntemplate typecheck FAILED — a scaffolded template would not compile in a user's app.`);
console.error(`(scratch dir left at ${OUT} for inspection)`);
process.exit(1);
}
42 changes: 34 additions & 8 deletions src/protect/engine/client.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
const DEFAULT_BASE_URL = 'https://api.patchstack.com';
const DEFAULT_CACHE_TTL = 300_000;
// Randomly shorten the effective TTL by up to this fraction so many long-lived clients don't all
// revalidate on the same tick (spreads load against the rules API).
const JITTER_FRACTION = 0.1;

// Token-authenticated rules client. Conditional fetch (If-None-Match / 304) and the persisted
// `etag` mirror PulseRuleClient; both are a no-op until the API emits an ETag and honors the
// conditional request — with no ETag in the response the client behaves as before (full fetch).
export class PatchstackRuleClient {
#token;
#baseUrl;
#cacheTtl;
#cache = null;
#cacheTime = null;
#ttlEffective = 0;
#etag;

constructor({ token, baseUrl, cacheTtl } = {}) {
constructor({ token, baseUrl, cacheTtl, etag } = {}) {
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL;
this.#etag = etag ?? null;

if (!this.#token) {
throw new Error('Patchstack WAF token is required. Pass { token } or set PATCHSTACK_WAF_TOKEN env var.');
Expand All @@ -21,24 +30,32 @@ export class PatchstackRuleClient {
async getRules() {
const now = Date.now();

if (this.#cache && this.#cacheTime && (now - this.#cacheTime) < this.#cacheTtl) {
if (this.#cache && this.#cacheTime !== null && (now - this.#cacheTime) < this.#ttlEffective) {
return this.#cache;
}

const url = `${this.#baseUrl}/api/get-rules/3`;

try {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.#token}`,
'LicenseID': '1' // Hard-coded, is never actually used but needed by the API
};
if (this.#etag) headers['If-None-Match'] = this.#etag;

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.#token}`,
'LicenseID': '1' // Hard-coded, is never actually used but needed by the API
},
headers,
body: JSON.stringify({}),
signal: AbortSignal.timeout(30_000)
});

if (response.status === 304) {
this.#touch(now); // revalidated — reset the clock (fresh jitter)
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
}

if (!response.ok) {
return {
success: false,
Expand All @@ -53,13 +70,16 @@ export class PatchstackRuleClient {

const result = {
success: true,
notModified: false,
etag: response.headers?.get?.('etag') ?? null,
firewall: Array.isArray(data.firewall) ? data.firewall : [],
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
whitelist_keys: data.whitelist_keys ?? {}
};

this.#cache = result;
this.#cacheTime = now;
this.#etag = result.etag;
this.#touch(now);

return result;
} catch (err) {
Expand All @@ -75,8 +95,14 @@ export class PatchstackRuleClient {
}
}

#touch(now) {
this.#cacheTime = now;
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION);
}

clearCache() {
this.#cache = null;
this.#cacheTime = null;
this.#etag = null;
}
}
40 changes: 32 additions & 8 deletions src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,67 @@
const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse';
const DEFAULT_CACHE_TTL = 300_000;
// Randomly shorten the effective TTL by up to this fraction so many long-lived clients don't all
// revalidate on the same tick (spreads load / avoids a thundering herd against the rules API).
const JITTER_FRACTION = 0.1;

// Per-site rules client for Pulse (npm/JS) apps. Public endpoint — the site UUID is the only
// credential, passed in the path. Fail-open: any error returns success:false + empty rules so
// createProtection falls back to the disk cache or the bundled rules.
//
// Conditional fetch: pass a prior `etag` (persisted with the last cached bundle) and the client
// sends `If-None-Match`; a 304 returns `{ notModified: true }` so the caller reuses its cache
// without re-downloading, and a 200 returns the new bundle plus its `etag` to persist. This is a
// no-op until the rules API emits an ETag and honors If-None-Match — with no ETag in the response
// the client simply behaves as before (a full fetch every refresh).
export class PulseRuleClient {
#siteUuid;
#baseUrl;
#cacheTtl;
#cache = null;
#cacheTime = null;
#ttlEffective = 0;
#etag;

constructor({ siteUuid, baseUrl, cacheTtl } = {}) {
constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL;
this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL;
this.#etag = etag ?? null;
if (!this.#siteUuid) {
throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.');
}
}

async getRules() {
const now = Date.now();
if (this.#cache && this.#cacheTime && now - this.#cacheTime < this.#cacheTtl) {
if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
return this.#cache;
}
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
});
const headers = { Accept: 'application/json' };
if (this.#etag) headers['If-None-Match'] = this.#etag;
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(30_000) });

if (response.status === 304) {
this.#touch(now); // revalidated — reset the clock (fresh jitter)
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
}
if (!response.ok) {
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
}
const data = await response.json();
const result = {
success: true,
notModified: false,
etag: response.headers?.get?.('etag') ?? null,
firewall: Array.isArray(data.firewall) ? data.firewall : [],
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
whitelist_keys: data.whitelist_keys ?? {},
};
this.#cache = result;
this.#cacheTime = now;
this.#etag = result.etag;
this.#touch(now);
return result;
} catch (err) {
return {
Expand All @@ -54,8 +72,14 @@ export class PulseRuleClient {
}
}

#touch(now) {
this.#cacheTime = now;
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION);
}

clearCache() {
this.#cache = null;
this.#cacheTime = null;
this.#etag = null;
}
}
Loading
Loading