Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Runtime rule polling also has a request-triggered check, for hosts that freeze i
- **Mapping is automatic in `setup`, in its prebuild hook, and in the explicitly opted-in `dev -- <command>` wrapper.** It parses server source and sends structure, not source text. A standalone `map` command prints locally and uploads only with `--upload`. `protect`, `scan`, `guide`, `status` and `mark-build` do not invoke mapping.
- **`scan` makes up to three source edits:** the Patchstack Connector's `<script>` tag, the production marker, and — during `prebuild` only — the existing guard rules file, where it removes a previous `_patchstack.build_id` and records `_patchstack.manifest_checksum`, the checksum of the package list being built. None runs on `--dry-run`; all are idempotent. `"widget": false` disables only the first; the production marker and the rules-file edit are independent, the marker because Patchstack needs it to recognise the live build, stamp removal because it prevents old coordinates being attributed to a new build, and the checksum because the deployed guard sends it to name the build it runs.
- 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/`, `_site/`), never to source. `guide` writes nothing, `init` writes only its own `.patchstackrc.json`, and `status` writes only the connected note in `.patchstackrc.json`. `guide` sends nothing either: it reports a site as connected from that note, which `scan`, `status` and `claim` keep current.
- 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 marks only a production build. In any other build the same block sets `window.__PATCHSTACK_ENV__="sandbox"` instead, which tells the widget it is running in a preview or on a dev server, so a builder's preview shows the owner's panel. 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/`, `_site/`), never to source. `guide` writes nothing, `init` writes only its own `.patchstackrc.json`, and `status` writes only the connected note in `.patchstackrc.json`. `guide` sends nothing either: it reports a site as connected from that note, which `scan`, `status` and `claim` keep current.
- **`setup` runs `scan` → `protect` → map upload → live-rule lookup**, then reports the outcome. Provisioning precedes guard installation. The map is stamped into the guard source for the NEXT startup/build; restart an already-running preview/server to load it. The rule lookup uses the runtime validator and source-scoped local cache without creating a running guard or installing global hooks. Pending, ready-empty, and unknown generation states are distinguished; none proves the running app loaded the guard. Failed uploads/pulls appear under Missing and can be retried by rerunning setup. It also wires install scans, prebuild scans + map uploads, and postbuild marking (explicit build chains for Yarn, pnpm and Bun), preserving existing commands. It never starts, builds or deploys the app. In a Lovable or Replit project, or with explicit `--dev-sync`, recognizable single-command development scripts are wrapped for continuous synchronization; other scripts are left untouched with a manual command suggestion. Ambiguous/custom integration code still requires review rather than being overwritten.
- 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 `src/start.ts` to screen requests, responses and server-function arguments, and the Supabase client to tunnel browser traffic through the guard when the client has the layout it recognises — otherwise the client is left untouched and `protect --check` lists that browser-to-Supabase traffic as not covered; when `src/start.ts` cannot be patched either, the TanStack Start server entry below is used), **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; a handler it cannot compose is left unchanged and listed by `protect --check` as optional coverage, since the middleware already screens every request), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **React Router** (framework mode — a `.server` guard wired from the root route's `middleware` export, the seam Hostinger Horizons and similar builders leave writable), **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). Lines marked `!` are optional coverage the guard works without; they do not fail the check. 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 Patchstack for JavaScript 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.
Expand Down
18 changes: 11 additions & 7 deletions src/mark-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,23 +323,27 @@ export function buildSourceMarkerSnippet(
if (checksum !== null && checksum !== '') {
statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
}
// A development build is never the published site: it is what a builder's preview, or a local dev server,
// runs. It carries the sandbox stamp the widget reads (the same one `mark-build` writes into a sandbox
// build's HTML), so a preview on a host the widget does not know still shows the owner's panel.
const sandbox = 'window.__PATCHSTACK_ENV__="sandbox";';
if (framework === ASTRO) {
// `is:inline` keeps Astro from bundling the script into a module, which would run after the
// deferred widget instead of before it.
return (
`{${gate} && (\n` +
`{${gate} ? (\n` +
` <script is:inline ${MARKER_ATTR}="true">${statements.join('')}</script>\n` +
`) : (\n` +
` <script is:inline ${MARKER_ATTR}="true">${sandbox}</script>\n` +
`)}`
);
}
return (
`{${gate} && (\n` +
const script = (body: string) =>
` <script\n` +
` ${MARKER_ATTR}="true"\n` +
` dangerouslySetInnerHTML={{ __html: '${statements.join('')}' }}\n` +
` />\n` +
`)}`
);
` dangerouslySetInnerHTML={{ __html: '${body}' }}\n` +
` />\n`;
return `{${gate} ? (\n${script(statements.join(''))}) : (\n${script(sandbox)})}`;
}

/** Global the widget reads to decide it is running on a published build. */
Expand Down
2 changes: 1 addition & 1 deletion tests/guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ describe('guide', () => {
expect(renderGuideChecklist(state, false)).toContain('Your live app does not tell Patchstack it is live yet');
const output = renderGuideChecklist(state, false, {}, { verbose: true });
expect(output).toContain('Run: npx @patchstack/connect scan');
expect(output).toContain('import.meta.env.PROD &&');
expect(output).toContain('import.meta.env.PROD ?');
expect(output).toContain('window.__PATCHSTACK_PROD__=true;');
});

Expand Down
21 changes: 17 additions & 4 deletions tests/mark-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,22 @@ describe('buildSourceMarkerSnippet', () => {
it('sets the marker the widget reads, behind the framework production gate', () => {
const snippet = buildSourceMarkerSnippet('tanstack-start');
expect(snippet).toContain('window.__PATCHSTACK_PROD__=true;');
expect(snippet).toContain('import.meta.env.PROD &&');
expect(snippet).toContain('import.meta.env.PROD ?');
expect(snippet).toContain(MARKER_ATTR);
});

it('stamps a development build as a sandbox, and never marks it as the published site', () => {
for (const framework of ['tanstack-start', 'next', 'remix', 'react-router', 'gatsby', 'astro']) {
const snippet = buildSourceMarkerSnippet(framework, 'abcdef123456');
const otherwise = snippet.indexOf(') : (');
expect(otherwise, framework).toBeGreaterThan(snippet.indexOf('__PATCHSTACK_PROD__'));
const development = snippet.slice(otherwise);
expect(development, framework).toContain('window.__PATCHSTACK_ENV__="sandbox";');
expect(development, framework).not.toContain('__PATCHSTACK_PROD__');
expect(development, framework).not.toContain('__PATCHSTACK_BUILD__');
}
});

it('never emits an ungated marker, which would also hide the claim flow in dev', () => {
for (const framework of ['tanstack-start', 'next', 'remix', 'react-router', 'gatsby']) {
const snippet = buildSourceMarkerSnippet(framework);
Expand Down Expand Up @@ -380,7 +392,7 @@ describe('ensureMarkerInJsxShell', () => {
const { source, action } = ensureMarkerInJsxShell(doc(widgetLine), 'tanstack-start');
expect(action).toBe('added');
expect(source.indexOf('__PATCHSTACK_PROD__')).toBeLessThan(source.indexOf('patchstack-widget'));
expect(source).toContain('import.meta.env.PROD &&');
expect(source).toContain('import.meta.env.PROD ?');
});

it('falls back to <head> when no widget tag is present yet', () => {
Expand Down Expand Up @@ -619,8 +631,9 @@ describe('ensureMarkerInJsxShell on an Astro layout', () => {

expect(action).toBe('added');
expect(source).toContain(
'{import.meta.env.PROD && (\n <script is:inline data-patchstack-build="true">' +
'window.__PATCHSTACK_PROD__=true;window.__PATCHSTACK_BUILD__="ac749db1ff30";</script>\n )}',
'{import.meta.env.PROD ? (\n <script is:inline data-patchstack-build="true">' +
'window.__PATCHSTACK_PROD__=true;window.__PATCHSTACK_BUILD__="ac749db1ff30";</script>\n ) : (\n' +
' <script is:inline data-patchstack-build="true">window.__PATCHSTACK_ENV__="sandbox";</script>\n )}',
);
expect(source.indexOf('__PATCHSTACK_PROD__')).toBeGreaterThan(source.indexOf(' <head>'));
expect(source.indexOf('__PATCHSTACK_PROD__')).toBeLessThan(source.indexOf('<BaseHead />'));
Expand Down
Loading