@patchstack/connect 0.3.30 → 0.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ // Public types for `@patchstack/connect/protect` (the vendored runtime is plain JS, so the
2
+ // declarations are hand-authored and shipped alongside dist/protect.js).
3
+
4
+ export interface RuleBundle {
5
+ firewall: unknown[];
6
+ whitelists: unknown[];
7
+ whitelist_keys: Record<string, unknown>;
8
+ /** From the Pulse rules API when present (`block` = enforce, `dry-run` = detect only). */
9
+ enforcement?: "block" | "dry-run";
10
+ }
11
+
12
+ export type Phase = "request" | "response" | "egress";
13
+
14
+ export interface Protection {
15
+ mode: "block" | "dry-run";
16
+ /** Active rules split by phase. */
17
+ rules: { request: unknown[]; response: unknown[]; egress: unknown[] };
18
+ /** (request) => Response (403 when blocked) | null (allow / dry-run). Request phase only. */
19
+ fetchGuard(): (request: Request) => Promise<Response | null>;
20
+ /** Screens the request, then the response (secret-leak redaction / withhold). */
21
+ fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise<unknown>;
22
+ /**
23
+ * Screen a fetch Response through the response-phase rules (redact/withhold/encode).
24
+ *
25
+ * Pass the originating `request` wherever it is available. A response rule can be scoped to a route or a
26
+ * method (`when`), and that scope can only be applied if the engine is given the request the response
27
+ * belongs to — without it, a scoped response rule is delivered, counted as protection, and never matches.
28
+ */
29
+ screenResponse(response: Response, request?: Request): Promise<Response>;
30
+ express(options?: { screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
31
+ node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
32
+ /** Present when `egress: true` — restores the original global fetch. */
33
+ uninstallEgress?: () => void;
34
+ /** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push).
35
+ * Resolves with the outcome of the attempt: `ok: false` means the rules in force came from the
36
+ * cache or the bundled fallback, not from the source. It does not reject on a source failure. */
37
+ /** Refresh the rules now. `ok` is whether the resolution was clean; `origin` is which source supplied
38
+ * the rules now in force — `api` and `cache` are Patchstack-delivered, `bundled` is the caller's own
39
+ * `rules` option, `empty` is none. A fallback is `ok: false` with the origin it fell back to. */
40
+ refresh?: () => Promise<{
41
+ ok: boolean;
42
+ origin?: "api" | "cache" | "bundled" | "empty";
43
+ reason?: string;
44
+ }>;
45
+ /** Present with a live source — a fetch handler that runs `refresh()` when the request carries
46
+ * the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */
47
+ refreshHandler?: () => (request: Request) => Promise<Response>;
48
+ /** Stops everything with a timer or a buffer behind it: the refresh loop, the block log, the
49
+ * detection reporter (flushing what it holds). Always present, and safe to call twice. */
50
+ /**
51
+ * Stop everything holding a timer or a buffer.
52
+ *
53
+ * Resolves once the reporters this reaches — the detection reporter and the block log — have finished
54
+ * or been given up on, so a shutdown handler can await it instead of racing process exit. Each has its
55
+ * own budget, and when one elapses that reporter is ENDED: its requests are aborted, it starts nothing
56
+ * further, and it discards what it was holding. Ignoring the promise behaves as it always has.
57
+ *
58
+ * Two limits are worth knowing, because neither can be promised away:
59
+ *
60
+ * - A request is aborted, not guaranteed to stop. A transport that ignores its abort signal is
61
+ * DETACHED — this stops waiting on it and stops acting on its result — so "resolved" means the
62
+ * reporter is finished with it, not that the underlying request has ended.
63
+ * - A runtime that terminates the process regardless still wins, whatever this resolves.
64
+ *
65
+ * What is accounted for also differs by reporter. Every detection event ends up delivered, refused or
66
+ * dropped, and `detectionHealth()` reports each. Block-log records have no counters at all, so one lost
67
+ * to a failed token exchange, a failed post, or a shutdown that ran out of time is reported nowhere.
68
+ */
69
+ stop: () => Promise<void>;
70
+ /** Alias of `stop`, under the name callers already have. */
71
+ stopRefresh: () => Promise<void>;
72
+ /** Whether this guard reports security events, and if not, why not.
73
+ *
74
+ * Reporting is on for a site enrolled in Patchstack-managed mitigation that is running managed rules
75
+ * with a credential, and off everywhere else. Each state is distinct so "no events arrived" can be
76
+ * told apart from "reporting is off" — and it follows refreshes, so a guard that starts on cached or
77
+ * bundled rules and later receives managed rules begins reporting without a restart.
78
+ *
79
+ * - `on` — events are being sent
80
+ * - `disabled-by-config` — `PATCHSTACK_REPORT_DETECTIONS` is false, or `reportDetections: false`
81
+ * - `disabled-by-telemetry-opt-out` — `PATCHSTACK_TELEMETRY` is false
82
+ * - `not-enrolled` — no site identity
83
+ * - `no-managed-rules` — the rules in force did not come from Patchstack
84
+ * - `unavailable-no-credential` — enrolled, but no credential resolved */
85
+ detectionReporting:
86
+ | "on"
87
+ | "disabled-by-config"
88
+ | "disabled-by-telemetry-opt-out"
89
+ | "not-enrolled"
90
+ | "no-managed-rules"
91
+ | "unavailable-no-credential";
92
+ /** Present when detection reporting is on — delivery counts (in events) and the last acknowledgement.
93
+ * Carries no request data. */
94
+ detectionHealth?: () => {
95
+ /** Events attempted, acknowledged, refused or unreachable, and dropped for queue pressure. */
96
+ sent: number;
97
+ delivered: number;
98
+ failed: number;
99
+ dropped: number;
100
+ /** Backoff attempts beyond the first. A path that only ever succeeds on a retry is working, and is
101
+ * worth telling apart from one that never has to retry. */
102
+ retried: number;
103
+ /** Redeliveries made after the endpoint refused a token, which are not backoff retries: a rotated or
104
+ * revoked credential is worth seeing as itself rather than as a delivery failure. */
105
+ reauthorized: number;
106
+ lastDeliveredAt: string | null;
107
+ /** Capability announcements, counted separately: these carry no events, so they never move the
108
+ * counters above. Zero here alongside delivered events is normal, and so is the reverse. */
109
+ capability: {
110
+ announced: number;
111
+ acknowledged: number;
112
+ failed: number;
113
+ /** Retries of a declaration, counted apart from event retries for the same reason as the rest. */
114
+ retried: number;
115
+ lastAcknowledgedAt: string | null;
116
+ };
117
+ };
118
+ }
119
+
120
+ export interface CreateProtectionOptions {
121
+ /**
122
+ * Fallback when the Pulse rules API does not send `enforcement`.
123
+ * Overridden by `PATCHSTACK_MODE` when set, otherwise by API `enforcement`.
124
+ * Default "dry-run". Scaffolded guards pass "block" when env is unset.
125
+ */
126
+ mode?: "block" | "dry-run";
127
+ /** Explicit rule bundle (used as the token-less fallback). */
128
+ rules?: unknown;
129
+ /** Patchstack WAF token — pull live per-site rules from the API. */
130
+ token?: string;
131
+ baseUrl?: string;
132
+ /** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */
133
+ siteUuid?: string;
134
+ /**
135
+ * WP-format site API key (`{secret}-{oauth.id}`) for authenticated block logs
136
+ * via connector `POST /api/logs/log`. Falls back to `PATCHSTACK_API_KEY`, then
137
+ * `apiKey` in `.patchstackrc.local.json` (where setup writes it) and then in
138
+ * `.patchstackrc.json` (where installs that predate the split still hold it).
139
+ * Never put this in the public widget.
140
+ */
141
+ apiKey?: string;
142
+ /**
143
+ * Credential for the authenticated rules lookup. Falls back to
144
+ * `PATCHSTACK_PULSE_AUTH`, then `pulseAuth` in `.patchstackrc.local.json` and
145
+ * then in `.patchstackrc.json`, then to `apiKey`. Exchanged for a short-lived
146
+ * token; never sent directly. Never put this in the public widget.
147
+ */
148
+ pulseAuth?: string;
149
+ /** Override the Pulse rules API base URL. */
150
+ pulseRulesUrl?: string;
151
+ /**
152
+ * When false, skip posting block events to connector `/api/logs/log`.
153
+ * Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
154
+ */
155
+ reportFirewallLog?: boolean;
156
+ /**
157
+ * Opt OUT of reporting every rule that fired — including one in `dry-run` that did not block — to the
158
+ * Pulse detections endpoint.
159
+ *
160
+ * Reporting is ON by default for a site enrolled with Patchstack that is running Patchstack-delivered
161
+ * rules and has a resolvable credential; it is off for a local install and for a guard running its own
162
+ * `rules`. This option can only switch it OFF: passing `true` cannot enable reporting for a site that is
163
+ * not enrolled, because whether a site is managed is Patchstack's answer and not a caller's to assert.
164
+ * `PATCHSTACK_REPORT_DETECTIONS=0` does the same thing from the environment.
165
+ *
166
+ * Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a rule
167
+ * that is protecting look identical from the outside.
168
+ *
169
+ * What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
170
+ * parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
171
+ * rule-bundle ETag, and a timestamp — plus the values of the parameters the matched rule names, under a
172
+ * capture plan derived from that rule.
173
+ *
174
+ * Two fields depend on the phase. A request or response detection also carries the user agent and the
175
+ * client address with its provenance. An egress detection carries neither: the call was the
176
+ * application's own, so there is no visitor to attribute it to, and both read `null`/`unavailable`.
177
+ *
178
+ * A rule earns each captured value by naming what it reads. A rule reading the whole request (`raw`,
179
+ * `all`) permits nothing; response values are never captured; raw request bytes need an explicit,
180
+ * reviewed opt-in on the rule itself. Values are bounded in number and length, and what a bound left
181
+ * out is counted. The User-Agent is the one exception to the rule-scoped policy: it is part of the
182
+ * baseline and travels whether or not a rule names it, because a detection nobody can attribute is of
183
+ * little use. It does NOT send the value of any other parameter the matched rule does not name, any
184
+ * response value, or the query string's values as baseline metadata — `route` and `query_keys`
185
+ * describe a URL without disclosing what was in it, while a rule naming `egress.url` captures that URL
186
+ * as the rule read it. An egress detection has no user agent and no client address: the call was the
187
+ * application's own, so there is no visitor to attribute it to.
188
+ *
189
+ * `AGENT-INSTALL.md` carries the full statement, and is the version to read before enabling this.
190
+ *
191
+ * `detectionReporting` names the state, including the reason when reporting is off.
192
+ */
193
+ reportDetections?: boolean;
194
+ /** How long to buffer detections before posting a batch. Default 5000ms. */
195
+ detectionFlushMs?: number;
196
+ /** Optional Source-Host header for connector hostname checks. */
197
+ sourceHost?: string;
198
+ /** Optional fetch override (tests). */
199
+ fetchImpl?: typeof fetch;
200
+ /**
201
+ * Re-fetch and hot-swap the live rules every N ms. For long-lived runtimes that aren't restarted
202
+ * on change (an AI builder's sandbox/preview) so a rule that becomes relevant after boot still
203
+ * applies. Default 0 (off) — a real deploy restarts the process, which re-fetches anyway. Only
204
+ * meaningful with a live source (`siteUuid`/`token`).
205
+ */
206
+ refreshMs?: number;
207
+ /**
208
+ * Shared secret gating the push refresh endpoint (`refreshHandler()`): the platform/SaaS hits the
209
+ * endpoint with this secret to trigger an immediate refresh. Falls back to `PATCHSTACK_REFRESH_SECRET`.
210
+ * Unset → the endpoint 404s (never an open refresh trigger).
211
+ */
212
+ refreshSecret?: string;
213
+ /**
214
+ * During a refresh, also re-post the dependency manifest (the runtime counterpart to `scan`) so a
215
+ * dependency added after boot — e.g. via `npm install <pkg>`, which fires no npm lifecycle hook —
216
+ * is reported and enforced without a restart. Defaults on when a `siteUuid` is set; set false to
217
+ * refresh rules only. Only meaningful with `refreshMs > 0` and a Pulse `siteUuid`.
218
+ */
219
+ reportManifest?: boolean;
220
+ /** Directory the manifest re-scan reads the lockfile from during a refresh. Default process.cwd(). */
221
+ cwd?: string;
222
+ /** Directory for the last-known-good rule cache (disk — the default cache backend). */
223
+ cacheDir?: string;
224
+ /**
225
+ * Pluggable last-known-good cache, for runtimes without a filesystem (Workers/Deno). Overrides
226
+ * the disk cache. Stores/returns an opaque envelope; read may return null when nothing is cached.
227
+ */
228
+ ruleCache?: {
229
+ read(): unknown | Promise<unknown>;
230
+ write(envelope: unknown): unknown | Promise<unknown>;
231
+ };
232
+ /**
233
+ * Declare which peers are this deployment's own reverse proxies, so a forwarded header can be believed.
234
+ *
235
+ * With no policy, the client address is whatever the transport observed — the socket peer on Node, and
236
+ * nothing at all in a runtime that exposes no peer, where the provenance reads `unavailable`. A
237
+ * forwarded header is never trusted implicitly: it is ordinary request input that any caller can send.
238
+ *
239
+ * A policy must say WHO is trusted, not just which header to read. Declare at least one of:
240
+ *
241
+ * - `peers` — CIDRs or bare addresses of your front end. An empty list means no peer is trusted, and
242
+ * one unparseable entry rejects the whole policy.
243
+ * - `hops` — the number of trusted proxies counting from the peer inward, as in the numeric form of
244
+ * Express's `trust proxy`.
245
+ * - `isTrusted` — a predicate over an address.
246
+ *
247
+ * `header` defaults to `x-forwarded-for`. The chain is read from the application side inward, stopping
248
+ * at the first address that is not trusted, because a proxy appends rather than replaces — so a value
249
+ * the caller prepended is ignored.
250
+ *
251
+ * Any unrecognised key, or any malformed value, rejects the policy rather than being ignored. There are
252
+ * no provider presets: a provider's name does not establish that the provider overwrote the header.
253
+ *
254
+ * Note that `req.ip` is never consulted on the Express path. Under `trust proxy` it is itself
255
+ * header-derived by a policy this guard has not verified, so an application behind a proxy sees the
256
+ * proxy's address until it declares a policy here.
257
+ */
258
+ trustedProxy?: {
259
+ peers?: string[];
260
+ hops?: number;
261
+ header?: string;
262
+ isTrusted?: (ip: string) => boolean;
263
+ };
264
+ /** Override the default response-phase (secret-leak) rule set. */
265
+ responseRules?: unknown[];
266
+ /** Override the default egress-phase (SSRF) rule set. */
267
+ egressRules?: unknown[];
268
+ /** Opt in to wrapping global fetch to screen the app's outbound calls (SSRF). */
269
+ egress?: boolean;
270
+ /** Hosts exempt from egress screening. */
271
+ allowHosts?: string[];
272
+ /**
273
+ * Screen the Node http/https path against DNS rebinding: resolve outbound hostnames and block +
274
+ * pin to the vetted address when they map to a disallowed (internal/metadata) IP. Default true;
275
+ * only active when `egress` is on and node:dns is available (a no-op on edge runtimes).
276
+ */
277
+ screenDns?: boolean;
278
+ /** Redaction mask (string or per-category function). Default "[REDACTED]". */
279
+ maskWith?: string | ((category?: string) => string);
280
+ onError?: (err: unknown) => void;
281
+ onEgressBlock?: (info: { url: string; host: string | null; method: string }) => void;
282
+ onDetect?: (detection: {
283
+ phase?: Phase;
284
+ mode: string;
285
+ category?: string;
286
+ rule?: { id?: string | number; category?: string };
287
+ message?: string;
288
+ method?: string | null;
289
+ path?: string | null;
290
+ /** The client address resolved for this request, or null when none could be established. */
291
+ ip?: string | null;
292
+ /** Where `ip` came from: the transport peer, a forwarded header a declared trusted proxy set, or
293
+ * nothing this guard can stand behind. `ip` is null when this is `unavailable`. */
294
+ clientIpSource?: "runtime" | "trusted-proxy" | "unavailable";
295
+ userAgent?: string | null;
296
+ }) => void;
297
+ }
298
+
299
+ export function createProtection(options?: CreateProtectionOptions): Promise<Protection>;
300
+
301
+ export const GUARD_PATH: string;
302
+
303
+ /** Browser-tunnel guard: evaluate then forward to the app's own Supabase project (SSRF-pinned). */
304
+ export function createSupabaseGuard(opts: {
305
+ protection: Protection;
306
+ supabaseUrl?: string;
307
+ fetchImpl?: typeof fetch;
308
+ }): (request: Request) => Promise<Response>;
309
+
310
+ /**
311
+ * Server-function guard: inspect a TanStack server function's decoded args against the same
312
+ * policy. Returns a block receipt to throw on (aborts the call before it writes), or null to allow.
313
+ */
314
+ export function createServerFnGuard(opts: {
315
+ protection: Protection;
316
+ }): (data: unknown) => Promise<{ rule?: string; message: string } | null>;
package/dist/protect.d.ts CHANGED
@@ -34,27 +34,86 @@ export interface Protection {
34
34
  /** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push).
35
35
  * Resolves with the outcome of the attempt: `ok: false` means the rules in force came from the
36
36
  * cache or the bundled fallback, not from the source. It does not reject on a source failure. */
37
- refresh?: () => Promise<{ ok: boolean; reason?: string }>;
37
+ /** Refresh the rules now. `ok` is whether the resolution was clean; `origin` is which source supplied
38
+ * the rules now in force — `api` and `cache` are Patchstack-delivered, `bundled` is the caller's own
39
+ * `rules` option, `empty` is none. A fallback is `ok: false` with the origin it fell back to. */
40
+ refresh?: () => Promise<{
41
+ ok: boolean;
42
+ origin?: "api" | "cache" | "bundled" | "empty";
43
+ reason?: string;
44
+ }>;
38
45
  /** Present with a live source — a fetch handler that runs `refresh()` when the request carries
39
46
  * the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */
40
47
  refreshHandler?: () => (request: Request) => Promise<Response>;
41
48
  /** Stops everything with a timer or a buffer behind it: the refresh loop, the block log, the
42
49
  * detection reporter (flushing what it holds). Always present, and safe to call twice. */
43
- stop: () => void;
50
+ /**
51
+ * Stop everything holding a timer or a buffer.
52
+ *
53
+ * Resolves once the reporters this reaches — the detection reporter and the block log — have finished
54
+ * or been given up on, so a shutdown handler can await it instead of racing process exit. Each has its
55
+ * own budget, and when one elapses that reporter is ENDED: its requests are aborted, it starts nothing
56
+ * further, and it discards what it was holding. Ignoring the promise behaves as it always has.
57
+ *
58
+ * Two limits are worth knowing, because neither can be promised away:
59
+ *
60
+ * - A request is aborted, not guaranteed to stop. A transport that ignores its abort signal is
61
+ * DETACHED — this stops waiting on it and stops acting on its result — so "resolved" means the
62
+ * reporter is finished with it, not that the underlying request has ended.
63
+ * - A runtime that terminates the process regardless still wins, whatever this resolves.
64
+ *
65
+ * What is accounted for also differs by reporter. Every detection event ends up delivered, refused or
66
+ * dropped, and `detectionHealth()` reports each. Block-log records have no counters at all, so one lost
67
+ * to a failed token exchange, a failed post, or a shutdown that ran out of time is reported nowhere.
68
+ */
69
+ stop: () => Promise<void>;
44
70
  /** Alias of `stop`, under the name callers already have. */
45
- stopRefresh: () => void;
46
- /** Whether detection reporting is running, requested but undeliverable, or not requested.
47
- * `unavailable-no-credential` means `reportDetections` was set but no credential resolved, so
48
- * nothing is being sent. */
49
- detectionReporting: "on" | "off" | "unavailable-no-credential";
71
+ stopRefresh: () => Promise<void>;
72
+ /** Whether this guard reports security events, and if not, why not.
73
+ *
74
+ * Reporting is on for a site enrolled in Patchstack-managed mitigation that is running managed rules
75
+ * with a credential, and off everywhere else. Each state is distinct so "no events arrived" can be
76
+ * told apart from "reporting is off" — and it follows refreshes, so a guard that starts on cached or
77
+ * bundled rules and later receives managed rules begins reporting without a restart.
78
+ *
79
+ * - `on` — events are being sent
80
+ * - `disabled-by-config` — `PATCHSTACK_REPORT_DETECTIONS` is false, or `reportDetections: false`
81
+ * - `disabled-by-telemetry-opt-out` — `PATCHSTACK_TELEMETRY` is false
82
+ * - `not-enrolled` — no site identity
83
+ * - `no-managed-rules` — the rules in force did not come from Patchstack
84
+ * - `unavailable-no-credential` — enrolled, but no credential resolved */
85
+ detectionReporting:
86
+ | "on"
87
+ | "disabled-by-config"
88
+ | "disabled-by-telemetry-opt-out"
89
+ | "not-enrolled"
90
+ | "no-managed-rules"
91
+ | "unavailable-no-credential";
50
92
  /** Present when detection reporting is on — delivery counts (in events) and the last acknowledgement.
51
93
  * Carries no request data. */
52
94
  detectionHealth?: () => {
95
+ /** Events attempted, acknowledged, refused or unreachable, and dropped for queue pressure. */
53
96
  sent: number;
54
97
  delivered: number;
55
98
  failed: number;
56
99
  dropped: number;
100
+ /** Backoff attempts beyond the first. A path that only ever succeeds on a retry is working, and is
101
+ * worth telling apart from one that never has to retry. */
102
+ retried: number;
103
+ /** Redeliveries made after the endpoint refused a token, which are not backoff retries: a rotated or
104
+ * revoked credential is worth seeing as itself rather than as a delivery failure. */
105
+ reauthorized: number;
57
106
  lastDeliveredAt: string | null;
107
+ /** Capability announcements, counted separately: these carry no events, so they never move the
108
+ * counters above. Zero here alongside delivered events is normal, and so is the reverse. */
109
+ capability: {
110
+ announced: number;
111
+ acknowledged: number;
112
+ failed: number;
113
+ /** Retries of a declaration, counted apart from event retries for the same reason as the rest. */
114
+ retried: number;
115
+ lastAcknowledgedAt: string | null;
116
+ };
58
117
  };
59
118
  }
60
119
 
@@ -95,20 +154,41 @@ export interface CreateProtectionOptions {
95
154
  */
96
155
  reportFirewallLog?: boolean;
97
156
  /**
98
- * Report EVERY rule that fired — including one in `dry-run` that did not block — to the Pulse
99
- * detections endpoint. Off unless explicitly `true`.
157
+ * Opt OUT of reporting every rule that fired — including one in `dry-run` that did not block — to the
158
+ * Pulse detections endpoint.
159
+ *
160
+ * Reporting is ON by default for a site enrolled with Patchstack that is running Patchstack-delivered
161
+ * rules and has a resolvable credential; it is off for a local install and for a guard running its own
162
+ * `rules`. This option can only switch it OFF: passing `true` cannot enable reporting for a site that is
163
+ * not enrolled, because whether a site is managed is Patchstack's answer and not a caller's to assert.
164
+ * `PATCHSTACK_REPORT_DETECTIONS=0` does the same thing from the environment.
165
+ *
166
+ * Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a rule
167
+ * that is protecting look identical from the outside.
168
+ *
169
+ * What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
170
+ * parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
171
+ * rule-bundle ETag, and a timestamp — plus the values of the parameters the matched rule names, under a
172
+ * capture plan derived from that rule.
100
173
  *
101
- * Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a
102
- * rule that is protecting look identical from the outside.
174
+ * Two fields depend on the phase. A request or response detection also carries the user agent and the
175
+ * client address with its provenance. An egress detection carries neither: the call was the
176
+ * application's own, so there is no visitor to attribute it to, and both read `null`/`unavailable`.
103
177
  *
104
- * What it sends, per detection: the rule id, the request PATH with the query string removed, the
105
- * parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a
106
- * timestamp. It does NOT send the matched value, the request body, headers, or query-string values —
107
- * this is a counting channel, not a copy of your traffic.
178
+ * A rule earns each captured value by naming what it reads. A rule reading the whole request (`raw`,
179
+ * `all`) permits nothing; response values are never captured; raw request bytes need an explicit,
180
+ * reviewed opt-in on the rule itself. Values are bounded in number and length, and what a bound left
181
+ * out is counted. The User-Agent is the one exception to the rule-scoped policy: it is part of the
182
+ * baseline and travels whether or not a rule names it, because a detection nobody can attribute is of
183
+ * little use. It does NOT send the value of any other parameter the matched rule does not name, any
184
+ * response value, or the query string's values as baseline metadata — `route` and `query_keys`
185
+ * describe a URL without disclosing what was in it, while a rule naming `egress.url` captures that URL
186
+ * as the rule read it. An egress detection has no user agent and no client address: the call was the
187
+ * application's own, so there is no visitor to attribute it to.
108
188
  *
109
- * Off by default because switching it on adds an outbound request to every guard with a site UUID.
110
- * Needs a resolvable API credential: the endpoint requires a verified, site-bound token, so with no
111
- * credential no reporter is created and `detectionReporting` reads `unavailable-no-credential`.
189
+ * `AGENT-INSTALL.md` carries the full statement, and is the version to read before enabling this.
190
+ *
191
+ * `detectionReporting` names the state, including the reason when reporting is off.
112
192
  */
113
193
  reportDetections?: boolean;
114
194
  /** How long to buffer detections before posting a batch. Default 5000ms. */
@@ -149,6 +229,38 @@ export interface CreateProtectionOptions {
149
229
  read(): unknown | Promise<unknown>;
150
230
  write(envelope: unknown): unknown | Promise<unknown>;
151
231
  };
232
+ /**
233
+ * Declare which peers are this deployment's own reverse proxies, so a forwarded header can be believed.
234
+ *
235
+ * With no policy, the client address is whatever the transport observed — the socket peer on Node, and
236
+ * nothing at all in a runtime that exposes no peer, where the provenance reads `unavailable`. A
237
+ * forwarded header is never trusted implicitly: it is ordinary request input that any caller can send.
238
+ *
239
+ * A policy must say WHO is trusted, not just which header to read. Declare at least one of:
240
+ *
241
+ * - `peers` — CIDRs or bare addresses of your front end. An empty list means no peer is trusted, and
242
+ * one unparseable entry rejects the whole policy.
243
+ * - `hops` — the number of trusted proxies counting from the peer inward, as in the numeric form of
244
+ * Express's `trust proxy`.
245
+ * - `isTrusted` — a predicate over an address.
246
+ *
247
+ * `header` defaults to `x-forwarded-for`. The chain is read from the application side inward, stopping
248
+ * at the first address that is not trusted, because a proxy appends rather than replaces — so a value
249
+ * the caller prepended is ignored.
250
+ *
251
+ * Any unrecognised key, or any malformed value, rejects the policy rather than being ignored. There are
252
+ * no provider presets: a provider's name does not establish that the provider overwrote the header.
253
+ *
254
+ * Note that `req.ip` is never consulted on the Express path. Under `trust proxy` it is itself
255
+ * header-derived by a policy this guard has not verified, so an application behind a proxy sees the
256
+ * proxy's address until it declares a policy here.
257
+ */
258
+ trustedProxy?: {
259
+ peers?: string[];
260
+ hops?: number;
261
+ header?: string;
262
+ isTrusted?: (ip: string) => boolean;
263
+ };
152
264
  /** Override the default response-phase (secret-leak) rule set. */
153
265
  responseRules?: unknown[];
154
266
  /** Override the default egress-phase (SSRF) rule set. */
@@ -175,7 +287,11 @@ export interface CreateProtectionOptions {
175
287
  message?: string;
176
288
  method?: string | null;
177
289
  path?: string | null;
290
+ /** The client address resolved for this request, or null when none could be established. */
178
291
  ip?: string | null;
292
+ /** Where `ip` came from: the transport peer, a forwarded header a declared trusted proxy set, or
293
+ * nothing this guard can stand behind. `ip` is null when this is `unavailable`. */
294
+ clientIpSource?: "runtime" | "trusted-proxy" | "unavailable";
179
295
  userAgent?: string | null;
180
296
  }) => void;
181
297
  }