Skip to content

Commit f863454

Browse files
feat(protect): SvelteKit, Astro, Fastify adapters
Three more per-stack adapters (Arcjet-parity for the JS frameworks, minus NestJS/Python), dependency-free — same anchor + #region approach, no AST parser, dependencies:{} unchanged. - SvelteKit + Astro: seam-file-is-the-guard. New shared `seam.ts` (wireSeam/verifySeam) scaffolds the framework's server hook from a template (`src/hooks.server.ts` → handle, `src/middleware.ts` → onRequest) + co-located patchstack.rules.json. An EXISTING hook is never overwritten — scaffold rules + print a compose-with-sequence() plan instead. - Fastify: register-into-app (like express). scaffoldGeneric now takes a guard-template arg so the Fastify plugin (builds a Request from the parsed fastify request → fetchGuard) lands as the guard; wire `app.register(patchstackFastify)` after the fastify() instance. - Registered most-specific-first (tanstack → next → sveltekit → astro → fastify → express → generic). - AGENT-INSTALL lists the newly auto-wired stacks. +5 tests. 456 pass, typecheck + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bc77899 commit f863454

11 files changed

Lines changed: 489 additions & 5 deletions

File tree

AGENT-INSTALL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
88
- 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.)
99
- **`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`.
1010
- **`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.
11-
- 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`), 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).
11+
- 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).
1212
- Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.
1313

