diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md
index 33474c6..3b00d6c 100644
--- a/AGENT-INSTALL.md
+++ b/AGENT-INSTALL.md
@@ -73,7 +73,7 @@ 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. 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:**
@@ -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.
diff --git a/src/cli.ts b/src/cli.ts
index adaf681..53d6bc9 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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,
@@ -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)
@@ -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}).`);
diff --git a/src/config.ts b/src/config.ts
index 0d5b3b1..b0dfff0 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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;
@@ -69,8 +71,11 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise {
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 {
+ const existing = await readConfigFile(cwd);
+ return writeConfigFile(cwd, { ...existing, apiKey });
+}
+
async function readConfigFile(cwd: string): Promise {
const target = path.join(cwd, CONFIG_FILENAME);
let raw: string;
@@ -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:
diff --git a/src/index.ts b/src/index.ts
index f8b7175..f515e19 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 { 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,
@@ -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,
diff --git a/src/protect/firewall-log.js b/src/protect/firewall-log.js
new file mode 100644
index 0000000..4b7c0bd
--- /dev/null
+++ b/src/protect/firewall-log.js
@@ -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>} */
+ let queue = [];
+ /** @type {ReturnType | null} */
+ let timer = null;
+ let stopped = false;
+
+ /** @type {{ token: string, expiresAt: number } | null} */
+ let cachedToken = null;
+ /** @type {Promise | 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));
+}
diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts
index 4cbb378..2a3d1cc 100644
--- a/src/protect/protect.d.ts
+++ b/src/protect/protect.d.ts
@@ -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
@@ -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;
}
diff --git a/src/protect/runtime.js b/src/protect/runtime.js
index fc7d9cd..3240098 100644
--- a/src/protect/runtime.js
+++ b/src/protect/runtime.js
@@ -23,6 +23,9 @@ import { renderBlockPage } from './block-page.js';
import { makeStore } from './rules/store.js';
import { resolveRules } from './rules/source.js';
import { startRefresh, makeRefreshHandler } from './rules/refresh.js';
+import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase).
export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js';
@@ -64,7 +67,34 @@ export function createServerFnGuard({ protection }) {
export async function createProtection(options = {}) {
const onError = options.onError;
- const onDetect = options.onDetect ?? defaultOnDetect;
+ const userOnDetect = options.onDetect ?? defaultOnDetect;
+
+ // Report enforced blocks via existing connector POST /api/logs/log (WP path).
+ // Needs api_key from provision / PATCHSTACK_API_KEY / .patchstackrc.json.
+ // Opt out: PATCHSTACK_TELEMETRY=off. Never embed api_key in the public widget.
+ const apiKey = resolveApiKey(options);
+ const firewallLog =
+ apiKey && telemetryEnabled() && options.reportFirewallLog !== false
+ ? createFirewallLogReporter({
+ apiKey,
+ apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
+ sourceHost: options.sourceHost,
+ fetchImpl: options.fetchImpl,
+ })
+ : null;
+
+ const onDetect = (detection) => {
+ userOnDetect(detection);
+ if (firewallLog && detection?.mode === 'block') {
+ firewallLog.record({
+ rule: detection.rule,
+ method: detection.method,
+ path: detection.path,
+ ip: detection.ip,
+ userAgent: detection.userAgent,
+ });
+ }
+ };
// One tiered store (memory → filesystem/pluggable) shared by the initial load and every refresh.
const store = makeStore(options);
@@ -115,9 +145,19 @@ export async function createProtection(options = {}) {
: () => (typeof options.maskWith === 'string' ? options.maskWith : '[REDACTED]');
// Given a request/egress result, enforce (block mode) or just record (dry-run).
- const decide = (phase, result, block, allow) => {
+ const decide = (phase, result, block, allow, ctx = {}) => {
if (!result || !result.blocked) return allow();
- onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
+ onDetect({
+ phase,
+ mode,
+ category: result.rule?.category,
+ rule: result.rule,
+ message: result.message,
+ method: ctx.method,
+ path: ctx.path,
+ ip: ctx.ip,
+ userAgent: ctx.userAgent,
+ });
return mode === 'block' ? block() : allow();
};
@@ -303,7 +343,7 @@ export async function createProtection(options = {}) {
onError?.(err);
return null; // fail open
}
- return decide('request', result, () => blockResponse(result, request), () => null);
+ return decide('request', result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
};
},
@@ -344,6 +384,7 @@ export async function createProtection(options = {}) {
if (exprOptions.screenResponses) wrapNodeResponse(res);
next();
},
+ nodeRequestMeta(req),
);
};
},
@@ -399,6 +440,7 @@ export async function createProtection(options = {}) {
if (nodeOptions.screenResponses) wrapNodeResponse(res);
next();
},
+ nodeRequestMeta(req),
);
});
};
@@ -462,7 +504,12 @@ export async function createProtection(options = {}) {
if (options.refreshMs > 0 && live) {
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
- protection.stopRefresh = loop.stop;
+ protection.stopRefresh = () => {
+ loop.stop();
+ firewallLog?.stop();
+ };
+ } else if (firewallLog) {
+ protection.stopRefresh = () => firewallLog.stop();
}
return protection;
@@ -481,6 +528,25 @@ function resolveMode(options, bundle) {
return 'dry-run';
}
+/** WP-format api_key for connector /api/logs/log. Never use the public site UUID. */
+function resolveApiKey(options) {
+ if (typeof options?.apiKey === 'string' && options.apiKey.length > 0) return options.apiKey;
+ if (typeof process !== 'undefined') {
+ const fromEnv = process.env?.PATCHSTACK_API_KEY;
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
+ }
+ try {
+ if (typeof process === 'undefined' || typeof process.cwd !== 'function') return undefined;
+ const cwd = options?.cwd ?? process.cwd();
+ const raw = readFileSync(join(cwd, '.patchstackrc.json'), 'utf8');
+ const key = JSON.parse(raw)?.apiKey;
+ if (typeof key === 'string' && key.length > 0) return key;
+ } catch {
+ /* missing — reporting stays off */
+ }
+ return undefined;
+}
+
// --- phase / response helpers -------------------------------------------
function byPhase(rules, phase) {
@@ -796,3 +862,34 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
const tag = mode === 'block' ? 'BLOCK' : 'DETECT (dry-run)';
console.warn(`[patchstack] ${tag} phase=${phase ?? 'request'} category=${category ?? '?'} rule=${rule?.id ?? '?'} ${message ?? ''}`.trim());
}
+
+/** @param {Request} request */
+function fetchRequestMeta(request) {
+ if (!request) return {};
+ let path = null;
+ try {
+ path = new URL(request.url).pathname;
+ } catch {
+ path = typeof request.url === 'string' ? request.url : null;
+ }
+ return {
+ method: request.method ?? null,
+ path,
+ ip: request.headers?.get?.('x-forwarded-for') ?? null,
+ userAgent: request.headers?.get?.('user-agent') ?? null,
+ };
+}
+
+/** @param {import('http').IncomingMessage & { ip?: string, originalUrl?: string }} req */
+function nodeRequestMeta(req) {
+ if (!req) return {};
+ const headers = req.headers ?? {};
+ const ua = headers['user-agent'] ?? headers['User-Agent'];
+ const fwd = headers['x-forwarded-for'] ?? headers['X-Forwarded-For'];
+ return {
+ method: req.method ?? null,
+ path: req.originalUrl || req.url || null,
+ ip: req.ip ?? (typeof fwd === 'string' ? fwd : null),
+ userAgent: typeof ua === 'string' ? ua : Array.isArray(ua) ? ua[0] : null,
+ };
+}
diff --git a/src/types.ts b/src/types.ts
index fd9d200..51ced42 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -33,6 +33,12 @@ export interface Config {
* UUID it returns should be persisted via `persistSiteUuid()`.
*/
siteUuid: string | null;
+ /**
+ * WP-format site API key (`{oauth.secret}-{oauth.id}`) for authenticated
+ * block-log reporting via connector `/api/logs/log`. Issued once as `api_key`
+ * on first provision. Prefer `PATCHSTACK_API_KEY` in production deploys.
+ */
+ apiKey: string | null;
endpoint: string;
timeoutMs: number;
/** Environment to report the manifest under. Defaults to 'production'. */
@@ -48,6 +54,11 @@ export interface Config {
export interface StoreManifestResponse {
/** The UUID of the site the manifest was stored against. Always returned. */
uuid?: string;
+ /**
+ * WP-format API key for connector log ingest. Present only when oauth
+ * credentials are created in this request (first provision / backfill).
+ */
+ api_key?: string;
stored: boolean;
manifest_id?: number;
checksum?: string;
diff --git a/tests/protect/firewall-log.test.ts b/tests/protect/firewall-log.test.ts
new file mode 100644
index 0000000..6c1a3fd
--- /dev/null
+++ b/tests/protect/firewall-log.test.ts
@@ -0,0 +1,194 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ createFirewallLogReporter,
+ parseApiKey,
+ resolveApiBase,
+ telemetryEnabled,
+} from '../../src/protect/firewall-log.js';
+import { createProtection } from '../../src/protect/runtime.js';
+
+describe('parseApiKey / resolveApiBase', () => {
+ it('parses WP-format api_key', () => {
+ expect(parseApiKey('abcdefghijabcdefghijabcdefghijabcdefghij-42')).toEqual({
+ clientId: '42',
+ clientSecret: 'abcdefghijabcdefghijabcdefghijabcdefghij',
+ });
+ expect(parseApiKey('nope')).toBeNull();
+ });
+
+ it('resolveApiBase prefers PATCHSTACK_API_BASE then URL origin', () => {
+ process.env.PATCHSTACK_API_BASE = 'https://staging.example/';
+ expect(resolveApiBase('https://ignored.test/monitor/pulse')).toBe('https://staging.example');
+ delete process.env.PATCHSTACK_API_BASE;
+ expect(resolveApiBase('https://x.test/monitor/pulse')).toBe('https://x.test');
+ });
+});
+
+describe('createFirewallLogReporter (connector /api/logs/log)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ delete process.env.PATCHSTACK_TELEMETRY;
+ delete process.env.PATCHSTACK_API_KEY;
+ delete process.env.PATCHSTACK_API_BASE;
+ });
+
+ it('exchanges oauth token then POSTs firewall logs', async () => {
+ const fetchImpl = vi.fn(async (url) => {
+ if (String(url).includes('/oauth/token')) {
+ return new Response(JSON.stringify({ access_token: 'jwt-token', expires_in: 3600, token_type: 'Bearer' }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
+ });
+
+ const reporter = createFirewallLogReporter({
+ apiKey: 'sekretsekretsekretsekretsekretsekretsekre-99',
+ apiBase: 'https://x.test',
+ fetchImpl,
+ flushMs: 10,
+ });
+
+ reporter.record({ rule: { id: 18843 }, method: 'POST', path: '/api/todos' });
+ await vi.advanceTimersByTimeAsync(20);
+ // flush kicks off async token+log; drain microtasks
+ await Promise.resolve();
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(fetchImpl).toHaveBeenCalled();
+ const tokenCall = fetchImpl.mock.calls.find(([u]) => String(u).includes('/oauth/token'));
+ expect(tokenCall).toBeTruthy();
+ expect(JSON.parse(tokenCall[1].body)).toEqual({
+ grant_type: 'client_credentials',
+ client_id: '99',
+ client_secret: 'sekretsekretsekretsekretsekretsekretsekre',
+ });
+
+ // Allow the async flush to complete
+ await vi.waitFor(() => {
+ expect(fetchImpl.mock.calls.some(([u]) => String(u).includes('/api/logs/log'))).toBe(true);
+ });
+
+ const logCall = fetchImpl.mock.calls.find(([u]) => String(u).includes('/api/logs/log'));
+ expect(logCall[1].headers.Authorization).toBe('Bearer jwt-token');
+ expect(logCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded');
+ const params = new URLSearchParams(logCall[1].body);
+ expect(params.get('type')).toBe('firewall');
+ const logs = JSON.parse(params.get('logs'));
+ expect(logs[0].fid).toBe(18843);
+ });
+
+ it('telemetryEnabled respects PATCHSTACK_TELEMETRY=off', () => {
+ expect(telemetryEnabled()).toBe(true);
+ process.env.PATCHSTACK_TELEMETRY = 'off';
+ expect(telemetryEnabled()).toBe(false);
+ });
+});
+
+describe('createProtection connector log reporting', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ delete process.env.PATCHSTACK_TELEMETRY;
+ delete process.env.PATCHSTACK_MODE;
+ delete process.env.PATCHSTACK_API_KEY;
+ });
+
+ it('POSTs a block via /api/logs/log when apiKey is set', async () => {
+ vi.useFakeTimers();
+ const fetchImpl = vi.fn(async (url) => {
+ if (String(url).includes('/rules/')) {
+ return new Response(
+ JSON.stringify({
+ firewall: [
+ {
+ id: 18843,
+ title: 'node-serialize',
+ rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '_$$ND_FUNC$$_' } }],
+ },
+ ],
+ whitelists: [],
+ whitelist_keys: {},
+ enforcement: 'block',
+ }),
+ { status: 200 },
+ );
+ }
+ if (String(url).includes('/oauth/token')) {
+ return new Response(JSON.stringify({ access_token: 'jwt', expires_in: 3600 }), { status: 200 });
+ }
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
+ });
+ vi.stubGlobal('fetch', fetchImpl);
+
+ const protection = await createProtection({
+ siteUuid: 'site-1',
+ apiKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-7',
+ pulseRulesUrl: 'https://x.test/monitor/pulse',
+ mode: 'block',
+ reportManifest: false,
+ fetchImpl,
+ });
+
+ const blocked = await protection.fetchGuard()(
+ new Request('https://app.test/api', {
+ method: 'POST',
+ body: '_$$ND_FUNC$$_evil',
+ }),
+ );
+ expect(blocked?.status).toBe(403);
+
+ await vi.advanceTimersByTimeAsync(1100);
+ await vi.waitFor(() => {
+ expect(fetchImpl.mock.calls.some(([u]) => String(u).includes('/api/logs/log'))).toBe(true);
+ });
+
+ protection.stopRefresh?.();
+ });
+
+ it('does not report without an api key', async () => {
+ vi.useFakeTimers();
+ const fetchImpl = vi.fn(async (url) => {
+ if (String(url).includes('/rules/')) {
+ return new Response(
+ JSON.stringify({
+ firewall: [
+ {
+ id: 18843,
+ rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '_$$ND_FUNC$$_' } }],
+ },
+ ],
+ whitelists: [],
+ whitelist_keys: {},
+ enforcement: 'block',
+ }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
+ });
+ vi.stubGlobal('fetch', fetchImpl);
+
+ const protection = await createProtection({
+ siteUuid: 'site-1',
+ pulseRulesUrl: 'https://x.test/monitor/pulse',
+ mode: 'block',
+ reportManifest: false,
+ fetchImpl,
+ });
+
+ await protection.fetchGuard()(
+ new Request('https://app.test/api', { method: 'POST', body: '_$$ND_FUNC$$_evil' }),
+ );
+ await vi.advanceTimersByTimeAsync(1100);
+
+ expect(fetchImpl.mock.calls.filter(([u]) => String(u).includes('/api/logs/log'))).toHaveLength(0);
+ expect(fetchImpl.mock.calls.filter(([u]) => String(u).includes('/oauth/token'))).toHaveLength(0);
+ protection.stopRefresh?.();
+ });
+});