From 36aa10acbb4e59cb3f90fca7b1504e0786d572d7 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 1 Oct 2026 20:26:11 +0200 Subject: [PATCH 1/4] feat(protect): support native TanStack entries and Next proxy --- AGENT-INSTALL.md | 25 +++- README.md | 22 +++- package.json | 1 + scripts/tanstack-consumer.mjs | 42 ++++++ src/protect/install/adapters/next-source.ts | 6 +- src/protect/install/adapters/next.ts | 52 ++++++-- .../install/adapters/tanstack-supabase.ts | 38 ++++-- src/protect/install/adapters/tanstack.ts | 121 ++++++++++++++++++ src/protect/install/index.ts | 4 +- src/protect/protect.d.ts | 2 +- src/protect/templates/fetch-guard.ts | 52 ++++++++ tests/protect/declaration-drift.test.ts | 2 +- tests/protect/every-seam-steps-aside.test.ts | 1 + tests/protect/fetch-template.test.ts | 44 +++++++ tests/protect/framework-entries.test.ts | 99 ++++++++++++++ tests/protect/install.test.ts | 8 +- tests/protect/protect-install.test.ts | 2 +- tests/protect/protection-init.test.ts | 6 +- 18 files changed, 479 insertions(+), 48 deletions(-) create mode 100644 scripts/tanstack-consumer.mjs create mode 100644 src/protect/install/adapters/tanstack.ts create mode 100644 src/protect/templates/fetch-guard.ts create mode 100644 tests/protect/fetch-template.test.ts create mode 100644 tests/protect/framework-entries.test.ts diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 8808784a..1f36c020 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -99,7 +99,7 @@ Only `map` produces an attack-surface analysis, and only `map --upload` sends th - The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID. - The **production marker** goes in a root shell that is JSX rather than HTML (e.g. `src/routes/__root.tsx`, `app/layout.tsx`), inside a `{/* #region patchstack */}` block placed above the widget tag. It is written *before* the post: it carries no site UUID and needs no network, and build scripts commonly chain `patchstack-connect scan || true`, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (`import.meta.env.PROD`, or `process.env.NODE_ENV === 'production'`), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML for `mark-build` to stamp, and the widget treats the published site as build mode. `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 `protect`, then edits `package.json` scripts:** provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, adds `scan` after dependency installs and before builds, adds `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code. -- The package also exposes **`protect`** directly (runtime exploit guard; its templates live under `dist/protect/`). `setup` invokes it automatically; `scan`, `guide`, `status`, and `mark-build` do not. It writes only local files and auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds or composes middleware and adds request/response checks to supported App Router handlers), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Nuxt** (`server/middleware/`), **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). +- The package also exposes **`protect`** directly (runtime exploit guard; its templates live under `dist/protect/`). `setup` invokes it automatically; `scan`, `guide`, `status`, and `mark-build` do not. It writes only local files and auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **TanStack Start** (documented server Fetch entry without requiring Supabase), **Next.js** (scaffolds or composes middleware/proxy and adds request/response checks to supported App Router handlers), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Nuxt** (`server/middleware/`), **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). - **`demo node-serialize` is an explicit production-backed walkthrough.** It requires `node-serialize@0.0.4` to already be present in the lockfile; it does not install the vulnerable dependency. It runs the same production `scan`, polls the configured site's public Pulse rules endpoint until rule `18843` is served, runs `protect`, verifies the generated guard, and prints exploit/benign test requests. It writes the same manifest/widget and guard files as those underlying commands. It does not start/restart the app and does not send the printed requests. - **`map` is local unless you pass `--upload`.** It walks the project's server source (skipping `node_modules`, build output and dot-directories; it does not follow symlinks out of the project unless you pass `--follow-symlinks`), parses it with the project's **own** `typescript`, and prints JSON describing the attack surface: entry points, the inputs each reads, the sinks they can reach (database / file system / process / outbound HTTP) with the npm package behind each, and evidence-backed input→sink flows, each labelled with how the link was established — from an exact read at the sink's own call site, through a transformed or cross-module link, down to the two being present together with no proven link. Static analysis is best-effort, so the output reports the *detected* surface with coverage counters — not a completeness guarantee. Without `--upload` it writes nothing except the file named by `--out`, and it is never invoked by `scan`, `setup`, `guide`, `protect`, or `mark-build`. - **`map --upload` is the only command that sends a description of your source.** (The runtime guard can also report rule detections, which carry route paths and parameter names — see "Runtime guard reporting" below.) It POSTs the same JSON document to `monitor/pulse/input-map/` so Patchstack can pin protection rules to your app's own parameter names instead of guessing them. During a pre-bundle build hook it hashes the policy-relevant document (all fields except analyser timing and memory observations), writes the SHA-256 value as `_patchstack.build_id` in the existing rules file imported by the scaffolded guard, and sends the same value as `build_id`. Outside that lifecycle it sends no identity and changes no file, so any generated scoped rule remains detect-only. **No source code, no file contents, no environment variable values.** A map with no recognised entry points is still uploaded because its import inventory and coverage limits are evidence; a failure to reach Patchstack is reported and ignored rather than failing your build. Omit the flag and the command stays entirely local. @@ -336,14 +336,29 @@ It is server-only. Never put it in the widget tag, client bundles, or public env check requests and filter returned responses. It writes a shared server-only `patchstack.next` helper alongside `patchstack.rules.json`. Unsupported exports, complex matchers or handlers are left unchanged and reported by `--check`; re-run `protect` after adding routes. An existing - `proxy.ts`/`proxy.js` requires manual integration: no competing middleware is scaffolded and - `--check` reports the gap. Middleware alone + `proxy.ts`/`proxy.js` is composed on Next 16+ using the same conservative export/matcher rules; + a new Next 16+ install uses `proxy.ts`. Conflicting entries stay untouched. Middleware/proxy alone cannot filter downstream page bodies. Rendered pages, Server Actions and Pages API response filtering are not verified by this adapter. Keep Next.js patched: a framework middleware bypass also bypasses a guard in middleware. Edge middleware needs `PATCHSTACK_API_KEY` in the server environment; it cannot read `.patchstackrc.local.json`. Do not put credentials in public variables 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 + `server-entry`; custom entry paths, spreads, getters and competing files need manual integration. + The existing TanStack/Supabase adapter screens native requests by default and filters the response + inside TanStack's middleware result. No `PATCHSTACK_ROUTE_WAF` switch is needed. Browser-direct + services and separately deployed functions are not protected by guarding the frontend server; + keep backend authorization/RLS and install a guard at each independently exposed backend. + + Generated guards refresh live rules every five minutes (15 seconds in an explicit sandbox). + Express/Node guards enable bounded response filtering; Fastify filters buffered `onSend` output, + leaving streams and bodyless replies untouched. Unmodified recognized helpers can be upgraded; + customized helpers are preserved and flagged for manual review. Wiring checks inspect executable + statements, not just marker comments, and cannot establish live delivery or complete coverage. + `--check` reads the app's source. It can establish that the guard is imported and called on a request path; it cannot establish that a request ever reaches it — an app can wire the guard onto one server and serve traffic from another, and that passes. To settle the difference there is an opt-in check @@ -449,13 +464,13 @@ which integration API is available. | [Solid](https://docs.solidjs.com/quick-start) | Separate the UI library from SolidStart or a custom server; keep protection out of client components. | | [Qwik](https://qwik.dev/docs/qwikcity/) | Inspect Qwik City and the deployment adapter; component resumability does not identify the request entry. | | [Ember](https://guides.emberjs.com/release/getting-started/quick-start/) | Inspect the deployed backend or SSR host separately; browser routes and the development server are not production coverage. | -| [Next.js](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) | Inspect root or `src/` middleware/proxy, matchers, APIs and Server Actions. Next 16 renamed middleware to proxy; Connect scaffolds `middleware.ts`. Do not leave competing files or assume its source check validates `proxy.ts`. | +| [Next.js](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) | Inspect root or `src/` middleware/proxy, matchers, APIs and Server Actions. Connect uses `proxy.ts` for new Next 16+ installs and composes supported existing proxies. Conflicting entries and complex routing require manual review. | | [Nuxt](https://nuxt.com/docs/4.x/directory-structure/server) | Inspect the configured server directory and Nitro server middleware, not client navigation middleware. Distinguish a server deployment from generated static output. | | [SvelteKit](https://svelte.dev/docs/kit/hooks) | Compose the existing server `handle` hook; check endpoints, actions, prerendering and the deployed adapter. | | [Astro](https://docs.astro.build/en/guides/middleware/) | Compose `onRequest` in server middleware; distinguish execution during prerendering from on-demand routes behind an adapter. | | [Remix](https://v2.remix.run/docs/discussion/runtimes/) | Inspect the adapter around `createRequestHandler`; cover document requests, loaders, actions and resource routes, not only `entry.server` rendering. | | [React Router](https://reactrouter.com/how-to/middleware) | Determine library versus framework/SSR mode. Inspect the server adapter and version-specific server middleware; client middleware cannot guard loaders/actions on the server. | -| [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/middleware) | Inspect the server entry and global request middleware, including server functions. The automatic TanStack/Supabase adapter matches a particular project layout, not every Start app. | +| [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/guide/middleware) | Inspect the server entry and global request middleware, including server functions. The native Fetch-entry adapter does not require Supabase. Custom entry paths need manual review; the separate TanStack/Supabase adapter matches a specific layout. | | [SolidStart](https://docs.solidjs.com/solid-start/v1/advanced/middleware) | Inspect configured server middleware and adapter; verify API and server action paths separately rather than assuming rendering middleware covers them. | | [Qwik City](https://qwik.dev/docs/middleware/) | Inspect deployment entry and request middleware, including endpoints, loaders and actions. Confirm route/layout scope and static output. | | [Gatsby](https://www.gatsbyjs.com/docs/reference/functions/) | Static pages need no request guard, but `src/api` functions and SSR deployments need their own server entry review. | diff --git a/README.md b/README.md index 1d32055a..49cd3cb0 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ npm install --save @patchstack/connect && npx @patchstack/connect setup > **Use your project's own package manager.** On Bun-managed projects (including many Lovable projects) install with `bun add @patchstack/connect` instead — running `npm install` there plants a `package-lock.json` that the platform's native dependency flow never updates again, leaving a stale lockfile next to the live one. Connect detects and works around that (see *Stale lockfiles* below), but not creating the fossil is better. Protection imports `@patchstack/connect/protect` at runtime, so deployments that prune dev dependencies need the package in `dependencies`. -> **Hosted builders:** set `PATCHSTACK_ENVIRONMENT=sandbox` in the workspace process environment (or scope it to the setup command above), persist every file written by `setup`, and restart any already-running server so it loads the new middleware. Do not write `"environment": "sandbox"` to the committed `.patchstackrc.json`: the same project files reach production, where scans should inherit no override and default to `production`. TanStack Start + Supabase (the server shape emitted by Lovable) is auto-wired: browser Supabase traffic is tunneled through a same-origin guard, server-function arguments are inspected, and responses are screened. A client-only SPA has no server request path to protect; setup will leave a generic scaffold and `protect --check` will remain red until the host adds a server/edge seam. Set `PATCHSTACK_ROUTE_WAF=1` when the deployment should additionally screen every TanStack route request. +> **Hosted builders:** set `PATCHSTACK_ENVIRONMENT=sandbox` in the workspace process environment (or scope it to the setup command above), persist every file written by `setup`, and restart any already-running server so it loads the new middleware. Do not write `"environment": "sandbox"` to the committed `.patchstackrc.json`: the same project files reach production, where scans should inherit no override and default to `production`. TanStack Start + Supabase (the server shape emitted by Lovable) is auto-wired: browser Supabase traffic is tunneled through a same-origin guard, server-function arguments are inspected, and responses are screened. A client-only SPA has no server request path to protect; setup will leave a generic scaffold and `protect --check` will remain red until the host adds a server/edge seam. Native TanStack requests are screened by default; no additional route-WAF switch is needed. New Start apps without that Supabase layout use the documented server Fetch entry. Browser-direct services and separately deployed functions still require their own protection. That's it. `setup`: @@ -230,16 +230,26 @@ Options (for demo and demo-guide): ### Next.js request and response protection +Framework detection follows the exported application's server entry, not the builder's name. +[Lovable documents TanStack Start for new projects and React/Vite for older ones](https://docs.lovable.dev/introduction/faq); +[Hostinger Horizons offers a hosted backend](https://www.hostinger.com/blog/horizons-integrated-backend/), +and [Airo exports React/TypeScript applications](https://airo-builder.godaddy.com/discover/features). +Those frontends do not establish where backend requests execute. Browser-direct APIs, Supabase Edge +Functions and other separately deployed services need their own server-side integration; a browser +tunnel does not replace backend authorization or RLS. Tests use synthetic framework-shaped apps, +not proprietary builder templates, and do not certify a builder's live hosting environment. + `protect` composes straightforward existing middleware instead of replacing its authentication or redirect logic. The request guard gets a catch-all matcher; the application's middleware still runs only within its original scope. Automatic composition accepts directly exported handlers and literal path matchers (including a terminal `/:path*`). Complex matchers, re-exports and custom URL routing -are left untouched with an integration message. Source-aware edits use the application's installed -`typescript` parser; configuration files are never executed. +are left untouched with an integration message. Source-aware edits use a TypeScript parser supplied +by the app or Connect's CLI dependency; configuration files are never executed. -Apps with an existing `proxy.ts`/`proxy.js` (including under `src/`) require manual integration. -The installer leaves them unchanged and `protect --check` reports the gap. It never adds middleware -alongside a proxy, since Next.js does not allow both. +On Next 16+, the installer creates `proxy.ts` for new wiring and composes supported existing +`proxy.ts`/`proxy.js` handlers (including under `src/`). Older versions retain middleware. Conflicting +entries, unsupported proxy exports, and custom normalization stay untouched and are reported by +`protect --check`. Middleware and proxy are never created alongside one another. For App Router `app/**/route.ts` or `route.js` files, it also adds request checks and screens each returned response. The shared server-only `patchstack.next` helper initializes one policy per module diff --git a/package.json b/package.json index 847428b9..4910d8db 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:consumers": "node scripts/compat-matrix.mjs", "test:bundled": "node scripts/bundled-consumer.mjs", "test:next": "node scripts/next-consumer.mjs", + "test:tanstack": "node scripts/tanstack-consumer.mjs", "test:demos": "npm run build && node scripts/run-demos.mjs", "audit:side-effects": "npm run build && node scripts/side-effect-audit.mjs --selftest && node scripts/side-effect-audit.mjs", "test:manifest": "bun scripts/test-manifest.ts", diff --git a/scripts/tanstack-consumer.mjs b/scripts/tanstack-consumer.mjs new file mode 100644 index 00000000..48440f77 --- /dev/null +++ b/scripts/tanstack-consumer.mjs @@ -0,0 +1,42 @@ +// Optional networked consumer check: only synthetic source and a local package tarball are used. +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +const root = process.cwd(); +const scratch = mkdtempSync(join(tmpdir(), 'ps-tanstack-consumer-')); +const write = (file, text) => { mkdirSync(dirname(join(scratch,file)),{recursive:true}); writeFileSync(join(scratch,file),text); }; +const run = (command,args,cwd=scratch) => execFileSync(command,args,{cwd,stdio:'pipe',timeout:180000}); +try { + const packed = JSON.parse(run('npm',['pack','--ignore-scripts','--json','--pack-destination',scratch],root).toString())[0].filename; + write('package.json',JSON.stringify({private:true,type:'module',dependencies:{ + '@patchstack/connect':`file:./${packed}`, '@tanstack/react-start':'1.168.60', react:'^19.0.0', 'react-dom':'^19.0.0', + typescript:'5.9.3', '@types/react':'^19.0.0', '@types/react-dom':'^19.0.0', '@types/node':'^22.0.0', + }})); + run('npm',['install','--ignore-scripts','--no-audit','--no-fund']); + const cli = join(scratch,'node_modules/@patchstack/connect/dist/cli.js'); + const tsc = join(scratch,'node_modules/typescript/bin/tsc'); + const flags = ['--noEmit','--strict','--skipLibCheck','--module','ESNext','--moduleResolution','Bundler','--target','ES2022','--resolveJsonModule','--esModuleInterop','--lib','ES2022,DOM']; + run(process.execPath,[cli,'protect']); + run(process.execPath,[cli,'protect','--check']); + run(process.execPath,[tsc,...flags,'src/server.ts']); + write('src/start.ts',`import {createStart, createMiddleware} from '@tanstack/react-start'; +export const startInstance = createStart(() => ({requestMiddleware: []}));\n`); + write('src/integrations/supabase/client.ts',`export function createSupabaseFetch(supabaseKey: string): typeof fetch { + return (input, init) => { + const headers = new Headers(init?.headers); + headers.set('apikey', supabaseKey); + return fetch(input, {...init,headers}); + }; +}\n`); + write('tsconfig.json',JSON.stringify({compilerOptions:{strict:true,skipLibCheck:true,module:'ESNext',moduleResolution:'Bundler',target:'ES2022',resolveJsonModule:true,esModuleInterop:true,lib:['ES2022','DOM'],baseUrl:'.',paths:{'@/*':['src/*']}},include:['src/**/*.ts']})); + run(process.execPath,[cli,'protect']); + run(process.execPath,[cli,'protect','--check']); + run(process.execPath,[tsc,'--noEmit']); + console.log('TanStack consumer: native entry and middleware composition passed against real framework types.'); +} catch (error) { + console.error(error.stdout?.toString() ?? error.message); + console.error(error.stderr?.toString() ?? ''); + process.exitCode=1; +} finally { rmSync(scratch,{recursive:true,force:true}); } diff --git a/src/protect/install/adapters/next-source.ts b/src/protect/install/adapters/next-source.ts index 71a2e1bd..7bf4d87f 100644 --- a/src/protect/install/adapters/next-source.ts +++ b/src/protect/install/adapters/next-source.ts @@ -91,7 +91,7 @@ export function standardNextRouting(ts: Compiler, file: string, source: string): return false; }; if (!literal(target) || ![...objects.values()].every(literal)) return false; - return target.properties.every(p => p.name && !['basePath', 'i18n', 'skipMiddlewareUrlNormalize', 'pageExtensions', '__proto__'].includes( + return target.properties.every(p => p.name && !['basePath', 'i18n', 'skipMiddlewareUrlNormalize', 'skipProxyUrlNormalize', 'pageExtensions', '__proto__'].includes( ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) ? p.name.text : '', )); } @@ -158,7 +158,7 @@ function prelude(request: string, marker: string): string { export function composeNextMiddleware(ts: Compiler, file: string, source: string, guardImport: string): string | null { const sf = parse(ts, file, source); if (!sf || /\b(?:psProtection|psBlocked|psRequest|getPatchstackProtection)\b/.test(source)) return null; - const found = handlers(ts, sf, new Set(['middleware'])); + const found = handlers(ts, sf, new Set([/proxy\.[jt]s$/.test(file) ? 'proxy' : 'middleware'])); if (found.length !== 1 || sf.statements.some(s => ts.isExportAssignment(s) || ts.isExportDeclaration(s))) return null; const fn = found[0]!; const request = requestName(ts, fn); @@ -225,7 +225,7 @@ export function nextSourceWired(ts: Compiler, file: string, source: string, guar if (route && !sf.statements.some(s => ts.isImportDeclaration(s) && ts.isStringLiteral(s.moduleSpecifier) && s.moduleSpecifier.text === guardImport && s.importClause?.namedBindings && ts.isNamedImports(s.importClause.namedBindings) && s.importClause.namedBindings.elements.some(e => !e.propertyName && e.name.text === 'screenPatchstackResponse'))) return false; - const found = handlers(ts, sf, route ? METHODS : new Set(['middleware'])); + const found = handlers(ts, sf, route ? METHODS : new Set([/proxy\.[jt]s$/.test(file) ? 'proxy' : 'middleware'])); if (!found.length || sf.statements.some(s => ts.isExportDeclaration(s) || ts.isExportAssignment(s))) return false; if (route && methodExportCount(ts, sf) !== found.length) return false; const compact = (text: string) => text.replace(/\s+/g, ''); diff --git a/src/protect/install/adapters/next.ts b/src/protect/install/adapters/next.ts index 9dcc7001..ffc21357 100644 --- a/src/protect/install/adapters/next.ts +++ b/src/protect/install/adapters/next.ts @@ -1,5 +1,6 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; +import { createRequire } from 'node:module'; import { bakeSiteUuid, hasDependency, read, log, templatesDir } from '../util.js'; import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js'; import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js'; @@ -7,14 +8,37 @@ import { composeNextMiddleware, composeNextRoute, nextCompiler, nextSourceWired, import { installTemplate } from '../template-upgrade.js'; function middlewareInfo(cwd: string) { - const candidates = ['middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js']; + const candidates = entryFiles(cwd); + const name = (nextMajor(cwd) ?? 0) >= 16 ? 'proxy' : 'middleware'; const relFile = candidates.find(rel => existsSync(join(cwd, rel))) - ?? (existsSync(join(cwd, 'src')) ? 'src/middleware.ts' : 'middleware.ts'); + ?? (existsSync(join(cwd, 'src')) ? `src/${name}.ts` : `${name}.ts`); return { relFile, relDir: dirname(relFile), exists: existsSync(join(cwd, relFile)) }; } -function proxyFiles(cwd: string): string[] { - return ['proxy.ts', 'proxy.js', 'src/proxy.ts', 'src/proxy.js'].filter(file => existsSync(join(cwd, file))); +function nextMajor(cwd: string): number | null { + try { + const file = createRequire(join(cwd, 'package.json')).resolve('next/package.json'); + return Number(JSON.parse(read(file)).version.split('.')[0]); + } catch { + try { + const pkg = JSON.parse(read(join(cwd, 'package.json'))); + const version = pkg.dependencies?.next ?? pkg.devDependencies?.next; + const match = typeof version === 'string' && /^[~^]?(\d+)\./.exec(version); + return match ? Number(match[1]) : null; + } catch { return null; } + } +} + +function entryFiles(cwd: string): string[] { + return ['middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js', + 'proxy.ts', 'proxy.js', 'src/proxy.ts', 'src/proxy.js'].filter(file => existsSync(join(cwd, file))); +} + +function entryProblem(cwd: string): string | null { + const files = entryFiles(cwd); + if (files.length > 1) return `multiple Next.js middleware/proxy entries (${files.join(', ')}); select one manually`; + if (files.some(file => /proxy\.[jt]s$/.test(file)) && (nextMajor(cwd) ?? 0) < 16) return `${files.join(', ')} requires a verified Next.js 16+ installation`; + return null; } function paths(cwd: string) { @@ -69,9 +93,9 @@ function sharedGuardPresent(file: string): boolean { } function wire(cwd: string, opts: WireOptions): WireResult { - const proxies = proxyFiles(cwd); - if (proxies.length) { - log(`left ${proxies.join(', ')} untouched: Next.js proxy integration requires manual wiring. Do not add middleware alongside a proxy; protect --check reports this gap.`); + const problem = entryProblem(cwd); + if (problem) { + log(`Next.js entries left untouched: ${problem}. Do not add middleware alongside a proxy.`); return { ok: false, changed: [] }; } const templates = templatesDir(); @@ -100,7 +124,12 @@ function wire(cwd: string, opts: WireOptions): WireResult { const existing = mw.exists ? read(join(cwd, mw.relFile)) : ''; if (!mw.exists) { - copyProjectFileSync(cwd, join(templates, 'next-middleware.ts'), join(cwd, mw.relFile)); + if (/proxy\.[jt]s$/.test(mw.relFile)) { + const composed = ts && !guardConflict && composeNextMiddleware(ts, mw.relFile, 'export function proxy(request: Request) {}\nexport const config = { matcher: "/:path*" };\n', importFrom(mw.relFile, mw.guard)); + if (!composed) return {ok:false,changed}; + ensureGuard(); + writeProjectFileSync(cwd, join(cwd, mw.relFile), composed); + } else copyProjectFileSync(cwd, join(templates, 'next-middleware.ts'), join(cwd, mw.relFile)); if (!opts.demo) bakeSiteUuid(cwd, mw.relFile); changed.push(mw.relFile); log(`scaffolded ${mw.relFile} (request-phase guard)`); @@ -145,11 +174,10 @@ function wire(cwd: string, opts: WireOptions): WireResult { } function verify(cwd: string): VerifyResult { - const proxies = proxyFiles(cwd); - if (proxies.length) return { + const problem = entryProblem(cwd); + if (problem) return { wired: false, - checks: [{ label: 'Next.js proxy wiring requires manual verification', ok: false, - hint: `review ${proxies.join(', ')}; automatic proxy integration is not supported. Next.js cannot use middleware and proxy together.` }], + checks: [{ label: 'Next.js request entry needs review', ok: false, hint: problem }], }; const mw = paths(cwd); const ts = nextCompiler(cwd); diff --git a/src/protect/install/adapters/tanstack-supabase.ts b/src/protect/install/adapters/tanstack-supabase.ts index ffb2dd3e..e9998774 100644 --- a/src/protect/install/adapters/tanstack-supabase.ts +++ b/src/protect/install/adapters/tanstack-supabase.ts @@ -40,19 +40,19 @@ const START_IMPORTS = ['import { getRequest } from "@tanstack/react-start/server // reconcileBlock() keys off them. const REQUEST_MIDDLEWARE_BLOCK = [ '// #region patchstack-guard (managed by patchstack-connect protect — do not edit)', - '// Browser→Supabase tunnel + response screening; optional route WAF via PATCHSTACK_ROUTE_WAF=1.', + '// Screen native requests and responses as well as the browser→Supabase tunnel.', 'const patchstackGuard = createMiddleware().server(async ({ next }) => {', ' const request = getRequest();', ' if (request) {', ' const { pathname } = new URL(request.url);', ' if (pathname === GUARD_PATH) return handleGuardRequest(request);', - ' if (process.env.PATCHSTACK_ROUTE_WAF === "1") {', - ' const blocked = await guardRequest(request);', - ' if (blocked) return blocked;', - ' }', + ' const blocked = await guardRequest(request);', + ' if (blocked) return blocked;', ' }', ' // The request is passed so route/method-scoped response rules can apply their scope.', - ' return screenResponse(await next(), request);', + ' const result = await next();', + ' if (result instanceof Response) return screenResponse(result, request);', + ' return { ...result, response: await screenResponse(result.response, request) };', '});', '// #endregion patchstack-guard', ].join('\n'); @@ -140,9 +140,6 @@ function scaffold(cwd: string, opts: WireOptions): string[] { } function patchClient(ts: Compiler, s: string): string | null { - if (s.includes('x-ps-target')) { - return s; - } const tree = parsedSource(ts, 'client.ts', s); if (!tree) return null; const anchors: number[] = []; @@ -151,12 +148,25 @@ function patchClient(ts: Compiler, s: string): string | null { const call = node.expression; if (call.expression.getText(tree) === 'headers.set' && call.arguments[0] && ts.isStringLiteral(call.arguments[0]) && call.arguments[0].text === 'apikey' - && call.arguments[1]?.getText(tree) === 'supabaseKey') anchors.push(node.end); + && call.arguments[1]?.getText(tree) === 'supabaseKey') { + const block = node.parent; + const fn = block.parent; + if (ts.isBlock(block) && ts.isArrowFunction(fn) && fn.parameters.length === 2 + && fn.parameters[0]!.name.getText(tree) === 'input' && fn.parameters[1]!.name.getText(tree) === 'init' + && block.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(d => + ts.isIdentifier(d.name) && d.name.text === 'headers' && d.initializer && ts.isNewExpression(d.initializer) + && d.initializer.expression.getText(tree) === 'Headers'))) anchors.push(node.end); + } } ts.forEachChild(node, visit); }; visit(tree); if (anchors.length !== 1) return null; + if (s.includes('x-ps-target')) { + // Existing custom tunnels are not replaced automatically. A marker alone is not a tunnel. + const code = ts.createPrinter({removeComments:true}).printFile(tree); + return /\.set\(['"]x-ps-target['"],/.test(code) && /return fetch\(/.test(code) ? s : null; + } return s.slice(0, anchors[0]) + '\n' + CLIENT_TUNNEL + s.slice(anchors[0]); } @@ -169,6 +179,7 @@ function patchStart(ts: Compiler, original: string): string | null { if (!tree) return null; const startImport = tree.statements.find(node => ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@tanstack/react-start' + && !node.importClause?.isTypeOnly && !node.importClause?.name && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && node.importClause.namedBindings.elements.length === 2 && node.importClause.namedBindings.elements.every(e => !e.propertyName && !e.isTypeOnly @@ -221,8 +232,8 @@ function patchStart(ts: Compiler, original: string): string | null { } else { const requestImport = tree.statements.some(node => ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@tanstack/react-start/server' - && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) - && node.importClause.namedBindings.elements.some(e => !e.propertyName && e.name.text === 'getRequest')); + && !node.importClause?.isTypeOnly && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) + && node.importClause.namedBindings.elements.some(e => !e.propertyName && !e.isTypeOnly && e.name.text === 'getRequest')); if (!requestImport && /\bgetRequest\b/.test(original)) return null; s = s.replace(importAnchor, importAnchor + '\n' + (requestImport ? GUARD_IMPORT : START_IMPORTS)); } @@ -297,11 +308,12 @@ function verify(cwd: string): VerifyResult { const guard = existsSync(guardPath) ? read(guardPath) : ''; const client = existsSync(clientPath) ? read(clientPath) : ''; const start = existsSync(startPath) ? read(startPath) : ''; + const ts = sourceCompiler(cwd); const checks = [ { label: 'guard.ts scaffolded', ok: guard.length > 0, hint: 'run `patchstack-connect protect`' }, { label: 'guard helper implementation verified', ok: matchesGuardTemplate(cwd, GUARD_FILE, 'guard.ts'), hint: 'preserved custom helpers require manual review before redirecting browser traffic' }, - { label: 'Supabase client tunnels through the guard', ok: client.includes('x-ps-target'), hint: 'run `patchstack-connect protect` to re-patch src/integrations/supabase/client.ts' }, + { label: 'Supabase client tunnels through the guard', ok: !!ts && client.includes('x-ps-target') && patchClient(ts, client) === client, hint: 'run `patchstack-connect protect` to re-patch src/integrations/supabase/client.ts' }, { label: 'request middleware defined + registered', ok: start.includes('const patchstackGuard =') && start.includes('requestMiddleware: [patchstackGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' }, { label: 'server-function middleware defined + registered', ok: start.includes('const patchstackFunctionGuard =') && start.includes('functionMiddleware: [patchstackFunctionGuard'), hint: 'run `patchstack-connect protect` to re-patch src/start.ts' }, ]; diff --git a/src/protect/install/adapters/tanstack.ts b/src/protect/install/adapters/tanstack.ts new file mode 100644 index 00000000..8e67886a --- /dev/null +++ b/src/protect/install/adapters/tanstack.ts @@ -0,0 +1,121 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { bakeSiteUuid, hasDependency, log, read, templatesDir } from '../util.js'; +import { sourceCompiler, parsedSource, type Compiler } from '../syntax.js'; +import { installTemplate } from '../template-upgrade.js'; +import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js'; +import type { Adapter } from '../types.js'; +import { templateWiringPresent } from '../seam.js'; + +const ENTRY = 'src/server.ts'; +const GUARD = 'src/patchstack/guard.ts'; +const IMPORT = 'import { protectFetch } from "./patchstack/guard";'; +const DEFAULT = `import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; +export default createServerEntry({ fetch: handler.fetch.bind(handler) }); +`; + +function entryProblem(cwd: string): string | null { + try { + const file = createRequire(join(cwd, 'package.json')).resolve('@tanstack/react-start/package.json'); + if (!JSON.parse(read(file)).exports?.['./server-entry']) return 'the installed TanStack Start version does not expose server-entry'; + } + catch { return 'the installed TanStack Start version must expose its server-entry contract'; } + if (['src/server.js', 'src/server.tsx', 'src/server.jsx', 'server.ts', 'server.js'].some(file => existsSync(join(cwd, file)))) { + return 'non-default or competing server entries require manual integration'; + } + // A custom source directory or server entry can make src/server.ts an unused file. + for (const file of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'app.config.ts']) { + if (!existsSync(join(cwd, file))) continue; + const source = read(join(cwd, file)); + if (/\b(?:srcDirectory|serverEntry|entry|server|root)\s*:|\.\.\./.test(source)) return 'custom Vite/Start entry configuration requires manual integration'; + } + return null; +} + +/** Compose only the documented literal createServerEntry shape; never evaluate app config. */ +export function composeTanstackEntry(ts: Compiler, source: string): string | null { + const tree = parsedSource(ts, ENTRY, source); + if (!tree || /\bprotectFetch\b/.test(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]!; + 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; + const fetch = properties[0]!; + let replacement: string; + if (ts.isPropertyAssignment(fetch)) replacement = `fetch: protectFetch(${fetch.initializer.getText(tree)})`; + else if (ts.isMethodDeclaration(fetch) && fetch.body && !fetch.asteriskToken) { + const text = fetch.getText(tree); + replacement = `fetch: protectFetch(${text.replace(/^(async\s+)?fetch\s*\(/, '$1function (')})`; + } else if (ts.isShorthandPropertyAssignment(fetch)) replacement = 'fetch: protectFetch(fetch)'; + else return null; + const patched = source.slice(0, fetch.getStart(tree)) + replacement + source.slice(fetch.end); + const insertion = tree.statements.filter(ts.isImportDeclaration).at(-1)?.end ?? 0; + const result = patched.slice(0, insertion) + '\n' + IMPORT + '\n' + patched.slice(insertion); + return parsedSource(ts, ENTRY, result) ? result : null; +} + +function wiredEntry(ts: Compiler, source: string): boolean { + const tree = parsedSource(ts, ENTRY, source); + if (!tree) return false; + const imported = tree.statements.filter(ts.isImportDeclaration).filter(n => n.getText(tree) === IMPORT); + if (imported.length !== 1) return false; + // Removing just the managed wrapper must recover a supported entry that composes identically. + const wrappers: import('typescript').CallExpression[] = []; + const visit = (node: import('typescript').Node) => { + if (ts.isCallExpression(node) && node.expression.getText(tree) === 'protectFetch') wrappers.push(node); + ts.forEachChild(node, visit); + }; + visit(tree); + if (wrappers.length !== 1 || wrappers[0]!.arguments.length !== 1) return false; + const wrapper = wrappers[0]!; + const plain = source.slice(0, wrapper.getStart(tree)) + wrapper.arguments[0]!.getText(tree) + source.slice(wrapper.end); + const composed = composeTanstackEntry(ts, plain.replace(IMPORT, '')); + const reparsed = composed && parsedSource(ts, ENTRY, composed); + const printer = ts.createPrinter({removeComments:true}); + return !!reparsed && printer.printFile(reparsed) === printer.printFile(tree); +} + +export const tanstackAdapter: Adapter = { + name: 'tanstack-start', label: 'TanStack Start', detect: cwd => hasDependency(cwd, '@tanstack/react-start'), + wire(cwd, opts) { + const problem = entryProblem(cwd); + 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; + 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[] = []; + ensureProjectDirectorySync(cwd, join(cwd, 'src/patchstack')); + if (installTemplate(cwd, GUARD, 'fetch-guard.ts')) changed.push(GUARD); + const rules = 'src/patchstack/rules.json'; + if (opts.demo || !existsSync(join(cwd,rules))) { + copyProjectFileSync(cwd, join(templatesDir(),opts.demo ? 'demo-rules.json' : 'rules.json'),join(cwd,rules)); + changed.push(rules); + } + if (!opts.demo && bakeSiteUuid(cwd, GUARD)) changed.push(GUARD); + if (next !== previous || !existsSync(join(cwd,ENTRY))) { + writeProjectFileSync(cwd,join(cwd,ENTRY),next); + changed.push(ENTRY); + } + log('TanStack server entry screens requests and final Responses; browser-direct backend traffic and separately deployed functions need their own protection.'); + return {ok:true,changed}; + }, + verify(cwd) { + const ts = sourceCompiler(cwd); + const problem = entryProblem(cwd); + const wired = !problem && !!ts && existsSync(join(cwd,ENTRY)) && wiredEntry(ts,read(join(cwd,ENTRY))) + && existsSync(join(cwd,GUARD)) && templateWiringPresent(cwd,GUARD,'fetch-guard.ts') && existsSync(join(cwd,'src/patchstack/rules.json')); + return {wired,checks:[{label:'TanStack server Fetch boundary wired',ok:wired,hint:problem ?? 'run protect; manually review unsupported custom server entries'}, + {label:'browser-direct services and separately deployed functions are not covered by this entry',ok:true,unverifiable:true,hint:'install protection at each independently exposed backend; retain backend authorization and RLS'}]}; + }, +}; diff --git a/src/protect/install/index.ts b/src/protect/install/index.ts index 108ad994..00b83d3f 100644 --- a/src/protect/install/index.ts +++ b/src/protect/install/index.ts @@ -9,6 +9,7 @@ import { classifyArchitecture } from '../../architecture.js'; import { log } from './util.js'; import { tanstackSupabaseAdapter } from './adapters/tanstack-supabase.js'; +import { tanstackAdapter } from './adapters/tanstack.js'; import { nextAdapter } from './adapters/next.js'; import { sveltekitAdapter } from './adapters/sveltekit.js'; import { astroAdapter } from './adapters/astro.js'; @@ -25,6 +26,7 @@ import type { Adapter, VerifyCheck, WireOptions, ProtectResult, VerifyReport } f // bare server libraries (a SvelteKit/Astro app may also carry express/fastify as a transitive dep). const ADAPTERS: Adapter[] = [ tanstackSupabaseAdapter, + tanstackAdapter, nextAdapter, sveltekitAdapter, astroAdapter, @@ -88,7 +90,7 @@ export function runProtect(cwd: string, opts: WireOptions = {}): ProtectResult { * an environment variable in the deployment. Without it the guard runs on the rules it shipped with: it * screens every request, reports healthy, and never receives another rule. */ -const RUNTIMES_WITHOUT_CONFIG_FILE = new Set(['nextjs', 'sveltekit', 'astro', 'nuxt', 'generic']); +const RUNTIMES_WITHOUT_CONFIG_FILE = new Set(['tanstack-start', 'nextjs', 'sveltekit', 'astro', 'nuxt', 'generic']); /** * A note about the deployment credential, when the stack needs one and this machine cannot confirm it. diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 8617517c..e1e86386 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -19,7 +19,7 @@ export interface Protection { * Any further arguments are the host's handler arguments, passed on to `peerAddress`. */ fetchGuard(): (request: Request, ...hostArgs: unknown[]) => Promise; /** Screens the request, then the response (secret-leak redaction / withhold). */ - fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise; + fetch(handler: (this: This, request: Request, ...rest: Args) => Response | Promise): (this: This, request: Request, ...rest: Args) => Promise; /** * Screen a fetch Response through the response-phase rules (redact/withhold/encode). * diff --git a/src/protect/templates/fetch-guard.ts b/src/protect/templates/fetch-guard.ts new file mode 100644 index 00000000..cf8a2484 --- /dev/null +++ b/src/protect/templates/fetch-guard.ts @@ -0,0 +1,52 @@ +// Server-only Fetch guard for bundled applications. Never import this module into client code. +import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect"; +import fallbackRules from "./rules.json"; + +const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__"; +type Protection = Awaited>; +const policies = new WeakMap>(); +const emptyEnvironment = {}; +let warned = false; + +function environment(value: unknown): Record { + if (value && typeof value === "object" && ["PATCHSTACK_API_KEY", "PATCHSTACK_SITE_UUID", "PATCHSTACK_PULSE_AUTH"].some(key => key in value)) { + return value as Record; + } + return typeof process === "undefined" ? emptyEnvironment : process.env; +} + +async function getProtection(bindings: unknown) { + const env = environment(bindings); + let pending = policies.get(env); + if (!pending) { + const value = (key: string) => typeof env[key] === "string" ? env[key] as string : undefined; + pending = createProtection({ + siteUuid: PS_SITE_UUID.startsWith("__") ? value("PATCHSTACK_SITE_UUID") : PS_SITE_UUID, + apiKey: value("PATCHSTACK_API_KEY"), + pulseAuth: value("PATCHSTACK_PULSE_AUTH") ?? value("PATCHSTACK_API_KEY"), + mode: value("PATCHSTACK_MODE") === "dry-run" ? "dry-run" : "block", + rules: fallbackRules as never, + cacheDir: ".patchstack", + refreshMs: value("PATCHSTACK_ENVIRONMENT") === "sandbox" ? 15000 : 300000, + egress: true, + }).catch(error => { policies.delete(env); throw error; }); + policies.set(env, pending); + } + return pending.catch(error => { + if (!warned) { + warned = true; + console.warn("[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds.", error instanceof Error ? error.message : String(error)); + } + return null; + }); +} + +export function protectFetch(handler: (request: Request, ...args: Args) => Response | Promise) { + return async function(this: unknown, request: Request, ...args: Args): Promise { + const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER)); + if (answered) return new Response(answered, { headers: { "content-type": "text/plain" } }); + const protection = await getProtection(args[0]); + // Only initialization fails open here. Application failures propagate without repeating the handler. + return protection ? protection.fetch(handler).call(this, request, ...args) : handler.call(this, request, ...args); + }; +} diff --git a/tests/protect/declaration-drift.test.ts b/tests/protect/declaration-drift.test.ts index 80412241..8fa08c7d 100644 --- a/tests/protect/declaration-drift.test.ts +++ b/tests/protect/declaration-drift.test.ts @@ -21,7 +21,7 @@ function declaredMembers(name: string): Set { for (const line of body.split('\n')) { if (depth === 0) { if (line.startsWith('}')) break; - const member = /^ {2}(?:readonly )?([A-Za-z_$][\w$]*)\??(?:\(|:)/.exec(line); + const member = /^ {2}(?:readonly )?([A-Za-z_$][\w$]*)\??(?:<[^>]*>)?(?:\(|:)/.exec(line); if (member) members.add(member[1]); } for (const ch of line.replace(/\/\*.*?\*\/|\/\/.*$|"[^"]*"|'[^']*'|`[^`]*`/g, '')) { diff --git a/tests/protect/every-seam-steps-aside.test.ts b/tests/protect/every-seam-steps-aside.test.ts index 451620cd..9495ad04 100644 --- a/tests/protect/every-seam-steps-aside.test.ts +++ b/tests/protect/every-seam-steps-aside.test.ts @@ -71,6 +71,7 @@ const nodeSeam: Seam = async (api, _served, ran) => const fastifySeam: Seam = async (api, _served, ran) => (ran(), throughFastifyHooks(api)); const SEAMS: Record = { + 'fetch-guard.ts#protectFetch': fetchSeam, 'generic-guard.ts#protectFetch': fetchSeam, 'generic-guard.js#protectFetch': fetchSeam, 'generic-guard.cjs#protectFetch': fetchSeam, diff --git a/tests/protect/fetch-template.test.ts b/tests/protect/fetch-template.test.ts new file mode 100644 index 00000000..0c8f64a1 --- /dev/null +++ b/tests/protect/fetch-template.test.ts @@ -0,0 +1,44 @@ +import {describe,it,expect} from 'vitest'; +import {readFileSync} from 'node:fs'; +import ts from 'typescript'; +import {createProtection} from '../../src/protect/runtime.js'; + +function load(factory: unknown, processValue?: unknown) { + const environment = arguments.length > 1 ? processValue : {env:{}}; + const source = readFileSync(new URL('../../src/protect/templates/fetch-guard.ts',import.meta.url),'utf8').replace(/^import .*$/gm,'').replace(/export /g,''); + const compiled = ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText; + return new Function('createProtection','fallbackRules','sentinelAnswer','VERIFY_HEADER','process',compiled+'\nreturn protectFetch;')(factory,{firewall:[],whitelists:[]},async()=>null,'x-test-verify',environment); +} + +describe('generated Fetch guard',()=>{ + it('preserves body, receiver, host arguments, status and response scope without a process global',async()=>{ + const active = await createProtection({rules:{firewall:[{id:'synthetic-output',phase:'response',action:'redact',when:{path:'/contact'},rule_v2:[{parameter:'response.body',match:{type:'contains',value:'synthetic-secret'}}]}],whitelists:[]},mode:'block',reportDetections:false}); + const factoryOptions: any[]=[]; + const protect=load(async(opts:unknown)=>{factoryOptions.push(opts);return active;},undefined); + const env={PATCHSTACK_API_KEY:'synthetic-credential',PATCHSTACK_SITE_UUID:'00000000-0000-4000-8000-000000000001'}; + const execution={waitUntil:()=>{}}; + const server={label:'server',fetch:protect(async function(this:any,request:Request,bindings:unknown,ctx:unknown){ + expect(this.label).toBe('server'); expect(bindings).toBe(env); expect(ctx).toBe(execution); + return new Response(await request.text(),{status:201,headers:{'content-type':'text/plain','x-app':'unchanged'}}); + })}; + try { + const request=()=>new Request('https://app.example/contact',{method:'POST',body:'synthetic-secret'}); + const responses=await Promise.all([server.fetch(request(),env,execution),server.fetch(request(),env,execution)]); + expect(factoryOptions).toHaveLength(1); + expect(factoryOptions[0].pulseAuth).toBe('synthetic-credential'); + expect(factoryOptions[0].refreshMs).toBe(300000); + for(const response of responses) { expect(response.status).toBe(201);expect(await response.text()).not.toContain('synthetic-secret');expect(response.headers.get('x-app')).toBe('unchanged'); } + } finally { active.stop(); } + }); + + it('retries a failed initialization without swallowing or repeating application exceptions',async()=>{ + let builds=0,calls=0; + const active = await createProtection({rules:{firewall:[],whitelists:[]},mode:'block',reportDetections:false}); + const protect=load(async()=>{if(++builds===1)throw new Error('synthetic unavailable');return active;}); + const wrapped=protect(()=>{calls++;throw new Error('application error');}); + try { + for(let i=0;i<2;i++)await expect(wrapped(new Request('https://app.example/'))).rejects.toThrow('application error'); + expect(builds).toBe(2);expect(calls).toBe(2); + } finally { active.stop(); } + }); +}); diff --git a/tests/protect/framework-entries.test.ts b/tests/protect/framework-entries.test.ts new file mode 100644 index 00000000..1f47f453 --- /dev/null +++ b/tests/protect/framework-entries.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import ts from 'typescript'; +import { runProtect, runVerify } from '../../src/protect/install/index.js'; +import { composeTanstackEntry } from '../../src/protect/install/adapters/tanstack.js'; +import { composeNextMiddleware } from '../../src/protect/install/adapters/next-source.js'; + +const dirs: string[] = []; +const read = (cwd: string,file:string) => readFileSync(join(cwd,file),'utf8'); +function put(cwd:string,file:string,source:string) { mkdirSync(dirname(join(cwd,file)),{recursive:true}); writeFileSync(join(cwd,file),source); } +function fixture(dependencies: Record) { + vi.spyOn(console,'log').mockImplementation(()=>{}); + const cwd = mkdtempSync(join(tmpdir(),'ps-framework-')); dirs.push(cwd); + put(cwd,'package.json',JSON.stringify({type:'module',dependencies})); + return cwd; +} +function tanstack() { + const cwd = fixture({'@tanstack/react-start':'^1.168.0'}); + put(cwd,'node_modules/@tanstack/react-start/package.json',JSON.stringify({name:'@tanstack/react-start',exports:{'./package.json':'./package.json','./server-entry':{import:'./server.js'}}})); + return cwd; +} +afterEach(()=>{ dirs.splice(0).forEach(cwd=>rmSync(cwd,{recursive:true,force:true})); vi.restoreAllMocks(); }); + +describe('native TanStack server boundary',()=>{ + it('does not depend on a Supabase client or start.ts and is idempotent',()=>{ + const cwd=tanstack(); + expect(runProtect(cwd).status).toBe('wired'); + expect(read(cwd,'src/server.ts')).toContain('protectFetch(handler.fetch.bind(handler))'); + expect(existsSync(join(cwd,'src/start.ts'))).toBe(false); + expect(existsSync(join(cwd,'src/integrations/supabase/client.ts'))).toBe(false); + const source=read(cwd,'src/server.ts'); runProtect(cwd); + expect(read(cwd,'src/server.ts')).toBe(source); + expect(runVerify(cwd).wired).toBe(true); + }); + it.each([ + 'fetch(request: Request, options: {context:unknown}) { return handler.fetch(request, options); }', + 'async fetch(request: Request, options: {context:unknown}) { return handler.fetch(request, options); }', + 'fetch: handler.fetch.bind(handler)', + 'fetch', + ])('composes a literal handler: %s',property=>{ + const source=`import handler, {createServerEntry} from '@tanstack/react-start/server-entry';\nconst fetch = handler.fetch;\nexport default createServerEntry({${property}, scheduled() { return 1; }});`; + const composed=composeTanstackEntry(ts,source)!; + expect(composed).toContain('protectFetch('); + expect(composed).toContain('scheduled() { return 1; }'); + expect((ts.createSourceFile('server.ts',composed,ts.ScriptTarget.Latest,true) as any).parseDiagnostics).toEqual([]); + }); + it.each(['...options, fetch: handler.fetch','get fetch() { return handler.fetch; }','fetch: handler.fetch, fetch: other','[key]: handler.fetch'])('preserves unsupported configurations: %s',property=>{ + const cwd=tanstack(); + const source=`import {createServerEntry} from '@tanstack/react-start/server-entry';\nexport default createServerEntry({${property}});`; + put(cwd,'src/server.ts',source); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd,'src/server.ts')).toBe(source); + }); + it('refuses ambiguous entry configuration and reports a removed wrapper',()=>{ + const cwd=tanstack(); + put(cwd,'vite.config.ts','export default { server: { entry: "custom.ts" } };'); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(existsSync(join(cwd,'src/server.ts'))).toBe(false); + put(cwd,'vite.config.ts','export default {};'); runProtect(cwd); + put(cwd,'src/server.ts',read(cwd,'src/server.ts').replace('fetch: protectFetch(handler.fetch.bind(handler))','fetch: handler.fetch.bind(handler)')); + expect(runVerify(cwd).wired).toBe(false); + }); +}); + +describe('version-aware Next proxy',()=>{ + it('creates proxy, not middleware, on Next 16 and retains Next 15 middleware',()=>{ + for(const major of [15,16]) { + const cwd=fixture({next:`^${major}.0.0`}); + expect(runProtect(cwd).status).toBe('wired'); + expect(existsSync(join(cwd,major===16?'proxy.ts':'middleware.ts'))).toBe(true); + expect(existsSync(join(cwd,major===16?'middleware.ts':'proxy.ts'))).toBe(false); + expect(runVerify(cwd).wired).toBe(true); + } + }); + it.each(['proxy.ts','proxy.js','src/proxy.ts','src/proxy.js'])('preserves an existing scoped %s',file=>{ + const cwd=fixture({next:'^16.0.0'}); + put(cwd,file,'export function proxy(request) { return Response.redirect(new URL("/login",request.url)); }\nexport const config={matcher:"/admin/:path*"};'); + runProtect(cwd); + expect(read(cwd,file)).toContain('getPatchstackProtection'); + expect(read(cwd,file)).toContain('/admin'); + expect(runVerify(cwd).wired).toBe(true); + const source=read(cwd,file); runProtect(cwd); expect(read(cwd,file)).toBe(source); + }); + it('refuses conflicting entries and custom URL-normalization flags',()=>{ + const cwd=fixture({next:'^16.0.0'}); + const source='export function proxy(request) { return; }'; + put(cwd,'proxy.ts',source); put(cwd,'next.config.ts','export default { skipProxyUrlNormalize: true };'); + runProtect(cwd); expect(read(cwd,'proxy.ts')).toBe(source); expect(runVerify(cwd).wired).toBe(false); + put(cwd,'middleware.ts','export function middleware() {}'); + expect(runProtect(cwd).changed).toEqual([]); + }); + it('guards proxy requests outside the original application matcher',()=>{ + const source='export function proxy(request) { return Response.redirect(new URL("/login", request.url)); }\nexport const config = { matcher: "/admin/:path*" };'; + const result=composeNextMiddleware(ts,'proxy.ts',source,'./guard')!; + expect(result.indexOf('if (psBlocked) return psBlocked')).toBeLessThan(result.indexOf('new RegExp(pattern)')); + }); +}); diff --git a/tests/protect/install.test.ts b/tests/protect/install.test.ts index ff8ffc24..84e40211 100644 --- a/tests/protect/install.test.ts +++ b/tests/protect/install.test.ts @@ -72,7 +72,7 @@ describe('runProtect scaffolder', () => { expect(start).toContain('const patchstackFunctionGuard ='); // response screening is wired on the non-tunnel path // With the request: a response rule scoped to a route or method cannot apply that scope without it. - expect(start).toContain('return screenResponse(await next(), request);'); + expect(start).toContain('response: await screenResponse(result.response, request)'); // guards registered FIRST, existing middleware kept expect(start).toContain('requestMiddleware: [patchstackGuard, errorMiddleware]'); expect(start).toContain('functionMiddleware: [patchstackFunctionGuard, attachSupabaseAuth]'); @@ -87,11 +87,11 @@ describe('runProtect scaffolder', () => { expect(guard).toContain('export async function screenResponse(response: T, request?: Request): Promise'); }); - it('scaffolds the opt-in route-level WAF (gated on PATCHSTACK_ROUTE_WAF)', () => { + it('screens native routes without requiring an extra environment switch', () => { runProtect(dir); const start = read(dir, 'src/start.ts'); const guard = read(dir, 'src/integrations/patchstack/guard.ts'); - expect(start).toContain('process.env.PATCHSTACK_ROUTE_WAF === "1"'); + expect(start).not.toContain('PATCHSTACK_ROUTE_WAF'); expect(start).toContain('const blocked = await guardRequest(request);'); expect(guard).toContain('export async function guardRequest('); }); @@ -164,7 +164,7 @@ export const startInstance = createStart(() => ({ const start = read(dir, 'src/start.ts'); // upgraded in place: route-WAF hook + guardRequest import now present, wrapped in markers expect(start).toContain('// #region patchstack-guard '); - expect(start).toContain('process.env.PATCHSTACK_ROUTE_WAF === "1"'); + expect(start).not.toContain('PATCHSTACK_ROUTE_WAF'); expect(start).toContain('const blocked = await guardRequest(request);'); expect(start).toMatch(/import \{[^}]*guardRequest[^}]*\} from "@\/integrations\/patchstack\/guard";/); // no duplication, old comment header gone, registrations intact diff --git a/tests/protect/protect-install.test.ts b/tests/protect/protect-install.test.ts index 090f1629..10b2fac0 100644 --- a/tests/protect/protect-install.test.ts +++ b/tests/protect/protect-install.test.ts @@ -13,7 +13,7 @@ beforeEach(() => { 'import { createStart, createMiddleware } from "@tanstack/react-start";\n' + 'export const startInstance = createStart(() => ({ functionMiddleware: [], requestMiddleware: [] }));\n'); writeFileSync(join(dir, 'src/integrations/supabase/client.ts'), - "const headers = new Headers();\n headers.set('apikey', supabaseKey);\n"); + "function createSupabaseFetch(supabaseKey: string) { return (input: RequestInfo, init?: RequestInit) => {\nconst headers = new Headers();\nheaders.set('apikey', supabaseKey);\nreturn fetch(input, { ...init, headers });\n}; }\n"); // A real site UUID, matching what `patchstack-connect scan` actually writes. writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: '3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f' })); }); diff --git a/tests/protect/protection-init.test.ts b/tests/protect/protection-init.test.ts index 63603f01..e1e2d909 100644 --- a/tests/protect/protection-init.test.ts +++ b/tests/protect/protection-init.test.ts @@ -111,7 +111,11 @@ describe('every scaffolded guard', () => { if (!/createProtection\(/.test(source)) continue; expect(source, name).not.toMatch(/\b_?protection\s*=\s*await createProtection\(/); - expect(source, name).toMatch(/\b_?protection\s*=\s*buildProtection\(\)/); + if (name === 'fetch-guard.ts') { + expect(source).toContain('pending = createProtection('); + expect(source).toContain('policies.set(env, pending)'); + expect(source).toContain('policies.delete(env)'); + } else expect(source, name).toMatch(/\b_?protection\s*=\s*buildProtection\(\)/); } }); From 0c053ceb7136437baa69b135312924a46beae161 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 1 Oct 2026 20:39:36 +0200 Subject: [PATCH 2/4] test(protect): exercise composed and fresh Next proxy builds --- scripts/next-consumer.mjs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/next-consumer.mjs b/scripts/next-consumer.mjs index 90bd90eb..d29bf5ef 100644 --- a/scripts/next-consumer.mjs +++ b/scripts/next-consumer.mjs @@ -210,22 +210,33 @@ try { await serve(base => probes(base, true)); if (major === '16') { - // A real proxy build proves refusal does not leave a conflicting middleware behind. + // A real proxy build must preserve routing and screen requests without competing middleware. rmSync(path.join(app, 'src/middleware.ts')); const proxy = middleware.replace('function middleware(', 'function proxy('); put('src/proxy.ts', proxy); - assert.match(run(process.execPath, [...cli, 'protect']), /proxy integration requires manual wiring/); - assert.equal(read('src/proxy.ts'), proxy); + assert.match(run(process.execPath, [...cli, 'protect']), /composed src\/proxy.ts/); + const composedProxy = read('src/proxy.ts'); + assert.match(composedProxy, /patchstack-next-composed/); assert.equal(existsSync(path.join(app, 'src/middleware.ts')), false); - assert.throws(() => run(process.execPath, [...cli, 'protect', '--check']), /proxy wiring requires manual verification/); + run(process.execPath, [...cli, 'protect']); + assert.equal(read('src/proxy.ts'), composedProxy); + assert.match(run(process.execPath, [...cli, 'protect', '--check']), /guard is wired/); + build(); + await serve(base => probes(base, true)); + console.log('PASS Next proxy: preserved routing, request screening, and no competing middleware'); + + // A fresh Next 16 app gets a proxy too. No application routing is invented by this scaffold. + rmSync(path.join(app, 'src/proxy.ts')); + run(process.execPath, [...cli, 'protect']); + assert.equal(existsSync(path.join(app, 'src/middleware.ts')), false); + assert.match(read('src/proxy.ts'), /export async function proxy/); + assert.match(run(process.execPath, [...cli, 'protect', '--check']), /guard is wired/); build(); await serve(async base => { - assert.equal((await fetch(base + '/members', { redirect: 'manual' })).status, 307); - assert.match(await (await fetch(base + '/tenant')).text(), /Tenant fixture/); - // The unchanged proxy does not claim protection; already-composed routes still enforce rules. - assert.equal((await fetch(base + '/api/contact', { method: 'POST', body: 'synthetic-deny' })).status, 403); + assert.equal((await fetch(base)).status, 200); + assert.equal((await fetch(base, { method:'POST', body:'synthetic-deny' })).status, 403); }); - console.log('PASS Next proxy: unchanged routing, no competing middleware, explicit manual-integration gap'); + console.log('PASS new Next proxy: default scaffold builds and screens requests'); } } catch (error) { console.error(String(error.stack ?? error).replaceAll(scratch, '').replaceAll(root, '')); From 50ff4612edd1477ba9eb12e452dcf440fcdd25e8 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 1 Oct 2026 20:43:34 +0200 Subject: [PATCH 3/4] ci: require the real TanStack consumer contract check --- .github/workflows/ci.yml | 22 ++++++++++++++++++++-- scripts/tanstack-consumer.mjs | 14 ++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a696fb4..16e4f49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -381,6 +381,23 @@ jobs: echo "consuming ${tarball} on $(node -v), engine-strict on" node scripts/compat-matrix.mjs --manager npm --self-contained --tarball "${tarball}" + tanstack-consumer: + name: TanStack Start entry and middleware types + needs: pack + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + - uses: actions/download-artifact@v8 + with: + name: packed-tarball + path: packed + - name: Install and check the real TanStack framework contract + run: node scripts/tanstack-consumer.mjs --tarball packed/*.tgz + next-consumers: name: Next.js ${{ matrix.next }} (${{ matrix.bundler }}) production integration needs: pack @@ -430,6 +447,7 @@ jobs: - validate - production-audit - next-consumers + - tanstack-consumer runs-on: ubuntu-latest steps: @@ -447,8 +465,8 @@ jobs: # otherwise leave a green required check that verifies nothing at all — the one failure mode a # gate must not have, since it is indistinguishable from a working one. count=$(printf '%s' "$RESULTS" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))') - if [ "$count" -lt 9 ]; then - echo "::error::This gate is standing on ${count} job(s); it is meant to require 9. A required check that verifies nothing passes exactly when something is broken." + if [ "$count" -lt 10 ]; then + echo "::error::This gate is standing on ${count} job(s); it is meant to require 10. A required check that verifies nothing passes exactly when something is broken." exit 1 fi diff --git a/scripts/tanstack-consumer.mjs b/scripts/tanstack-consumer.mjs index 48440f77..867ea311 100644 --- a/scripts/tanstack-consumer.mjs +++ b/scripts/tanstack-consumer.mjs @@ -1,6 +1,6 @@ -// Optional networked consumer check: only synthetic source and a local package tarball are used. +// Synthetic source against the real public framework and the package artifact; no live API calls. import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -9,9 +9,11 @@ const scratch = mkdtempSync(join(tmpdir(), 'ps-tanstack-consumer-')); const write = (file, text) => { mkdirSync(dirname(join(scratch,file)),{recursive:true}); writeFileSync(join(scratch,file),text); }; const run = (command,args,cwd=scratch) => execFileSync(command,args,{cwd,stdio:'pipe',timeout:180000}); try { - const packed = JSON.parse(run('npm',['pack','--ignore-scripts','--json','--pack-destination',scratch],root).toString())[0].filename; + const tarballFlag = process.argv.indexOf('--tarball'); + const packed = tarballFlag >= 0 ? resolve(process.argv[tarballFlag + 1]) + : join(scratch,JSON.parse(run('npm',['pack','--ignore-scripts','--json','--pack-destination',scratch],root).toString())[0].filename); write('package.json',JSON.stringify({private:true,type:'module',dependencies:{ - '@patchstack/connect':`file:./${packed}`, '@tanstack/react-start':'1.168.60', react:'^19.0.0', 'react-dom':'^19.0.0', + '@patchstack/connect':`file:${packed}`, '@tanstack/react-start':'1.168.60', react:'^19.0.0', 'react-dom':'^19.0.0', typescript:'5.9.3', '@types/react':'^19.0.0', '@types/react-dom':'^19.0.0', '@types/node':'^22.0.0', }})); run('npm',['install','--ignore-scripts','--no-audit','--no-fund']); @@ -36,7 +38,7 @@ export const startInstance = createStart(() => ({requestMiddleware: []}));\n`); run(process.execPath,[tsc,'--noEmit']); console.log('TanStack consumer: native entry and middleware composition passed against real framework types.'); } catch (error) { - console.error(error.stdout?.toString() ?? error.message); - console.error(error.stderr?.toString() ?? ''); + console.error((error.stdout?.toString() ?? error.message).replaceAll(scratch,'').replaceAll(root,'')); + console.error((error.stderr?.toString() ?? '').replaceAll(scratch,'').replaceAll(root,'')); process.exitCode=1; } finally { rmSync(scratch,{recursive:true,force:true}); } From 1f1dba19ab45be935ac369420a735b94d09f5fdb Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Fri, 2 Oct 2026 08:09:40 +0200 Subject: [PATCH 4/4] fix(protect): preserve unresolved TanStack server entries --- scripts/tanstack-consumer.mjs | 23 ++++++- src/protect/install/adapters/tanstack.ts | 72 ++++++++++++++++++--- tests/protect/framework-entries.test.ts | 79 +++++++++++++++++++++++- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/scripts/tanstack-consumer.mjs b/scripts/tanstack-consumer.mjs index 867ea311..32dcf770 100644 --- a/scripts/tanstack-consumer.mjs +++ b/scripts/tanstack-consumer.mjs @@ -1,5 +1,6 @@ // Synthetic source against the real public framework and the package artifact; no live API calls. -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import assert from 'node:assert/strict'; import { join, dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -20,6 +21,26 @@ try { const cli = join(scratch,'node_modules/@patchstack/connect/dist/cli.js'); const tsc = join(scratch,'node_modules/typescript/bin/tsc'); const flags = ['--noEmit','--strict','--skipLibCheck','--module','ESNext','--moduleResolution','Bundler','--target','ES2022','--resolveJsonModule','--esModuleInterop','--lib','ES2022,DOM']; + const config = `import {defineConfig} from 'vite'; +import {tanstackStart} from '@tanstack/react-start/plugin/vite'; +export default defineConfig({plugins:[tanstackStart()]});`; + write('vite.config.ts', config); + for (const extension of ['mts', 'mjs']) { + const file = `src/server.${extension}`; + const source = 'export default {fetch() {return new Response("Unauthorized", {status:401});}};'; + write(file, source); + run(process.execPath,[cli,'protect']); + assert.equal(existsSync(join(scratch,'src/server.ts')), false, 'must not shadow the existing server'); + assert.equal(readFileSync(join(scratch,file),'utf8'), source); + assert.throws(() => run(process.execPath,[cli,'protect','--check'])); + rmSync(join(scratch,file)); + } + write('start-options.ts','export default {srcDirectory:"web"};'); + write('vite.config.ts',"import options from './start-options';\n" + config.replace('tanstackStart()', 'tanstackStart(options)')); + run(process.execPath,[cli,'protect']); + assert.equal(existsSync(join(scratch,'src/server.ts')), false, 'must not assume an imported configuration uses src/server.ts'); + assert.throws(() => run(process.execPath,[cli,'protect','--check'])); + write('vite.config.ts',config); run(process.execPath,[cli,'protect']); run(process.execPath,[cli,'protect','--check']); run(process.execPath,[tsc,...flags,'src/server.ts']); diff --git a/src/protect/install/adapters/tanstack.ts b/src/protect/install/adapters/tanstack.ts index 8e67886a..27b3ad7a 100644 --- a/src/protect/install/adapters/tanstack.ts +++ b/src/protect/install/adapters/tanstack.ts @@ -6,7 +6,7 @@ import { sourceCompiler, parsedSource, type Compiler } from '../syntax.js'; import { installTemplate } from '../template-upgrade.js'; import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js'; import type { Adapter } from '../types.js'; -import { templateWiringPresent } from '../seam.js'; +import { matchesGuardTemplate } from '../template-match.js'; const ENTRY = 'src/server.ts'; const GUARD = 'src/patchstack/guard.ts'; @@ -14,6 +14,54 @@ const IMPORT = 'import { protectFetch } from "./patchstack/guard";'; const DEFAULT = `import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; export default createServerEntry({ fetch: handler.fetch.bind(handler) }); `; +const EXTENSIONS = ['ts', 'js', 'mts', 'mjs', 'tsx', 'jsx', 'cts', 'cjs']; + +/** Prove the default entry from literal configuration without executing application code. */ +function defaultEntryConfig(ts: Compiler, file: string, source: string): boolean { + const tree = parsedSource(ts, file, source); + if (!tree) return false; + const binding = (module: string, name: string) => tree.statements.filter(ts.isImportDeclaration) + .filter(n => ts.isStringLiteral(n.moduleSpecifier) && n.moduleSpecifier.text === module && !n.importClause?.isTypeOnly) + .flatMap(n => n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings) + ? n.importClause.namedBindings.elements.filter(e => !e.isTypeOnly && (e.propertyName ?? e.name).text === name).map(e => e.name.text) : []); + const defineConfig = binding('vite', 'defineConfig'); + const start = binding('@tanstack/react-start/plugin/vite', 'tanstackStart'); + const unwrap = (expression: import('typescript').Expression): import('typescript').Expression => { + while (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) expression = expression.expression; + return expression; + }; + const exports = tree.statements.filter(ts.isExportAssignment); + if (exports.length !== 1 || exports[0]!.isExportEquals) return false; + let config = unwrap(exports[0]!.expression); + if (ts.isCallExpression(config)) { + if (!ts.isIdentifier(config.expression) || !defineConfig.includes(config.expression.text) || config.arguments.length !== 1) return false; + config = unwrap(config.arguments[0]!); + } + if (!ts.isObjectLiteralExpression(config)) return false; + let unsafe = false; + 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(); + 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; + names.add(name); + } + } + ts.forEachChild(node, visit); + }; + visit(config); + if (unsafe) return false; + const plugins = config.properties.find(p => ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === 'plugins'); + if (!plugins || !ts.isPropertyAssignment(plugins) || !ts.isArrayLiteralExpression(plugins.initializer)) return false; + const calls = plugins.initializer.elements.map(unwrap).filter(n => ts.isCallExpression(n) && ts.isIdentifier(n.expression) && start.includes(n.expression.text)); + if (calls.length !== 1) return false; + const call = calls[0]!; + return ts.isCallExpression(call) && (call.arguments.length === 0 + || (call.arguments.length === 1 && ts.isObjectLiteralExpression(unwrap(call.arguments[0]!)))); +} function entryProblem(cwd: string): string | null { try { @@ -21,14 +69,18 @@ function entryProblem(cwd: string): string | null { if (!JSON.parse(read(file)).exports?.['./server-entry']) return 'the installed TanStack Start version does not expose server-entry'; } catch { return 'the installed TanStack Start version must expose its server-entry contract'; } - if (['src/server.js', 'src/server.tsx', 'src/server.jsx', 'server.ts', 'server.js'].some(file => existsSync(join(cwd, file)))) { + const entries = ['src/server', 'server', ...EXTENSIONS.flatMap(ext => [`src/server.${ext}`, `server.${ext}`])]; + if (entries.some(file => file !== ENTRY && existsSync(join(cwd, file)))) { return 'non-default or competing server entries require manual integration'; } - // A custom source directory or server entry can make src/server.ts an unused file. - for (const file of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'app.config.ts']) { - if (!existsSync(join(cwd, file))) continue; - const source = read(join(cwd, file)); - if (/\b(?:srcDirectory|serverEntry|entry|server|root)\s*:|\.\.\./.test(source)) return 'custom Vite/Start entry configuration requires manual integration'; + const configs = ['vite', 'app', 'rsbuild'].flatMap(name => EXTENSIONS.map(ext => `${name}.config.${ext}`)) + .filter(file => existsSync(join(cwd, file))); + if (configs.length) { + const ts = sourceCompiler(cwd); + if (configs.length !== 1 || !configs[0]!.startsWith('vite.') || !ts + || !defaultEntryConfig(ts, configs[0]!, read(join(cwd, configs[0]!)))) { + return 'the default server entry cannot be established from this configuration; integrate it manually'; + } } return null; } @@ -97,6 +149,10 @@ export const tanstackAdapter: Adapter = { const changed: string[] = []; ensureProjectDirectorySync(cwd, join(cwd, 'src/patchstack')); if (installTemplate(cwd, GUARD, 'fetch-guard.ts')) changed.push(GUARD); + if (!matchesGuardTemplate(cwd, GUARD, 'fetch-guard.ts')) { + log('Custom Fetch helper needs manual review; the server entry was left untouched.'); + return {ok:false,changed}; + } const rules = 'src/patchstack/rules.json'; if (opts.demo || !existsSync(join(cwd,rules))) { copyProjectFileSync(cwd, join(templatesDir(),opts.demo ? 'demo-rules.json' : 'rules.json'),join(cwd,rules)); @@ -114,7 +170,7 @@ export const tanstackAdapter: Adapter = { const ts = sourceCompiler(cwd); const problem = entryProblem(cwd); const wired = !problem && !!ts && existsSync(join(cwd,ENTRY)) && wiredEntry(ts,read(join(cwd,ENTRY))) - && existsSync(join(cwd,GUARD)) && templateWiringPresent(cwd,GUARD,'fetch-guard.ts') && existsSync(join(cwd,'src/patchstack/rules.json')); + && matchesGuardTemplate(cwd,GUARD,'fetch-guard.ts') && existsSync(join(cwd,'src/patchstack/rules.json')); return {wired,checks:[{label:'TanStack server Fetch boundary wired',ok:wired,hint:problem ?? 'run protect; manually review unsupported custom server entries'}, {label:'browser-direct services and separately deployed functions are not covered by this entry',ok:true,unverifiable:true,hint:'install protection at each independently exposed backend; retain backend authorization and RLS'}]}; }, diff --git a/tests/protect/framework-entries.test.ts b/tests/protect/framework-entries.test.ts index 1f47f453..778f424e 100644 --- a/tests/protect/framework-entries.test.ts +++ b/tests/protect/framework-entries.test.ts @@ -21,6 +21,9 @@ function tanstack() { put(cwd,'node_modules/@tanstack/react-start/package.json',JSON.stringify({name:'@tanstack/react-start',exports:{'./package.json':'./package.json','./server-entry':{import:'./server.js'}}})); return cwd; } +const viteConfig = (options = '') => `import {defineConfig} from 'vite'; +import {tanstackStart} from '@tanstack/react-start/plugin/vite'; +export default defineConfig({plugins:[tanstackStart(${options})]});`; afterEach(()=>{ dirs.splice(0).forEach(cwd=>rmSync(cwd,{recursive:true,force:true})); vi.restoreAllMocks(); }); describe('native TanStack server boundary',()=>{ @@ -58,10 +61,84 @@ describe('native TanStack server boundary',()=>{ put(cwd,'vite.config.ts','export default { server: { entry: "custom.ts" } };'); expect(runProtect(cwd).status).toBe('scaffolded'); expect(existsSync(join(cwd,'src/server.ts'))).toBe(false); - put(cwd,'vite.config.ts','export default {};'); runProtect(cwd); + put(cwd,'vite.config.ts',viteConfig()); runProtect(cwd); put(cwd,'src/server.ts',read(cwd,'src/server.ts').replace('fetch: protectFetch(handler.fetch.bind(handler))','fetch: handler.fetch.bind(handler)')); expect(runVerify(cwd).wired).toBe(false); }); + + it.each(['js','mts','mjs','tsx','jsx','cts','cjs'])('never shadows an existing server.%s entry', ext => { + const cwd = tanstack(); + const source = 'export default { fetch() { return new Response("Unauthorized", {status:401}); } };'; + put(cwd, `src/server.${ext}`, source); + put(cwd, 'vite.config.ts', viteConfig()); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(existsSync(join(cwd, 'src/server.ts'))).toBe(false); + expect(read(cwd, `src/server.${ext}`)).toBe(source); + expect(runVerify(cwd).wired).toBe(false); + }); + + it.each([ + viteConfig('options'), + viteConfig('getOptions()'), + viteConfig('{...options}'), + viteConfig('{srcDirectory}'), + viteConfig('{"srcDirectory":"web"}'), + viteConfig('{[key]:"web"}'), + viteConfig('{server:{entry:"custom"}}'), + viteConfig().replace('defineConfig({plugins:[tanstackStart()]})', 'defineConfig(config)'), + viteConfig().replace('defineConfig({plugins:[tanstackStart()]})', 'defineConfig(() => ({plugins:[tanstackStart()]}))'), + viteConfig().replace('plugins:[tanstackStart()]', '...config,plugins:[tanstackStart()]'), + viteConfig().replace('plugins:[tanstackStart()]', 'plugins:plugins'), + viteConfig().replace('plugins:[tanstackStart()]', 'plugins:[...plugins,tanstackStart()]'), + 'export {default} from "./shared-config";', + ])('does not assume default entries from dynamic configuration: %s', config => { + const cwd = tanstack(); + put(cwd, 'vite.config.ts', "import options from './start-options';\n" + config); + put(cwd, 'start-options.ts', 'export default {srcDirectory:"web"};'); + const source = 'export default {fetch(){ return new Response("custom"); }};'; + put(cwd, 'web/server.ts', source); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(existsSync(join(cwd, 'src/server.ts'))).toBe(false); + expect(read(cwd, 'web/server.ts')).toBe(source); + expect(runVerify(cwd).wired).toBe(false); + }); + + it.each(['ts','js','mts','mjs'])('accepts literal defaults and import aliases in vite.config.%s', ext => { + const cwd = tanstack(); + const config = viteConfig('{}').replace('{defineConfig}', '{defineConfig as config}').replace('defineConfig(', 'config(') + .replace('{tanstackStart}', '{tanstackStart as start}').replace('tanstackStart(', 'start('); + put(cwd, `vite.config.${ext}`, config); + expect(runProtect(cwd).status).toBe('wired'); + expect(runVerify(cwd).wired).toBe(true); + }); + + 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");'); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(existsSync(join(cwd, 'src/server.ts'))).toBe(false); + }); + + it('invalidates verification if configuration changes after wiring', () => { + const cwd = tanstack(); + put(cwd, 'vite.config.ts', viteConfig()); + expect(runProtect(cwd).status).toBe('wired'); + const entry = read(cwd, 'src/server.ts'); + put(cwd, 'vite.config.ts', viteConfig('options')); + expect(runVerify(cwd).wired).toBe(false); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(read(cwd, 'src/server.ts')).toBe(entry); + }); + + it('does not create an entry that imports a custom Fetch helper', () => { + const cwd = tanstack(); + const helper = 'export const protectFetch = (handler: unknown) => handler;'; + put(cwd, 'src/patchstack/guard.ts', helper); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(existsSync(join(cwd, 'src/server.ts'))).toBe(false); + expect(read(cwd, 'src/patchstack/guard.ts')).toBe(helper); + expect(runVerify(cwd).wired).toBe(false); + }); }); describe('version-aware Next proxy',()=>{