From 23e736caa0f23639708df987addcc88552178d3b Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Fri, 11 Sep 2026 10:31:43 +0100 Subject: [PATCH 1/2] Install the widget into a JSX root, instead of asking somebody to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server-rendered project never produces an HTML shell, so `scan` had nothing it was willing to edit and fell back to printing the tag with "add this yourself". `guide` then listed it as a remaining step and setup finished successfully. That instruction is reliably missed. A hosted builder's agent runs setup, reads it, stops, and the published site carries no widget at all — which is the one thing that could have told Patchstack the site is live. Nothing downstream can tell that apart from an app that was never set up: the manifests arrive, the packages are watched, and the dashboard says it has never seen the live page. The tag needs no translation to get there. JSX reads `` as an element with a boolean `defer`, which is what the HTML form means — and it is already the exact snippet `guide` prints for these roots, so this writes what somebody following the instructions would have typed. Anchoring is the existing `` insertion, which finds the same anchor in JSX, so managed-tag updates, adoption of a hand-placed tag and idempotence all come along unchanged. Only the roots where a literal tag is known to belong — the set that already takes a literal production marker. Nuxt's `useHead` and an Astro layout are not script tags, and a wrong snippet there costs more than an accurate sentence. A real HTML shell still wins where a project has both: it is the document that gets served, and the JSX root only renders into it. Unlike the marker, the tag is not behind the production gate. The marker claims the site is live; the widget is how an owner claims the site in the first place, and that happens in the preview. Co-Authored-By: Claude Opus 5 --- AGENT-INSTALL.md | 2 +- README.md | 1 + src/cli.ts | 16 ++++--- src/widget.ts | 37 +++++++++++++--- tests/widget.test.ts | 103 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 13 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index f3ab3efa..36db5057 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -120,7 +120,7 @@ This is a request, not a mechanism: nothing in the install depends on it. Do it **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && && patchstack-connect mark-build"`. -3. **Verify the disclosure widget** — a floating "Report a vulnerability" button. `scan` installs it automatically into a plain HTML shell, and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell (frameworks whose root layout is code, e.g. Next.js/Nuxt/Astro) add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: +3. **Verify the disclosure widget** — a floating "Report a vulnerability" button. `scan` installs it automatically into a plain HTML shell **or a JSX root** (Next, Remix, React Router, TanStack Start, Gatsby), and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell at all — a root whose head mechanism is not a plain script tag, e.g. Nuxt's `useHead` or an Astro layout — add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: ```html diff --git a/README.md b/README.md index 8921b05a..1a40d0c8 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ The widget is a floating "Report a vulnerability" button — a disclosure channe ``` +- **`scan`** installs the widget tag into a plain HTML shell, and — where there is none — into a JSX root (`src/routes/__root.tsx`, `app/layout.tsx`, …), just before ``. The same tag serves both: JSX reads `defer` as a boolean attribute and passes `data-*` through. A server-rendered app has no HTML shell at all, so without this its published site carries no widget and Patchstack never hears from the live page. - **`scan`** also adds the production marker when the root shell is JSX rather than HTML (`src/routes/__root.tsx`, `app/layout.tsx`, …), above the widget tag and guarded by the framework's production expression. A server-rendered app emits no built HTML for `mark-build` to stamp, so without it the widget reads the published site as build mode and shows the claim flow to visitors instead of the report form. Re-runs update the tag in place (the `data-patchstack-connect-widget` attribute marks it as connector-managed); a pre-existing manual widget tag is left untouched. `--dry-run` never edits anything; a failed post still skips the widget tag (it needs the site UUID) but the production marker may already have been written, since it runs before the post. Projects whose root layout is code rather than HTML (Next.js, Nuxt, Astro, …) get the exact snippet and target file printed instead — `guide` shows framework-specific placement. diff --git a/src/cli.ts b/src/cli.ts index 1ba4b1d9..3e8aab8b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -715,8 +715,9 @@ async function runScan( // server round-trip, while `scan` is commonly chained as `scan || true`, so a // failed or offline post must not be what decides whether a published build // gets its production flag. --dry-run has already returned by here. + const shellFramework = detectStack(payload.packages).framework; if (config.widget) { - reportSourceMarker(detectStack(payload.packages).framework); + reportSourceMarker(shellFramework); } const provisioning = config.siteUuid === null; @@ -789,7 +790,7 @@ async function runScan( // returned above. const effectiveUuid = config.siteUuid ?? response.uuid ?? null; if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) { - reportSourceWidget(effectiveUuid); + reportSourceWidget(effectiveUuid, shellFramework); } // On the first scan (provisioning), surface the dashboard URL so the user can @@ -840,9 +841,14 @@ async function runScan( * widget management is a convenience layered on top of a successful scan and * must not turn one into a failure. */ -function reportSourceWidget(siteUuid: string): void { +function reportSourceWidget(siteUuid: string, framework: string | null): void { try { - const result = ensureSourceWidget(process.cwd(), siteUuid); + // A server-rendered project has no HTML shell to edit, so the framework's JSX root stands in for + // one. Only where a literal tag is known to belong — the same set the marker will write into. + const hint = hasJsxShell(framework) ? resolveWidgetFileHint(process.cwd(), framework) : null; + const jsxShell = hint !== null && !hint.toLowerCase().endsWith('.html') ? hint : null; + + const result = ensureSourceWidget(process.cwd(), siteUuid, jsxShell); switch (result.action) { case 'added': console.log(`Widget: added the "Report a vulnerability" tag to ${result.shell}. Reload your preview to see it.`); @@ -861,7 +867,7 @@ function reportSourceWidget(siteUuid: string): void { console.log(` ${buildWidgetTag(siteUuid)}`); break; case 'no-shell': - console.log('Widget: no plain HTML shell found (index.html / public/index.html / src/app.html).'); + console.log('Widget: no root shell found to edit (index.html / public/index.html / src/app.html, or a JSX root).'); console.log('Add this tag to your root layout before (run `guide` for framework-specific placement):'); console.log(` ${buildWidgetTag(siteUuid)}`); break; diff --git a/src/widget.ts b/src/widget.ts index 2bf668db..e168edb5 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -21,9 +21,9 @@ const WIDGET_NEEDLE = 'patchstack-widget'; /** * Root HTML shells the connector is willing to edit, in priority order: - * Vite/plain SPA, CRA-style, SvelteKit. Framework layouts that are code rather - * than HTML (Next/Nuxt/Astro layouts) are never edited automatically — `guide` - * prints the snippet and the right file for those. + * Vite/plain SPA, CRA-style, SvelteKit. A framework whose root is code rather + * than HTML has no entry here; a JSX one is handled by `ensureSourceWidget`'s + * fallback below, and the rest get the snippet printed by `guide`. */ export const SOURCE_SHELL_CANDIDATES = ['index.html', 'public/index.html', 'src/app.html']; @@ -102,15 +102,38 @@ export interface SourceWidgetResult { } /** - * Ensure the managed widget tag in the project's root HTML shell. Edits at most - * that one file; returns what happened so the caller can report it. + * Ensure the managed widget tag in the project's root shell. Edits at most that one file; returns + * what happened so the caller can report it. + * + * `jsxShell` is the framework's root component, used only when the project has no plain HTML shell — + * a server-rendered app (TanStack Start, Next, Remix) never produces one, and until this fallback + * existed those projects were told to paste the tag themselves. That instruction was reliably missed: + * a hosted builder's agent runs setup, reads "add this yourself", finishes, and the published site + * carries no widget at all. Nothing downstream can tell that apart from a site that was never set up. + * + * The same tag works in both places. JSX reads `')); + expect(ensureSourceWidget(cwd, UUID_A, shell).action).toBe('manual'); + expect(read()).not.toContain(UUID_A); + }); + + it('prefers a real HTML shell, which is the document actually served', () => { + // On a stack that has both, the JSX root only renders into the shell — editing it would put the + // tag one level further from the page than it needs to be. + writeFileSync(path.join(cwd, 'index.html'), SHELL); + const shell = jsxRoot(); + expect(ensureSourceWidget(cwd, UUID_A, shell).shell).toBe('index.html'); + expect(read()).not.toContain('patchstack-widget.js'); + }); + + it('reports no-shell when the hinted root does not exist', () => { + expect(ensureSourceWidget(cwd, UUID_A, 'src/routes/__root.tsx')).toEqual({ + shell: null, + action: 'no-shell', + }); + }); +}); From 7ba2702f91b67321e2ff186834c89f23f068011c Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Fri, 11 Sep 2026 10:37:23 +0100 Subject: [PATCH 2/2] Carry the build fingerprint into a server-rendered root, too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source marker said a site was live but not which build was live, and the second question is the one the dashboard grades on. So a server-rendered app could be set up perfectly, publish, be visited, report its packages — and still read "deploy state unknown", because the value that answers it is stamped into built HTML that this stack never produces. Both witnesses that name the live build read __PATCHSTACK_BUILD__: the widget's heartbeat and the scheduled page fetch. Neither had anything to read. The deployment header some platforms expose is not a substitute — it dates a redeploy, it does not say the live build is one we scanned. `scan` already has the value. It is a pre-build hook, and the checksum it is posting in the same run is the build about to be compiled, so the fingerprint it writes and the manifest it posts describe the same thing by construction. Verified byte-for-byte against the server's own computation, which sorts and hashes identically (and unescapes slashes, so scoped package names agree). Omitted where there is none, and where the snippet is printed for somebody to paste — a pasted fingerprint goes stale the next time dependencies change. The managed region refreshes in place instead, so it moves with the lockfile and never reports an older build as the live one. Co-Authored-By: Claude Opus 5 --- src/cli.ts | 9 ++++--- src/mark-build.ts | 33 ++++++++++++++++++++++---- tests/mark-build.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3e8aab8b..69e3a75b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -717,7 +717,10 @@ async function runScan( // gets its production flag. --dry-run has already returned by here. const shellFramework = detectStack(payload.packages).framework; if (config.widget) { - reportSourceMarker(shellFramework); + // The checksum of the manifest this run is posting — on a pre-build hook that is the build about + // to be compiled. Without it a server-rendered app's page says it is live but not which build is, + // which is the question the dashboard grades on. + reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages)); } const provisioning = config.siteUuid === null; @@ -889,7 +892,7 @@ function reportSourceWidget(siteUuid: string, framework: string | null): void { * follows. On a server-rendered root that is the only way the marker reaches * production — `mark-build` runs after the build and has no HTML to stamp. */ -function reportSourceMarker(framework: string | null): void { +function reportSourceMarker(framework: string | null, checksum: string | null = null): void { try { const shell = resolveWidgetFileHint(process.cwd(), framework); if (shell === null || shell.toLowerCase().endsWith('.html')) { @@ -897,7 +900,7 @@ function reportSourceMarker(framework: string | null): void { return; } - const result = ensureSourceMarker(process.cwd(), shell, framework); + const result = ensureSourceMarker(process.cwd(), shell, framework, checksum); switch (result.action) { case 'added': console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`); diff --git a/src/mark-build.ts b/src/mark-build.ts index e94a1efd..15fdd939 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -269,14 +269,32 @@ export function hasJsxShell(framework: string | null): boolean { * It stays an inline document script rather than a module-level assignment: the * widget tag is `defer`, and only a parser-executed inline script is ordered * ahead of it for certain. + * + * `checksum` carries the build fingerprint, and without it the marker can say a + * site is live but not WHICH build is live — which is the question the dashboard + * actually grades on. A server-rendered app whose page carried only the live flag + * reported as "deploy state unknown" no matter how healthy it was, because the one + * value that answers it is stamped into built HTML that this stack never produces. + * + * `scan` is a pre-build hook, so the value it has is the checksum of the manifest + * it is posting in the same run — which is exactly the build about to be compiled. + * Omitted when there is none to give, and when the snippet is printed for somebody + * to paste, where a pasted fingerprint would go stale the next time deps changed. */ -export function buildSourceMarkerSnippet(framework: string | null): string { +export function buildSourceMarkerSnippet( + framework: string | null, + checksum: string | null = null, +): string { const gate = productionGate(framework); + const statements = ['window.__PATCHSTACK_PROD__=true;']; + if (checksum !== null && checksum !== '') { + statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`); + } return ( `{${gate} && (\n` + ` \n` + `)}` ); @@ -389,7 +407,11 @@ function findJsxShellAnchor(source: string, tagName: 'head' | 'body'): JsxShellA * in source. A marker the developer placed themselves is adopted rather than * duplicated. */ -export function ensureMarkerInJsxShell(source: string, framework: string | null): SourceMarkerResult { +export function ensureMarkerInJsxShell( + source: string, + framework: string | null, + checksum: string | null = null, +): SourceMarkerResult { if (!hasJsxShell(framework)) { return { source, action: 'unsupported' }; } @@ -400,7 +422,7 @@ export function ensureMarkerInJsxShell(source: string, framework: string | null) } const block = (indent: string): string => - [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split('\n'), REGION_CLOSE] + [REGION_OPEN, ...buildSourceMarkerSnippet(framework, checksum).split('\n'), REGION_CLOSE] .map((line) => `${indent}${line}`) .join('\n'); @@ -434,13 +456,14 @@ export function ensureSourceMarker( cwd: string, shell: string | null, framework: string | null, + checksum: string | null = null, ): EnsureSourceMarkerResult { if (shell === null) { return { shell: null, source: '', action: 'no-anchor' }; } const file = path.resolve(cwd, shell); const before = readFileSync(file, 'utf8'); - const result = ensureMarkerInJsxShell(before, framework); + const result = ensureMarkerInJsxShell(before, framework, checksum); if (result.source !== before) { writeFileSync(file, result.source); } diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts index 81a8476e..7dbfaeff 100644 --- a/tests/mark-build.test.ts +++ b/tests/mark-build.test.ts @@ -219,6 +219,57 @@ describe('productionGate', () => { }); }); +describe('buildSourceMarkerSnippet with a build fingerprint', () => { + it('carries the fingerprint, which is what names WHICH build is live', () => { + // Without it the marker says a site is live but not what it is running, and the dashboard grades + // on the second question. A server-rendered app has no built HTML for mark-build to stamp, so + // this snippet is the only path the fingerprint has to production on those stacks. + const snippet = buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30'); + expect(snippet).toContain('window.__PATCHSTACK_PROD__=true;'); + expect(snippet).toContain('window.__PATCHSTACK_BUILD__="ac749db1ff30";'); + }); + + it('omits it when there is none to give', () => { + const snippet = buildSourceMarkerSnippet('tanstack-start'); + expect(snippet).toContain('__PATCHSTACK_PROD__'); + expect(snippet).not.toContain('__PATCHSTACK_BUILD__'); + }); + + it('keeps the fingerprint behind the production gate too', () => { + const snippet = buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30'); + expect(snippet.indexOf('import.meta.env.PROD')).toBeLessThan(snippet.indexOf('__PATCHSTACK_BUILD__')); + }); + + it('stays parseable TSX with a fingerprint in it', () => { + const source = `export const Root = () => (\n \n \n ${buildSourceMarkerSnippet('tanstack-start', 'ac749db1ff30')}\n \n \n \n);`; + const parsed = ts.createSourceFile('root.tsx', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX); + expect(parsed.parseDiagnostics ?? []).toHaveLength(0); + }); +}); + +describe('ensureMarkerInJsxShell with a build fingerprint', () => { + const doc = (body: string): string => + `export const Root = () => (\n \n \n${body}\n \n \n \n);`; + + it('refreshes a stale fingerprint in place rather than stacking a second block', () => { + // scan runs on every build, so the value changes whenever the lockfile does. The managed region + // has to carry the current one — a stale fingerprint would report the wrong build as live. + const first = ensureMarkerInJsxShell(doc(' t'), 'tanstack-start', 'aaaaaaaaaaaa'); + const second = ensureMarkerInJsxShell(first.source, 'tanstack-start', 'bbbbbbbbbbbb'); + + expect(second.action).toBe('added'); + expect(second.source).toContain('__PATCHSTACK_BUILD__="bbbbbbbbbbbb"'); + expect(second.source).not.toContain('aaaaaaaaaaaa'); + expect((second.source.match(/#region patchstack/g) ?? []).length).toBe(1); + }); + + it('is byte-identical when the fingerprint has not moved', () => { + const first = ensureMarkerInJsxShell(doc(' t'), 'tanstack-start', 'aaaaaaaaaaaa'); + const second = ensureMarkerInJsxShell(first.source, 'tanstack-start', 'aaaaaaaaaaaa'); + expect(second.source).toBe(first.source); + }); +}); + describe('hasJsxShell', () => { it('is true for React-family roots and false otherwise', () => { expect(hasJsxShell('tanstack-start')).toBe(true);