@patchstack/connect 0.3.22 → 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
@@ -3068,6 +3071,169 @@ function makeRefreshHandler(tick, secret) {
3068
3071
  };
3069
3072
  }
3070
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
+
3071
3237
  // src/protect/supabase-guard.js
3072
3238
  var GUARD_PATH = "/_patchstack/guard";
3073
3239
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -3140,7 +3306,26 @@ function createServerFnGuard({ protection }) {
3140
3306
  }
3141
3307
  async function createProtection(options = {}) {
3142
3308
  const onError = options.onError;
3143
- 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
+ };
3144
3329
  const store = makeStore(options);
3145
3330
  const bundle = await resolveRules(options, store);
3146
3331
  let mode = resolveMode(options, bundle);
@@ -3172,9 +3357,19 @@ async function createProtection(options = {}) {
3172
3357
  };
3173
3358
  applyBundle(bundle);
3174
3359
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3175
- const decide = (phase, result, block, allow2) => {
3360
+ const decide = (phase, result, block, allow2, ctx = {}) => {
3176
3361
  if (!result || !result.blocked) return allow2();
3177
- 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
+ });
3178
3373
  return mode === "block" ? block() : allow2();
3179
3374
  };
3180
3375
  const isTextCT = (ct) => {
@@ -3357,7 +3552,7 @@ async function createProtection(options = {}) {
3357
3552
  onError?.(err);
3358
3553
  return null;
3359
3554
  }
3360
- return decide("request", result, () => blockResponse(result, request), () => null);
3555
+ return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
3361
3556
  };
3362
3557
  },
3363
3558
  // Wrap a fetch handler: screens the request, then the response (redact/block).
@@ -3395,7 +3590,8 @@ async function createProtection(options = {}) {
3395
3590
  () => {
3396
3591
  if (exprOptions.screenResponses) wrapNodeResponse(res);
3397
3592
  next();
3398
- }
3593
+ },
3594
+ nodeRequestMeta(req)
3399
3595
  );
3400
3596
  };
3401
3597
  },
@@ -3447,7 +3643,8 @@ async function createProtection(options = {}) {
3447
3643
  if (req.body === void 0) req.body = shaped.body;
3448
3644
  if (nodeOptions.screenResponses) wrapNodeResponse(res);
3449
3645
  next();
3450
- }
3646
+ },
3647
+ nodeRequestMeta(req)
3451
3648
  );
3452
3649
  });
3453
3650
  };
@@ -3491,7 +3688,12 @@ async function createProtection(options = {}) {
3491
3688
  }
3492
3689
  if (options.refreshMs > 0 && live) {
3493
3690
  const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3494
- protection.stopRefresh = loop.stop;
3691
+ protection.stopRefresh = () => {
3692
+ loop.stop();
3693
+ firewallLog?.stop();
3694
+ };
3695
+ } else if (firewallLog) {
3696
+ protection.stopRefresh = () => firewallLog.stop();
3495
3697
  }
3496
3698
  return protection;
3497
3699
  }
@@ -3503,6 +3705,22 @@ function resolveMode(options, bundle) {
3503
3705
  if (options?.mode === "dry-run") return "dry-run";
3504
3706
  return "dry-run";
3505
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
+ }
3506
3724
  function byPhase(rules, phase) {
3507
3725
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
3508
3726
  }
@@ -3766,6 +3984,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
3766
3984
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
3767
3985
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
3768
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
+ }
3769
4014
  // Annotate the CommonJS export names for ESM import in node:
3770
4015
  0 && (module.exports = {
3771
4016
  GUARD_PATH,