@patchstack/connect 0.3.21 → 0.3.23

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.
package/dist/protect.cjs CHANGED
@@ -155,8 +155,10 @@ async function resolveConfig(options) {
155
155
  "CONFIG_MISSING"
156
156
  );
157
157
  }
158
+ const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
158
159
  return {
159
160
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
161
+ apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
160
162
  endpoint,
161
163
  timeoutMs,
162
164
  environment,
@@ -204,6 +206,7 @@ function readEnv() {
204
206
  const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
205
207
  return {
206
208
  siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
209
+ apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
207
210
  endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
208
211
  timeoutMs,
209
212
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
@@ -2243,7 +2246,8 @@ var PulseRuleClient = class {
2243
2246
  etag: response.headers?.get?.("etag") ?? null,
2244
2247
  firewall: data.firewall,
2245
2248
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
2246
- whitelist_keys: data.whitelist_keys ?? {}
2249
+ whitelist_keys: data.whitelist_keys ?? {},
2250
+ ...enforcementField(data)
2247
2251
  };
2248
2252
  this.#cache = result;
2249
2253
  this.#etag = result.etag;
@@ -2269,6 +2273,11 @@ var PulseRuleClient = class {
2269
2273
  this.#etag = null;
2270
2274
  }
2271
2275
  };
2276
+ function enforcementField(data) {
2277
+ if (!data || typeof data !== "object") return {};
2278
+ const v = data.enforcement ?? data.mode;
2279
+ return v === "block" || v === "dry-run" ? { enforcement: v } : {};
2280
+ }
2272
2281
 
2273
2282
  // src/protect/engine/fetch.js
2274
2283
  var MAX_BODY_BYTES = 1024 * 1024;
@@ -2996,10 +3005,12 @@ async function resolveRules(options, store) {
2996
3005
  return emptyBundle();
2997
3006
  }
2998
3007
  function normalizeBundle(b) {
3008
+ const enforcement = b?.enforcement ?? b?.mode;
2999
3009
  return {
3000
3010
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
3001
3011
  whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
3002
- whitelist_keys: b.whitelist_keys ?? {}
3012
+ whitelist_keys: b.whitelist_keys ?? {},
3013
+ ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3003
3014
  };
3004
3015
  }
3005
3016
  function emptyBundle() {
@@ -3060,6 +3071,169 @@ function makeRefreshHandler(tick, secret) {
3060
3071
  };
3061
3072
  }
3062
3073
 
3074
+ // src/protect/firewall-log.js
3075
+ var DEFAULT_API_BASE = "https://api.patchstack.com";
3076
+ var DEFAULT_FLUSH_MS = 1e3;
3077
+ var MAX_BATCH = 50;
3078
+ var TOKEN_SKEW_MS = 6e4;
3079
+ function parseApiKey(apiKey) {
3080
+ if (typeof apiKey !== "string" || apiKey.length === 0) return null;
3081
+ const idx = apiKey.lastIndexOf("-");
3082
+ if (idx <= 0 || idx === apiKey.length - 1) return null;
3083
+ const clientSecret = apiKey.slice(0, idx);
3084
+ const clientId = apiKey.slice(idx + 1);
3085
+ if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
3086
+ return { clientId, clientSecret };
3087
+ }
3088
+ function resolveApiBase(pulseOrManifestUrl) {
3089
+ const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
3090
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
3091
+ return fromEnv.replace(/\/$/, "");
3092
+ }
3093
+ if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
3094
+ try {
3095
+ return new URL(pulseOrManifestUrl).origin;
3096
+ } catch {
3097
+ }
3098
+ }
3099
+ const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
3100
+ if (typeof endpoint === "string" && endpoint.length > 0) {
3101
+ try {
3102
+ return new URL(endpoint).origin;
3103
+ } catch {
3104
+ }
3105
+ }
3106
+ return DEFAULT_API_BASE;
3107
+ }
3108
+ function createFirewallLogReporter(opts) {
3109
+ const creds = parseApiKey(opts.apiKey);
3110
+ if (!creds) {
3111
+ return { record() {
3112
+ }, flush() {
3113
+ }, stop() {
3114
+ } };
3115
+ }
3116
+ const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3117
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3118
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
3119
+ const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
3120
+ let queue = [];
3121
+ let timer = null;
3122
+ let stopped = false;
3123
+ let cachedToken = null;
3124
+ let tokenInflight = null;
3125
+ const fetchAccessToken = async () => {
3126
+ if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
3127
+ return cachedToken.token;
3128
+ }
3129
+ if (tokenInflight) return tokenInflight;
3130
+ tokenInflight = (async () => {
3131
+ try {
3132
+ const res = await fetchImpl(`${apiBase}/oauth/token`, {
3133
+ method: "POST",
3134
+ headers: {
3135
+ "Content-Type": "application/json",
3136
+ Accept: "application/json",
3137
+ "User-Agent": "@patchstack/connect"
3138
+ },
3139
+ body: JSON.stringify({
3140
+ grant_type: "client_credentials",
3141
+ client_id: creds.clientId,
3142
+ client_secret: creds.clientSecret
3143
+ })
3144
+ });
3145
+ if (!res || !res.ok) return null;
3146
+ const body = await res.json();
3147
+ const token = body?.access_token;
3148
+ if (typeof token !== "string" || token.length === 0) return null;
3149
+ const expiresIn = Number(body?.expires_in);
3150
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
3151
+ cachedToken = { token, expiresAt: Date.now() + ttlMs };
3152
+ return token;
3153
+ } catch {
3154
+ return null;
3155
+ } finally {
3156
+ tokenInflight = null;
3157
+ }
3158
+ })();
3159
+ return tokenInflight;
3160
+ };
3161
+ const flush = () => {
3162
+ if (timer) {
3163
+ clearTimeout(timer);
3164
+ timer = null;
3165
+ }
3166
+ if (queue.length === 0 || typeof fetchImpl !== "function") return;
3167
+ const batch = queue.splice(0, MAX_BATCH);
3168
+ void (async () => {
3169
+ const token = await fetchAccessToken();
3170
+ if (!token) return;
3171
+ const body = new URLSearchParams();
3172
+ body.set("type", "firewall");
3173
+ body.set("logs", JSON.stringify(batch));
3174
+ try {
3175
+ const p = fetchImpl(`${apiBase}/api/logs/log`, {
3176
+ method: "POST",
3177
+ headers: {
3178
+ Authorization: `Bearer ${token}`,
3179
+ Accept: "application/json",
3180
+ "Content-Type": "application/x-www-form-urlencoded",
3181
+ "User-Agent": "@patchstack/connect",
3182
+ ...sourceHost ? { "Source-Host": sourceHost } : {}
3183
+ },
3184
+ body
3185
+ });
3186
+ if (p && typeof p.then === "function") p.catch(() => {
3187
+ });
3188
+ } catch {
3189
+ }
3190
+ })();
3191
+ };
3192
+ return {
3193
+ /**
3194
+ * @param {{
3195
+ * rule?: { id?: string | number },
3196
+ * method?: string | null,
3197
+ * path?: string | null,
3198
+ * ip?: string | null,
3199
+ * userAgent?: string | null,
3200
+ * }} event
3201
+ */
3202
+ record(event) {
3203
+ if (stopped) return;
3204
+ const fid = event?.rule?.id;
3205
+ if (fid === void 0 || fid === null || fid === "") return;
3206
+ queue.push({
3207
+ fid,
3208
+ method: event.method ?? null,
3209
+ request_uri: event.path ?? null,
3210
+ ip: event.ip ?? null,
3211
+ user_agent: event.userAgent ?? null,
3212
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
3213
+ });
3214
+ if (queue.length >= MAX_BATCH) {
3215
+ flush();
3216
+ return;
3217
+ }
3218
+ if (!timer) timer = setTimeout(flush, flushMs);
3219
+ },
3220
+ flush,
3221
+ stop() {
3222
+ stopped = true;
3223
+ flush();
3224
+ }
3225
+ };
3226
+ }
3227
+ function telemetryEnabled() {
3228
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
3229
+ if (v === void 0 || v === "") return true;
3230
+ return !/^(0|false|off|no)$/i.test(String(v));
3231
+ }
3232
+
3233
+ // src/protect/runtime.js
3234
+ var import_node_fs2 = require("fs");
3235
+ var import_node_path8 = require("path");
3236
+
3063
3237
  // src/protect/supabase-guard.js
