From 7ccffe0a7d9e9c7a63acc85118bb2ba1d1a08c38 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Tue, 14 Jul 2026 12:05:03 +0200 Subject: [PATCH] feat: automate disclosure widget installation --- .gitignore | 3 + AGENT-INSTALL.md | 20 ++-- GETTING-STARTED.md | 4 +- README.md | 34 ++++++- src/cli.ts | 90 ++++++++++++++++- src/config.ts | 2 + src/guide.ts | 35 ++++--- src/index.ts | 11 ++ src/types.ts | 6 ++ src/widget.ts | 120 ++++++++++++++++++++++ test-build/README.md | 55 ++++++++++ test-build/build.mjs | 19 ++++ test-build/package.json | 11 ++ test-build/run.mjs | 200 +++++++++++++++++++++++++++++++++++++ test-build/site/index.html | 25 +++++ test-build/site/style.css | 25 +++++ tests/client.test.ts | 16 +-- tests/guide.test.ts | 32 +++++- tests/widget.test.ts | 136 +++++++++++++++++++++++++ 19 files changed, 800 insertions(+), 44 deletions(-) create mode 100644 src/widget.ts create mode 100644 test-build/README.md create mode 100644 test-build/build.mjs create mode 100644 test-build/package.json create mode 100644 test-build/run.mjs create mode 100644 test-build/site/index.html create mode 100644 test-build/site/style.css create mode 100644 tests/widget.test.ts diff --git a/.gitignore b/.gitignore index 27650c9..c53e637 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ coverage # Field-test harness output (agent reports, request logs, scorecards) field-test/results/ + +# test-build throwaway fixture (recreated on every run) +test-build/.work/ diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index af10a02..da0dae7 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -9,7 +9,8 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL - The connector is a normal npm package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. Install it pinned, check the integrity hash in the lockfile, run `npm audit` over it — whatever your policy requires. - First published May 2026. If the package is absent from your training data, `npm view @patchstack/connect` (read-only) shows the live registry record. - It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.) -- The package also bundles an **opt-in** `protect` command (runtime exploit guard, currently for TanStack Start + Supabase apps; its templates live under `dist/protect/`). It patches the app's Supabase client to route traffic through a same-origin guard — and it runs **only** when you explicitly run `patchstack-connect protect`. Nothing in this document, and none of `scan`, `guide`, `status`, or `mark-build`, modifies application source. +- **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's ` - + ``` - Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The `userToken` is public by design — it ships in client-side HTML and is not a secret. + Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it. -5. **Commit** `.patchstackrc.json`, the updated `package.json`, and the layout/HTML file carrying the widget snippet, so every developer and CI run reports to the same site. +5. **Commit** `.patchstackrc.json`, the updated `package.json`, and the layout/HTML file carrying the widget tag, so every developer and CI run reports to the same site. 6. **Tell the user to claim the site** (free): open the claim URL from step 2 in a browser and sign in. The site is monitored either way, but the vulnerability reports are only visible after claiming. They can also claim directly from the widget's "Connect this website" prompt. On the published site, the owner reaches the widget login by appending `#patchstack` to the live URL. diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index 2f2d08e..d1c89dd 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -28,11 +28,11 @@ If you hit a refusal with a *new* reason (the agent quotes a specific phrase or ```bash npm install --save-dev @patchstack/connect # bun-managed projects (Lovable, Bolt): bun add -d @patchstack/connect -npx @patchstack/connect scan # registers the project, prints the claim URL — open it in your browser +npx @patchstack/connect scan # registers the project, installs the widget tag, prints the claim URL — open it in your browser npx @patchstack/connect guide # prints what's left, with the exact snippets for your project ``` -`guide` tailors its output to your project — right package manager, real site UUID, framework-specific widget placement — so finishing setup is copy-paste: the `prebuild`/`postbuild` hooks into `package.json`, and the report-a-vulnerability widget snippet into your HTML/layout file. +`scan` also drops the report-a-vulnerability widget tag into your root HTML file (`index.html`-style shells) automatically. `guide` tailors the rest to your project — right package manager, real site UUID, framework-specific widget placement when your root layout is code instead of HTML — so finishing setup is copy-paste: the `prebuild`/`postbuild` hooks into `package.json`, plus the widget one-liner if `scan` couldn't place it itself. ## 4. You're done when diff --git a/README.md b/README.md index 163cca5..5eab958 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ That's it. The first `scan`: 2. POSTs the package list to Patchstack with **no** UUID. 3. Patchstack provisions a fresh site and returns its UUID. 4. The connector writes the UUID to `.patchstackrc.json` so the next `scan` targets the same site. -5. The connector prints a claim URL — open it in a browser to attach the new site to your Patchstack account. You can re-display it any time with `npx @patchstack/connect status`. +5. The connector installs the disclosure widget's ` + ``` + + 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` and failed posts never edit anything. 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. + +- **`mark-build`** ensures the same tag in built HTML output, covering builds whose source shell the connector couldn't edit, and stamps `window.__PATCHSTACK_PROD__` so the widget hides the claim/login UI on the published site (owners reach it by appending `#patchstack` to the live URL). + +- **Opting out:** persist `"widget": false` in `.patchstackrc.json` to disable both passes (dependency scanning only). Without it, the next successful scan re-adds the managed tag. ## Programmatic API diff --git a/src/cli.ts b/src/cli.ts index 7b6158e..1b3366a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,18 +15,25 @@ import { collectGuideState, countRemainingSteps, renderGuideChecklist } from './ import { runProtect } from './protect/install.js'; import { detectStack, type StackDescriptor } from './stack.js'; import { PatchstackError } from './types.js'; +import { buildWidgetTag, ensureSourceWidget, ensureWidgetInHtml } from './widget.js'; const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack. Usage: patchstack-connect scan [options] Scan lockfile and POST to Patchstack. If no UUID is configured, the server - provisions one and we persist it. + provisions one and we persist it. After a + successful post it also adds/updates the + disclosure-widget `)); - const token = state.siteUuid ?? ''; - lines.push(detail(` `)); - lines.push(detail('The userToken is public by design — it ships in client-side HTML.')); + lines.push(detail(` ${buildWidgetTag(state.siteUuid)}`)); + lines.push(detail('The site UUID is public by design — it ships in client-side HTML.')); } // 5. Claim — the conversion moment; always the loudest line. diff --git a/src/index.ts b/src/index.ts index 77fb4b6..f8b7175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,17 @@ export { isEmptyStack, type StackDescriptor, } from './stack.js'; +export { + WIDGET_SCRIPT_URL, + WIDGET_MARKER_ATTR, + buildWidgetTag, + ensureWidgetInHtml, + ensureSourceWidget, + findSourceShell, + type WidgetEnsureAction, + type WidgetEnsureResult, + type SourceWidgetResult, +} from './widget.js'; export { PatchstackError, type Config, diff --git a/src/types.ts b/src/types.ts index 9c8883c..fd9d200 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,12 @@ export interface Config { timeoutMs: number; /** Environment to report the manifest under. Defaults to 'production'. */ environment: Environment; + /** + * Whether the connector manages the disclosure-widget tag (source shell on + * `scan`, built HTML on `mark-build`). Defaults to true; persist + * `"widget": false` in .patchstackrc.json for dependency-scanning only. + */ + widget: boolean; } export interface StoreManifestResponse { diff --git a/src/widget.ts b/src/widget.ts new file mode 100644 index 0000000..2bf668d --- /dev/null +++ b/src/widget.ts @@ -0,0 +1,120 @@ +// Managed disclosure-widget tag — the connector installs the widget for you. +// +// After a successful scan the connector ensures the site's root HTML shell +// carries the widget's one-liner CDN tag (the canonical install form from the +// widget docs: a single `; +} + +export type WidgetEnsureAction = + /** No widget present — the managed tag was inserted before . */ + | 'added' + /** A managed tag existed with a different UUID — replaced in place. */ + | 'updated' + /** The managed tag is already present and current. */ + | 'unchanged' + /** A manual (non-managed) widget install exists — left untouched. */ + | 'manual' + /** The document has no to anchor on — nothing was changed. */ + | 'no-body'; + +export interface WidgetEnsureResult { + html: string; + action: WidgetEnsureAction; +} + +const MANAGED_TAG_RE = new RegExp( + `]*${WIDGET_MARKER_ATTR}[^>]*>\\s*`, + 'i', +); + +/** + * Ensure a single HTML document carries the managed widget tag. Idempotent: + * updates the managed tag in place, adopts (leaves alone) manual installs, and + * only ever inserts immediately before . + */ +export function ensureWidgetInHtml(html: string, siteUuid: string): WidgetEnsureResult { + const tag = buildWidgetTag(siteUuid); + + const managed = html.match(MANAGED_TAG_RE); + if (managed !== null) { + if (managed[0].includes(`data-site-uuid="${siteUuid}"`)) { + return { html, action: 'unchanged' }; + } + return { html: html.replace(MANAGED_TAG_RE, tag), action: 'updated' }; + } + + if (html.includes(WIDGET_NEEDLE)) { + return { html, action: 'manual' }; + } + + const bodyClose = html.match(/([ \t]*)<\/body>/i); + if (bodyClose === null || bodyClose.index === undefined) { + return { html, action: 'no-body' }; + } + const indent = bodyClose[1] ?? ''; + const insertion = `${indent} ${tag}\n${indent}`; + return { + html: html.slice(0, bodyClose.index) + insertion + html.slice(bodyClose.index + bodyClose[0].length), + action: 'added', + }; +} + +/** First editable root HTML shell in the project, or null when there is none. */ +export function findSourceShell(cwd: string): string | null { + for (const candidate of SOURCE_SHELL_CANDIDATES) { + if (existsSync(path.join(cwd, candidate))) { + return candidate; + } + } + return null; +} + +export interface SourceWidgetResult { + /** Project-relative path of the shell that was inspected, or null. */ + shell: string | null; + action: WidgetEnsureAction | 'no-shell'; +} + +/** + * 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. + */ +export function ensureSourceWidget(cwd: string, siteUuid: string): SourceWidgetResult { + const shell = findSourceShell(cwd); + if (shell === null) { + return { shell: null, action: 'no-shell' }; + } + const file = path.join(cwd, shell); + const before = readFileSync(file, 'utf8'); + const { html, action } = ensureWidgetInHtml(before, siteUuid); + if (html !== before) { + writeFileSync(file, html); + } + return { shell, action }; +} diff --git a/test-build/README.md b/test-build/README.md new file mode 100644 index 0000000..d4ee4ec --- /dev/null +++ b/test-build/README.md @@ -0,0 +1,55 @@ +# test-build — see the hooks work on a real example site + +A one-command, end-to-end check of the `prebuild`/`postbuild` flow on a small +static Node.js site, without touching the real Patchstack API. It exists so a +change to `scan`'s widget injection or `mark-build`'s stamping can be verified +in a browser in under a minute. + +From this folder, run the complete automated test with one command: + +```bash +npm test +``` + +To run the same checks and then serve the example site at +: + +```bash +npm start +``` + +Both commands build the local connector first, so no preparation from the +repository root is needed. + +What one run does: + +1. Copies `site/` into `test-build/.work/` — a throwaway fixture, recreated on + every run, so the repo itself is never edited. +2. Starts the mocked Patchstack API from `field-test/mock-api.mjs`. Nothing + leaves the machine; no real site is provisioned. +3. Runs `npm run build` inside the fixture — the real npm hook mechanism: + - `prebuild` → `scan`: posts the fixture lockfile to the mock, persists the + mock UUID to `.patchstackrc.json`, and injects the managed widget tag into + the fixture's `index.html`. + - `build` → copies the site into `dist/`. + - `postbuild` → `mark-build`: stamps `dist/` HTML with the production flag + + build fingerprint and verifies the widget tag came through. +4. Prints a ✔/✖ checklist (source tag, exactly one tag in output, prod flag, + fingerprint, manifest POST) and exits non-zero if anything failed. +5. Serves `dist/` on so you can see the widget button. + +## Advanced usage + +- `node run.mjs --no-serve` — build + verify without rebuilding the connector. +- `--widget-js ` — serve a local widget bundle instead of the CDN copy, + e.g. `node run.mjs --widget-js ../../sass-webvdp-widget/dist/patchstack-widget.js`, + to test unreleased widget builds against the connector flow. + +## Notes + +- The page loads the widget from `https://cdn.patchstack.com` unless + `--widget-js` is given, so seeing the button needs network access. +- The served build carries the production flag, so the widget shows the + report-only UI; append `#patchstack` to the URL for the owner login flow. +- This harness tests the local `dist/cli.js` — unlike `field-test/`, which + installs the *published* package and exercises the AI-agent install prompt. diff --git a/test-build/build.mjs b/test-build/build.mjs new file mode 100644 index 0000000..522084a --- /dev/null +++ b/test-build/build.mjs @@ -0,0 +1,19 @@ +// The fixture's "framework build": copy every static file into dist/. Just +// enough to give prebuild/postbuild something real to wrap. +import { cpSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; + +const cwd = process.cwd(); +const dist = path.join(cwd, 'dist'); + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist); + +for (const entry of readdirSync(cwd)) { + if (['dist', 'node_modules', 'package.json', 'package-lock.json', 'build.mjs', '.patchstackrc.json'].includes(entry)) { + continue; + } + cpSync(path.join(cwd, entry), path.join(dist, entry), { recursive: true }); +} + +console.log('build: copied site files to dist/'); diff --git a/test-build/package.json b/test-build/package.json new file mode 100644 index 0000000..4090743 --- /dev/null +++ b/test-build/package.json @@ -0,0 +1,11 @@ +{ + "name": "patchstack-connect-test-build", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build:connector": "npm --prefix .. run build", + "test": "npm run build:connector && node run.mjs --no-serve", + "start": "npm run build:connector && node run.mjs" + } +} diff --git a/test-build/run.mjs b/test-build/run.mjs new file mode 100644 index 0000000..11c944b --- /dev/null +++ b/test-build/run.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +// One-command end-to-end test of the build hooks against a throwaway example +// site. What it does: +// +// 1. copies test-build/site/ into test-build/.work/ (a fresh fixture each run) +// 2. starts the mocked Patchstack API (field-test/mock-api.mjs) — no real +// sites are provisioned and nothing leaves the machine +// 3. runs `npm run build` inside the fixture, which exercises the real npm +// hook mechanism: prebuild → `scan` (provisions a mock UUID, injects the +// widget tag into index.html), build → copy site to dist/, postbuild → +// `mark-build` (stamps the production flag + fingerprint) +// 4. verifies dist/index.html carries exactly one widget tag + the prod flag +// 5. serves dist/ on localhost so you can see the widget button in a browser +// +// Usage: +// node test-build/run.mjs # build, verify, serve +// node test-build/run.mjs --no-serve # build + verify only (exit code 0/1) +// node test-build/run.mjs --widget-js /path/to/patchstack-widget.js +// # serve a local widget build (e.g. sass-webvdp-widget/dist) instead of +// # loading it from the real CDN +// +// Requires a built connector: run `npm run build` in the repo root first. + +import { spawn } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { startMockApi } from '../field-test/mock-api.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..'); +const cli = path.join(repoRoot, 'dist', 'cli.js'); +const workDir = path.join(here, '.work'); +const args = process.argv.slice(2); +const noServe = args.includes('--no-serve'); +const widgetJsIdx = args.indexOf('--widget-js'); +const widgetJs = widgetJsIdx !== -1 ? path.resolve(args[widgetJsIdx + 1] ?? '') : null; + +if (!existsSync(cli)) { + console.error('dist/cli.js not found — build the connector first: npm run build (in the repo root).'); + process.exit(1); +} +if (widgetJs !== null && !existsSync(widgetJs)) { + console.error(`--widget-js: ${widgetJs} does not exist.`); + process.exit(1); +} + +// 1. Fresh fixture. +rmSync(workDir, { recursive: true, force: true }); +mkdirSync(workDir, { recursive: true }); +cpSync(path.join(here, 'site'), workDir, { recursive: true }); +cpSync(path.join(here, 'build.mjs'), path.join(workDir, 'build.mjs')); + +// The fixture is a plain npm project; the hooks run the locally built CLI the +// same way `patchstack-connect ` would run the installed bin. +writeFileSync( + path.join(workDir, 'package.json'), + JSON.stringify( + { + name: 'patchstack-test-build', + version: '1.0.0', + private: true, + scripts: { + prebuild: `node ${JSON.stringify(cli)} scan`, + build: 'node build.mjs', + postbuild: `node ${JSON.stringify(cli)} mark-build`, + }, + dependencies: { axios: '^1.6.0', lodash: '^4.17.21' }, + }, + null, + 2, + ), +); + +// A minimal but valid npm v3 lockfile so `scan` has something real to parse. +writeFileSync( + path.join(workDir, 'package-lock.json'), + JSON.stringify( + { + name: 'patchstack-test-build', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'patchstack-test-build', + version: '1.0.0', + dependencies: { axios: '^1.6.0', lodash: '^4.17.21' }, + }, + 'node_modules/axios': { + version: '1.6.0', + resolved: 'https://registry.npmjs.org/axios/-/axios-1.6.0.tgz', + integrity: 'sha512-fixture', + }, + 'node_modules/lodash': { + version: '4.17.21', + resolved: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz', + integrity: 'sha512-fixture', + }, + }, + }, + null, + 2, + ), +); + +// 2. Mock API. +const mock = await startMockApi(); +console.log(`mock Patchstack API on ${mock.endpoint} (site uuid ${mock.uuid})\n`); + +// 3. The real npm hook mechanism: prebuild → build → postbuild. Must be async +// (not spawnSync) so the in-process mock API can answer while the build runs. +const buildStatus = await new Promise((resolve) => { + const child = spawn('npm', ['run', 'build'], { + cwd: workDir, + stdio: 'inherit', + env: { ...process.env, PATCHSTACK_ENDPOINT: mock.endpoint, NO_COLOR: '1' }, + }); + child.on('close', resolve); + child.on('error', () => resolve(1)); +}); +if (buildStatus !== 0) { + console.error('\n✖ npm run build failed inside the fixture.'); + await mock.close(); + process.exit(1); +} + +// 4. Verify the artifacts the hooks are responsible for. +const distDir = path.join(workDir, 'dist'); +const builtHtml = readFileSync(path.join(distDir, 'index.html'), 'utf8'); +const sourceHtml = readFileSync(path.join(workDir, 'index.html'), 'utf8'); +const widgetTags = builtHtml.split('patchstack-widget.js').length - 1; + +const checks = [ + ['scan wrote .patchstackrc.json with the mock UUID', + existsSync(path.join(workDir, '.patchstackrc.json')) && + readFileSync(path.join(workDir, '.patchstackrc.json'), 'utf8').includes(mock.uuid)], + ['scan injected the widget tag into the source shell (index.html)', + sourceHtml.includes(`data-site-uuid="${mock.uuid}"`)], + ['built HTML carries exactly one widget tag', widgetTags === 1], + ['mark-build stamped the production flag', builtHtml.includes('window.__PATCHSTACK_PROD__=true')], + ['mark-build stamped the build fingerprint', builtHtml.includes('window.__PATCHSTACK_BUILD__=')], + ['scan posted the manifest to the (mock) API', + mock.requests.some((r) => r.method === 'POST' && r.url.startsWith('/monitor/pulse/manifest'))], +]; + +console.log(''); +let failed = 0; +for (const [label, ok] of checks) { + console.log(` ${ok ? '✔' : '✖'} ${label}`); + if (!ok) failed += 1; +} + +if (failed > 0) { + console.error(`\n✖ ${failed} check(s) failed. Fixture kept at ${workDir} for inspection.`); + await mock.close(); + process.exit(1); +} +console.log('\n✔ all checks passed.'); + +if (noServe) { + await mock.close(); + process.exit(0); +} + +// 5. Serve the built site. `--widget-js` swaps the CDN loader for a local +// widget build so widget changes can be tested before they reach the CDN. +if (widgetJs !== null) { + cpSync(widgetJs, path.join(distDir, 'patchstack-widget.js')); + writeFileSync( + path.join(distDir, 'index.html'), + builtHtml.replace('https://cdn.patchstack.com/patchstack-widget.js', '/patchstack-widget.js'), + ); + console.log(`serving local widget build: ${widgetJs}`); +} + +const MIME = { + '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', +}; +const server = createServer((req, res) => { + const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname); + let file = path.join(distDir, urlPath === '/' ? 'index.html' : urlPath); + if (!path.resolve(file).startsWith(distDir) || !existsSync(file)) { + res.writeHead(404).end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream' }); + res.end(readFileSync(file)); +}); + +server.listen(4173, '127.0.0.1', () => { + console.log('\nserving the production build on http://localhost:4173'); + console.log(' → the "Report a vulnerability" button should appear bottom-right'); + console.log(' → the page carries the production flag, so the widget shows report-only UI'); + console.log(' (append #patchstack to the URL to reveal the owner login flow)'); + console.log('\nCtrl+C to stop (this also stops the mock API).'); +}); diff --git a/test-build/site/index.html b/test-build/site/index.html new file mode 100644 index 0000000..9662cfe --- /dev/null +++ b/test-build/site/index.html @@ -0,0 +1,25 @@ + + + + + + Patchstack connect — test build + + + +
+

