From 8383a834a033ba392080b30dda76f4d32053a3cb Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 12:33:57 +0000 Subject: [PATCH] doctor: check that the live page names the build it runs The build ID on the live page (window.__PATCHSTACK_BUILD__, written by setup and mark-build) is how Patchstack knows which build is published. Without it the dashboard has to guess, and its "Unpublished changes" compares against that guess. doctor --url now reports whether the page carries it, with the fix when it does not. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 2 +- src/doctor.ts | 11 +++++++++++ tests/doctor.test.ts | 13 +++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 020c28e..e09adee 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -958,7 +958,7 @@ Three more endpoints the package can call, for completeness: ## Verifying the install - `npx @patchstack/connect status` re-prints the site UUID and dashboard URL, and checks whether the site still exists on Patchstack (`Site status: active / removed / could not be verified`). -- `npx @patchstack/connect doctor` runs every check at once and prints each problem with its fix; `--url ` adds the live app's widget and a test attack. +- `npx @patchstack/connect doctor` runs every check at once and prints each problem with its fix; `--url ` adds the live app: whether its page names the build it runs (without that, the dashboard has to guess what is published), the widget, and a test attack. - `npx @patchstack/connect protect --check` verifies from the source that the runtime guard is connected to the request path. It does not run the app. When the project records a site, it also fails if a guard Connect wrote does not carry it (`the guard carries this project's Patchstack site`): such a guard blocks with the rules it shipped with but fetches no rule updates and reports nothing. Run `npx @patchstack/connect protect` from the project root to fill the site in, then deploy. - `npx @patchstack/connect protect --check --runtime` additionally **starts the app** on a loopback port and sends it one request, to establish that a request reaches the guard seam. Opt-in, and the only command that runs the application; exit `0`/`1`/`2` as described in step 4. - Load the site in a browser — the widget should appear, as the "Connect this website" panel while the site is unclaimed. Refresh a page that was already open before the tag was added: the widget only loads with the page. diff --git a/src/doctor.ts b/src/doctor.ts index 4e09b29..5a5119b 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -229,6 +229,17 @@ async function liveFindings(url: string, state: GuideState, fetchImpl: typeof fe if (!response.ok) { return { reached: false, findings: [{ status: 'unknown', text: `The live app answered ${response.status}, so it could not be checked (is it published? a private publish shows a sign-in page)` }] }; } + // The build the live page names is how Patchstack knows what is published; without it the dashboard + // has to guess, and its "Unpublished changes" compares against that guess. + findings.push( + html.includes('__PATCHSTACK_BUILD__') + ? { status: 'ok', text: 'The live app tells Patchstack which build it runs' } + : { + status: 'problem', + text: 'The live app does not tell Patchstack which build it runs, so the dashboard has to guess what is published and its "Unpublished changes" can be wrong', + fix: ['Run: npx @patchstack/connect setup --widget (it adds the marker to your page layout)', 'Then publish the app again.'], + }, + ); if (!state.widgetOptOut && state.siteUuid !== null) { findings.push( html.includes('patchstack-widget.js') && html.includes(state.siteUuid) diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index dfb972b..d169e75 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -54,7 +54,7 @@ const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000). type SyncAnswer = { reader?: { at: string; rule_count: number } | null; site_key?: { at: string; rule_count: number } | null } | number; /** Patchstack's token and sync-status endpoints, and a live app answering the page and the test attack. */ -function network({ token = 200, page = 200, attack = 403, sync = { reader: { at: minutesAgo(3), rule_count: 17 }, site_key: null } }: { token?: number | 'down'; page?: number; attack?: number; sync?: SyncAnswer } = {}) { +function network({ token = 200, page = 200, attack = 403, marker = true, sync = { reader: { at: minutesAgo(3), rule_count: 17 }, site_key: null } }: { token?: number | 'down'; page?: number; attack?: number; marker?: boolean; sync?: SyncAnswer } = {}) { const calls: string[] = []; const auth: string[] = []; const fetchImpl = vi.fn(async (input: string | URL, init: RequestInit = {}) => { @@ -69,7 +69,8 @@ function network({ token = 200, page = 200, attack = 403, sync = { reader: { at: return typeof sync === 'number' ? new Response('{}', { status: sync }) : Response.json({ site_uuid: UUID, rules_fetches: { reader: null, site_key: null, ...sync } }); } if (url.includes('file=')) return new Response(attack === 403 ? '{"error":"This request has been blocked by Patchstack."}' : '', { status: attack }); - return new Response(``, { status: page }); + const build = marker ? '' : ''; + return new Response(`${build}`, { status: page }); }) as unknown as typeof fetch; return { fetchImpl, calls, auth }; } @@ -138,11 +139,19 @@ describe('doctor', () => { writeFileSync(join(cwd, '.patchstackrc.json'), JSON.stringify({ siteUuid: UUID, widget: true, claimed: true })); const findings = await doctor(cwd, { url: 'https://app.example' }); expect(findings.filter((f) => f.text.startsWith('The live app')).map((f) => [f.status, f.text])).toEqual([ + ['ok', 'The live app tells Patchstack which build it runs'], ['ok', 'The live app carries the Patchstack widget for this site'], ['ok', "The live app's guard blocked a test attack"], ]); }); + it('flags a live app whose page does not say which build it runs', async () => { + const findings = await doctor(project(), { url: 'https://app.example', net: network({ marker: false }) }); + const finding = findings.find((f) => f.text.startsWith('The live app does not tell Patchstack which build')); + expect(finding?.status).toBe('problem'); + expect(finding?.fix?.[0]).toContain('setup --widget'); + }); + it('flags a live app that lets the test attack through', async () => { const findings = await doctor(project(), { url: 'https://app.example', net: network({ attack: 200 }) }); expect(problems(findings)).toEqual(['The live app let a test attack through (it answered 200 to ?file=../../../../etc/passwd)']);