3064
3238
  var GUARD_PATH = "/_patchstack/guard";
3065
3239
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -3131,11 +3305,30 @@ function createServerFnGuard({ protection }) {
3131
3305
  };
3132
3306
  }
3133
3307
  async function createProtection(options = {}) {
3134
- const mode = options.mode === "block" ? "block" : "dry-run";
3135
3308
  const onError = options.onError;
3136
- const onDetect = options.onDetect ?? defaultOnDetect;
3309
+ const userOnDetect = options.onDetect ?? defaultOnDetect;
3310
+ const apiKey = resolveApiKey(options);
3311
+ const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
3312
+ apiKey,
3313
+ apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
3314
+ sourceHost: options.sourceHost,
3315
+ fetchImpl: options.fetchImpl
3316
+ }) : null;
3317
+ const onDetect = (detection) => {
3318
+ userOnDetect(detection);
3319
+ if (firewallLog && detection?.mode === "block") {
3320
+ firewallLog.record({
3321
+ rule: detection.rule,
3322
+ method: detection.method,
3323
+ path: detection.path,
3324
+ ip: detection.ip,
3325
+ userAgent: detection.userAgent
3326
+ });
3327
+ }
3328
+ };
3137
3329
  const store = makeStore(options);
3138
3330
  const bundle = await resolveRules(options, store);
