@patchstack/connect 0.5.0 → 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
@@ -142,6 +142,9 @@ var init_types = __esm({
142
142
  });
143
143
 
144
144
  // src/client.ts
145
+ function claimTokenHeader(config) {
146
+ return typeof config.claimToken === "string" && config.claimToken !== "" ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
147
+ }
145
148
  function buildEndpointUrl(base, siteUuid) {
146
149
  const trimmed = base.replace(/\/$/, "");
147
150
  return siteUuid !== void 0 && siteUuid !== null && siteUuid.length > 0 ? `${trimmed}/${encodeURIComponent(siteUuid)}` : trimmed;
@@ -177,7 +180,8 @@ async function postManifest(config, payload) {
177
180
  headers: {
178
181
  "Content-Type": "application/json",
179
182
  Accept: "application/json",
180
- "User-Agent": "@patchstack/connect"
183
+ "User-Agent": "@patchstack/connect",
184
+ ...claimTokenHeader(config)
181
185
  },
182
186
  body: JSON.stringify(buildManifestBody(config, payload)),
183
187
  signal: AbortSignal.timeout(timeoutMs)
@@ -236,7 +240,7 @@ function isTimeoutError(cause) {
236
240
  }
237
241
  return false;
238
242
  }
239
- var DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS;
243
+ var DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS, CLAIM_TOKEN_HEADER;
240
244
  var init_client = __esm({
241
245
  "src/client.ts"() {
242
246
  "use strict";
@@ -244,6 +248,7 @@ var init_client = __esm({
244
248
  init_pulse_token();
245
249
  DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
246
250
  DEFAULT_TIMEOUT_MS = 3e4;
251
+ CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
247
252
  }
248
253
  });
249
254
 
@@ -478,6 +483,7 @@ async function resolveConfig(options) {
478
483
  const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
479
484
  const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
480
485
  const identity = options.detectSiteIdentity === true ? await resolveSiteIdentity(options.cwd, fromEnv, fromFile) : { url: null, name: null };
486
+ const claimToken = stated(options.cliClaimToken) ?? stated(process.env.PATCHSTACK_CLAIM_TOKEN);
481
487
  return {
482
488
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
483
489
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -487,7 +493,8 @@ async function resolveConfig(options) {
487
493
  endpoint,
488
494
  timeoutMs,
489
495
  environment,
490
- widget: fromFile.widget !== false
496
+ widget: fromFile.widget !== false,
497
+ claimToken
491
498
  };
492
499
  }
493
500
  async function readSecretFile(cwd) {
@@ -1838,9 +1845,11 @@ var runtime_exports = {};
1838
1845
  __export(runtime_exports, {
1839
1846
  GUARD_PATH: () => GUARD_PATH,
1840
1847
  PulseRuleClient: () => PulseRuleClient,
1848
+ VERIFY_HEADER: () => VERIFY_HEADER,
1841
1849
  createProtection: () => createProtection,
1842
1850
  createServerFnGuard: () => createServerFnGuard,
1843
- createSupabaseGuard: () => createSupabaseGuard
1851
+ createSupabaseGuard: () => createSupabaseGuard,
1852
+ sentinelAnswer: () => sentinelAnswer
1844
1853
  });
1845
1854
  module.exports = __toCommonJS(runtime_exports);
1846
1855
 
@@ -6323,6 +6332,33 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
6323
6332
  };
6324
6333
  }
6325
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
+
6326
6362
  // src/protect/runtime.js
6327
6363
  function createServerFnGuard({ protection }) {
6328
6364
  const guard = protection.fetchGuard();
@@ -7660,8 +7696,10 @@ function nodeRequestMeta(req, client) {
7660
7696
  0 && (module.exports = {
7661
7697
  GUARD_PATH,
7662
7698
  PulseRuleClient,
7699
+ VERIFY_HEADER,
7663
7700
  createProtection,
7664
7701
  createServerFnGuard,
7665
- createSupabaseGuard
7702
+ createSupabaseGuard,
7703
+ sentinelAnswer
7666
7704
  });
7667
7705
  //# sourceMappingURL=protect.cjs.map