@stopnetic/js 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @stopnetic/js - JavaScript and TypeScript website guard
2
2
 
3
+ Repository increment after0.1.4: optional `diagnostics: true` and
4
+ `connectionInsights: 'vercel'` add correlated references and daily scoped JA4
5
+ observations. Server helpers read `STOPNETIC_DIAGNOSTICS` and
6
+ `STOPNETIC_CONNECTION_INSIGHTS`. Defaults false/none; matching service/new SDK
7
+ required. [Configuration and measurement guide](../../docs/request-monitoring.md).
8
+
3
9
  Experimental repository increment: `interactionTrap: 'monitor' | 'block'` adds a
4
10
  server-backed hidden-action observation. Default off; requires the matching new
5
11
  service and SDK release after 0.1.3. Server helpers also read
package/dist/guard.d.ts CHANGED
@@ -30,6 +30,12 @@ export type StopneticOptions = {
30
30
  * off. Monitor records explicit activation; block withholds that bound session.
31
31
  * Requires matching service; ignoring the action never proves human identity. */
32
32
  interactionTrap?: 'off' | 'monitor' | 'block';
33
+ /** Opt-in short-lived trace/request references on guard responses and events.
34
+ * These are diagnostic labels, never access credentials. Default false. */
35
+ diagnostics?: boolean;
36
+ /** Observe a configured Vercel JA4 header as a daily, installation-scoped keyed
37
+ * group. No raw fingerprint/IP is sent. Missing data never selects a denial. */
38
+ connectionInsights?: 'none' | 'vercel';
33
39
  /** Optional coarse request analytics. No raw visitor IP or user-agent is sent. */
34
40
  trafficInsights?: boolean;
35
41
  /** Opt into country controls; requires trusted countrySource and no publicPaths. */
@@ -43,7 +49,10 @@ export type StopneticOptions = {
43
49
  export function parseAdditionalDecisionPublicKeys(value?: string): string[];
44
50
  export function createStopnetic(options: StopneticOptions): {
45
51
  /** Local configured capability declaration, not a deployment attestation. */
46
- readonly capabilities: { readonly approvedAccountGate: boolean; readonly botProtocol: 2; readonly interactionTrapProtocol: 1 };
52
+ readonly capabilities: { readonly approvedAccountGate: boolean; readonly botProtocol: 2; readonly interactionTrapProtocol: 1; readonly monitoringProtocol: 1 };
47
53
  check(request: Request): Promise<Response | null>;
48
54
  verify(request: Request): Promise<Response>;
55
+ /** References for this exact Request after check/verify. Add to an allowed
56
+ * downstream response when using a custom Proxy; empty when diagnostics off. */
57
+ diagnosticHeaders(request: Request): Headers;
49
58
  };
package/dist/guard.js CHANGED
@@ -5,6 +5,7 @@ import { verificationPage } from './verification-page.js';
5
5
  import { automationSignals, sameBotControl, botClientContext } from './bot-protection.js';
6
6
  import { validBotWork, verificationScript } from './browser-check.js';
7
7
  import { interactionMarkup, validateTrapDecision } from './interaction-trap.js';
8
+ import { connectionObservation, sameMonitoring } from './request-monitoring.js';
8
9
  export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
9
10
 
10
11
  const unavailable = retryAfter => new Response(null, { status: 503, headers: {
@@ -66,7 +67,7 @@ function bootstrap(ticket, verifyPath, botProtection, challengeId, trap) {
66
67
 
67
68
  /** Pilot guard for server-side Next.js Proxy and Route Handlers. */
68
69
  export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
69
- cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false, botProtection = 'off', botClientSource = 'none', interactionTrap = 'off', requireApprovedAccount = false, authorizeVisitor }) {
70
+ cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false, botProtection = 'off', botClientSource = 'none', interactionTrap = 'off', diagnostics = false, connectionInsights = 'none', requireApprovedAccount = false, authorizeVisitor }) {
70
71
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
71
72
  const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
72
73
  if (!credential) throw new Error('apiToken must be an issued installation key');
@@ -84,6 +85,8 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
84
85
  if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
85
86
  if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
86
87
  if (typeof trafficInsights !== 'boolean') throw new Error('Invalid trafficInsights');
88
+ if (typeof diagnostics !== 'boolean') throw new Error('Invalid diagnostics');
89
+ if (!['none', 'vercel'].includes(connectionInsights)) throw new Error('Invalid connectionInsights');
87
90
  if (!['none', 'vercel', 'cloudflare'].includes(countrySource)) throw new Error('Invalid countrySource');
88
91
  if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
89
92
  if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
@@ -104,6 +107,41 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
104
107
  const eventsEndpoint = new URL('./events', endpoint);
105
108
  const browserCookieName = `${cookieName}_browser`;
106
109
  const installation = hash(apiToken);
110
+ const requestMonitoring = new WeakMap();
111
+
112
+ function startMonitoring(request) {
113
+ if (!diagnostics && connectionInsights === 'none') return;
114
+ const session = validSession(request);
115
+ const previous = session?.diagnostic_trace;
116
+ const trace = /^[A-Za-z0-9_-]{22}$/.test(previous || '') ? previous
117
+ : session?.challenge_id ? createHmac('sha256', cookieSecret)
118
+ .update(`stopnetic-trace-v1:${siteId}:${installationId}:${session.challenge_id}`).digest('base64url').slice(0, 22)
119
+ : randomBytes(16).toString('base64url');
120
+ const connection = connectionObservation(request, connectionInsights, cookieSecret, `${siteId}:${installationId}`);
121
+ requestMonitoring.set(request, { request_id: randomBytes(16).toString('base64url'), validated: false,
122
+ monitoring_context: { version: 1, ...(diagnostics ? { trace_id: trace } : {}), ...(connection ? { connection } : {}) } });
123
+ }
124
+
125
+ function monitoringInput(request) {
126
+ const state = requestMonitoring.get(request);
127
+ return state ? { request_id: state.request_id, monitoring_context: state.monitoring_context } : {};
128
+ }
129
+
130
+ function diagnosticHeaders(request) {
131
+ const headers = new Headers();
132
+ const state = requestMonitoring.get(request);
133
+ if (diagnostics && state) {
134
+ headers.set('x-stopnetic-request-id', state.request_id);
135
+ headers.set('x-stopnetic-trace-id', state.monitoring_context.trace_id);
136
+ headers.set('x-stopnetic-diagnostic-status', state.validated ? 'decision-validated' : 'no-validated-decision');
137
+ }
138
+ return headers;
139
+ }
140
+
141
+ function diagnosticResponse(request, response) {
142
+ if (response) diagnosticHeaders(request).forEach((value, name) => response.headers.set(name, value));
143
+ return response;
144
+ }
107
145
 
108
146
  function browserBinding(request) {
109
147
  const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
@@ -116,7 +154,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
116
154
  }
117
155
 
118
156
  async function remote(purpose, input) {
119
- const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
157
+ const context = { site_id: siteId, path: input.path, method: input.method, request_id: input.request_id || randomBytes(16).toString('base64url') };
120
158
  const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
121
159
  method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
122
160
  headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
@@ -138,6 +176,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
138
176
  || !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
139
177
  || payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
140
178
  || payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
179
+ if (input.monitoring_context && !sameMonitoring(payload.monitoring_context, input.monitoring_context)) throw new Error('Invalid monitoring context');
141
180
  if (countryBlocking) {
142
181
  const control = payload.country_control;
143
182
  const country = payload.country_decision;
@@ -241,7 +280,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
241
280
  return { approved_access: { version: 1, required: true, status } };
242
281
  }
243
282
 
244
- async function check(request) {
283
+ async function checkRequest(request) {
245
284
  const path = new URL(request.url).pathname;
246
285
  if (path === verifyPath) return null;
247
286
  if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
@@ -257,8 +296,9 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
257
296
  let policy;
258
297
  try {
259
298
  const context = trafficContext(request, trafficInsights, countrySource);
260
- policy = await remote('decision', { path, method: request.method, ...countryControl(request), ...await approvedAccess(request), ...botControl(request, session?.webdriver, session?.webdriver_inconsistent, session?.webdriver_tampered), ...clientContext(request), ...trapControl(session), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
299
+ policy = await remote('decision', { path, method: request.method, ...monitoringInput(request), ...countryControl(request), ...await approvedAccess(request), ...botControl(request, session?.webdriver, session?.webdriver_inconsistent, session?.webdriver_tampered), ...clientContext(request), ...trapControl(session), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
261
300
  ...(challenge ? { session_challenge: challenge } : {}) });
301
+ const observed = requestMonitoring.get(request); if (observed) observed.validated = true;
262
302
  } catch (error) { return unavailable(error?.retryAfter); }
263
303
  if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
264
304
  // An older service must not silently apply selected-path semantics here.
@@ -276,6 +316,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
276
316
  ...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
277
317
  ...(botProtection !== 'off' ? { bot_mode: botProtection, bot_version: 2 } : {}),
278
318
  ...(trap ? { trap_mode: interactionTrap, trap_token: trap.token } : {}),
319
+ ...(diagnostics ? { diagnostic_trace: requestMonitoring.get(request).monitoring_context.trace_id } : {}),
279
320
  challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
280
321
  const response = bootstrap(ticket, verifyPath, botProtection, policy.challenge_id, trap);
281
322
  response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
@@ -284,7 +325,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
284
325
  return response;
285
326
  }
286
327
 
287
- async function verify(request) {
328
+ async function verifyRequest(request) {
288
329
  if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
289
330
  const origin = request.headers.get('origin');
290
331
  if (!origin) return concealed();
@@ -307,6 +348,11 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
307
348
  if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
308
349
  || typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
309
350
  || payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
351
+ // Never take a flow reference from a visitor header/body field. Only this
352
+ // installation's authenticated ticket may link verification to a challenge.
353
+ if (diagnostics && /^[A-Za-z0-9_-]{22}$/.test(payload.diagnostic_trace || '')) {
354
+ requestMonitoring.get(request).monitoring_context.trace_id = payload.diagnostic_trace;
355
+ }
310
356
  if (requireApprovedAccount && payload.approval_required !== true) return concealed();
311
357
  if (interactionTrap !== 'off' && (payload.trap_mode !== interactionTrap || !/^[A-Za-z0-9_-]{43}$/.test(payload.trap_token || ''))) return concealed();
312
358
  if (interaction !== undefined && (interactionTrap === 'off' || !interaction || Object.keys(interaction).length !== 2
@@ -325,8 +371,9 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
325
371
  }
326
372
  let workVerified = false;
327
373
  try {
328
- const result = await remote(interaction ? 'trap' : 'verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...countryControl(request), ...await approvedAccess(request), ...botControl(request, browser?.webdriver, browser?.webdriverInconsistent, browser?.webdriverTampered), ...clientContext(request), ...trapControl(),
374
+ const result = await remote(interaction ? 'trap' : 'verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...monitoringInput(request), ...countryControl(request), ...await approvedAccess(request), ...botControl(request, browser?.webdriver, browser?.webdriverInconsistent, browser?.webdriverTampered), ...clientContext(request), ...trapControl(),
329
375
  ...(interaction ? { trap_activation: true } : {}), ...(botProtection !== 'off' && proof ? { bot_proof: proof } : {}) });
376
+ const observed = requestMonitoring.get(request); if (observed) observed.validated = true;
330
377
  if (interaction || result.trap_decision?.result === 'block') {
331
378
  if (result.challenge_id !== payload.challenge_id || !['block', 'observed'].includes(result.action)) return unavailable();
332
379
  await report(result, result.action === 'block' ? 'BLOCK' : 'OBSERVED');
@@ -353,6 +400,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
353
400
  webdriver_inconsistent: browser.webdriverInconsistent, webdriver_tampered: browser.webdriverTampered, work_verified: workVerified } : {}),
354
401
  ...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
355
402
  ...(interactionTrap !== 'off' ? { trap_mode: interactionTrap } : {}),
403
+ ...(diagnostics ? { diagnostic_trace: requestMonitoring.get(request).monitoring_context.trace_id } : {}),
356
404
  browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
357
405
  exp: Date.now() + 300_000 }, cookieSecret);
358
406
  return new Response(null, { status: 204, headers: {
@@ -361,5 +409,15 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
361
409
  } });
362
410
  }
363
411
 
364
- return { check, verify, capabilities: Object.freeze({ approvedAccountGate: requireApprovedAccount, botProtocol: 2, interactionTrapProtocol: 1 }) };
412
+ async function check(request) {
413
+ startMonitoring(request);
414
+ return diagnosticResponse(request, await checkRequest(request));
415
+ }
416
+
417
+ async function verify(request) {
418
+ startMonitoring(request);
419
+ return diagnosticResponse(request, await verifyRequest(request));
420
+ }
421
+
422
+ return { check, verify, diagnosticHeaders, capabilities: Object.freeze({ approvedAccountGate: requireApprovedAccount, botProtocol: 2, interactionTrapProtocol: 1, monitoringProtocol: 1 }) };
365
423
  }
package/dist/index.js CHANGED
@@ -84,6 +84,7 @@ export function createStopneticHandler(options) {
84
84
  // Copy headers, including individual cookies, without buffering the body.
85
85
  const headers = new Headers(result.headers);
86
86
  headers.set('cache-control', 'private, no-store');
87
+ guard.diagnosticHeaders(request).forEach((value, name) => headers.set(name, value));
87
88
  return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
88
89
  };
89
90
  }
@@ -103,6 +104,8 @@ function requestHeaders(req, origin) {
103
104
  try { headers.set(name, value); } catch { /* Leave unsupported metadata unknown. */ }
104
105
  }
105
106
  }
107
+ const ja4 = req.headers['x-vercel-ja4-digest'];
108
+ if (ja4 !== undefined) headers.set('x-vercel-ja4-digest', typeof ja4 === 'string' && ja4.length <= 128 ? ja4 : 'invalid');
106
109
  return headers;
107
110
  }
108
111
 
@@ -145,6 +148,7 @@ export function createStopneticMiddleware(options) {
145
148
  const verifyPath = options.verifyPath || '/__stopnetic/verify';
146
149
  return async function stopnetic(req, res, next) {
147
150
  let response;
151
+ let checked;
148
152
  let close = false;
149
153
  try {
150
154
  const host = req.headers.host;
@@ -165,7 +169,8 @@ export function createStopneticMiddleware(options) {
165
169
  }
166
170
  } else {
167
171
  // Checking ordinary requests must leave their bodies for the application.
168
- response = await guard.check(new Request(url, { method, headers }));
172
+ checked = new Request(url, { method, headers });
173
+ response = await guard.check(checked);
169
174
  }
170
175
  }
171
176
  } catch { response = unavailable(); close = true; }
@@ -174,6 +179,7 @@ export function createStopneticMiddleware(options) {
174
179
  return;
175
180
  }
176
181
  res.setHeader('cache-control', 'private, no-store');
182
+ if (checked) guard.diagnosticHeaders(checked).forEach((value, name) => res.setHeader(name, value));
177
183
  return next();
178
184
  };
179
185
  }
@@ -1,42 +1,42 @@
1
- // Optional positive interaction evidence. Nothing here classifies visitors who
2
- // ignore the decoy. No resource URL, form or focusable target is exposed.
3
- export function interactionMarkup(id) {
4
- return `<section hidden inert aria-hidden="true"><button type="button" tabindex="-1" id="${id}">Automated client verification shortcut</button></section>`;
5
- }
6
-
7
- // Serialized into the same CSP-authorized script as the normal browser check.
8
- // Merely reading the DOM, focusing, hovering or inspecting it is not activation.
9
- export function installInteractionTrap(id, token, ticket, verifyPath, mode, evidence, blocked) {
10
- let sent = false;
11
- document.getElementById(id).addEventListener('click', () => {
12
- if (sent) return;
13
- sent = true;
14
- if (mode === 'block') blocked();
15
- const controller = new AbortController();
16
- const deadline = setTimeout(() => controller.abort(), 8000);
17
- fetch(verifyPath, { method: 'POST', credentials: 'same-origin', signal: controller.signal,
18
- headers: { 'content-type': 'application/json' },
19
- body: JSON.stringify({ ticket, browser: evidence(), interaction: { version: 1, token } }),
20
- }).catch(() => {}).finally(() => clearTimeout(deadline));
21
- });
22
- }
23
-
24
- export function validateTrapDecision(payload, expected, purpose) {
25
- const control = payload.interaction_trap;
26
- const decision = payload.trap_decision;
27
- if (!control || Object.keys(control).length !== Object.keys(expected).length
28
- || Object.entries(expected).some(([key, value]) => control[key] !== value)
29
- || decision?.version !== 1 || decision.mode !== expected.mode
30
- || typeof decision.activated !== 'boolean' || Object.keys(decision).length !== 4) return false;
31
- const result = decision.result;
32
- const priorDenial = payload.action === 'block' && (payload.approval_decision?.result === 'required'
33
- || payload.country_decision && !['disabled', 'pass'].includes(payload.country_decision.result));
34
- if (!['clear', 'observed', 'block', 'public'].includes(result)
35
- || (result === 'clear' && decision.activated)
36
- || (result === 'observed' && (!decision.activated || expected.mode !== 'monitor'))
37
- || (result === 'block' && (!decision.activated || expected.mode !== 'block' || payload.action !== 'block'))
38
- || (result === 'public' && (purpose !== 'decision' || (payload.action !== 'public' && !priorDenial) || decision.activated))) return false;
39
- // An activation receipt cannot silently lose the positive evidence. Country
40
- // and approval denials can precede activation, and retain their own reason.
41
- return purpose !== 'trap' || decision.activated || !!priorDenial;
42
- }
1
+ // Optional positive interaction evidence. Nothing here classifies visitors who
2
+ // ignore the decoy. No resource URL, form or focusable target is exposed.
3
+ export function interactionMarkup(id) {
4
+ return `<section hidden inert aria-hidden="true"><button type="button" tabindex="-1" id="${id}">Automated client verification shortcut</button></section>`;
5
+ }
6
+
7
+ // Serialized into the same CSP-authorized script as the normal browser check.
8
+ // Merely reading the DOM, focusing, hovering or inspecting it is not activation.
9
+ export function installInteractionTrap(id, token, ticket, verifyPath, mode, evidence, blocked) {
10
+ let sent = false;
11
+ document.getElementById(id).addEventListener('click', () => {
12
+ if (sent) return;
13
+ sent = true;
14
+ if (mode === 'block') blocked();
15
+ const controller = new AbortController();
16
+ const deadline = setTimeout(() => controller.abort(), 8000);
17
+ fetch(verifyPath, { method: 'POST', credentials: 'same-origin', signal: controller.signal,
18
+ headers: { 'content-type': 'application/json' },
19
+ body: JSON.stringify({ ticket, browser: evidence(), interaction: { version: 1, token } }),
20
+ }).catch(() => {}).finally(() => clearTimeout(deadline));
21
+ });
22
+ }
23
+
24
+ export function validateTrapDecision(payload, expected, purpose) {
25
+ const control = payload.interaction_trap;
26
+ const decision = payload.trap_decision;
27
+ if (!control || Object.keys(control).length !== Object.keys(expected).length
28
+ || Object.entries(expected).some(([key, value]) => control[key] !== value)
29
+ || decision?.version !== 1 || decision.mode !== expected.mode
30
+ || typeof decision.activated !== 'boolean' || Object.keys(decision).length !== 4) return false;
31
+ const result = decision.result;
32
+ const priorDenial = payload.action === 'block' && (payload.approval_decision?.result === 'required'
33
+ || payload.country_decision && !['disabled', 'pass'].includes(payload.country_decision.result));
34
+ if (!['clear', 'observed', 'block', 'public'].includes(result)
35
+ || (result === 'clear' && decision.activated)
36
+ || (result === 'observed' && (!decision.activated || expected.mode !== 'monitor'))
37
+ || (result === 'block' && (!decision.activated || expected.mode !== 'block' || payload.action !== 'block'))
38
+ || (result === 'public' && (purpose !== 'decision' || (payload.action !== 'public' && !priorDenial) || decision.activated))) return false;
39
+ // An activation receipt cannot silently lose the positive evidence. Country
40
+ // and approval denials can precede activation, and retain their own reason.
41
+ return purpose !== 'trap' || decision.activated || !!priorDenial;
42
+ }
@@ -0,0 +1,28 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ // Observe the explicitly selected hosting header, not a browser identity.
4
+ // Raw connection metadata never leaves the customer's server. Group comparisons
5
+ // are meaningful only for one installation/site/UTC day, not across customers.
6
+ export function connectionObservation(request, source, secret, scope, now = Date.now()) {
7
+ if (source === 'none') return undefined;
8
+ const epoch = Math.floor(now / 86400000);
9
+ const base = { source: 'vercel', epoch, status: 'unavailable', ja4_group: null };
10
+ if (process.env.VERCEL !== '1') return base;
11
+ const value = request.headers.get('x-vercel-ja4-digest');
12
+ if (!value) return { ...base, status: 'missing' };
13
+ // Support the compact JA4 representation only; unknown formats are telemetry
14
+ // gaps, never access denials. No arbitrary forwarding/country fallback.
15
+ if (!/^[tqd][a-z0-9]{2}[di][0-9]{4}[a-z0-9]{2}_[a-f0-9]{12}_[a-f0-9]{12}$/.test(value)) {
16
+ return { ...base, status: 'invalid' };
17
+ }
18
+ return { ...base, status: 'observed', ja4_group: createHmac('sha256', secret)
19
+ .update(`stopnetic-ja4-v1:${scope}:${epoch}:${value}`).digest('hex') };
20
+ }
21
+
22
+ export function sameMonitoring(actual, expected) {
23
+ if (!actual || actual.version !== 1 || Object.keys(actual).length !== Object.keys(expected).length
24
+ || actual.trace_id !== expected.trace_id || Boolean(actual.connection) !== Boolean(expected.connection)) return false;
25
+ if (expected.connection && (Object.keys(actual.connection).length !== 4
26
+ || Object.entries(expected.connection).some(([key, value]) => actual.connection[key] !== value))) return false;
27
+ return true;
28
+ }
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@ export const accessBase = '/api/stopnetic';
4
4
 
5
5
  /** Server environment only. Never import this module into browser code. */
6
6
  export function optionsFromEnv(env = process.env) {
7
+ if (env.STOPNETIC_DIAGNOSTICS && !['true', 'false'].includes(env.STOPNETIC_DIAGNOSTICS)) throw new Error('Invalid STOPNETIC_DIAGNOSTICS');
7
8
  if (env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT && !['true', 'false'].includes(env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT)) throw new Error('Invalid STOPNETIC_REQUIRE_APPROVED_ACCOUNT');
8
9
  return {
9
10
  origin: env.STOPNETIC_WEBSITE_ORIGIN,
@@ -20,6 +21,8 @@ export function optionsFromEnv(env = process.env) {
20
21
  botProtection: env.STOPNETIC_BOT_PROTECTION || 'off',
21
22
  botClientSource: env.STOPNETIC_BOT_CLIENT_SOURCE || 'none',
22
23
  interactionTrap: env.STOPNETIC_INTERACTION_TRAP || 'off',
24
+ diagnostics: env.STOPNETIC_DIAGNOSTICS === 'true',
25
+ connectionInsights: env.STOPNETIC_CONNECTION_INSIGHTS || 'none',
23
26
  requireApprovedAccount: env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT === 'true',
24
27
  countryBlocking: env.STOPNETIC_COUNTRY_BLOCKING === 'true',
25
28
  countrySource: env.STOPNETIC_COUNTRY_SOURCE || 'none',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stopnetic/js",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Server-side Stopnetic guard for JavaScript websites, Node.js and Express-compatible servers.",
5
5
  "type": "module",
6
6
  "license": "MIT",