@patchstack/connect 0.5.1 → 0.5.2

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.
@@ -1,6 +1,6 @@
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
- const { createProtection } = require("@patchstack/connect/protect");
3
+ const { createProtection, sentinelAnswer, VERIFY_HEADER } = require("@patchstack/connect/protect");
4
4
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
5
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
6
  // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
@@ -66,6 +66,12 @@ function psStepAside(err) {
66
66
  }
67
67
  function protectFetch(handler) {
68
68
  return async (request, ...rest) => {
69
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
70
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
71
+ // by accident, and there is nothing to answer unless that process started this one. Without a
72
+ // challenge in the environment this is a header read.
73
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
74
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
69
75
  const active = await getProtection().catch(psStepAside);
70
76
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
71
77
  // failure, and must not be read as one or answered twice.
@@ -92,19 +98,37 @@ function patchstackMiddleware(req, res, next) {
92
98
  passedOn = true;
93
99
  next(err);
94
100
  };
95
- getProtection().then(
96
- (active) => active.node()(req, res, carryOn),
97
- (err) => {
101
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
102
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
103
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
104
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
105
+ // there is nothing to answer and the request is screened as normal.
106
+ const screen = () => {
107
+ getProtection().then(
108
+ (active) => active.node()(req, res, carryOn),
109
+ (err) => {
110
+ psStepAside(err);
111
+ carryOn();
112
+ },
113
+ ).catch((err) => {
114
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
115
+ // request is carried on only if it never was — an error here must not take the process down and
116
+ // must not answer twice.
98
117
  psStepAside(err);
99
118
  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
- });
119
+ });
120
+ };
121
+
122
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
123
+ if (answered) {
124
+ res.statusCode = 200;
125
+ res.setHeader("content-type", "text/plain");
126
+ res.end(answered);
127
+
128
+ return;
129
+ }
130
+ screen();
131
+ }, screen);
108
132
  }
109
133
 
110
134
  module.exports = { getProtection, protectFetch, patchstackMiddleware };
@@ -1,7 +1,7 @@
1
1
  // Patchstack runtime protection — GENERIC guard (ESM). Managed by `patchstack-connect protect`.
2
2
  // Wire whichever helper fits your server into your request path, then run `protect --check`.
3
3
  import { readFileSync } from "node:fs";
4
- import { createProtection } from "@patchstack/connect/protect";
4
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
5
5
 
6
6
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
7
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
@@ -68,6 +68,12 @@ function psStepAside(err) {
68
68
  // Web-Fetch: export default { fetch: protectFetch(originalFetch) }
69
69
  export function protectFetch(handler) {
70
70
  return async (request, ...rest) => {
71
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
72
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
73
+ // by accident, and there is nothing to answer unless that process started this one. Without a
74
+ // challenge in the environment this is a header read.
75
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
76
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
71
77
  const active = await getProtection().catch(psStepAside);
72
78
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
73
79
  // failure, and must not be read as one or answered twice.
@@ -94,17 +100,35 @@ export function patchstackMiddleware(req, res, next) {
94
100
  passedOn = true;
95
101
  next(err);
96
102
  };
97
- getProtection().then(
98
- (active) => active.node()(req, res, carryOn),
99
- (err) => {
103
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
104
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
105
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
106
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
107
+ // there is nothing to answer and the request is screened as normal.
108
+ const screen = () => {
109
+ getProtection().then(
110
+ (active) => active.node()(req, res, carryOn),
111
+ (err) => {
112
+ psStepAside(err);
113
+ carryOn();
114
+ },
115
+ ).catch((err) => {
116
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
117
+ // request is carried on only if it never was — an error here must not take the process down and
118
+ // must not answer twice.
100
119
  psStepAside(err);
101
120
  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
- });
121
+ });
122
+ };
123
+
124
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
125
+ if (answered) {
126
+ res.statusCode = 200;
127
+ res.setHeader("content-type", "text/plain");
128
+ res.end(answered);
129
+
130
+ return;
131
+ }
132
+ screen();
133
+ }, screen);
110
134
  }
@@ -4,7 +4,7 @@
4
4
  // whichever helper below fits your server into your request path (see the plan the CLI printed),
5
5
  // then run `patchstack-connect protect --check` to confirm it's hooked up. The engine ships inside
6
6
  // @patchstack/connect — nothing else to install.
7
- import { createProtection } from "@patchstack/connect/protect";
7
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
8
8
  import fallbackRules from "./rules.json";
9
9
 
10
10
  // Baked by `patchstack-connect protect` from .patchstackrc.json when available.
