@patchstack/connect 0.3.28 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +37 -21
  2. package/README.md +24 -10
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1267 -224
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +419 -104
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +28 -9
  10. package/dist/index.d.ts +28 -9
  11. package/dist/index.js +412 -97
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1039 -235
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +663 -132
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +665 -134
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
  37. package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/AGENT-INSTALL.md CHANGED
@@ -8,8 +8,8 @@ Every command at a glance — what it does, whether it reads your source, what i
8
8
 
9
9
  | Command | What it does | Reads your source? | Writes to your project | Sends over the network |
10
10
  |---|---|---|---|---|
11
- | `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No — lockfile only (bun: enumerates `node_modules/`) | `.patchstackrc.json`; the widget `<script>` tag in the root HTML shell — only after a successful post | Package names + versions |
12
- | `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | No | Config, widget tag, guard files, `package.json` scripts | Package names + versions (via `scan`) |
11
+ | `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree | `.patchstackrc.json` (public: site UUID + settings); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions |
12
+ | `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | No | Config, widget tag, production marker, guard files, `package.json` scripts | Package names + versions (via `scan`) |
13
13
  | `map` | Local, read-only attack-surface analysis (entry points → inputs → sinks → evidence-backed flows). Never run by another command. | **Yes** — via the app's own TypeScript | Nothing (only the file named by `--out`) | Nothing — **unless `--upload`**: structure only (routes, parameter names, the package behind each sink, file:line). Never source code or env values |
14
14
  | `protect` | Install the always-on runtime guard; auto-wire known stacks, or scaffold a generic guard + print a wiring plan. `--check` verifies the guard is wired (exit 1 if not); `--demo` seeds a broad sample rule set. Runs automatically **only** via `setup` — never by `scan`, `guide`, `status`, or `mark-build`. | No — writes guard files, does not analyze your code | Guard/framework files (e.g. `middleware.ts`, `src/patchstack/`) | Nothing |
15
15
  | `demo node-serialize` | Production-backed walkthrough: confirm the vulnerable package is present, scan, wait for live rule `18843`, install + verify the guard, print test requests. Does not install the package or start/restart the app. | No | Same files as `scan` + `protect` | `scan` payload; polls the public Pulse rules endpoint (never the printed test requests) |
@@ -18,7 +18,7 @@ Every command at a glance — what it does, whether it reads your source, what i
18
18
  | `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify). | No | Nothing | Site-existence check |
19
19
  | `init <site-uuid>` | Optional: pre-seed `.patchstackrc.json` with an existing UUID. | No | `.patchstackrc.json` only | Nothing |
20
20
  | `mark-build` | Stamp built HTML with a production flag + build fingerprint and ensure the widget tag in built pages. Run as a `postbuild` step. | No | Build output only (`dist/ build/ out/ .output/public`) — never source | Nothing |
21
- | `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.json` on approval | Device-code request + approval poll |
21
+ | `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.local.json` on approval | Device-code request + approval poll |
22
22
  | `uninstall` | Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does **not** touch local files. | No | Nothing local | Removal signal |
23
23
 
24
24
  Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` transmits nothing but package names + versions — never source code, env var values, file paths, or git history.
@@ -28,7 +28,9 @@ Only `map` reads your source, and only `map --upload` sends anything derived fro
28
28
  - Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata.