3331
+ let mode = resolveMode(options, bundle);
3139
3332
  let requestRules;
3140
3333
  let responseRules;
3141
3334
  let egressRules;
@@ -3164,9 +3357,19 @@ async function createProtection(options = {}) {
3164
3357
  };
3165
3358
  applyBundle(bundle);
3166
3359
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3167
- const decide = (phase, result, block, allow2) => {
3360
+ const decide = (phase, result, block, allow2, ctx = {}) => {
3168
3361
  if (!result || !result.blocked) return allow2();
3169
- onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
3362
+ onDetect({
3363
+ phase,
3364
+ mode,
3365
+ category: result.rule?.category,
3366
+ rule: result.rule,
3367
+ message: result.message,
3368
+ method: ctx.method,
3369
+ path: ctx.path,
3370
+ ip: ctx.ip,
3371
+ userAgent: ctx.userAgent
3372
+ });
3170
3373
  return mode === "block" ? block() : allow2();
3171
3374
  };
3172
3375
  const isTextCT = (ct) => {
@@ -3330,7 +3533,9 @@ async function createProtection(options = {}) {
3330
3533
  return mode === "block";
3331
3534
  };
3332
3535
  const protection = {
3333
- mode,
3536
+ get mode() {
3537
+ return mode;
3538
+ },
3334
3539
  get rules() {
3335
3540
  return { request: requestRules, response: responseRules, egress: egressRules };
3336
3541
  },
@@ -3347,7 +3552,7 @@ async function createProtection(options = {}) {
3347
3552
  onError?.(err);
3348
3553
  return null;
3349
3554
  }
3350
- return decide("request", result, () => blockResponse(result, request), () => null);
3555
+ return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
3351
3556
  };
3352
3557
  },
3353
3558
  // Wrap a fetch handler: screens the request, then the response (redact/block).
@@ -3385,7 +3590,8 @@ async function createProtection(options = {}) {
3385
3590
  () => {
3386
3591
  if (exprOptions.screenResponses) wrapNodeResponse(res);
3387
3592
  next();
3388
- }
3593
+ },
3594
+ nodeRequestMeta(req)
3389
3595
  );
