@patchstack/connect 0.4.2 → 0.5.1

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.
Files changed (34) hide show
  1. package/AGENT-INSTALL.md +24 -4
  2. package/README.md +27 -3
  3. package/dist/cli.js +319 -61
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.cjs +230 -20
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +65 -0
  8. package/dist/index.d.ts +65 -0
  9. package/dist/index.js +227 -17
  10. package/dist/index.js.map +1 -1
  11. package/dist/protect/templates/astro-middleware.ts +22 -1
  12. package/dist/protect/templates/express-guard.cjs +54 -4
  13. package/dist/protect/templates/express-guard.js +54 -4
  14. package/dist/protect/templates/express-guard.ts +43 -5
  15. package/dist/protect/templates/fastify-plugin.cjs +41 -3
  16. package/dist/protect/templates/fastify-plugin.js +41 -3
  17. package/dist/protect/templates/fastify-plugin.ts +30 -2
  18. package/dist/protect/templates/generic-guard.cjs +58 -5
  19. package/dist/protect/templates/generic-guard.js +58 -5
  20. package/dist/protect/templates/generic-guard.ts +47 -4
  21. package/dist/protect/templates/guard.ts +42 -8
  22. package/dist/protect/templates/next-middleware.ts +22 -1
  23. package/dist/protect/templates/nuxt-middleware.ts +22 -1
  24. package/dist/protect/templates/sveltekit-hooks.ts +22 -1
  25. package/dist/protect.cjs +351 -97
  26. package/dist/protect.cjs.map +1 -1
  27. package/dist/protect.edge.js +56 -24
  28. package/dist/protect.edge.js.map +1 -1
  29. package/dist/protect.js +57 -25
  30. package/dist/protect.js.map +1 -1
  31. package/dist/{refresh-manifest-JNCTIAM5.js → refresh-manifest-2MMPQN2A.js} +243 -35
  32. package/dist/refresh-manifest-2MMPQN2A.js.map +1 -0
  33. package/package.json +3 -3
  34. package/dist/refresh-manifest-JNCTIAM5.js.map +0 -1
@@ -1,7 +1,17 @@
1
1
  // Patchstack runtime protection — GENERIC guard (CommonJS). Managed by `patchstack-connect protect`.
2
2
  // Wire whichever helper fits your server into your request path, then run `protect --check`.
3
3
  const { createProtection } = require("@patchstack/connect/protect");
4
- const fallbackRules = require("./rules.json");
4
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
7
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
8
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
9
+ let fallbackRules;
10
+ try {
11
+ fallbackRules = require("./rules.json");
12
+ } catch (err) {
13
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
14
+ }
5
15
 
6
16
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
7
17
  let protection;
@@ -34,9 +44,32 @@ async function buildProtection() {
34
44
  );
35
45
  }
36
46
 
