diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md
index 107c971..c082da8 100644
--- a/AGENT-INSTALL.md
+++ b/AGENT-INSTALL.md
@@ -76,12 +76,13 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
```
- 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 credentials are the opposite, and `scan` writes both of them for you — **there is no manual step, and you should never invent or ask the user for these values**:
+ 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**:
-- `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — authenticates block-log reporting through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.
-- `pulseAuth` (also `PATCHSTACK_PULSE_AUTH`) — authenticates Pulse ingest: the manifest, the attack-surface map and package removal. Exchanged for a short-lived token rather than sent directly. Falls back to `apiKey` when absent, so older projects keep working.
+- `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), 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.
-Both are server-only. Never put either in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` / `PATCHSTACK_PULSE_AUTH` in production; `.patchstackrc.json` is fine for local DX. If a credential 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.
+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.
+
+ 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.
4. **Install and verify runtime protection:**
@@ -102,7 +103,7 @@ Both are server-only. Never put either in the widget tag, client bundles, or pub
- The CLI never opens the dashboard link and never asks for Patchstack credentials.
- 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.
- If a step fails, stop and report it. Don't proceed with placeholders.
-- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH` 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.
+- 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.
## Verifying the install
diff --git a/README.md b/README.md
index b1d8d94..4c86cd3 100644
--- a/README.md
+++ b/README.md
@@ -119,22 +119,23 @@ Environment variables:
{
"siteUuid": "550e8400-e29b-41d4-a716-446655440000",
"apiKey": "…",
- "pulseAuth": "…",
"widget": true
}
```
`"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*).
-**You do not write `apiKey` or `pulseAuth` yourself.** The first `scan` provisions the site and the connector saves both, so setup needs no manual step. They hold the same value today and exist as separate fields so Pulse ingest and block-log reporting can diverge later.
+**You do not write `apiKey` yourself.** The first `scan` provisions the site and the connector saves it, so setup needs no manual step.
The site UUID identifies the site and is **not** a secret — the disclosure widget ships the same UUID in client-side HTML.
-`apiKey` and `pulseAuth` **are** secrets. `apiKey` authenticates block-log reporting; `pulseAuth` authenticates Pulse ingest (manifest, attack-surface map, package removal) and is exchanged for a short-lived token rather than sent directly. Keep both out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`). For deploys, prefer `PATCHSTACK_API_KEY` and `PATCHSTACK_PULSE_AUTH` in the platform's secret store over the committed file.
+`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.
-If a credential is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
+If it is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
-In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH`. Precedence is CLI flag → env var → `.patchstackrc.json`.
+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`.
+
+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.
### Sandbox and production manifests
diff --git a/src/cli.ts b/src/cli.ts
index 8e4e84c..e82548d 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -22,7 +22,7 @@ import {
resolveDemoScenario,
waitForDemoRule,
} from './demo.js';
-import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
+import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
findHtmlFiles,
@@ -126,7 +126,7 @@ Options (for demo and demo-guide):
Environment:
PATCHSTACK_SITE_UUID Site UUID
PATCHSTACK_API_KEY WP-format site API key for block-log reporting (never put in the widget)
- PATCHSTACK_PULSE_AUTH Credential for authenticated Pulse ingest (defaults to PATCHSTACK_API_KEY)
+ PATCHSTACK_PULSE_AUTH Only if Pulse ingest uses a different credential from block-logs
PATCHSTACK_TELEMETRY Set to off to disable block-log reporting
PATCHSTACK_API_BASE API origin for /oauth/token and /api/logs/log (default: https://api.patchstack.com)
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
@@ -404,10 +404,10 @@ async function runScan(
console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
+ // One credential for both paths: Pulse resolution falls back to apiKey, so
+ // a second copy under pulseAuth bought nothing except an obligation to keep
+ // the two in step. Never printed — only the path it landed in.
const target = await persistApiKey(process.cwd(), response.api_key);
- // Written to both fields so the Pulse and block-log paths can diverge later
- // without a re-provision. Never printed — only the path it landed in.
- await persistPulseAuth(process.cwd(), response.api_key);
console.log(`Saved API key to ${target} (authenticates Pulse ingest and block-log reporting; keep out of the public widget).`);
}
diff --git a/src/config.ts b/src/config.ts
index fe2a8eb..30599f3 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -110,17 +110,25 @@ export async function persistSiteUuid(cwd: string, siteUuid: string): Promise {
- const existing = await readConfigFile(cwd);
+ const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
return writeConfigFile(cwd, { ...existing, apiKey });
}
/**
- * Persist the credential used for the authenticated Pulse endpoints.
- * Kept separate from `apiKey` so block-log auth is never disturbed.
+ * Persist a Pulse-specific credential.
+ *
+ * Only needed when Pulse ingest and block-log reporting must use *different*
+ * credentials; they share one today, so `persistApiKey` covers both. Retained
+ * for callers that separate them.
*/
export async function persistPulseAuth(cwd: string, pulseAuth: string): Promise {
const existing = await readConfigFile(cwd);
diff --git a/src/index.ts b/src/index.ts
index b1ee3af..c3d6bfc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,7 @@
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { postManifest } from './client.js';
-import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig } from './config.js';
+import { persistApiKey, persistSiteUuid, resolveConfig } from './config.js';
import type { Config, Manifest, StoreManifestResponse } from './types.js';
export { scanLockfile, detectLockfile } from './parsers/index.js';
@@ -63,7 +63,6 @@ export async function scanAndReport(
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
await persistApiKey(cwd, response.api_key);
- await persistPulseAuth(cwd, response.api_key);
}
return {
diff --git a/src/login.ts b/src/login.ts
index f295323..c7ab682 100644
--- a/src/login.ts
+++ b/src/login.ts
@@ -1,4 +1,4 @@
-import { persistApiKey, persistPulseAuth } from './config.js';
+import { persistApiKey } from './config.js';
import type { Config } from './types.js';
/**
@@ -104,11 +104,10 @@ export async function login(
return { status: 'failed', message: 'Patchstack approved the request but returned no credential.' };
}
- // Approving rotates the site's single OAuth secret, which block-log
- // reporting also authenticates with. Both fields must therefore be
- // refreshed — writing only pulseAuth would leave apiKey holding a secret
- // the server has just invalidated, silently breaking block-logs.
- await persistPulseAuth(process.cwd(), apiKey);
+ // Approving rotates the site's single OAuth secret, which Pulse ingest and
+ // block-log reporting both authenticate with. persistApiKey also clears any
+ // pulseAuth an earlier version wrote, so nothing is left holding the value
+ // the server has just replaced.
await persistApiKey(process.cwd(), apiKey);
return { status: 'approved', userCode, verificationUri };
diff --git a/tests/config.test.ts b/tests/config.test.ts
index f9b9844..1e794a3 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
-import { persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js';
+import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js';
import { readFile } from 'node:fs/promises';
import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from '../src/client.js';
import { PatchstackError } from '../src/types.js';
@@ -125,3 +125,54 @@ describe('resolveConfig', () => {
});
});
});
+
+/**
+ * One credential authenticates Pulse ingest and block-log reporting. These pin
+ * the two things that must stay true for configs written by earlier versions.
+ */
+describe('credential resolution', () => {
+ let cwd: string;
+
+ beforeEach(async () => {
+ cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-'));
+ delete process.env.PATCHSTACK_API_KEY;
+ delete process.env.PATCHSTACK_PULSE_AUTH;
+ });
+
+ afterEach(() => rm(cwd, { recursive: true, force: true }));
+
+ it('uses apiKey for Pulse when no pulseAuth is present', async () => {
+ await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'secret-987' });
+
+ const config = await resolveConfig({ cwd });
+
+ expect(config.apiKey).toBe('secret-987');
+ expect(config.pulseAuth).toBe('secret-987');
+ });
+
+ it('still honours a pulseAuth written by an earlier version', async () => {
+ await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'a-1', pulseAuth: 'b-2' });
+
+ expect((await resolveConfig({ cwd })).pulseAuth).toBe('b-2');
+ });
+
+ it('drops a stale pulseAuth when the credential is replaced', async () => {
+ // pulseAuth resolves ahead of apiKey, so a leftover copy would keep
+ // authenticating Pulse with the value the server just replaced.
+ await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'old-1', pulseAuth: 'old-1' });
+
+ await persistApiKey(cwd, 'rotated-2');
+
+ const config = await resolveConfig({ cwd });
+ expect(config.apiKey).toBe('rotated-2');
+ expect(config.pulseAuth).toBe('rotated-2');
+ expect(JSON.parse(await readFile(path.join(cwd, '.patchstackrc.json'), 'utf8')).pulseAuth).toBeUndefined();
+ });
+
+ it('lets PATCHSTACK_PULSE_AUTH override the file', async () => {
+ await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'file-1' });
+ process.env.PATCHSTACK_PULSE_AUTH = 'env-2';
+
+ expect((await resolveConfig({ cwd })).pulseAuth).toBe('env-2');
+ });
+});
diff --git a/tests/login.test.ts b/tests/login.test.ts
index ea1a202..ea1b55c 100644
--- a/tests/login.test.ts
+++ b/tests/login.test.ts
@@ -54,11 +54,11 @@ describe('login', () => {
'https://api.patchstack.com/monitor/pulse/device?code=WDJB-MJHT',
);
- // Both fields: approving rotates the one secret block-logs use too, so
- // leaving apiKey behind would break block-log reporting.
+ // One credential for both paths. Pulse resolution falls back to apiKey,
+ // so a second copy is not written.
const written = JSON.parse(readFileSync('.patchstackrc.json', 'utf8'));
- expect(written.pulseAuth).toBe('new-secret-987');
expect(written.apiKey).toBe('new-secret-987');
+ expect(written.pulseAuth).toBeUndefined();
} finally {
process.chdir(original);
}