3390
3596
  };
3391
3597
  },
@@ -3437,7 +3643,8 @@ async function createProtection(options = {}) {
3437
3643
  if (req.body === void 0) req.body = shaped.body;
3438
3644
  if (nodeOptions.screenResponses) wrapNodeResponse(res);
3439
3645
  next();
3440
- }
3646
+ },
3647
+ nodeRequestMeta(req)
3441
3648
  );
3442
3649
  });
3443
3650
  };
@@ -3471,7 +3678,9 @@ async function createProtection(options = {}) {
3471
3678
  onError?.(err);
3472
3679
  }
3473
3680
  }
3474
- applyBundle(await resolveRules(options, store));
3681
+ const next = await resolveRules(options, store);
3682
+ mode = resolveMode(options, next);
3683
+ applyBundle(next);
3475
3684
  };
3476
3685
  if (live) {
3477
3686
  protection.refresh = () => runRefreshTick();
@@ -3479,10 +3688,39 @@ async function createProtection(options = {}) {
3479
3688
  }
3480
3689
  if (options.refreshMs > 0 && live) {
3481
3690
  const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3482
- protection.stopRefresh = loop.stop;
3691
+ protection.stopRefresh = () => {
3692
+ loop.stop();
3693
+ firewallLog?.stop();
3694
+ };
3695
+ } else if (firewallLog) {
3696
+ protection.stopRefresh = () => firewallLog.stop();
3483
3697
  }
3484
3698
  return protection;
3485
3699
  }
3700
+ function resolveMode(options, bundle) {
3701
+ const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
3702
+ if (env === "block" || env === "dry-run") return env;
3703
+ if (bundle?.enforcement === "block" || bundle?.enforcement === "dry-run") return bundle.enforcement;
3704
+ if (options?.mode === "block") return "block";
3705
+ if (options?.mode === "dry-run") return "dry-run";
3706
+ return "dry-run";
3707
+ }
3708
+ function resolveApiKey(options) {
3709
+ if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3710
+ if (typeof process !== "undefined") {
3711
+ const fromEnv = process.env?.PATCHSTACK_API_KEY;
3712
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3713
+ }
3714
+ try {
3715
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3716
+ const cwd = options?.cwd ?? process.cwd();
3717
+ const raw = (0, import_node_fs2.readFileSync)((0, import_node_path8.join)(cwd, ".patchstackrc.json"), "utf8");
3718
+ const key = JSON.parse(raw)?.apiKey;
3719
+ if (typeof key === "string" && key.length > 0) return key;
3720
+ } catch {
3721
+ }
3722
+ return void 0;
3723
+ }
3486
3724
  function byPhase(rules, phase) {
3487
3725
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
3488
3726
  }
@@ -3746,6 +3984,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
3746
3984
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
3747
3985
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
3748
3986
  }
3987
+ function fetchRequestMeta(request) {
3988
+ if (!request) return {};
3989
+ let path7 = null;
3990
+ try {
3991
+ path7 = new URL(request.url).pathname;
3992
+ } catch {
3993
+ path7 = typeof request.url === "string" ? request.url : null;
3994
+ }
3995
+ return {
3996
+ method: request.method ?? null,
3997
+ path: path7,
3998
+ ip: request.headers?.get?.("x-forwarded-for") ?? null,
3999
+ userAgent: request.headers?.get?.("user-agent") ?? null
4000
+ };
4001
+ }
4002
+ function nodeRequestMeta(req) {
4003
+ if (!req) return {};
4004
+ const headers = req.headers ?? {};
4005
+ const ua = headers["user-agent"] ?? headers["User-Agent"];
4006
+ const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
4007
+ return {
4008
+ method: req.method ?? null,
4009
+ path: req.originalUrl || req.url || null,
4010
+ ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
4011
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
4012
+ };
4013
+ }
3749
4014
  // Annotate the CommonJS export names for ESM import in node:
3750
4015
  0 && (module.exports = {
3751
4016
  GUARD_PATH,