47
+
48
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
49
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
50
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
51
+ // does not switch protection off for the life of the process.
52
+ //
53
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
54
+ // request. A later failure is not reported, including one with a different cause.
55
+ let psUnavailable = false;
56
+ function psStepAside(err) {
57
+ if (!psUnavailable) {
58
+ psUnavailable = true;
59
+ console.warn(
60
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
61
+ (err instanceof Error ? err.message : String(err)),
62
+ );
63
+ }
64
+
65
+ return null;
66
+ }
37
67
  function protectFetch(handler) {
38
68
  return async (request, ...rest) => {
39
- const active = await getProtection();
69
+ const active = await getProtection().catch(psStepAside);
70
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
71
+ // failure, and must not be read as one or answered twice.
72
+ if (!active) return handler(request, ...rest);
40
73
  const blocked = await active.fetchGuard()(request);
41
74
  if (blocked) return blocked;
42
75
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -49,9 +82,29 @@ function protectFetch(handler) {
49
82
  // Node / Connect: app.use(patchstackMiddleware) — before any body parser. This guard reads the request
50
83
  // stream itself and exposes what it read as req.body, so a parser is not also needed.
51
84
  function patchstackMiddleware(req, res, next) {
52
- getProtection()
53
- .then((active) => active.node()(req, res, next))
54
- .catch(() => next()); // fail open
85
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
86
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
87
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
88
+ // request on that was already passed on, nor leave one that never was.
89
+ let passedOn = false;
90
+ const carryOn = (err) => {
91
+ if (passedOn) return;
92
+ passedOn = true;
93
+ next(err);
94
+ };
95
+ getProtection().then(
96
+ (active) => active.node()(req, res, carryOn),
97
+ (err) => {
98
+ psStepAside(err);
99
+ carryOn();
100
+ },
101
+ ).catch((err) => {
102
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
103
+ // request is carried on only if it never was — an error here must not take the process down and
104
+ // must not answer twice.
105
+ psStepAside(err);
106
+ carryOn();
107
+ });
55
108
  }
56
109
 
57
110
  module.exports = { getProtection, protectFetch, patchstackMiddleware };
@@ -3,7 +3,17 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createProtection } from "@patchstack/connect/protect";
5
5
 
6
- const fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
6
+ // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
+ // throw here is the app failing to boot rather than protection failing open — and a rules file can be
8
+ // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
9
+ // Without it the guard holds no local bundle, and says so. Its other two rule sources are untouched:
10
+ // live rules for a configured site, and the engine's own compiled response/egress policy.
11
+ let fallbackRules;
12
+ try {
13
+ fallbackRules = JSON.parse(readFileSync(new URL("./rules.json", import.meta.url), "utf8"));
14
+ } catch (err) {
15
+ console.warn("[patchstack] ./rules.json was not read (" + err.message + "); the guard holds no local rules");
16
+ }
7
17
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
8
18
  let protection;
9
19
 
@@ -35,10 +45,33 @@ async function buildProtection() {
35
45
  );
36
46
  }
37
47
 
48
+
49
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
50
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
51
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
52
+ // does not switch protection off for the life of the process.
53
+ //
54
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
55
+ // request. A later failure is not reported, including one with a different cause.
56
+ let psUnavailable = false;
57
+ function psStepAside(err) {
58
+ if (!psUnavailable) {
59
+ psUnavailable = true;
60
+ console.warn(
61
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
62
+ (err instanceof Error ? err.message : String(err)),
63
+ );
64
+ }
65
+
66
+ return null;
67
+ }
38
68
  // Web-Fetch: export default { fetch: protectFetch(originalFetch) }
39
69
  export function protectFetch(handler) {
40
70
  return async (request, ...rest) => {
41
- const active = await getProtection();
71
+ const active = await getProtection().catch(psStepAside);
72
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
73
+ // failure, and must not be read as one or answered twice.
74
+ if (!active) return handler(request, ...rest);
42
75
  const blocked = await active.fetchGuard()(request);
43
76
  if (blocked) return blocked;
44
77
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -51,7 +84,27 @@ export function protectFetch(handler) {
51
84
  // Node / Connect: app.use(patchstackMiddleware) — before any body parser. This guard reads the request
52
85
  // stream itself and exposes what it read as req.body, so a parser is not also needed.
53
86
  export function patchstackMiddleware(req, res, next) {
54
- getProtection()
55
- .then((active) => active.node()(req, res, next))
56
- .catch(() => next()); // fail open
87
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
88
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
89
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
90
+ // request on that was already passed on, nor leave one that never was.
91
+ let passedOn = false;
92
+ const carryOn = (err) => {
93
+ if (passedOn) return;
94
+ passedOn = true;
95
+ next(err);
96
+ };
97
+ getProtection().then(
98
+ (active) => active.node()(req, res, carryOn),
99
+ (err) => {
100
+ psStepAside(err);
101
+ carryOn();
102
+ },
103
+ ).catch((err) => {
104
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
105
+ // request is carried on only if it never was — an error here must not take the process down and
106
+ // must not answer twice.
107
+ psStepAside(err);
108
+ carryOn();
109
+ });
57
110
  }
@@ -52,11 +52,34 @@ async function buildProtection() {
52
52
  );
53
53
  }
54
54
 
55
+
56
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
57
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
58
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
59
+ // does not switch protection off for the life of the process.
60
+ //
61
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
62
+ // request. A later failure is not reported, including one with a different cause.
63
+ let psUnavailable = false;
64
+ function psStepAside(err: unknown) {
65
+ if (!psUnavailable) {
66
+ psUnavailable = true;
67
+ console.warn(
68
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
69
+ (err instanceof Error ? err.message : String(err)),
70
+ );
71
+ }
72
+
73
+ return null;
74
+ }
55
75
  // --- Web Fetch (Cloudflare Workers, Bun, Deno, Hono, Next edge, TanStack server.ts) ---------
56
76
  // Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
57
77
  export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
58
78
  return (async (request: Request, ...rest: unknown[]) => {
59
- const protection = await getProtection();
79
+ const protection = await getProtection().catch(psStepAside);
80
+ // The handler, and nothing around it: an exception the app throws is the app's, not a protection
81
+ // failure, and must not be read as one or answered twice.
82
+ if (!protection) return handler(request, ...rest);
60
83
  const blocked = await protection.fetchGuard()(request);
61
84
  if (blocked) return blocked;
62
85
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
@@ -70,7 +93,27 @@ export function protectFetch<H extends (request: Request, ...rest: unknown[]) =>
70
93
  // app.use(patchstackMiddleware) — register it before any body parser and before your routes. This guard
71
94
  // reads the request stream itself and exposes what it read as req.body, so a parser is not also needed.
72
95
  export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) {
73
- getProtection()
74
- .then((protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next))
75
- .catch(() => next()); // fail open
96
+ // `next` is wrapped so it can run at most once, whatever happens. That is what makes the two
97
+ // handlers below safe: a rejection handler passed to `then` sees only a failed build, the trailing
98
+ // one sees whatever the guard or the app's own chain threw and reports it — and neither can pass a
99
+ // request on that was already passed on, nor leave one that never was.
100
+ let passedOn = false;
101
+ const carryOn = (err?: unknown) => {
102
+ if (passedOn) return;
103
+ passedOn = true;
104
+ next(err);
105
+ };
106
+ getProtection().then(
107
+ (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
108
+ (err) => {
109
+ psStepAside(err);
110
+ carryOn();
111
+ },
112
+ ).catch((err) => {
113
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
114
+ // request is carried on only if it never was — an error here must not take the process down and
115
+ // must not answer twice.
116
+ psStepAside(err);
117
+ carryOn();
118
+ });
76
119
  }
@@ -74,6 +74,26 @@ async function buildProtection() {
74
74
  );
75
75
  }
76
76
 
77
+
78
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
79
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
80
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
81
+ // does not switch protection off for the life of the process.
82
+ //
83
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
84
+ // request. A later failure is not reported, including one with a different cause.
85
+ let psUnavailable = false;
86
+ function psStepAside(err: unknown) {
87
+ if (!psUnavailable) {
88
+ psUnavailable = true;
89
+ console.warn(
90
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
91
+ (err instanceof Error ? err.message : String(err)),
92
+ );
93
+ }
94
+
95
+ return null;
96
+ }
77
97
  // Request-middleware path: the browser tunnels its direct Supabase calls here.
78
98
  let _handle: ((request: Request) => Promise<Response>) | undefined;
79
99
  export async function handleGuardRequest(request: Request): Promise<Response> {
@@ -91,7 +111,10 @@ export async function handleGuardRequest(request: Request): Promise<Response> {
91
111
  let _inspect: ((data: unknown) => Promise<{ rule?: string; message: string } | null>) | undefined;
92
112
  export async function inspectServerFn(data: unknown): Promise<{ rule?: string; message: string } | null> {
93
113
  if (!_inspect) {
94
- _inspect = createServerFnGuard({ protection: await getProtection() });
114
+ const protection = await getProtection().catch(psStepAside);
115
+ // Null is "allow" here, which is what the two paths below already answer when they cannot screen.
116
+ if (!protection) return null;
117
+ _inspect = createServerFnGuard({ protection });
95
118
  }
96
119
  return _inspect(data);
97
120
  }
@@ -105,12 +128,17 @@ export async function inspectServerFn(data: unknown): Promise<{ rule?: string; m
105
128
  // route or a method, and the engine can only apply that scope if it is given the request the response
106
129
  // belongs to. Without it a scoped response rule is delivered, counted as protection, and matches nothing.
107
130
  export async function screenResponse<T>(response: T, request?: Request): Promise<T> {
131
+ if (!(response instanceof Response)) return response;
132
+ const protection = await getProtection().catch(psStepAside);
133
+ if (!protection) return response;
108
134
  try {
109
- if (!(response instanceof Response)) return response;
110
- const protection = await getProtection();
111
135
  return (protection.screenResponse ? await protection.screenResponse(response, request) : response) as T;
112
- } catch {
113
- return response; // fail open
136
+ } catch (err) {
137
+ // Reported, then the response goes out as it is. A response that was never screened and a response
138
+ // that had nothing to redact look identical from here, so silence would make them one.
139
+ psStepAside(err);
140
+
141
+ return response;
114
142
  }
115
143
  }
116
144
 
@@ -120,10 +148,16 @@ export async function screenResponse<T>(response: T, request?: Request): Promise
120
148
  // WAF false-positive surface, so it is opt-in. `request.clone()` so the app can still read the body.
121
149
  let _reqGuard: ((request: Request) => Promise<Response | null>) | undefined;
122
150
  export async function guardRequest(request: Request): Promise<Response | null> {
151
+ if (!_reqGuard) {
152
+ const protection = await getProtection().catch(psStepAside);
153
+ if (!protection) return null; // null is "allow" here
154
+ _reqGuard = protection.fetchGuard();
155
+ }
123
156
  try {
124
- if (!_reqGuard) _reqGuard = (await getProtection()).fetchGuard();
125
157
  return await _reqGuard(request.clone());
126
- } catch {
127
- return null; // fail open
158
+ } catch (err) {
159
+ psStepAside(err);
160
+
161
+ return null;
128
162
  }
129
163
  }
@@ -42,9 +42,30 @@ async function buildProtection() {
42
42
  );
43
43
  }
44
44
 
45
+
46
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
47
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
48
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
49
+ // does not switch protection off for the life of the process.
50
+ //
51
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
52
+ // request. A later failure is not reported, including one with a different cause.
53
+ let psUnavailable = false;
54
+ function psStepAside(err: unknown) {
55
+ if (!psUnavailable) {
56
+ psUnavailable = true;
57
+ console.warn(
58
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
59
+ (err instanceof Error ? err.message : String(err)),
60
+ );
61
+ }
62
+
63
+ return null;
64
+ }
45
65
  // #region patchstack-next (managed by patchstack-connect protect — do not edit)
46
66
  export async function middleware(request: Request) {
47
- const protection = await getProtection();
67
+ const protection = await getProtection().catch(psStepAside);
68
+ if (!protection) return; // Next continues to the route
48
69
  const blocked = await protection.fetchGuard()(request);
49
70
  if (blocked) return blocked; // 403 — blocked before it reaches your route
50
71
  // otherwise fall through (Next continues to the route)
@@ -42,9 +42,30 @@ async function buildProtection() {
42
42
  );
43
43
  }
44
44
 
45
+
46
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
47
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
48
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
49
+ // does not switch protection off for the life of the process.
50
+ //
51
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
52
+ // request. A later failure is not reported, including one with a different cause.
53
+ let psUnavailable = false;
54
+ function psStepAside(err: unknown) {
55
+ if (!psUnavailable) {
56
+ psUnavailable = true;
57
+ console.warn(
58
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
59
+ (err instanceof Error ? err.message : String(err)),
60
+ );
61
+ }
62
+
63
+ return null;
64
+ }
45
65
  // #region patchstack-nuxt (managed by patchstack-connect protect — do not edit)
46
66
  export default defineEventHandler(async (event) => {
47
- const protection = await getProtection();
67
+ const protection = await getProtection().catch(psStepAside);
68
+ if (!protection) return; // Nitro continues to the route
48
69
  const method = event.method ?? "GET";
49
70
  // readRawBody caches on the event, so your route handlers can still read the body.
50
71
  const body = method !== "GET" && method !== "HEAD" ? await readRawBody(event) : undefined;
@@ -42,9 +42,30 @@ async function buildProtection() {
42
42
  );
43
43
  }
44
44
 
45
+
46
+ // A protection that could not be built must not become an app that cannot answer. Each seam below asks
47
+ // for one, steps aside when it cannot have one, and leaves the app to carry on unscreened.
48
+ // `getProtection` clears its slot on a failed build, so the next request builds again — one bad start
49
+ // does not switch protection off for the life of the process.
50
+ //
51
+ // Only the FIRST failure is reported: enough to know the guard is not screening, without a line per
52
+ // request. A later failure is not reported, including one with a different cause.
53
+ let psUnavailable = false;
54
+ function psStepAside(err: unknown) {
55
+ if (!psUnavailable) {
56
+ psUnavailable = true;
57
+ console.warn(
58
+ "[patchstack] protection is unavailable; traffic may pass through unscreened until a later attempt succeeds. Reported once per process. Cause: " +
59
+ (err instanceof Error ? err.message : String(err)),
60
+ );
61
+ }
62
+
63
+ return null;
64
+ }
45
65
  // #region patchstack-sveltekit (managed by patchstack-connect protect — do not edit)
46
66
  export const handle: Handle = async ({ event, resolve }) => {
47
- const protection = await getProtection();
67
+ const protection = await getProtection().catch(psStepAside);
68
+ if (!protection) return resolve(event);
48
69
  const blocked = await protection.fetchGuard()(event.request);
49
70
  if (blocked) return blocked; // 403 — blocked before it reaches your route
50
71
  // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is