29
29
  - **What is sent to Patchstack is the dependency list only** — read 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/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted. (`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.)
30
30
  - **One command reads source files:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. No other command reads source (`protect` writes guard files but does not analyze your code).
31
- - **`scan` makes one source edit, and only after a successful post:** it adds (or updates) the disclosure widget's `<script>` tag in the project's root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists. It touches no other file, never edits on `--dry-run` or after a failed post, leaves any pre-existing manual widget tag untouched, and is disabled entirely by `"widget": false` in `.patchstackrc.json`. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`.
31
+ - **`scan` makes up to two source edits, both in the project's root shell:** the disclosure widget's `<script>` tag, and the production marker. Neither runs on `--dry-run`, both are idempotent, both leave a pre-existing manual install untouched, and `"widget": false` in `.patchstackrc.json` disables both.
32
+ - 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.
33
+ - 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`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`.
32
34
  - **`setup` runs `scan`, then `protect`, then edits `package.json` scripts:** provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, adds `scan` after dependency installs and before builds, adds `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code.
33
35
  - 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 the Supabase client + `src/start.ts`), **Next.js** (scaffolds `middleware.ts`), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **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). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production).
34
36
  - **`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 Pulse 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.
@@ -67,7 +69,7 @@ Only `map` reads your source, and only `map --upload` sends anything derived fro
67
69
 
68
70
  ## Manual setup
69
71
 
70
- 1. **First scan** — provisions a Patchstack site automatically, writes the UUID to `.patchstackrc.json`, and installs the disclosure widget's `<script>` tag into the root HTML shell (`index.html`, `public/index.html`, or `src/app.html`) when one exists. No signup, dashboard step, or UUID is needed up front:
72
+ 1. **First scan** — provisions a Patchstack site automatically, writes the UUID to `.patchstackrc.json`, and installs the disclosure widget's `<script>` tag into the root HTML shell (`index.html`, `public/index.html`, or `src/app.html`) when one exists — or, when the root shell is JSX, the production marker instead. No signup, dashboard step, or UUID is needed up front:
71
73
 
72
74
  ```
73
75
  npx @patchstack/connect scan
@@ -91,7 +93,7 @@ Only `map` reads your source, and only `map --upload` sends anything derived fro
91
93
 
92
94
  **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build"`.
93
95
 
94
- 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 `</body>` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`:
96
+ 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 `</body>` (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:
95
97
 
96
98
  ```html
97
99
  <script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer></script>
@@ -99,9 +101,9 @@ Only `map` reads your source, and only `map --upload` sends anything derived fro
99
101
 
100
102
  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. The credential is the opposite, and `scan` writes it for you — **there is no manual step, and you should never invent or ask the user for this value**:
101
103
 
102
- - `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — one credential for both paths. It authenticates **Pulse ingest** (manifest, attack-surface map, package removal, rule detections), where it is exchanged for a short-lived token rather than sent directly, and **block-log reporting** through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.
104
+ - `apiKey` (in `.patchstackrc.local.json`, which is git-ignored; also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — one credential for both paths. It authenticates **Pulse ingest** (manifest, attack-surface map, package removal, rule detections), where it is exchanged for a short-lived token rather than sent directly, and **block-log reporting** through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.
103
105
 
104
- It is server-only. Never put it in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` in production; `.patchstackrc.json` is fine for local DX. If it is lost, `npx @patchstack/connect login` recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with `PATCHSTACK_TELEMETRY=off`. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.
106
+ It is server-only. Never put it in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` in production; the git-ignored `.patchstackrc.local.json` is fine for local DX. If it is lost, `npx @patchstack/connect login` recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with `PATCHSTACK_TELEMETRY=off`. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.
105
107
 
106
108
  A `pulseAuth` field is still honoured if a project has one, and `PATCHSTACK_PULSE_AUTH` still overrides it, for deployments that authenticate Pulse ingest with a different credential from block-logs. Do not add either yourself: they are unnecessary when the two share one credential, which is the default.
107
109
 
@@ -114,7 +116,9 @@ It is server-only. Never put it in the widget tag, client bundles, or public env
114
116
 
115
117
  `setup` performs both steps automatically. The explicit commands are for manual setup or repair. If verification reports a generic or existing framework seam, complete the printed source edit and re-run `--check`; do not report protection as active until it exits successfully.
116
118
 
117
- 5. **Commit** `.patchstackrc.json`, the updated `package.json`, the guard/framework source changes, and the layout/HTML file carrying the widget tag, so every developer and CI run reports to the same site.
119
+ 5. **Commit** `.patchstackrc.json`, the updated `package.json`, the guard/framework source changes, and the layout/HTML file carrying the widget tag (and the production marker, when `scan` wrote one into a JSX root), so every developer and CI run reports to the same site.
120
+
121
+ **Do not commit `.patchstackrc.local.json`.** That file holds the API key issued at provision; the scan writes it and adds it to `.gitignore`, and tells you if it could not. `.patchstackrc.json` holds only the site UUID and settings, and the UUID is public by design — it ships in the widget tag in served HTML.
118
122
 
119
123
  6. **Open the dashboard link** from the scan in a browser and sign in. The site is monitored either way, but the vulnerability reports are only visible after connecting it to an account. The same connection flow is available 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.
120
124
 
@@ -124,7 +128,7 @@ It is server-only. Never put it in the widget tag, client bundles, or public env
124
128
  - The CLI never opens the dashboard link and never asks for Patchstack credentials.
125
129
  - Label hosted workspace scans with `PATCHSTACK_ENVIRONMENT=sandbox` in that process only. Leave production builds unset (the default is `production`) and never commit a sandbox label into files shared with production.
126
130
  - If a step fails, stop and report it. Don't proceed with placeholders.
127
- - In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_API_KEY` as env vars instead. Precedence: CLI flag → env var → `.patchstackrc.json`. `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment.
131
+ - CI never has the credential in a file: `.patchstackrc.local.json` is git-ignored by design, so set `PATCHSTACK_API_KEY` as an env var there (and `PATCHSTACK_SITE_UUID` too where `.patchstackrc.json` is also absent). Precedence for the site UUID and settings: CLI flag → env var → `.patchstackrc.json`. For the API key: env var → `.patchstackrc.local.json` → `.patchstackrc.json` (where installs made before the split still hold it). `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment.
128
132
 
129
133
  ## Runtime guard reporting
130
134
 
@@ -138,13 +142,15 @@ would have stopped while it is still in dry-run. Two separate paths, with differ
138
142
  `reportFirewallLog: false` in `createProtection`.
139
143
  - **Every rule that matched** goes to `monitor/pulse/detections/<your site uuid>` — including matches that
140
144
  blocked, which are reported on both paths. This is **off unless you pass `reportDetections: true`** to
141
- `createProtection`; the scaffolded guard does not pass it. It also requires a provisioned site UUID and
142
- is disabled by `PATCHSTACK_TELEMETRY=off`. It exists because a rule carrying `dry-run` blocks nothing,
143
- so without it nothing distinguishes a rule that is protecting from one that is quietly wrong.
145
+ `createProtection`; the scaffolded guard does not pass it. It also requires a provisioned site UUID, a
146
+ resolvable credential, and is disabled by `PATCHSTACK_TELEMETRY=off`. It exists because a rule carrying
147
+ `dry-run` blocks nothing, so without it nothing distinguishes a rule that is protecting from one that is
148
+ quietly wrong.
144
149
 
145
150
  What a detection report contains, per matched rule: the rule id, the request path **with any query string
146
151
  removed**, the parameter names that rule reads (from the rule's own definition), which phase matched,
147
- whether it was enforced, the identifier of the rule bundle in use, and a timestamp. Each batch also
152
+ whether it was enforced, the identifier of the rule bundle in use, the revision of the rule itself when the
153
+ bundle carried one, and a timestamp. Each batch also
148
154
  carries a count of reports dropped when traffic outran the flush, so a partial sample is not read as a
149
155
  complete one.
150
156
 
@@ -159,6 +165,16 @@ and not the value of any header, cookie or query-string parameter — including
159
165
  named above. Reports are batched, capped in memory, and dropped rather than retried if Patchstack cannot
160
166
  be reached — a reporting failure never delays or fails a request.
161
167
 
168
+ The endpoint needs a credential, so `reportDetections: true` with none resolved starts nothing: the guard
169
+ warns once at boot and `protection.detectionReporting` reads `unavailable-no-credential` instead of `on`.
170
+ When reporting is on, `protection.detectionHealth()` returns local counts — detections attempted,
171
+ acknowledged, refused or unreachable, dropped for queue pressure — and the time of the last
172
+ acknowledgement. Those counts stay in your process; nothing extra is sent to report them.
173
+
174
+ `protection.stop()` stops everything the guard has running in the background — the rule-refresh loop, the
175
+ block-log reporter, the detection reporter — and flushes what is buffered. `protection.stopRefresh()` is
176
+ the same method under its older name. Call it on shutdown; it is safe to call twice.
177
+
162
178
  Two more endpoints the package can call, for completeness:
163
179
 
164
180
  - `GET monitor/widget/settings/<your site uuid>` — how `status` tells "this site was deleted on
@@ -181,13 +197,13 @@ These are **two independent states** — never conflate them:
181
197
  1. **The site record on Patchstack** (remote). Deleting the site in the dashboard or through the widget's uninstall flow removes it. Reporting stops and the widget stops rendering, but nothing in the project changes.
182
198
  2. **The local integration** (this repo): the widget `<script>` tag, `.patchstackrc.json`, the `@patchstack/connect` dependency, the runtime guard files, and the build hooks.
183
199
 
184
- Local files alone cannot tell you whether the site was removed from Patchstack. Run `npx @patchstack/connect status` and read the `Site status` line, then answer with both states. For example, when the site was removed but the local files remain, say: *"The site itself was removed from Patchstack — reporting has stopped and the widget no longer renders. The local integration code (widget tag, `.patchstackrc.json`, the dependency) is still in the project; want me to remove it?"* — not "Patchstack is still installed."
200
+ Local files alone cannot tell you whether the site was removed from Patchstack. Run `npx @patchstack/connect status` and read the `Site status` line, then answer with both states. For example, when the site was removed but the local files remain, say: *"The site itself was removed from Patchstack — reporting has stopped and the widget no longer renders. The local integration code (widget tag, `.patchstackrc.json`, `.patchstackrc.local.json`, the dependency) is still in the project; want me to remove it?"* — not "Patchstack is still installed."
185
201
 
186
202
  ## Recovering a lost credential — `login`
187
203
 
188
- Use this when the project **already has a site** but its credential is gone or rejected: `.patchstackrc.json` was deleted or never committed, the repo was cloned without it, a container was recycled, or ingest started failing with 401.
204
+ Use this when the project **already has a site** but its credential is gone or rejected: `.patchstackrc.local.json` was deleted, the repo was cloned without it (it is git-ignored, so a fresh clone never has it), a container was recycled, or ingest started failing with 401. The site UUID `login` needs comes from the committed `.patchstackrc.json`.
189
205
 
190
- > **Do not "fix" a missing credential by deleting `.patchstackrc.json` and running `scan` again.** That provisions a **second site**, and the original — with all its history and its widget tag already live on the deployed page — is orphaned. `login` recovers the existing one.
206
+ > **Do not "fix" a missing credential by deleting `.patchstackrc.json` and running `scan` again.** That file holds the site UUID, and without it `scan` provisions a **second site** — the original, with all its history and its widget tag already live on the deployed page, is orphaned. `login` recovers the credential for the site you already have.
191
207
 
192
208
  ### What it does
193
209
 
@@ -200,7 +216,7 @@ npx @patchstack/connect login
200
216
  Waiting for approval… ✓ Credential restored
201
217
  ```
202
218
 
203
- The command asks Patchstack for a short code, prints a link, and polls until the site's **owner approves it in the dashboard**. On approval it writes the new credential into `.patchstackrc.json` and exits. The link opens the approval page with the code already filled in, so the person only has to confirm.
219
+ The command asks Patchstack for a short code, prints a link, and polls until the site's **owner approves it in the dashboard**. On approval it writes the new credential into `.patchstackrc.local.json`, reports whether that file is covered by `.gitignore`, and exits. The link opens the approval page with the code already filled in, so the person only has to confirm.
204
220
 
205
221
  ### What you must do, as the agent — two commands, not one
206
222
 
@@ -233,7 +249,7 @@ You cannot complete this alone. It is deliberately a human-in-the-loop step: sta
233
249
 
234
250
  | Situation | What happens | What to do |
235
251
  |---|---|---|
236
- | Site was never claimed | `409` — no owner exists to approve | Ask the user to claim the site in the dashboard first, or, if the site is disposable, delete `.patchstackrc.json` and `scan` to provision a fresh one |
252
+ | Site was never claimed | `409` — no owner exists to approve | Ask the user to claim the site in the dashboard first, or, if the site is disposable, delete `.patchstackrc.json` **and** `.patchstackrc.local.json` and `scan` to provision a fresh one — leaving the old credential behind means the next scan starts out holding one that belongs to a different site |
237
253
  | Running in CI | Refuses to start | CI takes its credential from `PATCHSTACK_PULSE_AUTH`; `login` is for a developer machine |
238
254
  | No `siteUuid` configured | Refuses to start | There is no site to recover — run `scan` |
239
255
  | Code expired | `--wait` ends after 10 minutes | Start again from step 1 for a new code |
@@ -249,8 +265,8 @@ Remove only the pieces that are actually present — check for each first. If no
249
265
  4. **Remove the hooks from `package.json` scripts.** If a hook was chained (e.g. `"postbuild": "existing-command && patchstack-connect mark-build"`), remove only the `patchstack-connect …` part and keep the rest; if removal leaves a script empty, delete the key.
250
266
  5. **Signal Patchstack that the package is being removed**: run `npx @patchstack/connect uninstall` (while the package is still installed and `.patchstackrc.json` still exists). If the site was never claimed, this deletes its anonymous record on Patchstack; if the site is claimed, it is only flagged — the record stays until its owner removes it in the dashboard. A failed signal must not stop the uninstall; continue with the remaining steps.
251
267
  6. **Uninstall the package** with the manager matching the lockfile: `npm uninstall` / `pnpm remove` / `yarn remove` / `bun remove` `@patchstack/connect`. Don't hand-edit `node_modules` or the lockfile.
252
- 7. **Delete `.patchstackrc.json`** and remove `PATCHSTACK_SITE_UUID`, `PATCHSTACK_API_KEY` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
253
- 8. **Commit** the changes. Reporting stops immediately. The `window.__PATCHSTACK_PROD__` flag that `mark-build` injected lives only in build output, never in source — the next build simply won't contain it (rebuild if build output is committed).
268
+ 7. **Delete `.patchstackrc.json` and `.patchstackrc.local.json`** (the second holds the API key and is git-ignored, so it is present locally even when the repo shows nothing), remove the `.gitignore` entry setup added for it, and remove `PATCHSTACK_SITE_UUID`, `PATCHSTACK_API_KEY` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
269
+ 8. **Commit** the changes. Reporting stops immediately. On HTML shells the `window.__PATCHSTACK_PROD__` flag that `mark-build` stamped lives only in build output — the next build simply won't contain it (rebuild if build output is committed). On JSX roots `scan` wrote the same marker into source; remove that managed `#region patchstack` block (or the hand-pasted equivalent) with the widget tag in step 2.
254
270
 
255
271
  The `uninstall` signal is the only account-side effect local removal can have: it deletes an *unclaimed* (anonymous) record and merely flags a *claimed* one. A claimed site keeps using a site slot until its owner removes it in the dashboard at https://app.patchstack.com — end your report by telling the user this, alongside the site UUID from step 1. Never attempt to authenticate or remove a claimed site on the user's behalf.
256
272
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ That's it. `setup`:
28
28
  2. POSTs the package list to Patchstack with **no** UUID.
29
29
  3. Patchstack provisions a fresh site and returns its UUID.
30
30
  4. The connector writes the UUID to `.patchstackrc.json` so the next `scan` targets the same site.
31
- 5. The connector installs the disclosure widget's `<script>` tag into your root HTML shell (see *The disclosure widget* below) so the "Report a vulnerability" button shows up on the next preview reload.
31
+ 5. The connector installs the disclosure widget's `<script>` tag into your root HTML shell (see *The disclosure widget* below) so the "Report a vulnerability" button shows up on the next preview reload. On a server-rendered root it also adds the production marker, which is what tells the widget to switch from build mode to visitor report intake on the published site.
32
32
  6. Installs the runtime guard after provisioning, bakes the site UUID into it, and verifies the framework seam. Known server stacks are auto-wired; unmatched or conflicting layouts get a generic scaffold and exact manual checks.
33
33
  7. Adds `postinstall: patchstack-connect scan`, preserving any existing command, so dependencies added during a sandbox session and build-less production installs are reported immediately.
34
34
  8. Wires `scan` before builds and `mark-build` after builds, preserving existing commands and using direct build chaining for Bun.
@@ -51,8 +51,10 @@ patchstack-connect scan [options] Scan the lockfile and POST to
51
51
  If no UUID is configured the server provisions
52
52
  one and the connector persists it. After a
53
53
  successful post, adds/updates the disclosure
54
- widget tag in the root HTML shell (opt out
55
- with "widget": false in .patchstackrc.json)
54
+ widget tag in the root HTML shell. Also adds the
55
+ production marker to a JSX root shell, before the
56
+ post (opt out of both with "widget": false in
57
+ .patchstackrc.json)
56
58
  patchstack-connect setup [options] Run scan, manage the widget, and idempotently
57
59
  install + verify runtime protection and wire
58
60
  dependency/build scans. Never runs the build
@@ -104,7 +106,8 @@ Precedence (highest wins):
104
106
 
105
107
  1. CLI flag (`--site-uuid`, `--endpoint`)
106
108
  2. Environment variable
107
- 3. `.patchstackrc.json` in the current directory
109
+ 3. `.patchstackrc.local.json` in the current directory (the credential)
110
+ 4. `.patchstackrc.json` in the current directory
108
111
 
109
112
  Environment variables:
110
113
 
@@ -113,27 +116,36 @@ Environment variables:
113
116
  - `PATCHSTACK_TIMEOUT_MS` — request timeout in milliseconds (default `30000`)
114
117
  - `PATCHSTACK_ENVIRONMENT` — manifest label: `production` (default) or `sandbox`
115
118
 
116
- `.patchstackrc.json` example:
119
+ Two files, because one value is public and the other is not.
120
+
121
+ `.patchstackrc.json` — commit it:
117
122
 
118
123
  ```json
119
124
  {
120
125
  "siteUuid": "550e8400-e29b-41d4-a716-446655440000",
121
- "apiKey": "…",
122
126
  "widget": true
123
127
  }
124
128
  ```
125
129
 
130
+ `.patchstackrc.local.json` — the credential. `scan` writes it owner-only and adds it to your `.gitignore`, and says so if it could not:
131
+
132
+ ```json
133
+ {
134
+ "apiKey": "…"
135
+ }
136
+ ```
137
+
126
138
  `"widget"` is optional and defaults to `true`; set it to `false` to stop the connector from managing the disclosure-widget tag (see *The disclosure widget*).
127
139
 
128
140
  **You do not write `apiKey` yourself.** The first `scan` provisions the site and the connector saves it, so setup needs no manual step.
129
141
 
130
142
  The site UUID identifies the site and is **not** a secret — the disclosure widget ships the same UUID in client-side HTML.
131
143
 
132
- `apiKey` **is** a secret. One credential authenticates both paths: Pulse ingest (manifest, attack-surface map, package removal), where it is exchanged for a short-lived token rather than sent directly, and block-log reporting. Keep it out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`). For deploys, prefer `PATCHSTACK_API_KEY` in the platform's secret store over the committed file.
144
+ `apiKey` **is** a secret. One credential authenticates both paths: Pulse ingest (manifest, attack-surface map, package removal), where it is exchanged for a short-lived token rather than sent directly, and block-log reporting. Keep it out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`), and out of the committed config — `.patchstackrc.local.json` is git-ignored for that reason. For deploys, prefer `PATCHSTACK_API_KEY` in the platform's secret store.
133
145
 
134
146
  If it is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
135
147
 
136
- In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_API_KEY`. Precedence is CLI flag → env var → `.patchstackrc.json`.
148
+ The credential's file is never committed, so CI needs `PATCHSTACK_API_KEY` in the environment (and `PATCHSTACK_SITE_UUID` too where `.patchstackrc.json` is also absent). Precedence is CLI flag → env var → `.patchstackrc.local.json` → `.patchstackrc.json`.
137
149
 
138
150
  A `pulseAuth` field is still read if present, and `PATCHSTACK_PULSE_AUTH` still overrides, for deployments that authenticate Pulse ingest with a different credential from block-logs. Neither is written by default, and neither is needed when the two share one.
139
151
 
@@ -180,11 +192,13 @@ The widget is a floating "Report a vulnerability" button — a disclosure channe
180
192
  <script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer data-patchstack-connect-widget="true"></script>
181
193
  ```
182
194
 
183
- 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.
195
+ - **`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.
196
+
197
+ 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.
184
198
 
185
199
  - **`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).
