Skip to content
Merged
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 @@ -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 <live app>` 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 <live app>` 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.
Expand Down
11 changes: 11 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) => {
Expand All @@ -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."}' : '<html></html>', { status: attack });
return new Response(`<html><script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="${UUID}"></script></html>`, { status: page });
const build = marker ? '<script data-patchstack-build="true">window.__PATCHSTACK_PROD__=true;window.__PATCHSTACK_BUILD__="a0acb5e0d15a";</script>' : '';
return new Response(`<html><head>${build}</head><script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="${UUID}"></script></html>`, { status: page });
}) as unknown as typeof fetch;
return { fetchImpl, calls, auth };
}
Expand Down Expand Up @@ -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)']);
Expand Down
Loading