diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md
index 5a41e38..107c971 100644
--- a/AGENT-INSTALL.md
+++ b/AGENT-INSTALL.md
@@ -76,7 +76,12 @@ 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 `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) is the opposite: server-only, used to authenticate block-log reporting through the existing connector `POST /api/logs/log` so "Threats blocked" fills in the dashboard. Never put `apiKey` 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. 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.
+ 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**:
+
+- `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.
+
+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.
4. **Install and verify runtime protection:**
@@ -97,7 +102,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
- 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` as an env var instead. Precedence: CLI flag → env var → `.patchstackrc.json`.
+- 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.
## Verifying the install
diff --git a/README.md b/README.md
index 0e47365..b1d8d94 100644
--- a/README.md
+++ b/README.md
@@ -118,13 +118,23 @@ Environment variables:
```json
{
"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*).
-The site UUID identifies the site; it is not a secret — the disclosure widget ships the same UUID in client-side HTML, and committing `.patchstackrc.json` is the intended workflow so every developer and CI run reports to the same site. Possession of the UUID lets someone submit dependency manifests for that site (noise, not data access). In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` instead.
+**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.
+
+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.
+
+If a credential 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`.
### Sandbox and production manifests
diff --git a/src/cli.ts b/src/cli.ts
index 9ff722f..8e4e84c 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -22,7 +22,7 @@ import {
resolveDemoScenario,
waitForDemoRule,
} from './demo.js';
-import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
+import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
findHtmlFiles,
@@ -36,6 +36,7 @@ import {
installCommand,
renderGuideChecklist,
} from './guide.js';
+import { login } from './login.js';
import { runProtect, runVerify } from './protect/install/index.js';
import { buildInputMap } from './map/index.js';
import { isProvenFlow } from './map/coordinates.js';
@@ -102,6 +103,11 @@ Usage:
what's missing, with tailored commands), then
print the full setup guide. --full prints the
guide even when setup is complete
+ patchstack-connect login [options] Recover this site's Patchstack credential when
+ .patchstackrc.json has been lost. Prints a short
+ code to approve in the dashboard; approving
+ rotates the credential, so the old one stops
+ working
patchstack-connect help Print this message
Options (for scan, setup, status, and uninstall):
@@ -120,6 +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_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)
@@ -205,6 +212,37 @@ async function runInit(args: ParsedArgs): Promise {
return 0;
}
+async function runLogin(args: ParsedArgs): Promise {
+ // CI has no browser and no human; build agents must not print credentials
+ // into logs. Deploys use PATCHSTACK_PULSE_AUTH from the platform's secrets.
+ if (process.env.CI !== undefined && process.env.CI !== '' && process.env.CI !== 'false') {
+ console.error('`login` is interactive and cannot run in CI. Set PATCHSTACK_PULSE_AUTH instead.');
+ return 1;
+ }
+
+ const config = await resolveConfig({
+ cwd: process.cwd(),
+ cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
+ cliEndpoint: getStringFlag(args.flags, 'endpoint'),
+ });
+
+ const result = await login(config, (userCode, verificationUri) => {
+ console.log(`\n Your code: ${userCode}`);
+ console.log(` Approve at: ${verificationUri}\n`);
+ console.log(' Waiting for approval…');
+ });
+
+ if (result.status === 'approved') {
+ // The value itself is never printed — only that it landed.
+ console.log('\n ✓ Credential restored and saved to .patchstackrc.json.\n');
+ return 0;
+ }
+
+ console.error(`\n ${result.message ?? 'Login failed.'}\n`);
+
+ return 1;
+}
+
async function runMap(args: ParsedArgs): Promise {
const cwd = getStringFlag(args.flags, 'dir') ?? process.cwd();
const { map, error } = await buildInputMap(cwd, {
@@ -367,7 +405,10 @@ async function runScan(
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
const target = await persistApiKey(process.cwd(), response.api_key);
- console.log(`Saved API key to ${target} (for block-log reporting via /api/logs/log; keep out of the public widget).`);
+ // 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).`);
}
if (response.stored) {
@@ -880,6 +921,8 @@ async function main(): Promise {
return runSetup(args);
case 'map':
return runMap(args);
+ case 'login':
+ return runLogin(args);
default:
console.error(`Unknown command: ${args.command}\n`);
console.error(HELP);
diff --git a/src/client.ts b/src/client.ts
index d9e1b77..2f235a1 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,5 +1,6 @@
import { PatchstackError, type Config, type StoreManifestResponse } from './types.js';
import type { WirePayload } from './normalize.js';
+import { pulseFetch } from './pulse-token.js';
export const DEFAULT_ENDPOINT = 'https://api.patchstack.com/monitor/pulse/manifest';
export const DEFAULT_TIMEOUT_MS = 30_000;
@@ -95,7 +96,7 @@ export async function postInputMap(
const url = buildInputMapUrl(config.endpoint, config.siteUuid);
try {
- const response = await fetch(url, {
+ const response = await pulseFetch(config, url, {
method: 'POST',
headers: {
Accept: 'application/json',
@@ -155,7 +156,7 @@ export async function postPackageRemoved(config: Config): Promise {
+ const existing = await readConfigFile(cwd);
+ return writeConfigFile(cwd, { ...existing, pulseAuth });
+}
+
async function readConfigFile(cwd: string): Promise {
const target = path.join(cwd, CONFIG_FILENAME);
let raw: string;
@@ -152,6 +171,7 @@ function readEnv(): ConfigFile {
return {
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? undefined,
apiKey: process.env.PATCHSTACK_API_KEY ?? undefined,
+ pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? undefined,
endpoint: process.env.PATCHSTACK_ENDPOINT ?? undefined,
timeoutMs,
environment:
diff --git a/src/index.ts b/src/index.ts
index f515e19..b1ee3af 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,13 +1,13 @@
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { postManifest } from './client.js';
-import { persistApiKey, persistSiteUuid, resolveConfig } from './config.js';
+import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig } from './config.js';
import type { Config, Manifest, StoreManifestResponse } from './types.js';
export { scanLockfile, detectLockfile } from './parsers/index.js';
export { buildWirePayload, compareVersions } from './normalize.js';
export { postManifest, buildClaimUrl, buildEndpointUrl, DEFAULT_ENDPOINT } from './client.js';
-export { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
+export { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
export {
detectStack,
collectHostingEnvKeys,
@@ -63,6 +63,7 @@ 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
new file mode 100644
index 0000000..01c3d3d
--- /dev/null
+++ b/src/login.ts
@@ -0,0 +1,115 @@
+import { persistApiKey, persistPulseAuth } from './config.js';
+import type { Config } from './types.js';
+
+/**
+ * Device authorization flow (RFC 8628) for recovering a lost credential.
+ *
+ * The device code stays in this process; the short user code is what the human
+ * carries to the browser. Approving rotates the site's credential, so the old
+ * one — wherever it leaked to — stops working.
+ */
+
+export interface LoginDeps {
+ fetchImpl?: typeof fetch;
+ /** Injected so tests do not wait. */
+ sleep?: (ms: number) => Promise;
+ now?: () => number;
+}
+
+function baseFrom(manifestEndpoint: string): string {
+ const url = new URL(manifestEndpoint);
+ const path = url.pathname.replace(/\/$/, '');
+ url.pathname = path.endsWith('/manifest') ? path.slice(0, -'/manifest'.length) : '/monitor/pulse';
+ url.search = '';
+ url.hash = '';
+ return url.toString().replace(/\/$/, '');
+}
+
+export interface LoginResult {
+ status: 'approved' | 'denied' | 'expired' | 'unclaimed' | 'not-found' | 'failed';
+ message?: string;
+ userCode?: string;
+ verificationUri?: string;
+}
+
+/**
+ * Start a flow and poll until the owner approves or the code expires.
+ * `onPrompt` is called once with the code to show the user.
+ */
+export async function login(
+ config: Config,
+ onPrompt: (userCode: string, verificationUri: string) => void,
+ deps: LoginDeps = {},
+): Promise {
+ const fetchImpl = deps.fetchImpl ?? fetch;
+ const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
+ const now = deps.now ?? (() => Date.now());
+
+ if (config.siteUuid === null) {
+ return { status: 'failed', message: 'No site UUID configured — run `patchstack-connect scan` first.' };
+ }
+
+ const base = baseFrom(config.endpoint);
+
+ const started = await fetchImpl(`${base}/device/code`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ site_uuid: config.siteUuid }),
+ });
+
+ if (started.status === 409) {
+ return {
+ status: 'unclaimed',
+ message: 'This site has not been claimed yet, so there is no owner to approve the request. Claim it in the dashboard, or delete .patchstackrc.json to provision a new site.',
+ };
+ }
+ if (started.status === 404) {
+ return { status: 'not-found', message: 'Patchstack does not recognise this site UUID.' };
+ }
+ if (!started.ok) {
+ return { status: 'failed', message: `Could not start the login (HTTP ${started.status}).` };
+ }
+
+ const { device_code: deviceCode, user_code: userCode, expires_in: expiresIn, interval } =
+ (await started.json()) as {
+ device_code: string;
+ user_code: string;
+ expires_in: number;
+ interval: number;
+ };
+
+ const verificationUri = `${new URL(base).origin}/activate`;
+ onPrompt(userCode, verificationUri);
+
+ const deadline = now() + expiresIn * 1000;
+ const intervalMs = Math.max(1, interval) * 1000;
+
+ while (now() < deadline) {
+ await sleep(intervalMs);
+
+ const polled = await fetchImpl(`${base}/device/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ device_code: deviceCode }),
+ });
+
+ if (polled.status === 428) continue; // still waiting on the human
+ if (!polled.ok) return { status: 'expired', message: 'The login request expired. Run the command again.' };
+
+ const { api_key: apiKey } = (await polled.json()) as { api_key?: string };
+ if (typeof apiKey !== 'string' || apiKey.length === 0) {
+ 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);
+ await persistApiKey(process.cwd(), apiKey);
+
+ return { status: 'approved', userCode, verificationUri };
+ }
+
+ return { status: 'expired', message: 'The login request expired. Run the command again.' };
+}
diff --git a/src/protect/engine/pulse-client.js b/src/protect/engine/pulse-client.js
index 5e31483..7459f52 100644
--- a/src/protect/engine/pulse-client.js
+++ b/src/protect/engine/pulse-client.js
@@ -1,4 +1,5 @@
import { safeBaseUrl } from '../safe-origin.js';
+import { pulseAuthHeader } from '../../pulse-token.js';
const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse';
const DEFAULT_CACHE_TTL = 300_000;
@@ -24,8 +25,9 @@ export class PulseRuleClient {
#cacheTime = null;
#ttlEffective = 0;
#etag;
+ #pulseAuth;
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
// Bounded so app STARTUP can't hang on a slow API: hosted platforms fail a deploy whose health
// check is slow, and we always have a cache/bundled fallback to boot from.
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30_000;
@@ -34,6 +36,7 @@ export class PulseRuleClient {
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL, 'rule endpoint');
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
this.#etag = etag ?? null;
+ this.#pulseAuth = pulseAuth ?? null;
if (!this.#siteUuid) {
throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.');
}
@@ -46,7 +49,16 @@ export class PulseRuleClient {
}
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
try {
- const headers = { Accept: 'application/json' };
+ // Unauthenticated when no credential resolved, or when the exchange
+ // fails — the server still accepts the UUID, and protection must never
+ // hinge on getting a token.
+ const headers = {
+ Accept: 'application/json',
+ ...(await pulseAuthHeader(
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
+ fetch,
+ )),
+ };
if (this.#etag) headers['If-None-Match'] = this.#etag;
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(this.#timeoutMs) });
diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts
index 2a3d1cc..ef2f597 100644
--- a/src/protect/protect.d.ts
+++ b/src/protect/protect.d.ts
@@ -54,6 +54,13 @@ export interface CreateProtectionOptions {
* `.patchstackrc.json` `apiKey`. Never put this in the public widget.
*/
apiKey?: string;
+ /**
+ * Credential for the authenticated rules lookup. Falls back to `apiKey`, then
+ * `PATCHSTACK_PULSE_AUTH`, then `.patchstackrc.json` `pulseAuth`. Exchanged
+ * for a short-lived token; never sent directly. Never put this in the public
+ * widget.
+ */
+ pulseAuth?: string;
/** Override the Pulse rules API base URL. */
pulseRulesUrl?: string;
/**
diff --git a/src/protect/rules/source.js b/src/protect/rules/source.js
index 077a4e8..f352207 100644
--- a/src/protect/rules/source.js
+++ b/src/protect/rules/source.js
@@ -42,7 +42,7 @@ export async function resolveRules(options, store, ctx = {}) {
const timeoutMs = ctx.timeoutMs;
if (options.siteUuid) {
const prior = await store.read(); // { bundle, etag } | null
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs });
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
const res = await client.getRules();
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
if (res.success && !res.notModified) {
diff --git a/src/protect/runtime.js b/src/protect/runtime.js
index d0eb9ba..74b5ca6 100644
--- a/src/protect/runtime.js
+++ b/src/protect/runtime.js
@@ -105,7 +105,10 @@ export async function createProtection(options = {}) {
// check is slow, and the guard can always boot from last-known-good / the bundled fallback. Refreshes
// keep the full budget. Override with { bootTimeoutMs }.
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5_000;
- const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs });
+ // Resolved once and threaded through ctx: reading it is a filesystem hit on
+ // runtimes that have one, and refreshes should not repeat it.
+ const pulseAuth = await resolvePulseAuth(options);
+ const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
// Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production.
// Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run.
let mode = resolveMode(options, bundle);
@@ -609,7 +612,7 @@ export async function createProtection(options = {}) {
onError?.(err); // a failed report must not stop the rule refresh
}
}
- const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs });
+ const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
mode = resolveMode(options, next);
applyBundle(next);
};
@@ -673,6 +676,34 @@ async function resolveApiKey(options) {
return undefined;
}
+/**
+ * Credential for the authenticated rules lookup (ADR-0018). Same resolution
+ * order and the same edge-runtime caution as resolveApiKey, and falls back to
+ * it so guards installed before pulseAuth existed keep authenticating.
+ *
+ * Returning undefined is fine: the rules fetch then goes out unauthenticated,
+ * which the server still accepts.
+ */
+async function resolvePulseAuth(options) {
+ if (typeof options?.pulseAuth === 'string' && options.pulseAuth.length > 0) return options.pulseAuth;
+ if (typeof process !== 'undefined') {
+ const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
+ }
+ try {
+ if (typeof process === 'undefined' || typeof process.cwd !== 'function') return resolveApiKey(options);
+ const [{ readFileSync }, { join }] = await Promise.all([import('node:fs'), import('node:path')]);
+ const cwd = options?.cwd ?? process.cwd();
+ const raw = readFileSync(join(cwd, '.patchstackrc.json'), 'utf8');
+ const key = JSON.parse(raw)?.pulseAuth;
+ if (typeof key === 'string' && key.length > 0) return key;
+ } catch {
+ /* missing, or no filesystem here — fall through to the apiKey path */
+ }
+
+ return resolveApiKey(options);
+}
+
// --- phase / response helpers -------------------------------------------
function byPhase(rules, phase) {
diff --git a/src/pulse-token.ts b/src/pulse-token.ts
new file mode 100644
index 0000000..a03210b
--- /dev/null
+++ b/src/pulse-token.ts
@@ -0,0 +1,155 @@
+import type { Config } from './types.js';
+
+/**
+ * Bearer tokens for the authenticated Pulse endpoints (ADR-0018).
+ *
+ * Deliberately separate from the block-log token flow in
+ * `src/protect/firewall-log.js`: that path talks to the auth/ Lambda's
+ * /oauth/token and must keep working exactly as it does today.
+ */
+
+const TOKEN_SKEW_MS = 60_000;
+
+/** Build the Pulse token URL corresponding to a manifest endpoint override. */
+export function buildTokenUrl(manifestEndpoint: string): string {
+ const url = new URL(manifestEndpoint);
+ const path = url.pathname.replace(/\/$/, '');
+ url.pathname = path.endsWith('/manifest')
+ ? `${path.slice(0, -'/manifest'.length)}/token`
+ : '/monitor/pulse/token';
+ url.search = '';
+ url.hash = '';
+ return url.toString();
+}
+
+/** Split the WP-format `{secret}-{oauth.id}` credential on its last hyphen. */
+export function parsePulseAuth(credential: string): { clientId: string; clientSecret: string } | null {
+ const index = credential.lastIndexOf('-');
+ if (index <= 0 || index === credential.length - 1) return null;
+
+ const clientId = credential.slice(index + 1);
+ if (!/^\d+$/.test(clientId)) return null;
+
+ return { clientId, clientSecret: credential.slice(0, index) };
+}
+
+let cached: { token: string; expiresAt: number } | null = null;
+let inflight: Promise | null = null;
+
+/** Drops the cached token. Exported for tests and for 401 handling. */
+export function clearPulseToken(): void {
+ cached = null;
+}
+
+/**
+ * Resolve a bearer token for `config.pulseAuth`, exchanging one if needed.
+ *
+ * Returns null whenever a token cannot be obtained — no credential, a rejected
+ * exchange, a network failure. Callers then send the request unauthenticated,
+ * which the server still accepts while it runs dual-accept.
+ */
+export async function getPulseToken(
+ config: Config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ // Not `=== null`: Config is public, so callers can hand us an object that
+ // predates this field, and an unusable credential must never throw here.
+ if (typeof config.pulseAuth !== 'string' || config.pulseAuth.length === 0) return null;
+
+ if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
+ return cached.token;
+ }
+ if (inflight !== null) return inflight;
+
+ const credentials = parsePulseAuth(config.pulseAuth);
+ if (credentials === null) return null;
+
+ inflight = (async () => {
+ try {
+ const response = await fetchImpl(buildTokenUrl(config.endpoint), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'User-Agent': '@patchstack/connect',
+ },
+ body: JSON.stringify({
+ grant_type: 'client_credentials',
+ client_id: credentials.clientId,
+ client_secret: credentials.clientSecret,
+ }),
+ signal: AbortSignal.timeout(config.timeoutMs),
+ });
+
+ if (!response.ok) return null;
+
+ const body = (await response.json()) as { access_token?: unknown; expires_in?: unknown };
+ if (typeof body.access_token !== 'string' || body.access_token.length === 0) return null;
+
+ const expiresIn = Number(body.expires_in);
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 3600_000;
+ cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
+
+ return body.access_token;
+ } catch {
+ return null;
+ } finally {
+ inflight = null;
+ }
+ })();
+
+ return inflight;
+}
+
+/**
+ * `Authorization` header for a Pulse request, or `{}` when unauthenticated.
+ * Spread into an existing header object so call sites stay one line.
+ */
+export async function pulseAuthHeader(
+ config: Config,
+ fetchImpl: typeof fetch = fetch,
+): Promise> {
+ const token = await getPulseToken(config, fetchImpl);
+ return token === null ? {} : { Authorization: `Bearer ${token}` };
+}
+
+/**
+ * Send a Pulse request, attaching the bearer token and retrying once if the
+ * server rejects it.
+ *
+ * A cached token can stop being valid before it expires — the credential may
+ * have been rotated or revoked meanwhile — so the server's 401 is authoritative
+ * over our own clock. Without this a long-running process would keep presenting
+ * a dead token until its local expiry.
+ *
+ * Only 401 retries: a 403 is a scope or site mismatch, which a fresh token
+ * would not fix.
+ */
+export async function pulseFetch(
+ config: Config,
+ url: string,
+ init: RequestInit,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const send = async () => {
+ const auth = await pulseAuthHeader(config, fetchImpl);
+ const response = await fetchImpl(url, {
+ ...init,
+ headers: { ...(init.headers as Record | undefined), ...auth },
+ });
+
+ return { response, authenticated: auth.Authorization !== undefined };
+ };
+
+ const first = await send();
+
+ // Retrying an unauthenticated request would just repeat it: the 401 was
+ // about something other than our token.
+ if (first.response.status === 401 && first.authenticated) {
+ clearPulseToken();
+
+ return (await send()).response;
+ }
+
+ return first.response;
+}
diff --git a/src/types.ts b/src/types.ts
index 51ced42..4a76649 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -39,6 +39,12 @@ export interface Config {
* on first provision. Prefer `PATCHSTACK_API_KEY` in production deploys.
*/
apiKey: string | null;
+ /**
+ * Credential for the authenticated Pulse endpoints (ADR-0018). Exchanged for
+ * a short-lived bearer token at `monitor/pulse/token`; never sent directly.
+ * Falls back to `apiKey` when unset. Prefer `PATCHSTACK_PULSE_AUTH`.
+ */
+ pulseAuth: string | null;
endpoint: string;
timeoutMs: number;
/** Environment to report the manifest under. Defaults to 'production'. */
diff --git a/tests/login.test.ts b/tests/login.test.ts
new file mode 100644
index 0000000..0c0a6a0
--- /dev/null
+++ b/tests/login.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mkdtemp } from 'node:fs/promises';
+import { readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { login } from '../src/login.js';
+import type { Config } from '../src/types.js';
+
+function config(overrides: Partial = {}): Config {
+ return {
+ siteUuid: 'a-uuid',
+ apiKey: null,
+ pulseAuth: null,
+ endpoint: 'https://api.patchstack.com/monitor/pulse/manifest',
+ timeoutMs: 30_000,
+ environment: 'production',
+ widget: true,
+ ...overrides,
+ };
+}
+
+const json = (body: unknown, status = 200) =>
+ ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response;
+
+const started = {
+ device_code: 'device-code',
+ user_code: 'WDJB-MJHT',
+ expires_in: 600,
+ interval: 5,
+};
+
+const noSleep = { sleep: async () => {}, now: () => 0 };
+
+describe('login', () => {
+ it('prompts with the code, then persists the rotated credential', async () => {
+ const cwd = await mkdtemp(path.join(tmpdir(), 'ps-login-'));
+ const original = process.cwd();
+ process.chdir(cwd);
+
+ try {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(json(started))
+ .mockResolvedValueOnce(json({ api_key: 'new-secret-987' }));
+ const onPrompt = vi.fn();
+
+ const result = await login(config(), onPrompt, { fetchImpl: fetchImpl as never, ...noSleep });
+
+ expect(result.status).toBe('approved');
+ expect(onPrompt).toHaveBeenCalledWith('WDJB-MJHT', 'https://api.patchstack.com/activate');
+
+ // Both fields: approving rotates the one secret block-logs use too, so
+ // leaving apiKey behind would break block-log reporting.
+ const written = JSON.parse(readFileSync('.patchstackrc.json', 'utf8'));
+ expect(written.pulseAuth).toBe('new-secret-987');
+ expect(written.apiKey).toBe('new-secret-987');
+ } finally {
+ process.chdir(original);
+ }
+ });
+
+ it('keeps polling while the owner has not approved', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(json(started))
+ .mockResolvedValueOnce(json({ error: 'authorization_pending' }, 428))
+ .mockResolvedValueOnce(json({ error: 'authorization_pending' }, 428))
+ .mockResolvedValueOnce(json({ api_key: 'new-secret-987' }));
+
+ const cwd = await mkdtemp(path.join(tmpdir(), 'ps-login-'));
+ const original = process.cwd();
+ process.chdir(cwd);
+
+ try {
+ const result = await login(config(), vi.fn(), { fetchImpl: fetchImpl as never, ...noSleep });
+ expect(result.status).toBe('approved');
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
+ } finally {
+ process.chdir(original);
+ }
+ });
+
+ it('explains that an unclaimed site has nobody to approve it', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(json({ error: '…' }, 409));
+
+ const result = await login(config(), vi.fn(), { fetchImpl: fetchImpl as never, ...noSleep });
+
+ expect(result.status).toBe('unclaimed');
+ expect(result.message).toMatch(/claim/i);
+ });
+
+ it('reports an unknown site', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(json({ error: '…' }, 404));
+
+ expect((await login(config(), vi.fn(), { fetchImpl: fetchImpl as never, ...noSleep })).status).toBe(
+ 'not-found',
+ );
+ });
+
+ it('refuses without a site UUID, and never calls the network', async () => {
+ const fetchImpl = vi.fn();
+
+ const result = await login(config({ siteUuid: null }), vi.fn(), {
+ fetchImpl: fetchImpl as never,
+ ...noSleep,
+ });
+
+ expect(result.status).toBe('failed');
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('gives up once the code has expired', async () => {
+ let clock = 0;
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(json(started))
+ .mockResolvedValue(json({ error: 'authorization_pending' }, 428));
+
+ const result = await login(config(), vi.fn(), {
+ fetchImpl: fetchImpl as never,
+ sleep: async () => {
+ clock += 60_000;
+ },
+ now: () => clock,
+ });
+
+ expect(result.status).toBe('expired');
+ });
+});
diff --git a/tests/protect/edge-safe.test.ts b/tests/protect/edge-safe.test.ts
index 30a5d3d..dfdc133 100644
--- a/tests/protect/edge-safe.test.ts
+++ b/tests/protect/edge-safe.test.ts
@@ -26,8 +26,12 @@ function sourceFiles(dir: string, out: string[] = []): string[] {
const STATIC_NODE_IMPORT =
/^\s*(?:import\s[^;]*?\sfrom\s*|import\s*)['"](?:node:[a-z_/]+|fs|path|os|crypto|dns|net|http|https|child_process|worker_threads)['"]/m;
+// Modules outside src/protect/ that the runtime graph nevertheless imports, and which therefore
+// have to obey the same rule. pulse-client.js imports pulse-token for the rules credential.
+const IMPORTED_FROM_OUTSIDE = [fileURLToPath(new URL('../../src/pulse-token.ts', import.meta.url))];
+
describe('protect runtime stays edge-safe', () => {
- const files = sourceFiles(PROTECT_DIR);
+ const files = [...sourceFiles(PROTECT_DIR), ...IMPORTED_FROM_OUTSIDE.filter((f) => existsSync(f))];
it('has source files to check', () => {
expect(files.length).toBeGreaterThan(5);
diff --git a/tests/protect/pulse-client.test.ts b/tests/protect/pulse-client.test.ts
index 64f8a8e..c919c03 100644
--- a/tests/protect/pulse-client.test.ts
+++ b/tests/protect/pulse-client.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PulseRuleClient } from '../../src/protect/engine/pulse-client.js';
+import { clearPulseToken } from '../../src/pulse-token.js';
const RULES = { firewall: [{ id: 'rm-npm-0001', rule_v2: [{ parameter: 'post.title', match: { type: 'inline_xss' } }] }], whitelists: [], whitelist_keys: {} };
@@ -17,6 +18,57 @@ describe('PulseRuleClient', () => {
expect(fetchMock.mock.calls[0][1].method).toBe('GET');
});
+ it('exchanges the credential and sends a bearer token', async () => {
+ clearPulseToken();
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'tok', expires_in: 3600 }), { status: 200 }))
+ .mockResolvedValueOnce(new Response(JSON.stringify(RULES), { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const res = await new PulseRuleClient({
+ siteUuid: 'abc-123',
+ baseUrl: 'https://x.test/monitor/pulse',
+ pulseAuth: 'the-secret-987',
+ }).getRules();
+
+ expect(res.success).toBe(true);
+ // Exchanged on the same origin, then the rules request carried the token.
+ expect(fetchMock.mock.calls[0][0]).toBe('https://x.test/monitor/pulse/token');
+ expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer tok');
+ });
+
+ it('fetches unauthenticated when no credential is configured', async () => {
+ clearPulseToken();
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify(RULES), { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await new PulseRuleClient({ siteUuid: 'abc-123', baseUrl: 'https://x.test/monitor/pulse' }).getRules();
+
+ // One call: no exchange attempted, and no Authorization header.
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
+ });
+
+ it('still fetches rules when the credential exchange fails', async () => {
+ clearPulseToken();
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('nope', { status: 401 })) // exchange rejected
+ .mockResolvedValueOnce(new Response(JSON.stringify(RULES), { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const res = await new PulseRuleClient({
+ siteUuid: 'abc-123',
+ baseUrl: 'https://x.test/monitor/pulse',
+ pulseAuth: 'the-secret-987',
+ }).getRules();
+
+ // Protection must never hinge on getting a token.
+ expect(res.success).toBe(true);
+ expect(fetchMock.mock.calls[1][1].headers.Authorization).toBeUndefined();
+ });
+
it('fails open (success:false, empty rules) on a non-200', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500 })));
const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules();
diff --git a/tests/pulse-token.test.ts b/tests/pulse-token.test.ts
new file mode 100644
index 0000000..beefe8a
--- /dev/null
+++ b/tests/pulse-token.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import {
+ buildTokenUrl,
+ clearPulseToken,
+ getPulseToken,
+ parsePulseAuth,
+ pulseAuthHeader,
+ pulseFetch,
+} from '../src/pulse-token.js';
+import type { Config } from '../src/types.js';
+
+function config(overrides: Partial = {}): Config {
+ return {
+ siteUuid: 'a-uuid',
+ apiKey: null,
+ pulseAuth: 'the-secret-40-chars-long-ish-value-here-987',
+ endpoint: 'https://api.patchstack.com/monitor/pulse/manifest',
+ timeoutMs: 30_000,
+ environment: 'production',
+ widget: true,
+ ...overrides,
+ };
+}
+
+function tokenResponse(body: unknown, ok = true) {
+ return { ok, json: async () => body } as unknown as Response;
+}
+
+beforeEach(() => clearPulseToken());
+
+describe('buildTokenUrl', () => {
+ it('derives the token URL from the manifest endpoint', () => {
+ expect(buildTokenUrl('https://api.patchstack.com/monitor/pulse/manifest')).toBe(
+ 'https://api.patchstack.com/monitor/pulse/token',
+ );
+ });
+
+ it('honours a self-hosted endpoint override', () => {
+ expect(buildTokenUrl('https://staging.example.com/monitor/pulse/manifest')).toBe(
+ 'https://staging.example.com/monitor/pulse/token',
+ );
+ });
+
+ it('falls back to the canonical path for an unfamiliar endpoint', () => {
+ expect(buildTokenUrl('https://example.com/custom')).toBe(
+ 'https://example.com/monitor/pulse/token',
+ );
+ });
+});
+
+describe('parsePulseAuth', () => {
+ it('splits on the last hyphen', () => {
+ expect(parsePulseAuth('abc-def-987')).toEqual({ clientId: '987', clientSecret: 'abc-def' });
+ });
+
+ it('rejects a credential with no numeric client id', () => {
+ expect(parsePulseAuth('abc-def')).toBeNull();
+ expect(parsePulseAuth('no-hyphen-at-end-')).toBeNull();
+ expect(parsePulseAuth('-987')).toBeNull();
+ });
+});
+
+describe('getPulseToken', () => {
+ it('exchanges the credential and returns the token', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(tokenResponse({ access_token: 'tok', expires_in: 3600 }));
+
+ expect(await getPulseToken(config(), fetchImpl as never)).toBe('tok');
+ expect(fetchImpl).toHaveBeenCalledOnce();
+ expect(fetchImpl.mock.calls[0][0]).toBe('https://api.patchstack.com/monitor/pulse/token');
+ });
+
+ it('caches the token across calls', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(tokenResponse({ access_token: 'tok', expires_in: 3600 }));
+
+ await getPulseToken(config(), fetchImpl as never);
+ await getPulseToken(config(), fetchImpl as never);
+
+ expect(fetchImpl).toHaveBeenCalledOnce();
+ });
+
+ it('returns null without a credential, and never calls the network', async () => {
+ const fetchImpl = vi.fn();
+
+ expect(await getPulseToken(config({ pulseAuth: null }), fetchImpl as never)).toBeNull();
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('returns null when the exchange is rejected', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(tokenResponse({ error: 'invalid_client' }, false));
+
+ expect(await getPulseToken(config(), fetchImpl as never)).toBeNull();
+ });
+
+ it('returns null when the network fails', async () => {
+ const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'));
+
+ expect(await getPulseToken(config(), fetchImpl as never)).toBeNull();
+ });
+});
+
+describe('pulseFetch', () => {
+ const okResponse = { ok: true, status: 200 } as unknown as Response;
+ const unauthorized = { ok: false, status: 401 } as unknown as Response;
+ const forbidden = { ok: false, status: 403 } as unknown as Response;
+ const token = (t: string) => tokenResponse({ access_token: t, expires_in: 3600 });
+
+ it('re-exchanges and retries once when the server rejects a cached token', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(token('stale')) // first exchange
+ .mockResolvedValueOnce(unauthorized) // request rejected
+ .mockResolvedValueOnce(token('fresh')) // re-exchange after invalidating
+ .mockResolvedValueOnce(okResponse); // retry succeeds
+
+ const response = await pulseFetch(config(), 'https://api.patchstack.com/x', {}, fetchImpl as never);
+
+ expect(response.status).toBe(200);
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
+ expect(fetchImpl.mock.calls[3][1].headers.Authorization).toBe('Bearer fresh');
+ });
+
+ it('gives up after one retry rather than looping', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(token('a'))
+ .mockResolvedValueOnce(unauthorized)
+ .mockResolvedValueOnce(token('b'))
+ .mockResolvedValueOnce(unauthorized);
+
+ const response = await pulseFetch(config(), 'https://api.patchstack.com/x', {}, fetchImpl as never);
+
+ expect(response.status).toBe(401);
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
+ });
+
+ it('does not retry a 403, which a fresh token would not fix', async () => {
+ const fetchImpl = vi.fn().mockResolvedValueOnce(token('a')).mockResolvedValueOnce(forbidden);
+
+ const response = await pulseFetch(config(), 'https://api.patchstack.com/x', {}, fetchImpl as never);
+
+ expect(response.status).toBe(403);
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not retry when the request was unauthenticated to begin with', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(unauthorized);
+
+ const response = await pulseFetch(
+ config({ pulseAuth: null }),
+ 'https://api.patchstack.com/x',
+ {},
+ fetchImpl as never,
+ );
+
+ expect(response.status).toBe(401);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves the caller\'s headers alongside the bearer', async () => {
+ const fetchImpl = vi.fn().mockResolvedValueOnce(token('a')).mockResolvedValueOnce(okResponse);
+
+ await pulseFetch(
+ config(),
+ 'https://api.patchstack.com/x',
+ { method: 'POST', headers: { Accept: 'application/json' } },
+ fetchImpl as never,
+ );
+
+ const sent = fetchImpl.mock.calls[1][1];
+ expect(sent.method).toBe('POST');
+ expect(sent.headers).toEqual({ Accept: 'application/json', Authorization: 'Bearer a' });
+ });
+});
+
+describe('pulseAuthHeader', () => {
+ it('produces a bearer header when a token is available', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(tokenResponse({ access_token: 'tok', expires_in: 3600 }));
+
+ expect(await pulseAuthHeader(config(), fetchImpl as never)).toEqual({
+ Authorization: 'Bearer tok',
+ });
+ });
+
+ it('produces no header at all when unauthenticated, so the request stays legacy', async () => {
+ expect(await pulseAuthHeader(config({ pulseAuth: null }), vi.fn() as never)).toEqual({});
+ });
+});