186
200
 
187
- - **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.
201
+ - **Opting out:** persist `"widget": false` in `.patchstackrc.json` to disable both the widget tag and the production marker (dependency scanning only). Without it, the next successful scan re-adds the managed tag, and the next scan re-adds the marker on a JSX root.
188
202
 
189
203
  ## Programmatic API
190
204
 
@@ -84,4 +84,4 @@ export {
84
84
  pulseAuthHeader,
85
85
  pulseFetch
86
86
  };
87
- //# sourceMappingURL=chunk-MJOTFUDE.js.map
87
+ //# sourceMappingURL=chunk-LLKP5EJS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/pulse-token.ts"],"sourcesContent":["import type { Config } from './types.js';\n\n/**\n * Bearer tokens for the authenticated Pulse endpoints (ADR-0018).\n *\n * Deliberately separate from the block-log token flow in\n * `src/protect/firewall-log.js`: that path talks to the auth/ Lambda's\n * /oauth/token and must keep working exactly as it does today.\n */\n\nconst TOKEN_SKEW_MS = 60_000;\n\n/** Build the Pulse token URL corresponding to a manifest endpoint override. */\nexport function buildTokenUrl(manifestEndpoint: string): string {\n const url = new URL(manifestEndpoint);\n const path = url.pathname.replace(/\\/$/, '');\n url.pathname = path.endsWith('/manifest')\n ? `${path.slice(0, -'/manifest'.length)}/token`\n : '/monitor/pulse/token';\n url.search = '';\n url.hash = '';\n return url.toString();\n}\n\n/** Split the WP-format `{secret}-{oauth.id}` credential on its last hyphen. */\nexport function parsePulseAuth(credential: string): { clientId: string; clientSecret: string } | null {\n const index = credential.lastIndexOf('-');\n if (index <= 0 || index === credential.length - 1) return null;\n\n const clientId = credential.slice(index + 1);\n if (!/^\\d+$/.test(clientId)) return null;\n\n return { clientId, clientSecret: credential.slice(0, index) };\n}\n\nlet cached: { token: string; expiresAt: number } | null = null;\nlet inflight: Promise<string | null> | null = null;\n\n/** Drops the cached token. Exported for tests and for 401 handling. */\nexport function clearPulseToken(): void {\n cached = null;\n}\n\n/**\n * Resolve a bearer token for `config.pulseAuth`, exchanging one if needed.\n *\n * Returns null whenever a token cannot be obtained — no credential, a rejected\n * exchange, a network failure. Callers then send the request unauthenticated,\n * and every site-addressed Pulse endpoint refuses it: only a first-time\n * provisioning call is anonymous. Returning null rather than throwing keeps that\n * refusal on the caller's own error path, where it can fall back or report.\n */\nexport async function getPulseToken(\n config: Config,\n fetchImpl: typeof fetch = fetch,\n): Promise<string | null> {\n // Not `=== null`: Config is public, so callers can hand us an object that\n // predates this field, and an unusable credential must never throw here.\n if (typeof config.pulseAuth !== 'string' || config.pulseAuth.length === 0) return null;\n\n if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {\n return cached.token;\n }\n if (inflight !== null) return inflight;\n\n const credentials = parsePulseAuth(config.pulseAuth);\n if (credentials === null) return null;\n\n inflight = (async () => {\n try {\n const response = await fetchImpl(buildTokenUrl(config.endpoint), {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n 'User-Agent': '@patchstack/connect',\n },\n body: JSON.stringify({\n grant_type: 'client_credentials',\n client_id: credentials.clientId,\n client_secret: credentials.clientSecret,\n }),\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n\n if (!response.ok) return null;\n\n const body = (await response.json()) as { access_token?: unknown; expires_in?: unknown };\n if (typeof body.access_token !== 'string' || body.access_token.length === 0) return null;\n\n const expiresIn = Number(body.expires_in);\n const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 3600_000;\n cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };\n\n return body.access_token;\n } catch {\n return null;\n } finally {\n inflight = null;\n }\n })();\n\n return inflight;\n}\n\n/**\n * `Authorization` header for a Pulse request, or `{}` when unauthenticated.\n * Spread into an existing header object so call sites stay one line.\n */\nexport async function pulseAuthHeader(\n config: Config,\n fetchImpl: typeof fetch = fetch,\n): Promise<Record<string, string>> {\n const token = await getPulseToken(config, fetchImpl);\n return token === null ? {} : { Authorization: `Bearer ${token}` };\n}\n\n/**\n * Send a Pulse request, attaching the bearer token and retrying once if the\n * server rejects it.\n *\n * A cached token can stop being valid before it expires — the credential may\n * have been rotated or revoked meanwhile — so the server's 401 is authoritative\n * over our own clock. Without this a long-running process would keep presenting\n * a dead token until its local expiry.\n *\n * Only 401 retries: a 403 is a scope or site mismatch, which a fresh token\n * would not fix.\n */\nexport async function pulseFetch(\n config: Config,\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch = fetch,\n): Promise<Response> {\n const send = async () => {\n const auth = await pulseAuthHeader(config, fetchImpl);\n const response = await fetchImpl(url, {\n ...init,\n headers: { ...(init.headers as Record<string, string> | undefined), ...auth },\n });\n\n return { response, authenticated: auth.Authorization !== undefined };\n };\n\n const first = await send();\n\n // Retrying an unauthenticated request would just repeat it: the 401 was\n // about something other than our token.\n if (first.response.status === 401 && first.authenticated) {\n clearPulseToken();\n\n return (await send()).response;\n }\n\n return first.response;\n}\n"],"mappings":"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","names":[]}