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
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. On a **TanStack Start + Supabase** app it auto-wires the guard (patches the Supabase client + `src/start.ts`). 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`), **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
39 changes: 39 additions & 0 deletions src/protect/install/adapters/astro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Adapter: Astro. Wires the guard as middleware (`src/middleware.ts` → `onRequest`).
import { join } from 'node:path';
import { read } from '../util.js';
import { wireSeam, verifySeam, type SeamSpec } from '../seam.js';
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';

const SPEC: SeamSpec = {
templateName: 'astro-middleware.ts',
candidates: ['src/middleware.ts', 'src/middleware.js', 'src/middleware/index.ts', 'src/middleware/index.js'],
target: 'src/middleware.ts',
marker: 'patchstack-astro',
planHint: 'add the guard to your `onRequest` (compose with `sequence()` from "astro:middleware"), then: npx patchstack-connect protect --check',
seamLabel: 'Astro middleware',
};

function detect(cwd: string): boolean {
try {
const pkg = JSON.parse(read(join(cwd, 'package.json')));
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.astro);
} catch {
return false;
}
}

function wire(cwd: string, opts: WireOptions): WireResult {
return wireSeam(cwd, opts, SPEC);
}

function verify(cwd: string): VerifyResult {
return verifySeam(cwd, SPEC);
}

export const astroAdapter: Adapter = {
name: 'astro',
label: 'Astro',
detect,
wire,
verify,
};
130 changes: 130 additions & 0 deletions src/protect/install/adapters/express.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Adapter: Express (Node). Scaffolds the framework-agnostic guard and wires it as the first
// middleware — `app.use(patchstackMiddleware)` right after the `express()` app is created.
// Dependency-free anchor + #region-marker patching (same approach as the tanstack adapter).

import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { read, log } from '../util.js';
import { scaffoldGeneric } from '../generic.js';
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';

const APP_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*express\(\)/;
const REGION = '// #region patchstack (managed by patchstack-connect protect — do not edit)';

function hasExpressDep(cwd: string): boolean {
try {
const pkg = JSON.parse(read(join(cwd, 'package.json')));
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.express);
} catch {
return false;
}
}

// Find the source file + variable where the Express app is created (`const app = express()`).
function findExpressApp(cwd: string): { relPath: string; appVar: string } | null {
const root = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
let hit: { relPath: string; appVar: string } | null = null;
const walk = (dir: string): void => {
if (hit) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const name of entries) {
if (hit) return;
if (name === 'node_modules' || name === 'patchstack' || name.startsWith('.')) continue;
const p = join(dir, name);
let st;
try {
st = statSync(p);
} catch {
continue;
}
if (st.isDirectory()) walk(p);
else if (/\.(?:ts|js|mjs|cjs)$/.test(name)) {
let src: string;
try {
src = readFileSync(p, 'utf8');
} catch {
continue;
}
const m = APP_RE.exec(src);
if (m) hit = { relPath: relative(cwd, p).replace(/\\/g, '/'), appVar: m[1]! };
}
}
};
walk(root);
return hit;
}

function detect(cwd: string): boolean {
return hasExpressDep(cwd) && findExpressApp(cwd) !== null;
}

function importSpecifier(fromRel: string, toRel: string): string {
let spec = relative(dirname(fromRel), toRel).replace(/\\/g, '/').replace(/\.(?:ts|js)$/, '');
if (!spec.startsWith('.')) spec = `./${spec}`;
return spec;
}

function wire(cwd: string, opts: WireOptions): WireResult {
const { changed, dir } = scaffoldGeneric(cwd, opts);
const entry = findExpressApp(cwd);
if (!entry) {
log('express detected but no `= express()` site found — scaffolded guard; add app.use(patchstackMiddleware) yourself');
return { ok: true, changed };
}

const p = join(cwd, entry.relPath);
let s = read(p);
if (s.includes('patchstackMiddleware')) {
log(`express entry ${entry.relPath} already wired`);
return { ok: true, changed };
}

const spec = importSpecifier(entry.relPath, `${dir}/guard`);
const importLine = `import { patchstackMiddleware } from "${spec}";`;
// add the import after the last existing import (else at top)
const lines = s.split('\n');
let lastImport = -1;
for (let i = 0; i < lines.length; i++) if (/^\s*import\b/.test(lines[i] ?? '')) lastImport = i;
lines.splice(lastImport + 1, 0, importLine);
// insert `app.use(patchstackMiddleware)` right after the express() app line
const appIdx = lines.findIndex((l) => APP_RE.test(l));
if (appIdx !== -1) {
lines.splice(appIdx + 1, 0, REGION, `${entry.appVar}.use(patchstackMiddleware);`, '// #endregion patchstack');
}
s = lines.join('\n');
writeFileSync(p, s);
changed.push(entry.relPath);
log(`patched ${entry.relPath} (${entry.appVar}.use(patchstackMiddleware))`);
return { ok: true, changed: [...new Set(changed)] };
}

function verify(cwd: string): VerifyResult {
const dir = existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack';
const scaffolded = existsSync(join(cwd, dir, 'guard.ts'));
const entry = findExpressApp(cwd);
const wired = entry ? read(join(cwd, entry.relPath)).includes('patchstackMiddleware') : false;
return {
wired: scaffolded && wired,
checks: [
{ label: 'generic guard scaffolded', ok: scaffolded, hint: 'run `patchstack-connect protect`' },
{
label: 'app.use(patchstackMiddleware) wired into the Express app',
ok: wired,
hint: 'add `app.use(patchstackMiddleware)` right after you create your express() app',
},
],
};
}

export const expressAdapter: Adapter = {
name: 'express',
label: 'Express (Node)',
detect,
wire,
verify,
};
128 changes: 128 additions & 0 deletions src/protect/install/adapters/fastify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Adapter: Fastify (Node). Scaffolds the Fastify guard plugin and registers it on the app —
// `app.register(patchstackFastify)` right after the `fastify()` instance is created.
// Dependency-free anchor + #region-marker patching (same approach as the express adapter).

import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { read, log } from '../util.js';
import { scaffoldGeneric } from '../generic.js';
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';

const APP_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:fastify|Fastify)\(/;
const REGION = '// #region patchstack (managed by patchstack-connect protect — do not edit)';

function hasFastifyDep(cwd: string): boolean {
try {
const pkg = JSON.parse(read(join(cwd, 'package.json')));
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.fastify);
} catch {
return false;
}
}

// Find the source file + variable where the Fastify app is created (`const app = fastify()`).
function findFastifyApp(cwd: string): { relPath: string; appVar: string } | null {
const root = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
let hit: { relPath: string; appVar: string } | null = null;
const walk = (dir: string): void => {
if (hit) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const name of entries) {
if (hit) return;
if (name === 'node_modules' || name === 'patchstack' || name.startsWith('.')) continue;
const p = join(dir, name);
let st;
try {
st = statSync(p);
} catch {
continue;
}
if (st.isDirectory()) walk(p);
else if (/\.(?:ts|js|mjs|cjs)$/.test(name)) {
let src: string;
try {
src = readFileSync(p, 'utf8');
} catch {
continue;
}
const m = APP_RE.exec(src);
if (m) hit = { relPath: relative(cwd, p).replace(/\\/g, '/'), appVar: m[1]! };
}
}
};
walk(root);
return hit;
}

function detect(cwd: string): boolean {
return hasFastifyDep(cwd) && findFastifyApp(cwd) !== null;
}

function importSpecifier(fromRel: string, toRel: string): string {
let spec = relative(dirname(fromRel), toRel).replace(/\\/g, '/').replace(/\.(?:ts|js)$/, '');
if (!spec.startsWith('.')) spec = `./${spec}`;
return spec;
}

function wire(cwd: string, opts: WireOptions): WireResult {
const { changed, dir } = scaffoldGeneric(cwd, opts, 'fastify-plugin.ts');
const entry = findFastifyApp(cwd);
if (!entry) {
log('fastify detected but no `= fastify()` site found — scaffolded plugin; register it yourself: app.register(patchstackFastify)');
return { ok: true, changed };
}

const p = join(cwd, entry.relPath);
let s = read(p);
if (s.includes('patchstackFastify')) {
log(`fastify entry ${entry.relPath} already wired`);
return { ok: true, changed };
}

const spec = importSpecifier(entry.relPath, `${dir}/guard`);
const importLine = `import { patchstackFastify } from "${spec}";`;
const lines = s.split('\n');
let lastImport = -1;
for (let i = 0; i < lines.length; i++) if (/^\s*import\b/.test(lines[i] ?? '')) lastImport = i;
lines.splice(lastImport + 1, 0, importLine);
const appIdx = lines.findIndex((l) => APP_RE.test(l));
if (appIdx !== -1) {
lines.splice(appIdx + 1, 0, REGION, `${entry.appVar}.register(patchstackFastify);`, '// #endregion patchstack');
}
s = lines.join('\n');
writeFileSync(p, s);
changed.push(entry.relPath);
log(`patched ${entry.relPath} (${entry.appVar}.register(patchstackFastify))`);
return { ok: true, changed: [...new Set(changed)] };
}

function verify(cwd: string): VerifyResult {
const dir = existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack';
const scaffolded = existsSync(join(cwd, dir, 'guard.ts'));
const entry = findFastifyApp(cwd);
const wired = entry ? read(join(cwd, entry.relPath)).includes('patchstackFastify') : false;
return {
wired: scaffolded && wired,
checks: [
{ label: 'Fastify guard plugin scaffolded', ok: scaffolded, hint: 'run `patchstack-connect protect`' },
{
label: 'app.register(patchstackFastify) wired into the Fastify app',
ok: wired,
hint: 'add `app.register(patchstackFastify)` right after you create your fastify() app',
},
],
};
}

export const fastifyAdapter: Adapter = {
name: 'fastify',
label: 'Fastify (Node)',
detect,
wire,
verify,
};
Loading
Loading