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); }