@@ -76,6 +76,12 @@ function psStepAside(err: unknown) {
76
76
  // Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
77
77
  export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
78
78
  return (async (request: Request, ...rest: unknown[]) => {
79
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
80
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
81
+ // by accident, and there is nothing to answer unless that process started this one. Without a
82
+ // challenge in the environment this is a header read.
83
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
84
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
79
85
  const protection = await getProtection().catch(psStepAside);
80
86
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
81
87
  // failure, and must not be read as one or answered twice.
@@ -103,17 +109,40 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un
103
109
  passedOn = true;
104
110
  next(err);
105
111
  };
106
- getProtection().then(
107
- (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
108
- (err) => {
112
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
113
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
114
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
115
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
116
+ // there is nothing to answer and the request is screened as normal.
117
+ const screen = () => {
118
+ getProtection().then(
119
+ (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
120
+ (err) => {
121
+ psStepAside(err);
122
+ carryOn();
123
+ },
124
+ ).catch((err) => {
125
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
126
+ // request is carried on only if it never was — an error here must not take the process down and
127
+ // must not answer twice.
109
128
  psStepAside(err);
110
129
  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
- });
130
+ });
131
+ };
132
+
133
+ // The two shapes this seam touches, named rather than asserted wholesale: a request whose headers it
134
+ // reads, and a response it answers on.
135
+ const inbound = req as { headers?: Record<string, unknown> };
136
+ const outbound = res as { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void };
137
+
138
+ sentinelAnswer(inbound.headers?.[VERIFY_HEADER]).then((answered) => {
139
+ if (answered) {
140
+ outbound.statusCode = 200;
141
+ outbound.setHeader("content-type", "text/plain");
142
+ outbound.end(answered);
143
+
144
+ return;
145
+ }
146
+ screen();
147
+ }, screen);
119
148
  }
package/dist/protect.cjs CHANGED
@@ -1845,9 +1845,11 @@ var runtime_exports = {};
1845
1845
  __export(runtime_exports, {
1846
1846
  GUARD_PATH: () => GUARD_PATH,
1847
1847
  PulseRuleClient: () => PulseRuleClient,
1848
+ VERIFY_HEADER: () => VERIFY_HEADER,
1848
1849
  createProtection: () => createProtection,
1849
1850
  createServerFnGuard: () => createServerFnGuard,
1850
- createSupabaseGuard: () => createSupabaseGuard
1851
+ createSupabaseGuard: () => createSupabaseGuard,
1852
+ sentinelAnswer: () => sentinelAnswer
1851
1853
  });
1852
1854
  module.exports = __toCommonJS(runtime_exports);
1853
1855
 
@@ -6330,6 +6332,33 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
6330
6332
  };
6331
6333
  }
6332
6334
 
6335
+ // src/protect/verify-sentinel.js
6336
+ var VERIFY_HEADER = "x-patchstack-verify";
6337
+ var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
6338
+ function configuredChallenge() {
6339
+ const value = typeof process !== "undefined" ? process.env?.[CHALLENGE] : void 0;
6340
+ return typeof value === "string" && value.length >= 32 ? value : null;
6341
+ }
6342
+ function sameValue(a, b) {
6343
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
6344
+ let differs = 0;
6345
+ for (let i = 0; i < a.length; i += 1) differs |= a.charCodeAt(i) ^ b.charCodeAt(i);
6346
+ return differs === 0;
6347
+ }
6348
+ async function sentinelAnswer(offered) {
6349
+ const challenge = configuredChallenge();
6350
+ if (challenge === null || !sameValue(challenge, typeof offered === "string" ? offered : null)) return null;
6351
+ const subtle = globalThis.crypto?.subtle;
6352
+ if (!subtle || typeof subtle.digest !== "function" || typeof TextEncoder === "undefined") return null;
6353
+ try {
6354
+ const bytes = new TextEncoder().encode(`patchstack-verify:${challenge}`);
6355
+ const digest = await subtle.digest("SHA-256", bytes);
6356
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
6357
+ } catch {
6358
+ return null;
6359
+ }
6360
+ }
6361
+
6333
6362
  // src/protect/runtime.js
6334
6363
  function createServerFnGuard({ protection }) {
6335
6364
  const guard = protection.fetchGuard();
@@ -7667,8 +7696,10 @@ function nodeRequestMeta(req, client) {
7667
7696
  0 && (module.exports = {
7668
7697
  GUARD_PATH,
7669
7698
  PulseRuleClient,
7699
+ VERIFY_HEADER,
7670
7700
  createProtection,
7671
7701
  createServerFnGuard,
7672
- createSupabaseGuard
7702
+ createSupabaseGuard,
7703
+ sentinelAnswer
7673
7704
  });
7674
7705
  //# sourceMappingURL=protect.cjs.map