@stopnetic/js 0.0.6-pilot → 0.1.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.
- package/README.md +75 -48
- package/dist/client-guide.md +6 -3
- package/dist/guard.d.ts +7 -0
- package/dist/guard.js +44 -20
- package/dist/index.js +7 -0
- package/dist/server.d.ts +17 -17
- package/dist/server.js +128 -123
- package/dist/setup.js +126 -125
- package/dist/traffic-context.js +26 -0
- package/examples/frontend/angular/app/app.config.ts +8 -8
- package/examples/frontend/angular/app/app.routes.ts +20 -20
- package/examples/frontend/angular/app/app.ts +21 -21
- package/examples/frontend/angular/app/members.page.ts +21 -21
- package/examples/frontend/angular/app/stopnetic-access.service.ts +27 -27
- package/examples/frontend/angular/main.ts +4 -4
- package/examples/frontend/load-private-data.d.ts +1 -1
- package/examples/frontend/load-private-data.js +9 -9
- package/examples/frontend/react/App.tsx +48 -48
- package/examples/frontend/react/main.tsx +3 -3
- package/examples/frontend/solid/App.tsx +54 -54
- package/examples/frontend/solid/main.tsx +3 -3
- package/examples/frontend/svelte/App.svelte +56 -56
- package/examples/frontend/svelte/main.js +4 -4
- package/examples/frontend/vanilla/main.js +46 -46
- package/examples/frontend/vue/App.vue +17 -17
- package/examples/frontend/vue/Members.vue +14 -14
- package/examples/frontend/vue/main.js +4 -4
- package/examples/frontend/vue/router.js +40 -40
- package/examples/ssr/angular/app.routes.server.ts +5 -5
- package/examples/ssr/angular/server.ts +12 -12
- package/examples/ssr/astro/middleware.js +11 -11
- package/examples/ssr/astro/page.astro +4 -4
- package/examples/ssr/node/fetch.mjs +9 -9
- package/examples/ssr/node/install.mjs +9 -9
- package/examples/ssr/nuxt/middleware.mjs +10 -10
- package/examples/ssr/react/install.mjs +10 -10
- package/examples/ssr/react-router/install.mjs +22 -22
- package/examples/ssr/solidstart/middleware-v1.js +19 -19
- package/examples/ssr/solidstart/middleware-v2.js +25 -25
- package/examples/ssr/sveltekit/hooks.server.js +16 -16
- package/examples/ssr/sveltekit/page.server.js +3 -3
- package/examples/ssr/vue/install.mjs +10 -10
- package/package.json +35 -9
package/README.md
CHANGED
|
@@ -1,40 +1,62 @@
|
|
|
1
|
-
# @stopnetic/js
|
|
1
|
+
# @stopnetic/js - JavaScript and TypeScript website guard
|
|
2
|
+
|
|
3
|
+
Unreleased country controls: server options `countryBlocking: true` and a trusted
|
|
4
|
+
`countrySource` enable the compatible protocol. The `/server` environment helper
|
|
5
|
+
also reads `STOPNETIC_COUNTRY_BLOCKING=true`. Deploy and test guarded requests
|
|
6
|
+
before enabling the country policy in the dashboard. Remove local `publicPaths`
|
|
7
|
+
bypasses and secure your ingress/origin. Published 0.1.0 lacks this feature.
|
|
8
|
+
See [setup, recovery and coverage limits](../../docs/country-access.md).
|
|
2
9
|
|
|
3
10
|
Stopnetic checks access **on your server before sending pages, files or API data**.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
11
|
+
Use it with Node.js and Express, with frontend examples for Angular, React, Vue,
|
|
12
|
+
Svelte, Solid and plain JavaScript. No frontend framework is a required dependency.
|
|
13
|
+
Your hosting server must run **Node.js 22 or newer**; browser-only JavaScript does
|
|
14
|
+
not enforce protection on its own. Keep your existing SSR or static rendering.
|
|
15
|
+
|
|
16
|
+
## Optional traffic insights (unreleased)
|
|
17
|
+
|
|
18
|
+
The working tree supports `trafficInsights: true` and
|
|
19
|
+
`countrySource: 'none' | 'vercel' | 'cloudflare'` in server guard options. The
|
|
20
|
+
`@stopnetic/js/server` helpers also read `STOPNETIC_TRAFFIC_INSIGHTS=true` and
|
|
21
|
+
`STOPNETIC_COUNTRY_SOURCE=none` from the website's server environment. These
|
|
22
|
+
settings are optional and collection is off by default. Published 0.1.0 does
|
|
23
|
+
not send these fields; use a compatible future release or local packed SDK.
|
|
24
|
+
|
|
25
|
+
Only a country code/source and broad device category are sent. No raw IPs or full
|
|
26
|
+
user-agent strings leave the server for this feature, and no network location
|
|
27
|
+
lookup is made. Unsupported sources/devices remain Unknown. Reported metadata
|
|
28
|
+
is approximate and does not authorize requests or identify people/bots.
|
|
29
|
+
|
|
30
|
+
Explicitly trust a country source only on the correct hosting ingress. For
|
|
31
|
+
Cloudflare, prevent direct-origin access or strip and rewrite visitor-supplied
|
|
32
|
+
headers. Deploy compatible service ingestion before updating and deploying the
|
|
33
|
+
website's guard. Disabling collection stops new context; stored context follows
|
|
34
|
+
the event retention/deletion policy.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Run inside your website project:
|
|
29
39
|
|
|
30
40
|
```sh
|
|
31
|
-
npm install @stopnetic/js
|
|
41
|
+
npm install @stopnetic/js
|
|
32
42
|
```
|
|
33
43
|
|
|
44
|
+
For Next.js 16 App Router, use [@stopnetic/next](https://www.npmjs.com/package/@stopnetic/next).
|
|
45
|
+
|
|
46
|
+
This release includes server/browser helpers, framework-specific SSR instructions,
|
|
47
|
+
complete optional frontend examples, Netlify/Vercel/Node setup commands and bounded
|
|
48
|
+
public-key parsing for raw, escaped or JSON-quoted public PEM values. Existing
|
|
49
|
+
unmodified generated guides can migrate safely; customized files require merging.
|
|
50
|
+
|
|
51
|
+
Read the [complete generated guide](https://unpkg.com/@stopnetic/js@0.1.0/dist/client-guide.md)
|
|
52
|
+
for SSR, static builds, client navigation, API protection and runtime restrictions.
|
|
53
|
+
Validate the actual deployed routing, caching and provider configuration before
|
|
54
|
+
production use. Browser verification does not prove human identity.
|
|
55
|
+
|
|
34
56
|
## Framework implementation examples
|
|
35
57
|
|
|
36
58
|
SSR apps use the **SSR framework** selector, not the optional browser-demo
|
|
37
|
-
selector. [Framework SSR instructions](dist/client-guide.md#ssr-framework-instructions)
|
|
59
|
+
selector. [Framework SSR instructions](https://unpkg.com/@stopnetic/js@0.1.0/dist/client-guide.md#ssr-framework-instructions)
|
|
38
60
|
and `examples/ssr` contain matching server code:
|
|
39
61
|
|
|
40
62
|
| SSR app | Server connection |
|
|
@@ -104,19 +126,19 @@ npm run build
|
|
|
104
126
|
npm pack
|
|
105
127
|
```
|
|
106
128
|
|
|
107
|
-
Install the resulting `stopnetic-js-0.0.
|
|
129
|
+
Install the resulting `stopnetic-js-0.1.0.tgz` into the website project using
|
|
108
130
|
`npm install <absolute-path-to-tarball>`. The installed package has no Next.js or
|
|
109
131
|
other runtime dependency. The build packages the same signed decision implementation
|
|
110
132
|
as the Next.js adapter. Keep `@stopnetic/next` for its Next.js 16 guided installer.
|
|
111
133
|
|
|
112
|
-
## Reusable client/server setup
|
|
134
|
+
## Reusable client/server setup
|
|
113
135
|
|
|
114
|
-
Follow the [hosting guide](
|
|
136
|
+
Follow the [hosting guide](https://github.com/KlimentSidorov/stopnetic/blob/main/docs/javascript-hosting-guide.md) for the full
|
|
115
137
|
Netlify, Vercel or Node procedure, server settings, page HTTP 404 enforcement,
|
|
116
138
|
frontend/API wiring and diagnosis. The dashboard's Integration and Documentation
|
|
117
139
|
pages offer the same provider choices and complete copyable page-guard examples.
|
|
118
140
|
|
|
119
|
-
|
|
141
|
+
These commands and subpath exports are included in this release.
|
|
120
142
|
Older 0.0.1-pilot installations must upgrade before using them.
|
|
121
143
|
|
|
122
144
|
Run setup at the app's package.json, choose hosting, and preview before writing:
|
|
@@ -143,7 +165,7 @@ Functions secret store. Copy the generated cookie secret without committing it.
|
|
|
143
165
|
Wire the server page guard and EACH protected API following the generated guide.
|
|
144
166
|
Browser route checks are optional for retained SPA navigation.
|
|
145
167
|
Run `npm exec --no -- stopnetic-js check` after setting credentials (`--local-http`
|
|
146
|
-
for a loopback
|
|
168
|
+
for a loopback local test). This check validates configuration and generated files
|
|
147
169
|
offline; it cannot prove deployment routing, API coverage or browser integration.
|
|
148
170
|
Deploy once, then dashboard changes apply on subsequent guarded requests.
|
|
149
171
|
|
|
@@ -154,7 +176,7 @@ server environment loader. Production credentials belong in runtime settings.
|
|
|
154
176
|
|
|
155
177
|
### Shared browser startup and Node SSR
|
|
156
178
|
|
|
157
|
-
The generated [framework-independent guide](dist/client-guide.md) defaults to
|
|
179
|
+
The generated [framework-independent guide](https://unpkg.com/@stopnetic/js@0.1.0/dist/client-guide.md) defaults to
|
|
158
180
|
server document navigation: leave browser startup/layout intact, use normal
|
|
159
181
|
links, and let the server apply dashboard policies. It also gives an OPTIONAL
|
|
160
182
|
browser startup gate and separate OPTIONAL SPA router demos. Do not combine
|
|
@@ -232,10 +254,10 @@ request and preserve ordinary request bodies. Denials/outages return empty
|
|
|
232
254
|
|
|
233
255
|
For a real HTTP 404 on a Netlify SPA page, use the optional page-serving function
|
|
234
256
|
in `examples/netlify-spa-pages.mjs` and follow
|
|
235
|
-
[`docs/netlify-spa-page-requests.md`](
|
|
257
|
+
[`docs/netlify-spa-page-requests.md`](https://github.com/KlimentSidorov/stopnetic/blob/main/docs/netlify-spa-page-requests.md).
|
|
236
258
|
It checks signed access before reading the bundled entry HTML. The browser
|
|
237
259
|
boundary alone cannot change the initial document's HTTP status. This example
|
|
238
|
-
uses the
|
|
260
|
+
uses the server helpers included in this release; the setup CLI does not install it
|
|
239
261
|
automatically. Keep static bundles public and private data in guarded APIs.
|
|
240
262
|
|
|
241
263
|
For Vercel page responses, the manual Node routing middleware in
|
|
@@ -275,7 +297,7 @@ rollout. Hosting contracts are simulated locally; live provider tests are pendin
|
|
|
275
297
|
| Angular, Vue, React, Svelte SPA or plain HTML/JS | Node/Express server serving its built output, before static files and SPA fallback |
|
|
276
298
|
| Existing Node/Express server, including Express 4 and 5 | First root middleware, before body parsers, routes and data handlers |
|
|
277
299
|
| Node-hosted framework with Web `Request`/`Response` hooks | `createStopneticHandler` before the framework renders or serves data |
|
|
278
|
-
| Next.js 16 App Router | Published `@stopnetic/next
|
|
300
|
+
| Next.js 16 App Router | Published `@stopnetic/next` and its guided installer |
|
|
279
301
|
| Older Next.js with a customer-owned Node server | Generic middleware before the Next request handler; native older Next installers are not supplied |
|
|
280
302
|
| Static-only hosting / browser-only scripts | Needs a server or compatible edge/proxy in front, or a separately protected content API |
|
|
281
303
|
|
|
@@ -420,14 +442,14 @@ could expose an alternative path to protected content.
|
|
|
420
442
|
|
|
421
443
|
An installed guard's first valid request starts the site's account trial. Key
|
|
422
444
|
rotation/revocation and domain/account/subscription gates are checked by the service.
|
|
423
|
-
Access reports are best effort and cannot establish delivery guarantees.
|
|
424
|
-
With the updated dashboard,
|
|
425
|
-
and the first reported access result. The Challenge outcomes panel distinguishes
|
|
426
|
-
allowed, blocked, waiting, not completed and verified without an access result;
|
|
427
|
-
older evidence may be unavailable. It does not identify humans or bots. Install
|
|
428
|
-
the updated SDK on your server after deploying the dashboard update.
|
|
429
|
-
|
|
430
|
-
Validate your actual hosting, cache rules, browser flow, API paths and provider configuration
|
|
445
|
+
Access reports are best effort and cannot establish delivery guarantees.
|
|
446
|
+
With the updated dashboard, the SDK links same-page challenges, verification
|
|
447
|
+
and the first reported access result. The Challenge outcomes panel distinguishes
|
|
448
|
+
allowed, blocked, waiting, not completed and verified without an access result;
|
|
449
|
+
older evidence may be unavailable. It does not identify humans or bots. Install
|
|
450
|
+
the updated SDK on your server after deploying the dashboard update.
|
|
451
|
+
|
|
452
|
+
Validate your actual hosting, cache rules, browser flow, API paths and provider configuration
|
|
431
453
|
before onboarding paying customers.
|
|
432
454
|
|
|
433
455
|
## Maintainer checks and release
|
|
@@ -435,14 +457,19 @@ before onboarding paying customers.
|
|
|
435
457
|
From this folder: `npm ci`, `npm test`, `npm run test:package`. Tests include real
|
|
436
458
|
Node HTTP traffic, Express 4/5 static/SPA/API serving, challenges, body preservation,
|
|
437
459
|
signed decision failures and package consumption from ESM, CommonJS and TypeScript.
|
|
438
|
-
The tarball includes
|
|
439
|
-
examples,
|
|
460
|
+
The tarball includes runtime/types in `dist`, the setup CLI in `bin`, the public
|
|
461
|
+
frontend/SSR examples, README, MIT license and manifest. No secrets, tests, build
|
|
462
|
+
scripts or dashboard are published. Build from the repository
|
|
440
463
|
before packing; published consumers do not need the repository or build scripts.
|
|
441
464
|
|
|
442
465
|
Publishing is a separate owner step after these checks:
|
|
443
466
|
|
|
444
467
|
```sh
|
|
445
|
-
npm publish --access public --tag
|
|
468
|
+
npm publish --access public --tag latest --registry=https://registry.npmjs.org/
|
|
446
469
|
```
|
|
447
470
|
|
|
448
471
|
Publishing this package does not update the existing `@stopnetic/next` release.
|
|
472
|
+
|
|
473
|
+
## Source and support
|
|
474
|
+
|
|
475
|
+
[Source repository](https://github.com/KlimentSidorov/stopnetic/tree/main/integrations/javascript) | [Report an issue](https://github.com/KlimentSidorov/stopnetic/issues)
|
package/dist/client-guide.md
CHANGED
|
@@ -101,8 +101,11 @@ STOPNETIC_COOKIE_SECRET=your_separate_random_cookie_secret
|
|
|
101
101
|
|
|
102
102
|
The installer creates a private cookie secret in `.env.local` when missing.
|
|
103
103
|
Reuse it across deployments. Do not commit `.env.local`. Copy complete PEM
|
|
104
|
-
header/body/footer with real newlines or literal `\n
|
|
105
|
-
|
|
104
|
+
header/body/footer with real newlines or literal `\n`. The dashboard's generated
|
|
105
|
+
local configuration uses an unquoted single-line key with literal `\n`; the SDK
|
|
106
|
+
restores its line breaks. Hosting fields take only the value, with no variable
|
|
107
|
+
name, equals sign or surrounding quotes. Manually written actual multiline PEMs
|
|
108
|
+
still need quotes in env files. Previously generated quoted env files remain valid.
|
|
106
109
|
Never put private settings in frontend environment files or prefix them with
|
|
107
110
|
`VITE_`, `REACT_APP_` or `NEXT_PUBLIC_`. Angular's `environment.ts` is browser
|
|
108
111
|
configuration, not a private server environment.
|
|
@@ -1308,7 +1311,7 @@ server endpoints.
|
|
|
1308
1311
|
## Updating an existing generated guide
|
|
1309
1312
|
|
|
1310
1313
|
After installing the new SDK, rerun `stopnetic-js init --platform <same-platform>`
|
|
1311
|
-
(preview with `--dry-run`). An unchanged 0.0.2/0.0.3/0.0.4/0.0.5 generated guide is upgraded
|
|
1314
|
+
(preview with `--dry-run`). An unchanged 0.0.2/0.0.3/0.0.4/0.0.5/0.0.6 generated guide is upgraded
|
|
1312
1315
|
with its setup manifest. Your key, cookie secret and server files are preserved.
|
|
1313
1316
|
Customized guides or handlers require manual merging; switching platforms also
|
|
1314
1317
|
requires review. This command does not publish, deploy or contact Stopnetic.
|
package/dist/guard.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type StopneticOptions = {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
publicPaths?: string[];
|
|
15
15
|
reportEvents?: boolean;
|
|
16
|
+
/** Optional coarse request analytics. No raw visitor IP or user-agent is sent. */
|
|
17
|
+
trafficInsights?: boolean;
|
|
18
|
+
/** Opt into country controls; requires trusted countrySource and no publicPaths. */
|
|
19
|
+
countryBlocking?: boolean;
|
|
20
|
+
/** Explicitly trust country headers from your ingress; default none. Cloudflare
|
|
21
|
+
* origins must prevent direct requests/header spoofing. */
|
|
22
|
+
countrySource?: 'none' | 'vercel' | 'cloudflare';
|
|
16
23
|
/** Whole-site mode: unmatched routes public; requires the updated service. */
|
|
17
24
|
guardMode?: 'selected' | 'site';
|
|
18
25
|
};
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
|
|
2
2
|
import { trustedSigningKeys } from './signing-keys.js';
|
|
3
|
+
import { trafficContext } from './traffic-context.js';
|
|
3
4
|
export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
|
|
4
5
|
|
|
5
6
|
const unavailable = retryAfter => new Response(null, { status: 503, headers: {
|
|
@@ -62,14 +63,14 @@ function bootstrap(ticket, verifyPath) {
|
|
|
62
63
|
|
|
63
64
|
/** Pilot guard for server-side Next.js Proxy and Route Handlers. */
|
|
64
65
|
export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
|
|
65
|
-
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected' }) {
|
|
66
|
+
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false }) {
|
|
66
67
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
|
|
67
68
|
const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
|
|
68
69
|
if (!credential) throw new Error('apiToken must be an issued installation key');
|
|
69
70
|
const installationId = credential[1];
|
|
70
|
-
if (typeof cookieSecret !== 'string' || cookieSecret.trim().length < 32
|
|
71
|
-
|| /^<[^]*>$/.test(cookieSecret.trim())) throw new Error('cookieSecret must be at least 32 characters');
|
|
72
|
-
if (cookieSecret === apiToken) throw new Error('cookieSecret must be independent of apiToken');
|
|
71
|
+
if (typeof cookieSecret !== 'string' || cookieSecret.trim().length < 32
|
|
72
|
+
|| /^<[^]*>$/.test(cookieSecret.trim())) throw new Error('cookieSecret must be at least 32 characters');
|
|
73
|
+
if (cookieSecret === apiToken) throw new Error('cookieSecret must be independent of apiToken');
|
|
73
74
|
const endpoint = new URL(apiUrl);
|
|
74
75
|
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
|
|
75
76
|
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
|
|
@@ -79,8 +80,12 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
79
80
|
if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
|
|
80
81
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
|
|
81
82
|
if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
|
|
83
|
+
if (typeof trafficInsights !== 'boolean') throw new Error('Invalid trafficInsights');
|
|
84
|
+
if (!['none', 'vercel', 'cloudflare'].includes(countrySource)) throw new Error('Invalid countrySource');
|
|
82
85
|
if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
|
|
83
86
|
if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
|
|
87
|
+
if (typeof countryBlocking !== 'boolean') throw new Error('Invalid countryBlocking');
|
|
88
|
+
if (countryBlocking && (countrySource === 'none' || publicPaths.length)) throw new Error('countryBlocking requires an explicit countrySource and no publicPaths bypasses');
|
|
84
89
|
const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
|
|
85
90
|
const verifyEndpoint = new URL('./verify', endpoint);
|
|
86
91
|
const eventsEndpoint = new URL('./events', endpoint);
|
|
@@ -120,6 +125,15 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
120
125
|
|| !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
|
|
121
126
|
|| payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
|
|
122
127
|
|| payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
|
|
128
|
+
if (countryBlocking) {
|
|
129
|
+
const control = payload.country_control;
|
|
130
|
+
const country = payload.country_decision;
|
|
131
|
+
if (!control || control.version !== 1 || control.source !== input.country_control?.source || control.country !== input.country_control?.country
|
|
132
|
+
|| !country || country.version !== 1 || !['disabled', 'pass', 'country_blocked', 'country_unknown_blocked', 'country_guard_required'].includes(country.result)
|
|
133
|
+
|| (country.revision !== null && (typeof country.revision !== 'string' || !/^[a-f0-9-]{36}$/.test(country.revision)))
|
|
134
|
+
|| (country.result !== 'disabled' && country.revision === null)
|
|
135
|
+
|| (!['disabled', 'pass'].includes(country.result) && payload.action !== 'block')) throw new Error('Invalid country decision');
|
|
136
|
+
}
|
|
123
137
|
return { ...payload, receipt: envelope.decision };
|
|
124
138
|
}
|
|
125
139
|
|
|
@@ -139,27 +153,33 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
139
153
|
const value = cookieValue(request, cookieName);
|
|
140
154
|
const payload = verified(value, cookieSecret);
|
|
141
155
|
const binding = browserBinding(request);
|
|
142
|
-
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
143
|
-
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
156
|
+
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
157
|
+
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function countryControl(request) {
|
|
161
|
+
return countryBlocking ? { country_control: { version: 1, source: countrySource,
|
|
162
|
+
country: trafficContext(request, true, countrySource).country } } : {};
|
|
144
163
|
}
|
|
145
164
|
|
|
146
165
|
async function check(request) {
|
|
147
166
|
const path = new URL(request.url).pathname;
|
|
148
167
|
if (path === verifyPath) return null;
|
|
149
168
|
if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
|
|
150
|
-
const session = validSession(request);
|
|
151
|
-
// Only link access to the page that originally requested this challenge.
|
|
152
|
-
// Sessions still authorize other configured routes without attributing them
|
|
153
|
-
// to this page's verification outcome. Legacy sessions remain usable.
|
|
154
|
-
const challenge = session?.challenge_path === path && /^[A-Za-z0-9_-]{43}$/.test(session.challenge_id || '')
|
|
155
|
-
? session.challenge_id : null;
|
|
169
|
+
const session = validSession(request);
|
|
170
|
+
// Only link access to the page that originally requested this challenge.
|
|
171
|
+
// Sessions still authorize other configured routes without attributing them
|
|
172
|
+
// to this page's verification outcome. Legacy sessions remain usable.
|
|
173
|
+
const challenge = session?.challenge_path === path && /^[A-Za-z0-9_-]{43}$/.test(session.challenge_id || '')
|
|
174
|
+
? session.challenge_id : null;
|
|
156
175
|
const eligible = request.method === 'GET' && (request.headers.get('accept') || '').includes('text/html');
|
|
157
|
-
const binding = challenge ? browserBinding(request)
|
|
158
|
-
: !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
|
|
176
|
+
const binding = challenge ? browserBinding(request)
|
|
177
|
+
: !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
|
|
159
178
|
let policy;
|
|
160
179
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
const context = trafficContext(request, trafficInsights, countrySource);
|
|
181
|
+
policy = await remote('decision', { path, method: request.method, ...countryControl(request), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
182
|
+
...(challenge ? { session_challenge: challenge } : {}) });
|
|
163
183
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
164
184
|
if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
|
|
165
185
|
// An older service must not silently apply selected-path semantics here.
|
|
@@ -199,13 +219,17 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
199
219
|
|| typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
|
|
200
220
|
|| payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
|
|
201
221
|
try {
|
|
202
|
-
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
|
|
222
|
+
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...countryControl(request) });
|
|
223
|
+
if (result.action === 'block' && result.country_decision && !['disabled', 'pass'].includes(result.country_decision.result)) {
|
|
224
|
+
await report(result, 'BLOCK');
|
|
225
|
+
return concealed();
|
|
226
|
+
}
|
|
203
227
|
if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
|
|
204
228
|
await report(result, 'VERIFIED');
|
|
205
229
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
206
|
-
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
207
|
-
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
208
|
-
exp: Date.now() + 300_000 }, cookieSecret);
|
|
230
|
+
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
231
|
+
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
232
|
+
exp: Date.now() + 300_000 }, cookieSecret);
|
|
209
233
|
return new Response(null, { status: 204, headers: {
|
|
210
234
|
'cache-control': 'no-store',
|
|
211
235
|
'set-cookie': cookie(cookieName, session, 300),
|
package/dist/index.js
CHANGED
|
@@ -96,6 +96,13 @@ function requestHeaders(req, origin) {
|
|
|
96
96
|
if (value !== undefined) headers.set(name, value);
|
|
97
97
|
}
|
|
98
98
|
headers.set('host', new URL(origin).host);
|
|
99
|
+
for (const name of ['user-agent', 'x-vercel-ip-country', 'cf-ipcountry']) {
|
|
100
|
+
const value = req.headers[name];
|
|
101
|
+
// Optional analytics must not turn malformed metadata into an access failure.
|
|
102
|
+
if (typeof value === 'string' && value.length <= (name === 'user-agent' ? 1024 : 2)) {
|
|
103
|
+
try { headers.set(name, value); } catch { /* Leave unsupported metadata unknown. */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
99
106
|
return headers;
|
|
100
107
|
}
|
|
101
108
|
|
package/dist/server.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import type { StopneticServerOptions } from './index.js';
|
|
3
|
-
export const accessBase: '/api/stopnetic';
|
|
4
|
-
export function optionsFromEnv(env?: Record<string, string | undefined>): StopneticServerOptions;
|
|
5
|
-
export function createStopneticAccessHandler(options?: StopneticServerOptions): (request: Request) => Promise<Response>;
|
|
6
|
-
export function withStopnetic<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
7
|
-
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
8
|
-
/** Node.js application wrapper: control endpoints, pages and APIs before rendering. */
|
|
9
|
-
export function createStopneticAppHandler<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
10
|
-
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
11
|
-
export function createStopneticAccessMiddleware(options?: StopneticServerOptions): (
|
|
12
|
-
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse, next: () => unknown,
|
|
13
|
-
) => Promise<unknown>;
|
|
14
|
-
export function createStopneticServer(options?: StopneticServerOptions): {
|
|
15
|
-
access: ReturnType<typeof createStopneticAccessMiddleware>;
|
|
16
|
-
guard: ReturnType<typeof import('./index.js').createStopneticMiddleware>;
|
|
17
|
-
};
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StopneticServerOptions } from './index.js';
|
|
3
|
+
export const accessBase: '/api/stopnetic';
|
|
4
|
+
export function optionsFromEnv(env?: Record<string, string | undefined>): StopneticServerOptions;
|
|
5
|
+
export function createStopneticAccessHandler(options?: StopneticServerOptions): (request: Request) => Promise<Response>;
|
|
6
|
+
export function withStopnetic<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
7
|
+
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
8
|
+
/** Node.js application wrapper: control endpoints, pages and APIs before rendering. */
|
|
9
|
+
export function createStopneticAppHandler<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
10
|
+
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
11
|
+
export function createStopneticAccessMiddleware(options?: StopneticServerOptions): (
|
|
12
|
+
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse, next: () => unknown,
|
|
13
|
+
) => Promise<unknown>;
|
|
14
|
+
export function createStopneticServer(options?: StopneticServerOptions): {
|
|
15
|
+
access: ReturnType<typeof createStopneticAccessMiddleware>;
|
|
16
|
+
guard: ReturnType<typeof import('./index.js').createStopneticMiddleware>;
|
|
17
|
+
};
|