From 6e6f7ac17b0af6e5dafa52dff57836a412867bd5 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Tue, 28 Jul 2026 11:29:47 +0100 Subject: [PATCH] [NO-Ticket] paywall ability widget Parse and propagate Pulse's enforcement (or mode) field and let PATCHSTACK_MODE override it. Changes: pulse-client now extracts enforcement; types and normalizeBundle include enforcement; runtime adds resolveMode(), exposes dynamic protection.mode, and updates mode on rule refresh. Tests added to cover parsing, env override, and hot-swap behavior. Also added .agent-config.json and minor test import cleanup. --- src/protect/engine/pulse-client.js | 8 +++ src/protect/protect.d.ts | 8 ++- src/protect/rules/source.js | 2 + src/protect/runtime.js | 25 +++++++- tests/protect/pulse-client.test.ts | 28 +++++++++ tests/protect/runtime-pulse.test.ts | 88 ++++++++++++++++++++++++++++- 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/protect/engine/pulse-client.js b/src/protect/engine/pulse-client.js index 8c8f242..371fbbc 100644 --- a/src/protect/engine/pulse-client.js +++ b/src/protect/engine/pulse-client.js @@ -64,6 +64,7 @@ export class PulseRuleClient { firewall: data.firewall, whitelists: Array.isArray(data.whitelists) ? data.whitelists : [], whitelist_keys: data.whitelist_keys ?? {}, + ...enforcementField(data), }; this.#cache = result; this.#etag = result.etag; @@ -89,3 +90,10 @@ export class PulseRuleClient { this.#etag = null; } } + +/** @param {unknown} data @returns {{ enforcement?: 'block'|'dry-run' }} */ +function enforcementField(data) { + if (!data || typeof data !== 'object') return {}; + const v = data.enforcement ?? data.mode; + return v === 'block' || v === 'dry-run' ? { enforcement: v } : {}; +} diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 99cf600..4cbb378 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -5,6 +5,8 @@ export interface RuleBundle { firewall: unknown[]; whitelists: unknown[]; whitelist_keys: Record; + /** From the Pulse rules API when present (`block` = enforce, `dry-run` = detect only). */ + enforcement?: "block" | "dry-run"; } export type Phase = "request" | "response" | "egress"; @@ -33,7 +35,11 @@ export interface Protection { } export interface CreateProtectionOptions { - /** Default "dry-run". The scaffolded guard sets "block". */ + /** + * Fallback when the Pulse rules API does not send `enforcement`. + * Overridden by `PATCHSTACK_MODE` when set, otherwise by API `enforcement`. + * Default "dry-run". Scaffolded guards pass "block" when env is unset. + */ mode?: "block" | "dry-run"; /** Explicit rule bundle (used as the token-less fallback). */ rules?: unknown; diff --git a/src/protect/rules/source.js b/src/protect/rules/source.js index 60be4f1..62c5a6f 100644 --- a/src/protect/rules/source.js +++ b/src/protect/rules/source.js @@ -54,10 +54,12 @@ export async function resolveRules(options, store) { } export function normalizeBundle(b) { + const enforcement = b?.enforcement ?? b?.mode; return { firewall: Array.isArray(b.firewall) ? b.firewall : [], whitelists: Array.isArray(b.whitelists) ? b.whitelists : [], whitelist_keys: b.whitelist_keys ?? {}, + ...(enforcement === 'block' || enforcement === 'dry-run' ? { enforcement } : {}), }; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 635aab7..fc7d9cd 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -63,13 +63,15 @@ export function createServerFnGuard({ protection }) { } export async function createProtection(options = {}) { - const mode = options.mode === 'block' ? 'block' : 'dry-run'; const onError = options.onError; const onDetect = options.onDetect ?? defaultOnDetect; // One tiered store (memory → filesystem/pluggable) shared by the initial load and every refresh. const store = makeStore(options); const bundle = await resolveRules(options, store); + // 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); // Rule-derived runtime state. Held in `let` bindings the guard methods below close over, so a // refresh (see the loop near the end) can hot-swap the engines by reassigning them — the // egress interception and the protection object itself stay in place, no re-install. @@ -280,7 +282,9 @@ export async function createProtection(options = {}) { }; const protection = { - mode, + get mode() { + return mode; + }, get rules() { return { request: requestRules, response: responseRules, egress: egressRules }; }, @@ -443,7 +447,9 @@ export async function createProtection(options = {}) { onError?.(err); // a failed report must not stop the rule refresh } } - applyBundle(await resolveRules(options, store)); + const next = await resolveRules(options, store); + mode = resolveMode(options, next); + applyBundle(next); }; if (live) { @@ -462,6 +468,19 @@ export async function createProtection(options = {}) { return protection; } +/** + * Resolve runtime enforcement mode. + * Precedence: PATCHSTACK_MODE env > Pulse `enforcement` on the rules bundle > options.mode > dry-run. + */ +function resolveMode(options, bundle) { + const env = typeof process !== 'undefined' ? process.env?.PATCHSTACK_MODE : undefined; + if (env === 'block' || env === 'dry-run') return env; + if (bundle?.enforcement === 'block' || bundle?.enforcement === 'dry-run') return bundle.enforcement; + if (options?.mode === 'block') return 'block'; + if (options?.mode === 'dry-run') return 'dry-run'; + return 'dry-run'; +} + // --- phase / response helpers ------------------------------------------- function byPhase(rules, phase) { diff --git a/tests/protect/pulse-client.test.ts b/tests/protect/pulse-client.test.ts index 9d57f1b..64f8a8e 100644 --- a/tests/protect/pulse-client.test.ts +++ b/tests/protect/pulse-client.test.ts @@ -64,4 +64,32 @@ describe('PulseRuleClient', () => { it('requires a siteUuid', () => { expect(() => new PulseRuleClient({})).toThrow(); }); + + it('parses enforcement from the Pulse rules response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ ...RULES, enforcement: 'dry-run' }), { status: 200 })), + ); + const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules(); + expect(res.success).toBe(true); + expect(res.enforcement).toBe('dry-run'); + }); + + it('accepts mode as an alias for enforcement', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ ...RULES, mode: 'block' }), { status: 200 })), + ); + const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules(); + expect(res.enforcement).toBe('block'); + }); + + it('ignores invalid enforcement values', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ ...RULES, enforcement: 'maybe' }), { status: 200 })), + ); + const res = await new PulseRuleClient({ siteUuid: 'x' }).getRules(); + expect(res.enforcement).toBeUndefined(); + }); }); diff --git a/tests/protect/runtime-pulse.test.ts b/tests/protect/runtime-pulse.test.ts index 16c7370..cea78a4 100644 --- a/tests/protect/runtime-pulse.test.ts +++ b/tests/protect/runtime-pulse.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createProtection, createServerFnGuard } from '../../src/protect/runtime.js'; // A single-rule bundle mirroring the Pulse rules endpoint's { firewall, whitelists, whitelist_keys }. @@ -80,6 +80,92 @@ describe('createProtection with a siteUuid (live Pulse rules)', () => { }); }); +describe('createProtection Pulse enforcement field', () => { + const prevMode = process.env.PATCHSTACK_MODE; + + afterEach(() => { + if (prevMode === undefined) delete process.env.PATCHSTACK_MODE; + else process.env.PATCHSTACK_MODE = prevMode; + vi.restoreAllMocks(); + }); + + it('honors API enforcement=dry-run over options.mode=block (detect, do not block)', async () => { + delete process.env.PATCHSTACK_MODE; + const onDetect = vi.fn(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), { + status: 200, + }), + ), + ); + const protection = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + mode: 'block', + onDetect, + }); + expect(protection.mode).toBe('dry-run'); + expect(await createServerFnGuard({ protection })({ title: '' })).toBeNull(); + expect(onDetect).toHaveBeenCalled(); + }); + + it('lets PATCHSTACK_MODE override API enforcement', async () => { + process.env.PATCHSTACK_MODE = 'block'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), { + status: 200, + }), + ), + ); + const protection = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + mode: 'dry-run', + }); + expect(protection.mode).toBe('block'); + expect((await createServerFnGuard({ protection })({ title: '' }))?.rule).toBe('rm-npm-0001'); + }); + + it('hot-swaps mode when a refresh flips enforcement to block', async () => { + delete process.env.PATCHSTACK_MODE; + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'dry-run' }), { + status: 200, + }), + ) + .mockResolvedValue( + new Response(JSON.stringify({ firewall: rules.firewall, whitelists: [], whitelist_keys: {}, enforcement: 'block' }), { + status: 200, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const protection = await createProtection({ + siteUuid: 'site-1', + pulseRulesUrl: 'https://x.test/monitor/pulse', + mode: 'block', + refreshMs: 1000, + reportManifest: false, + }); + expect(protection.mode).toBe('dry-run'); + expect(await createServerFnGuard({ protection })({ title: '' })).toBeNull(); + + await vi.advanceTimersByTimeAsync(1100); + expect(protection.mode).toBe('block'); + expect((await createServerFnGuard({ protection })({ title: '' }))?.rule).toBe('rm-npm-0001'); + + protection.stopRefresh?.(); + vi.useRealTimers(); + }); +}); + describe('createProtection live rule refresh (refreshMs)', () => { it('hot-swaps in a rule that appears after boot, without recreating the protection', async () => { vi.useFakeTimers();