1414
## Before you start — never install twice
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Adapter: Astro. Wires the guard as middleware (`src/middleware.ts` → `onRequest`).
2+
import { join } from 'node:path';
3+
import { read } from '../util.js';
4+
import { wireSeam, verifySeam, type SeamSpec } from '../seam.js';
5+
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';
6+
7+
const SPEC: SeamSpec = {
8+
templateName: 'astro-middleware.ts',
9+
candidates: ['src/middleware.ts', 'src/middleware.js', 'src/middleware/index.ts', 'src/middleware/index.js'],
10+
target: 'src/middleware.ts',
11+
marker: 'patchstack-astro',
12+
planHint: 'add the guard to your `onRequest` (compose with `sequence()` from "astro:middleware"), then: npx patchstack-connect protect --check',
13+
seamLabel: 'Astro middleware',
14+
};
15+
16+
function detect(cwd: string): boolean {
17+
try {
18+
const pkg = JSON.parse(read(join(cwd, 'package.json')));
19+
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.astro);
20+
} catch {
21+
return false;
22+
}
23+
}
24+
25+
function wire(cwd: string, opts: WireOptions): WireResult {
26+
return wireSeam(cwd, opts, SPEC);
27+
}
28+
29+
function verify(cwd: string): VerifyResult {
30+
return verifySeam(cwd, SPEC);
31+
}
32+
33+
export const astroAdapter: Adapter = {
34+
name: 'astro',
35+
label: 'Astro',
36+
detect,
37+
wire,
38+
verify,
39+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Adapter: Fastify (Node). Scaffolds the Fastify guard plugin and registers it on the app —
2+
// `app.register(patchstackFastify)` right after the `fastify()` instance is created.
3+
// Dependency-free anchor + #region-marker patching (same approach as the express adapter).
4+
5+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
6+
import { join, dirname, relative } from 'node:path';
7+
import { read, log } from '../util.js';
8+
import { scaffoldGeneric } from '../generic.js';
9+
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';
10+
11+
const APP_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:fastify|Fastify)\(/;
12+
const REGION = '// #region patchstack (managed by patchstack-connect protect — do not edit)';
13+
14+
function hasFastifyDep(cwd: string): boolean {
15+
try {
16+
const pkg = JSON.parse(read(join(cwd, 'package.json')));
17+
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.fastify);
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
// Find the source file + variable where the Fastify app is created (`const app = fastify()`).
24+
function findFastifyApp(cwd: string): { relPath: string; appVar: string } | null {
25+
const root = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
26+
let hit: { relPath: string; appVar: string } | null = null;
27+
const walk = (dir: string): void => {
28+
if (hit) return;
29+
let entries: string[];
30+
try {
31+
entries = readdirSync(dir);
32+
} catch {
33+
return;
34+
}
35+
for (const name of entries) {
36+
if (hit) return;
37+
if (name === 'node_modules' || name === 'patchstack' || name.startsWith('.')) continue;
38+
const p = join(dir, name);
39+
let st;
40+
try {
41+
st = statSync(p);
42+
} catch {
43+
continue;
44+
}
45+
if (st.isDirectory()) walk(p);
46+
else if (/\.(?:ts|js|mjs|cjs)$/.test(name)) {
47+
let src: string;
48+
try {
49+
src = readFileSync(p, 'utf8');
50+
} catch {
51+
continue;
52+
}
53+
const m = APP_RE.exec(src);
54+
if (m) hit = { relPath: relative(cwd, p).replace(/\\/g, '/'), appVar: m[1]! };
55+
}
56+
}
57+
};
58+
walk(root);
59+
return hit;
60+
}
61+
62+
function detect(cwd: string): boolean {
63+
return hasFastifyDep(cwd) && findFastifyApp(cwd) !== null;
64+
}
65+
66+
function importSpecifier(fromRel: string, toRel: string): string {
67+
let spec = relative(dirname(fromRel), toRel).replace(/\\/g, '/').replace(/\.(?:ts|js)$/, '');
68+
if (!spec.startsWith('.')) spec = `./${spec}`;
69+
return spec;
70+
}
71+
72+
function wire(cwd: string, opts: WireOptions): WireResult {
73+
const { changed, dir } = scaffoldGeneric(cwd, opts, 'fastify-plugin.ts');
74+
const entry = findFastifyApp(cwd);
75+
if (!entry) {
76+
log('fastify detected but no `= fastify()` site found — scaffolded plugin; register it yourself: app.register(patchstackFastify)');
77+
return { ok: true, changed };
78+
}
79+
80+
const p = join(cwd, entry.relPath);
81+
let s = read(p);
82+
if (s.includes('patchstackFastify')) {
83+
log(`fastify entry ${entry.relPath} already wired`);
84+
return { ok: true, changed };
85+
}
86+
87+
const spec = importSpecifier(entry.relPath, `${dir}/guard`);
88+
const importLine = `import { patchstackFastify } from "${spec}";`;
89+
const lines = s.split('\n');
90+
let lastImport = -1;
91+
for (let i = 0; i < lines.length; i++) if (/^\s*import\b/.test(lines[i] ?? '')) lastImport = i;
92+
lines.splice(lastImport + 1, 0, importLine);
93+
const appIdx = lines.findIndex((l) => APP_RE.test(l));
94+
if (appIdx !== -1) {
95+
lines.splice(appIdx + 1, 0, REGION, `${entry.appVar}.register(patchstackFastify);`, '// #endregion patchstack');
96+
}
97+
s = lines.join('\n');
98+
writeFileSync(p, s);
99+
changed.push(entry.relPath);
100+
log(`patched ${entry.relPath} (${entry.appVar}.register(patchstackFastify))`);
101+
return { ok: true, changed: [...new Set(changed)] };
102+
}
103+
104+
function verify(cwd: string): VerifyResult {
105+
const dir = existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack';
106+
const scaffolded = existsSync(join(cwd, dir, 'guard.ts'));
107+
const entry = findFastifyApp(cwd);
108+
const wired = entry ? read(join(cwd, entry.relPath)).includes('patchstackFastify') : false;
109+
return {
110+
wired: scaffolded && wired,
111+
checks: [
112+
{ label: 'Fastify guard plugin scaffolded', ok: scaffolded, hint: 'run `patchstack-connect protect`' },
113+
{
114+
label: 'app.register(patchstackFastify) wired into the Fastify app',
115+
ok: wired,
116+
hint: 'add `app.register(patchstackFastify)` right after you create your fastify() app',
117+
},
118+
],
119+
};
120+
}
121+
122+
export const fastifyAdapter: Adapter = {
123+
name: 'fastify',
124+
label: 'Fastify (Node)',
125+
detect,
126+
wire,
127+
verify,
128+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Adapter: SvelteKit. Wires the guard as a server hook (`src/hooks.server.ts` → `handle`).
2+
import { join } from 'node:path';
3+
import { read } from '../util.js';
4+
import { wireSeam, verifySeam, type SeamSpec } from '../seam.js';
5+
import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js';
6+
7+
const SPEC: SeamSpec = {
8+
templateName: 'sveltekit-hooks.ts',
9+
candidates: ['src/hooks.server.ts', 'src/hooks.server.js'],
10+
target: 'src/hooks.server.ts',
11+
marker: 'patchstack-sveltekit',
12+
planHint: 'add the guard to your `handle` (compose with `sequence()` from "@sveltejs/kit/hooks"), then: npx patchstack-connect protect --check',
13+
seamLabel: 'SvelteKit server hook',
14+
};
15+
16+
function detect(cwd: string): boolean {
17+
try {
18+
const pkg = JSON.parse(read(join(cwd, 'package.json')));
19+
return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }['@sveltejs/kit']);
20+
} catch {
21+
return false;
22+
}
23+
}
24+
25+
function wire(cwd: string, opts: WireOptions): WireResult {
26+
return wireSeam(cwd, opts, SPEC);
27+
}
28+
29+
function verify(cwd: string): VerifyResult {
30+
return verifySeam(cwd, SPEC);
31+
}
32+
33+
export const sveltekitAdapter: Adapter = {
34+
name: 'sveltekit',
35+
label: 'SvelteKit',
36+
detect,
37+
wire,
38+
verify,
39+
};

src/protect/install/generic.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ function genericDir(cwd: string): string {
1414
return existsSync(join(cwd, 'src')) ? 'src/patchstack' : 'patchstack';
1515
}
1616

17-
export function scaffoldGeneric(cwd: string, opts: WireOptions): { changed: string[]; dir: string } {
17+
export function scaffoldGeneric(cwd: string, opts: WireOptions, guardTemplate = 'generic-guard.ts'): { changed: string[]; dir: string } {
1818
const templates = templatesDir();
1919
const dir = genericDir(cwd);
2020
const dst = join(cwd, dir);
2121
mkdirSync(dst, { recursive: true });
22-
copyFileSync(join(templates, 'generic-guard.ts'), join(dst, 'guard.ts'));
22+
copyFileSync(join(templates, guardTemplate), join(dst, 'guard.ts'));
2323
const changed = [`${dir}/guard.ts`];
2424
const rulesDst = join(dst, 'rules.json');
2525
if (opts.demo || !existsSync(rulesDst)) {

src/protect/install/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,23 @@
99
import { log } from './util.js';
1010
import { tanstackSupabaseAdapter } from './adapters/tanstack-supabase.js';
1111
import { nextAdapter } from './adapters/next.js';
12+
import { sveltekitAdapter } from './adapters/sveltekit.js';
13+
import { astroAdapter } from './adapters/astro.js';
14+
import { fastifyAdapter } from './adapters/fastify.js';
1215
import { expressAdapter } from './adapters/express.js';
1316
import { scaffoldGeneric, wiringPlan, genericVerify } from './generic.js';
1417
import type { Adapter, WireOptions, ProtectResult, VerifyReport } from './types.js';
1518

16-
// Registry — order = match priority (most specific first). Add new stacks here.
17-
const ADAPTERS: Adapter[] = [tanstackSupabaseAdapter, nextAdapter, expressAdapter];
19+
// Registry — order = match priority (most specific first): framework meta-frameworks before the
20+
// bare server libraries (a SvelteKit/Astro app may also carry express/fastify as a transitive dep).
21+
const ADAPTERS: Adapter[] = [
22+
tanstackSupabaseAdapter,
23+
nextAdapter,
24+
sveltekitAdapter,
25+
astroAdapter,
26+
fastifyAdapter,
27+
expressAdapter,
28+
];
1829

1930
/** Scaffold + wire the runtime guard into the app at `cwd`. Best-effort — never throws. */
2031
export function runProtect(cwd: string, opts: WireOptions = {}): ProtectResult {

src/protect/install/seam.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Shared "seam-file is the guard" wiring, used by adapters whose framework has a single server hook
2+
// that IS the guard (SvelteKit `hooks.server.ts`, Astro `src/middleware.ts`). Scaffold the seam from
3+
// a template + co-locate patchstack.rules.json. An EXISTING seam file is never clobbered — we scaffold
4+
// the rules and print a plan instead (so a hand-written hook is preserved).
5+
6+
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
7+
import { join, dirname } from 'node:path';
8+
import { read, log, templatesDir } from './util.js';
9+
import type { WireOptions, WireResult, VerifyResult } from './types.js';
10+
11+
export interface SeamSpec {
12+
templateName: string; // template copied to the seam target when none exists
13+
candidates: string[]; // existing seam files to look for, in order (repo-relative)
14+
target: string; // where to create the seam if none exists (repo-relative)
15+
marker: string; // #region marker id proving it's ours (e.g. 'patchstack-sveltekit')
16+
planHint: string; // guidance when an existing seam file can't be safely edited
17+
seamLabel: string; // human label for the check (e.g. 'SvelteKit hook')
18+
}
19+
20+
function rulesRel(seamRel: string): string {
21+
const d = dirname(seamRel);
22+
return (d === '.' ? 'patchstack.rules.json' : `${d}/patchstack.rules.json`).replace(/\\/g, '/');
23+
}
24+
25+
export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireResult {
26+
const templates = templatesDir();
27+
const existing = spec.candidates.find((c) => existsSync(join(cwd, c)));
28+
const seamRel = existing ?? spec.target;
29+
mkdirSync(dirname(join(cwd, seamRel)), { recursive: true });
30+
31+
// Rules co-locate next to the seam (the templates import ./patchstack.rules.json).
32+
const rulesDst = join(cwd, rulesRel(seamRel));
33+
const changed: string[] = [];
34+
if (opts.demo || !existsSync(rulesDst)) {
35+
copyFileSync(join(templates, opts.demo ? 'demo-rules.json' : 'rules.json'), rulesDst);
36+
changed.push(rulesRel(seamRel));
37+
}
38+
39+
const current = existing ? read(join(cwd, existing)) : '';
40+
if (existing && !current.includes(spec.marker)) {
41+
log(`existing ${existing} left untouched — scaffolded ${rulesRel(seamRel)}; ${spec.planHint}`);
42+
return { ok: true, changed };
43+
}
44+
45+
copyFileSync(join(templates, spec.templateName), join(cwd, seamRel));
46+
changed.push(seamRel);
47+
log(existing ? `refreshed ${seamRel}` : `scaffolded ${seamRel}`);
48+
return { ok: true, changed: [...new Set(changed)] };
49+
}
50+
51+
export function verifySeam(cwd: string, spec: SeamSpec): VerifyResult {
52+
const existing = spec.candidates.find((c) => existsSync(join(cwd, c)));
53+
const seamRel = existing ?? spec.target;
54+
const present = existing ? read(join(cwd, existing)).includes(spec.marker) : false;
55+
const rulesPresent = existsSync(join(cwd, rulesRel(seamRel)));
56+
return {
57+
wired: present && rulesPresent,
58+
checks: [
59+
{ label: `${spec.seamLabel} present`, ok: present, hint: `run \`patchstack-connect protect\` (writes ${spec.target})` },
60+
{ label: 'rules co-located with the guard', ok: rulesPresent, hint: 'run `patchstack-connect protect`' },
61+
],
62+
};
63+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Patchstack runtime guard for Astro — middleware. Managed by `patchstack-connect protect`.
2+
// Runs the request-phase WAF (+ egress SSRF) and screens the response body/headers for every request.
3+
// If you already have middleware, compose them with `sequence()` from "astro:middleware".
4+
import type { MiddlewareHandler } from "astro";
5+
import { createProtection } from "@patchstack/connect/protect";
6+
import fallbackRules from "./patchstack.rules.json";
7+
8+
let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
9+
async function getProtection() {
10+
if (!_protection) {
11+
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
12+
const token = process.env.PATCHSTACK_WAF_TOKEN;
13+
const siteUuid = process.env.PATCHSTACK_SITE_UUID;
14+
const common = { mode, egress: true } as const;
15+
_protection = await createProtection(
16+
siteUuid
17+
? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
18+
: token
19+
? { ...common, token, cacheDir: ".patchstack" }
20+
: { ...common, rules: fallbackRules as never },
21+
);
22+
}
23+
return _protection;
24+
}
25+
26+
// #region patchstack-astro (managed by patchstack-connect protect — do not edit)
27+
export const onRequest: MiddlewareHandler = async (context, next) => {
28+
const protection = await getProtection();
29+
const blocked = await protection.fetchGuard()(context.request);
30+
if (blocked) return blocked; // 403 — blocked before it reaches your route
31+
return protection.screenResponse(await next());
32+
};
33+
// #endregion patchstack-astro

0 commit comments

Comments
 (0)