Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
<script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer></script>
```

Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.
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.

4. **Install and verify runtime protection:**

Expand Down Expand Up @@ -120,7 +120,7 @@ Remove only the pieces that are actually present — check for each first. If no
3. **Remove runtime protection before uninstalling the package.** Delete the connector-managed guard/rules files and remove only their managed imports, middleware registrations, tunnel code, and `#region patchstack…` blocks from the framework/server files. Preserve unrelated middleware and application code. Run `rg "patchstack|x-ps-target"` (or the available equivalent) afterwards and inspect every remaining source hit.
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.
5. **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.
6. **Delete `.patchstackrc.json`** and remove `PATCHSTACK_SITE_UUID` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
6. **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.
7. **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).

Local removal does not delete the site record on Patchstack's side. An unclaimed site is an anonymous record that stops receiving reports; a claimed site is removed by the user in their dashboard at https://app.patchstack.com. There is no CLI command for account-side deletion — do not invent one, and never attempt to authenticate or remove the site on the user's behalf.
Expand Down
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
resolveDemoScenario,
waitForDemoRule,
} from './demo.js';
import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
findHtmlFiles,
Expand Down Expand Up @@ -95,6 +95,9 @@ 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_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)
PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000)
PATCHSTACK_ENVIRONMENT Manifest environment: production | sandbox (default: production)
Expand Down Expand Up @@ -243,6 +246,10 @@ async function runScan(
const target = await persistSiteUuid(process.cwd(), response.uuid);
console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
}
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).`);
}

if (response.stored) {
console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
Expand Down
17 changes: 16 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const DEFAULT_ENVIRONMENT: Environment = 'production';

interface ConfigFile {
siteUuid?: string;
/** WP-format `{secret}-{oauth.id}` for connector /api/logs/log. Server-only. */
apiKey?: string;
endpoint?: string;
timeoutMs?: number;
environment?: string;
Expand Down Expand Up @@ -69,8 +71,11 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf
);
}

const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;

return {
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
endpoint,
timeoutMs,
environment,
Expand All @@ -87,13 +92,22 @@ export async function writeConfigFile(cwd: string, config: ConfigFile): Promise<

/**
* Merge a new siteUuid into the existing `.patchstackrc.json` (or create it).
* Preserves any `endpoint` / `timeoutMs` the user already wrote.
* Preserves any `endpoint` / `timeoutMs` / `apiKey` the user already wrote.
*/
export async function persistSiteUuid(cwd: string, siteUuid: string): Promise<string> {
const existing = await readConfigFile(cwd);
return writeConfigFile(cwd, { ...existing, siteUuid });
}

/**
* Persist the WP-format api_key issued at provision (for connector log auth).
* Never embed this value in the public disclosure widget.
*/
export async function persistApiKey(cwd: string, apiKey: string): Promise<string> {
const existing = await readConfigFile(cwd);
return writeConfigFile(cwd, { ...existing, apiKey });
}

async function readConfigFile(cwd: string): Promise<ConfigFile> {
const target = path.join(cwd, CONFIG_FILENAME);
let raw: string;
Expand Down Expand Up @@ -137,6 +151,7 @@ function readEnv(): ConfigFile {
const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
return {
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? undefined,
apiKey: process.env.PATCHSTACK_API_KEY ?? undefined,
endpoint: process.env.PATCHSTACK_ENDPOINT ?? undefined,
timeoutMs,
environment:
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { postManifest } from './client.js';
import { 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';
export { buildWirePayload, compareVersions } from './normalize.js';
export { postManifest, buildClaimUrl, buildEndpointUrl, DEFAULT_ENDPOINT } from './client.js';
export { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
export { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
export {
detectStack,
collectHostingEnvKeys,
Expand Down Expand Up @@ -61,6 +61,9 @@ export async function scanAndReport(
if (config.siteUuid === null && response.uuid !== undefined && response.uuid.length > 0) {
await persistSiteUuid(cwd, response.uuid);
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
await persistApiKey(cwd, response.api_key);
}

return {
manifest,
Expand Down
201 changes: 201 additions & 0 deletions src/protect/firewall-log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Fire-and-forget reporter: Connect runtime → existing connector POST /api/logs/log
// (same path WordPress uses). Auth: WP-style api_key (`{secret}-{oauth.id}`) →
// POST /oauth/token (client_credentials) → Bearer JWT on /api/logs/log.
// Opt out: PATCHSTACK_TELEMETRY=off. Never put api_key in the public widget.

const DEFAULT_API_BASE = 'https://api.patchstack.com';
const DEFAULT_FLUSH_MS = 1000;
const MAX_BATCH = 50;
const TOKEN_SKEW_MS = 60_000;

/**
* Parse WP plugin api_key (`{secret}-{oauth.id}`) into client credentials.
* @param {string} apiKey
* @returns {{ clientId: string, clientSecret: string } | null}
*/
export function parseApiKey(apiKey) {
if (typeof apiKey !== 'string' || apiKey.length === 0) return null;
const idx = apiKey.lastIndexOf('-');
if (idx <= 0 || idx === apiKey.length - 1) return null;
const clientSecret = apiKey.slice(0, idx);
const clientId = apiKey.slice(idx + 1);
if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
return { clientId, clientSecret };
}

/**
* Derive api.patchstack.com origin from a Pulse manifest/rules URL override.
* @param {string | undefined} pulseOrManifestUrl
*/
export function resolveApiBase(pulseOrManifestUrl) {
const fromEnv = typeof process !== 'undefined' ? process.env?.PATCHSTACK_API_BASE : undefined;
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
return fromEnv.replace(/\/$/, '');
}
if (typeof pulseOrManifestUrl === 'string' && pulseOrManifestUrl.length > 0) {
try {
return new URL(pulseOrManifestUrl).origin;
} catch {
/* fall through */
}
}
const endpoint = typeof process !== 'undefined' ? process.env?.PATCHSTACK_ENDPOINT : undefined;
if (typeof endpoint === 'string' && endpoint.length > 0) {
try {
return new URL(endpoint).origin;
} catch {
/* fall through */
}
}
return DEFAULT_API_BASE;
}

/**
* @param {{
* apiKey: string,
* apiBase?: string,
* sourceHost?: string,
* fetchImpl?: typeof fetch,
* flushMs?: number,
* }} opts
*/
export function createFirewallLogReporter(opts) {
const creds = parseApiKey(opts.apiKey);
if (!creds) {
return { record() {}, flush() {}, stop() {} };
}

const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, '');
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
const sourceHost = typeof opts.sourceHost === 'string' ? opts.sourceHost : '';

/** @type {Array<Record<string, unknown>>} */
let queue = [];
/** @type {ReturnType<typeof setTimeout> | null} */
let timer = null;
let stopped = false;

/** @type {{ token: string, expiresAt: number } | null} */
let cachedToken = null;
/** @type {Promise<string | null> | null} */
let tokenInflight = null;

const fetchAccessToken = async () => {
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
return cachedToken.token;
}
if (tokenInflight) return tokenInflight;

tokenInflight = (async () => {
try {
const res = await fetchImpl(`${apiBase}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': '@patchstack/connect',
},
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: creds.clientId,
client_secret: creds.clientSecret,
}),
});
if (!res || !res.ok) return null;
const body = await res.json();
const token = body?.access_token;
if (typeof token !== 'string' || token.length === 0) return null;
const expiresIn = Number(body?.expires_in);
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 3600_000;
cachedToken = { token, expiresAt: Date.now() + ttlMs };
return token;
} catch {
return null;
} finally {
tokenInflight = null;
}
})();