Patchstack connect test site

+

+ This page was produced by npm run build in the throwaway fixture: + the prebuild hook ran patchstack-connect scan (which + injected the widget tag below) and the postbuild hook ran + patchstack-connect mark-build (which stamped the production flag). +

+

+ The "Report a vulnerability" button should be floating in the bottom-right + corner. View the page source to see the managed widget tag and the + __PATCHSTACK_PROD__ marker. +

+
+ + diff --git a/test-build/site/style.css b/test-build/site/style.css new file mode 100644 index 0000000..25eadba --- /dev/null +++ b/test-build/site/style.css @@ -0,0 +1,25 @@ +body { + font-family: system-ui, sans-serif; + background: #12141a; + color: #e8eaf0; + display: grid; + place-items: center; + min-height: 100vh; + margin: 0; +} + +main { + max-width: 42rem; + padding: 2rem; + line-height: 1.6; +} + +h1 { + color: #afe614; +} + +code { + background: #1f2330; + padding: 0.15em 0.4em; + border-radius: 4px; +} diff --git a/tests/client.test.ts b/tests/client.test.ts index 7770fb5..dffad57 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -77,7 +77,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); const result = await postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, ); expect(result.stored).toBe(true); @@ -91,7 +91,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); await postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'sandbox' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, widget: true, environment: 'sandbox' }, { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, ); @@ -111,7 +111,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'SITE_NOT_FOUND' }); @@ -129,7 +129,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); @@ -140,7 +140,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toBeInstanceOf(PatchstackError); @@ -153,7 +153,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); await postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 12345, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 12345, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ); @@ -171,7 +171,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); const result = await postManifest( - { siteUuid: null, endpoint: 'https://example.com/monitor/pulse/manifest', timeoutMs: 30_000, environment: 'production' }, + { siteUuid: null, endpoint: 'https://example.com/monitor/pulse/manifest', timeoutMs: 30_000, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, ); @@ -188,7 +188,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 1, environment: 'production' }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 1, widget: true, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' }); diff --git a/tests/guide.test.ts b/tests/guide.test.ts index c0b3a04..471ce84 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { collectGuideState, + countRemainingSteps, detectPackageManager, findWidgetMarker, installCommand, @@ -215,7 +216,7 @@ describe('guide', () => { const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain(`userToken: '${VALID_UUID}'`); + expect(output).toContain(`data-site-uuid="${VALID_UUID}"`); expect(output).toContain('/monitor/claim?site='); }); @@ -251,10 +252,37 @@ describe('guide', () => { expect(state.widgetTokenMatches).toBe(false); const output = renderGuideChecklist(state, false); - expect(output).toContain("userToken doesn't match"); + expect(output).toContain("site UUID doesn't match"); expect(output).toContain(VALID_UUID); }); + it('treats "widget": false as a completed widget step', async () => { + writeJson('package.json', { + name: 'optout-app', + devDependencies: { '@patchstack/connect': '0.3.6' }, + scripts: { + prebuild: 'patchstack-connect scan', + postbuild: 'patchstack-connect mark-build', + }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID, widget: false }); + + const state = await collectGuideState(cwd); + expect(state.widgetOptOut).toBe(true); + expect(countRemainingSteps(state)).toBe(0); + + const output = renderGuideChecklist(state, false); + expect(output).toContain('Disclosure widget disabled by config'); + expect(output).not.toContain('✖'); + }); + + it('tells unprovisioned projects the first scan installs the widget', async () => { + writeJson('package.json', { name: 'fresh-app' }); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + expect(output).toContain('the first scan does this for you'); + }); + it('points at the project root when package.json is missing', async () => { const output = renderGuideChecklist(await collectGuideState(cwd), false); expect(output).toContain('No package.json found'); diff --git a/tests/widget.test.ts b/tests/widget.test.ts new file mode 100644 index 0000000..23b3d59 --- /dev/null +++ b/tests/widget.test.ts @@ -0,0 +1,136 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + WIDGET_MARKER_ATTR, + WIDGET_SCRIPT_URL, + buildWidgetTag, + ensureSourceWidget, + ensureWidgetInHtml, + findSourceShell, +} from '../src/widget.js'; + +const UUID_A = '550e8400-e29b-41d4-a716-446655440000'; +const UUID_B = '11111111-1111-1111-1111-111111111111'; + +const SHELL = ` + + x + +
+ + +`; + +describe('buildWidgetTag', () => { + it('emits the one-liner CDN tag with the ownership marker', () => { + const tag = buildWidgetTag(UUID_A); + expect(tag).toContain(`src="${WIDGET_SCRIPT_URL}"`); + expect(tag).toContain(`data-site-uuid="${UUID_A}"`); + expect(tag).toContain('defer'); + expect(tag).toContain(`${WIDGET_MARKER_ATTR}="true"`); + }); +}); + +describe('ensureWidgetInHtml', () => { + it('adds the managed tag immediately before ', () => { + const { html, action } = ensureWidgetInHtml(SHELL, UUID_A); + expect(action).toBe('added'); + const bodyClose = html.indexOf(''); + const tagIdx = html.indexOf(buildWidgetTag(UUID_A)); + expect(tagIdx).toBeGreaterThan(-1); + expect(tagIdx).toBeLessThan(bodyClose); + }); + + it('is idempotent — a second pass with the same UUID changes nothing', () => { + const first = ensureWidgetInHtml(SHELL, UUID_A); + const second = ensureWidgetInHtml(first.html, UUID_A); + expect(second.action).toBe('unchanged'); + expect(second.html).toBe(first.html); + }); + + it('updates the managed tag in place when the UUID changes', () => { + const first = ensureWidgetInHtml(SHELL, UUID_A); + const second = ensureWidgetInHtml(first.html, UUID_B); + expect(second.action).toBe('updated'); + expect(second.html).toContain(`data-site-uuid="${UUID_B}"`); + expect(second.html).not.toContain(UUID_A); + // still exactly one loader + expect(second.html.split(WIDGET_SCRIPT_URL).length - 1).toBe(1); + }); + + it('adopts a manual install without touching or duplicating it', () => { + const manual = SHELL.replace( + '', + ``, + ); + const { html, action } = ensureWidgetInHtml(manual, UUID_A); + expect(action).toBe('manual'); + expect(html).toBe(manual); + }); + + it('adopts a legacy PatchstackWidget.init() install', () => { + const legacy = SHELL.replace( + '', + ``, + ); + const { html, action } = ensureWidgetInHtml(legacy, UUID_A); + expect(action).toBe('manual'); + expect(html).toBe(legacy); + }); + + it('declines documents without ', () => { + const fragment = '
partial
'; + const { html, action } = ensureWidgetInHtml(fragment, UUID_A); + expect(action).toBe('no-body'); + expect(html).toBe(fragment); + }); +}); + +describe('findSourceShell / ensureSourceWidget', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(path.join(tmpdir(), 'ps-widget-')); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('prefers index.html over public/index.html', () => { + mkdirSync(path.join(cwd, 'public')); + writeFileSync(path.join(cwd, 'public', 'index.html'), SHELL); + writeFileSync(path.join(cwd, 'index.html'), SHELL); + expect(findSourceShell(cwd)).toBe('index.html'); + }); + + it('falls back to src/app.html (SvelteKit)', () => { + mkdirSync(path.join(cwd, 'src')); + writeFileSync(path.join(cwd, 'src', 'app.html'), SHELL); + expect(findSourceShell(cwd)).toBe('src/app.html'); + }); + + it('returns no-shell when there is nothing editable', () => { + expect(findSourceShell(cwd)).toBeNull(); + expect(ensureSourceWidget(cwd, UUID_A)).toEqual({ shell: null, action: 'no-shell' }); + }); + + it('writes the managed tag into the shell on disk', () => { + writeFileSync(path.join(cwd, 'index.html'), SHELL); + const result = ensureSourceWidget(cwd, UUID_A); + expect(result).toEqual({ shell: 'index.html', action: 'added' }); + const written = readFileSync(path.join(cwd, 'index.html'), 'utf8'); + expect(written).toContain(buildWidgetTag(UUID_A)); + }); + + it('does not rewrite the file when nothing changed', () => { + writeFileSync(path.join(cwd, 'index.html'), SHELL); + ensureSourceWidget(cwd, UUID_A); + const afterFirst = readFileSync(path.join(cwd, 'index.html'), 'utf8'); + const result = ensureSourceWidget(cwd, UUID_A); + expect(result.action).toBe('unchanged'); + expect(readFileSync(path.join(cwd, 'index.html'), 'utf8')).toBe(afterFirst); + }); +});