return tokenInflight;
};

const flush = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (queue.length === 0 || typeof fetchImpl !== 'function') return;

const batch = queue.splice(0, MAX_BATCH);
void (async () => {
const token = await fetchAccessToken();
if (!token) return;

const body = new URLSearchParams();
body.set('type', 'firewall');
body.set('logs', JSON.stringify(batch));

try {
const p = fetchImpl(`${apiBase}/api/logs/log`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': '@patchstack/connect',
...(sourceHost ? { 'Source-Host': sourceHost } : {}),
},
body,
});
if (p && typeof p.then === 'function') p.catch(() => {});
} catch {
/* ignore */
}
})();
};

return {
/**
* @param {{
* rule?: { id?: string | number },
* method?: string | null,
* path?: string | null,
* ip?: string | null,
* userAgent?: string | null,
* }} event
*/
record(event) {
if (stopped) return;
const fid = event?.rule?.id;
if (fid === undefined || fid === null || fid === '') return;

queue.push({
fid,
method: event.method ?? null,
request_uri: event.path ?? null,
ip: event.ip ?? null,
user_agent: event.userAgent ?? null,
log_date: new Date().toISOString(),
});

if (queue.length >= MAX_BATCH) {
flush();
return;
}
if (!timer) timer = setTimeout(flush, flushMs);
},
flush,
stop() {
stopped = true;
flush();
},
};
}

/** @returns {boolean} */
export function telemetryEnabled() {
const v = typeof process !== 'undefined' ? process.env?.PATCHSTACK_TELEMETRY : undefined;
if (v === undefined || v === '') return true;
return !/^(0|false|off|no)$/i.test(String(v));
}
21 changes: 20 additions & 1 deletion src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,23 @@ export interface CreateProtectionOptions {
baseUrl?: string;
/** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */
siteUuid?: string;
/**
* WP-format site API key (`{secret}-{oauth.id}`) for authenticated block logs
* via connector `POST /api/logs/log`. Falls back to `PATCHSTACK_API_KEY`, then
* `.patchstackrc.json` `apiKey`. Never put this in the public widget.
*/
apiKey?: string;
/** Override the Pulse rules API base URL. */
pulseRulesUrl?: string;
/**
* When false, skip posting block events to connector `/api/logs/log`.
* Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
*/
reportFirewallLog?: boolean;
/** Optional Source-Host header for connector hostname checks. */
sourceHost?: string;
/** Optional fetch override (tests). */
fetchImpl?: typeof fetch;
/**
* Re-fetch and hot-swap the live rules every N ms. For long-lived runtimes that aren't restarted
* on change (an AI builder's sandbox/preview) so a rule that becomes relevant after boot still
Expand Down Expand Up @@ -104,8 +119,12 @@ export interface CreateProtectionOptions {
phase?: Phase;
mode: string;
category?: string;
rule?: { id?: string; category?: string };
rule?: { id?: string | number; category?: string };
message?: string;
method?: string | null;
path?: string | null;
ip?: string | null;
userAgent?: string | null;
}) => void;
}

Expand Down
Loading
Loading