@stopnetic/js 0.0.0-stage → 0.0.1-pilot

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stopnetic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,199 @@
1
- # Temporary Holding Version
1
+ # @stopnetic/js — JavaScript website guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stopnetic checks access **on your server before sending pages, files or API data**.
4
+ Your frontend can be Angular, Vue, React, Svelte, plain JavaScript, or an older
5
+ frontend build. No React/Vue/Angular dependency or version upgrade is required.
6
+ The hosting server must run Node.js 22 or newer. This is an MIT-licensed pilot SDK;
7
+ hosted production/provider validation is still required before paid rollout.
8
+
9
+ This package is prepared locally and **not published yet**. After publication:
10
+
11
+ ```sh
12
+ npm install @stopnetic/js@pilot
13
+ ```
14
+
15
+ For local testing, build and pack from this repository:
16
+
17
+ ```sh
18
+ cd integrations/javascript
19
+ npm run build
20
+ npm pack
21
+ ```
22
+
23
+ Install the resulting `stopnetic-js-0.0.1-pilot.tgz` into the website project using
24
+ `npm install <absolute-path-to-tarball>`. The installed package has no Next.js or
25
+ other runtime dependency. The build packages the same signed decision implementation
26
+ as the Next.js adapter. Keep `@stopnetic/next` for its Next.js 16 guided installer.
27
+
28
+ ## Which installation fits my site?
29
+
30
+ | Site | Where to install |
31
+ | --- | --- |
32
+ | Angular, Vue, React, Svelte SPA or plain HTML/JS | Node/Express server serving its built output, before static files and SPA fallback |
33
+ | Existing Node/Express server, including Express 4 and 5 | First root middleware, before body parsers, routes and data handlers |
34
+ | Node-hosted framework with Web `Request`/`Response` hooks | `createStopneticHandler` before the framework renders or serves data |
35
+ | Next.js 16 App Router | Published `@stopnetic/next@pilot` and its guided installer |
36
+ | Older Next.js with a customer-owned Node server | Generic middleware before the Next request handler; native older Next installers are not supplied |
37
+ | Static-only hosting / browser-only scripts | Needs a server or compatible edge/proxy in front, or a separately protected content API |
38
+
39
+ Do not import this SDK in browser components or add a public `<script>` containing
40
+ your installation key. Hiding a screen cannot protect HTML, JS bundles or data that
41
+ the hosting provider already serves publicly. This package uses `node:crypto`; it
42
+ is not a Cloudflare Workers/browser/Edge-runtime adapter. Framework-native SSR
43
+ adapters and serverless platform wiring need their own deployment validation.
44
+
45
+ ## Server configuration
46
+
47
+ Add a site in Stopnetic, verify its domain, and create an installation key in
48
+ Integration. Site ID identifies your website; the private installation key
49
+ authenticates it. Copy the service public signing key from Integration too.
50
+ Set these **server-only** environment variables, using your actual URLs and keys:
51
+
52
+ ```dotenv
53
+ STOPNETIC_WEBSITE_ORIGIN=https://www.example.com
54
+ STOPNETIC_SITE_ID=<generated-site-id>
55
+ STOPNETIC_API_URL=https://<your-stopnetic-service>/api/integration/decision
56
+ STOPNETIC_API_TOKEN=<your-one-time-installation-key>
57
+ STOPNETIC_DECISION_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n<service-public-key>\n-----END PUBLIC KEY-----\n"
58
+ STOPNETIC_COOKIE_SECRET=<separate-random-secret-at-least-32-characters>
59
+ ```
60
+
61
+ Generate the cookie secret with
62
+ `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`.
63
+ Never use frontend prefixes such as `VITE_`, `NEXT_PUBLIC_` or `REACT_APP_` for secrets.
64
+ The optional `STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS` is a JSON array of at most
65
+ two explicitly trusted public PEMs for staged signing-key rotation.
66
+
67
+ The origin is the public website's protocol and hostname, with an optional port.
68
+ No `/home`, credentials, query or fragment. HTTPS is required except on localhost.
69
+ Configure your TLS proxy to preserve the public `Host` header and keep the backend
70
+ private. The adapter uses the configured origin and never trusts forwarded host or
71
+ protocol headers. HTTPS origins always produce secure cookies; HTTP localhost
72
+ origins automatically use development cookies.
73
+
74
+ ## Node/Express middleware
75
+
76
+ ```js
77
+ import express from 'express';
78
+ import { createStopneticMiddleware, parseAdditionalDecisionPublicKeys } from '@stopnetic/js';
79
+
80
+ const app = express();
81
+ app.set('case sensitive routing', true);
82
+ app.set('strict routing', true);
83
+ app.use(createStopneticMiddleware({
84
+ origin: process.env.STOPNETIC_WEBSITE_ORIGIN,
85
+ siteId: process.env.STOPNETIC_SITE_ID,
86
+ apiUrl: process.env.STOPNETIC_API_URL,
87
+ apiToken: process.env.STOPNETIC_API_TOKEN,
88
+ decisionPublicKey: process.env.STOPNETIC_DECISION_PUBLIC_KEY?.replaceAll('\\n', '\n'),
89
+ additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(process.env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
90
+ cookieSecret: process.env.STOPNETIC_COOKIE_SECRET,
91
+ }));
92
+ // Your existing application follows the guard.
93
+ app.use(express.json());
94
+ app.post('/api/private', yourExistingApiHandler);
95
+ app.use(express.static('dist', { dotfiles: 'deny', cacheControl: false }));
96
+ // Add your existing guarded SPA fallback here.
97
+ app.listen(8080);
98
+ ```
99
+
100
+ Install `express` separately in your website if needed. The repository includes
101
+ `integrations/javascript/examples/express-server.mjs` for a complete SPA server.
102
+ Serve only the build output directory (`dist`, `build`, or Angular's browser output),
103
+ never your project root. A framework-independent fallback after your static middleware:
104
+
105
+ ```js
106
+ app.use((req, res) => {
107
+ if (req.method === 'GET' && req.get('accept')?.includes('text/html')
108
+ && !req.path.startsWith('/api/') && !req.path.split('/').some(p => p.startsWith('.'))) {
109
+ res.sendFile('index.html', { root: absoluteBuildDirectory, cacheControl: false });
110
+ } else res.status(404).end();
111
+ });
112
+ ```
113
+
114
+ Place the guard **first at the root**, before body parsers and before anything can
115
+ send bytes. It checks every request except explicitly configured `publicPaths`.
116
+ Ordinary POST bodies stay intact for your application's parser. The same middleware
117
+ handles `/__stopnetic/verify`; do not mount it only on `/members`.
118
+
119
+ With Node's built-in HTTP server, the same middleware works without Express:
120
+
121
+ ```js
122
+ import { createServer } from 'node:http';
123
+ const protect = createStopneticMiddleware(options);
124
+ createServer((req, res) => {
125
+ void protect(req, res, () => existingApplication(req, res)).catch(() => res.destroy());
126
+ }).listen(8080);
127
+ ```
128
+
129
+ For an older CommonJS application, initialize at startup with
130
+ `const { createStopneticMiddleware } = await import('@stopnetic/js')` inside an async
131
+ function. Only the server runtime needs to meet the minimum version; the frontend
132
+ framework can remain unchanged.
133
+
134
+ ## Request/Response hook
135
+
136
+ ```js
137
+ import { createStopneticHandler } from '@stopnetic/js';
138
+ const protect = createStopneticHandler(options); // same options, including origin
139
+
140
+ export async function handle({ request, resolve }) {
141
+ return protect(request, checkedRequest => resolve(checkedRequest));
142
+ }
143
+ ```
144
+
145
+ Wire this function to the Node-hosted framework's actual request hook. For SvelteKit,
146
+ the hook receives an `event`, so use
147
+ `protect(event.request, () => resolve(event))` in `hooks.server.js`. The request URL
148
+ must contain the configured public origin. Configure the framework's trusted proxy
149
+ settings explicitly. This is a generic hook example, not a tested matrix of all
150
+ SvelteKit/Nuxt/framework releases.
151
+
152
+ ## Policies, failures and hosting
153
+
154
+ In Policies, choose public, automatic browser flow, verified session, or blocked
155
+ routes. Automatic flow serves a short verification page first, then a cookie-bound
156
+ session; requests from ordinary fetchers and non-navigation clients get no protected
157
+ bytes. Browser-capable automation may complete the challenge. Your application must
158
+ still enforce its own sign-in, membership and purchase permissions. Entitlement
159
+ rules are not implemented and withhold content.
160
+
161
+ Both adapters automatically route verification, bound its body to 4 KiB and five
162
+ seconds, and reject foreign origins. Protected requests fail closed with empty
163
+ 404/503 responses on denial, malformed signed decisions or service outages. Approved
164
+ application responses receive `Cache-Control: private, no-store`; middleware users
165
+ must keep that header in their downstream handlers. Prevent CDN, prerendered files,
166
+ service workers or another origin from serving protected content before the guard.
167
+ WebSockets/upgrades are not covered; protect them separately or disable them.
168
+
169
+ By default **all paths are checked**, including assets and APIs. Optional local
170
+ `publicPaths: ['/health', '/public/*']` bypass the policy service even during outages.
171
+ Use only paths deliberately intended to remain public. Ambiguous encoded separators,
172
+ dot segments, encoded ASCII characters, repeated slashes and malformed targets are
173
+ rejected by the Node middleware. Policy paths are case sensitive. Match your router's
174
+ case/slash behavior to the policies, cover directory-index and other aliases, and
175
+ keep the service's default blocking behavior. Avoid a broad public catch-all that
176
+ could expose an alternative path to protected content.
177
+
178
+ An installed guard's first valid request starts the site's account trial. Key
179
+ rotation/revocation and domain/account/subscription gates are checked by the service.
180
+ Access reports are best effort and cannot establish delivery guarantees. Validate
181
+ your actual hosting, cache rules, browser flow, API paths and provider configuration
182
+ before onboarding paying customers.
183
+
184
+ ## Maintainer checks and release
185
+
186
+ From this folder: `npm ci`, `npm test`, `npm run test:package`. Tests include real
187
+ Node HTTP traffic, Express 4/5 static/SPA/API serving, challenges, body preservation,
188
+ signed decision failures and package consumption from ESM, CommonJS and TypeScript.
189
+ The tarball includes only `dist`, README, MIT license and manifest. No secrets,
190
+ examples, tests, build scripts or dashboard are published. Build from the repository
191
+ before packing; published consumers do not need the repository or build scripts.
192
+
193
+ Publishing is a separate owner step after these checks:
194
+
195
+ ```sh
196
+ npm publish --access public --tag pilot --registry=https://registry.npmjs.org/
197
+ ```
198
+
199
+ Publishing this package does not update the existing `@stopnetic/next` release.
@@ -0,0 +1,21 @@
1
+ export type StopneticOptions = {
2
+ siteId: string;
3
+ apiUrl: string;
4
+ apiToken: string;
5
+ decisionPublicKey: string;
6
+ /** Explicitly trusted replacement/retiring public PEMs, at most two. */
7
+ additionalDecisionPublicKeys?: string[];
8
+ cookieSecret: string;
9
+ verifyPath?: string;
10
+ cookieName?: string;
11
+ secureCookie?: boolean;
12
+ fetchImpl?: typeof fetch;
13
+ timeoutMs?: number;
14
+ publicPaths?: string[];
15
+ reportEvents?: boolean;
16
+ };
17
+ export function parseAdditionalDecisionPublicKeys(value?: string): string[];
18
+ export function createStopnetic(options: StopneticOptions): {
19
+ check(request: Request): Promise<Response | null>;
20
+ verify(request: Request): Promise<Response>;
21
+ };
package/dist/guard.js ADDED
@@ -0,0 +1,203 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
2
+ import { trustedSigningKeys } from './signing-keys.js';
3
+ export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
4
+
5
+ const unavailable = retryAfter => new Response(null, { status: 503, headers: {
6
+ 'cache-control': 'no-store', ...(retryAfter ? { 'retry-after': String(retryAfter) } : {}),
7
+ } });
8
+ const concealed = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+
11
+ function signed(payload, secret) {
12
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
13
+ const mac = createHmac('sha256', secret).update(data).digest('base64url');
14
+ return `${data}.${mac}`;
15
+ }
16
+
17
+ function verified(token, secret) {
18
+ if (!token || typeof token !== 'string' || token.length > 8192) return null;
19
+ const [data, mac, extra] = token.split('.');
20
+ if (!data || !mac || extra) return null;
21
+ const expected = createHmac('sha256', secret).update(data).digest();
22
+ let supplied;
23
+ try { supplied = Buffer.from(mac, 'base64url'); } catch { return null; }
24
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return null;
25
+ try {
26
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
27
+ return payload?.v === 1 && Number.isSafeInteger(payload.exp) && payload.exp > Date.now() ? payload : null;
28
+ } catch { return null; }
29
+ }
30
+
31
+ function cookieValue(request, name) {
32
+ const found = (request.headers.get('cookie') || '').split(';').map(part => part.trim())
33
+ .find(part => part.startsWith(`${name}=`));
34
+ return found ? found.slice(name.length + 1) : null;
35
+ }
36
+
37
+ async function limitedBody(request, maxBytes) {
38
+ const reader = request.body?.getReader();
39
+ if (!reader) throw new Error('Missing body');
40
+ const chunks = [];
41
+ let length = 0;
42
+ for (;;) {
43
+ const { value, done } = await reader.read();
44
+ if (done) break;
45
+ length += value.byteLength;
46
+ if (length > maxBytes) { await reader.cancel(); throw new Error('Body too large'); }
47
+ chunks.push(Buffer.from(value));
48
+ }
49
+ return Buffer.concat(chunks, length).toString('utf8');
50
+ }
51
+
52
+ function bootstrap(ticket, verifyPath) {
53
+ const nonce = randomBytes(16).toString('base64');
54
+ const script = `fetch(${JSON.stringify(verifyPath)}, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({ticket:${JSON.stringify(ticket)}})}).then(r=>{if(r.ok)location.reload()})`;
55
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Checking access</title></head><body><p>Checking access...</p><script nonce="${nonce}">${script}</script></body></html>`;
56
+ return new Response(html, { status: 404, headers: {
57
+ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'vary': 'Cookie',
58
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'`,
59
+ 'x-content-type-options': 'nosniff',
60
+ } });
61
+ }
62
+
63
+ /** Pilot guard for server-side Next.js Proxy and Route Handlers. */
64
+ 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 }) {
66
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
67
+ const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
68
+ if (!credential) throw new Error('apiToken must be an issued installation key');
69
+ const installationId = credential[1];
70
+ if (typeof cookieSecret !== 'string' || cookieSecret.length < 32) throw new Error('cookieSecret must be at least 32 characters');
71
+ const endpoint = new URL(apiUrl);
72
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
73
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
74
+ throw new Error('apiUrl must use HTTPS outside localhost');
75
+ }
76
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(verifyPath)) throw new Error('Invalid verifyPath');
77
+ if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
78
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
79
+ if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
80
+ if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
81
+ const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
82
+ const verifyEndpoint = new URL('./verify', endpoint);
83
+ const eventsEndpoint = new URL('./events', endpoint);
84
+ const browserCookieName = `${cookieName}_browser`;
85
+ const installation = hash(apiToken);
86
+
87
+ function browserBinding(request) {
88
+ const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
89
+ return payload?.kind === 'browser' && payload.site === siteId && payload.installation === installation
90
+ && /^[A-Za-z0-9_-]{43}$/.test(payload.binding || '') ? payload.binding : null;
91
+ }
92
+
93
+ function cookie(name, value, age) {
94
+ return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax${secureCookie ? '; Secure' : ''}`;
95
+ }
96
+
97
+ async function remote(purpose, input) {
98
+ const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
99
+ const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
100
+ method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
101
+ headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
102
+ body: JSON.stringify({ ...input, ...context }), cache: 'no-store',
103
+ });
104
+ if (!response.ok) {
105
+ const retry = response.headers.get('retry-after') || '';
106
+ const retryAfter = response.status === 429 && /^[1-9][0-9]?$/.test(retry) && Number(retry) <= 60 ? Number(retry) : undefined;
107
+ await response.body?.cancel();
108
+ throw Object.assign(new Error('Policy service unavailable'), { retryAfter });
109
+ }
110
+ const envelope = JSON.parse(await limitedBody(response, 16_384));
111
+ if (typeof envelope?.decision !== 'string' || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(envelope.decision)) throw new Error('Invalid signature envelope');
112
+ const [data, signature] = envelope.decision.split('.');
113
+ if (!publicKeys.some(key => verifySignature(null, Buffer.from(data), key, Buffer.from(signature, 'base64url')))) throw new Error('Invalid signature');
114
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
115
+ const now = Date.now();
116
+ if (payload?.v !== 1 || payload.purpose !== purpose || payload.key_id !== installationId || Object.entries(context).some(([name, value]) => payload[name] !== value)
117
+ || !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
118
+ || payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
119
+ || payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
120
+ return { ...payload, receipt: envelope.decision };
121
+ }
122
+
123
+ async function report(policy, outcome, session = false) {
124
+ if (!reportEvents) return;
125
+ try {
126
+ const response = await fetchImpl(eventsEndpoint, {
127
+ method: 'POST', signal: AbortSignal.timeout(Math.min(timeoutMs, 500)), redirect: 'error', cache: 'no-store',
128
+ headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
129
+ body: JSON.stringify({ receipt: policy.receipt, outcome, session_verified: !!session }),
130
+ });
131
+ await response.body?.cancel();
132
+ } catch { /* Reporting cannot change a validated access decision. */ }
133
+ }
134
+
135
+ function validSession(request) {
136
+ const value = cookieValue(request, cookieName);
137
+ const payload = verified(value, cookieSecret);
138
+ const binding = browserBinding(request);
139
+ return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
140
+ && binding && payload.browser_hash === hash(binding);
141
+ }
142
+
143
+ async function check(request) {
144
+ const path = new URL(request.url).pathname;
145
+ if (path === verifyPath) return null;
146
+ if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
147
+ const session = validSession(request);
148
+ const eligible = request.method === 'GET' && (request.headers.get('accept') || '').includes('text/html');
149
+ const binding = !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
150
+ let policy;
151
+ try {
152
+ policy = await remote('decision', { path, method: request.method, ...(binding ? { browser_binding: binding } : {}) });
153
+ } catch (error) { return unavailable(error?.retryAfter); }
154
+ if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
155
+ if (policy.action === 'public') { await report(policy, 'ALLOW', session); return null; }
156
+ if (policy.action === 'block' || policy.action === 'entitled' || policy.action === 'default') { await report(policy, 'BLOCK', session); return concealed(); }
157
+ if (session) { await report(policy, 'ALLOW', true); return null; }
158
+ if (policy.action === 'verified' || !eligible) { await report(policy, 'BLOCK'); return concealed(); }
159
+ if (!/^[A-Za-z0-9_-]{43}$/.test(policy.challenge_id || '')) return unavailable();
160
+ const ticket = signed({ v: 1, kind: 'challenge', site: siteId, path, exp: Date.now() + 60_000,
161
+ challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
162
+ const response = bootstrap(ticket, verifyPath);
163
+ response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
164
+ binding, installation, exp: Date.now() + 600_000 }, cookieSecret), 600));
165
+ await report(policy, 'CHALLENGE');
166
+ return response;
167
+ }
168
+
169
+ async function verify(request) {
170
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
171
+ const origin = request.headers.get('origin');
172
+ if (!origin) return concealed();
173
+ // Next.js may use an internal hostname in request.url. Host is the browser's
174
+ // destination; retain the request URL's scheme to reject cross-scheme calls.
175
+ const destination = new URL(request.url);
176
+ const host = request.headers.get('host') || destination.host;
177
+ if (origin !== `${destination.protocol}//${host}`) return concealed();
178
+ if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) return concealed();
179
+ if ((Number(request.headers.get('content-length')) || 0) > 4096) return concealed();
180
+ let body;
181
+ try { body = await limitedBody(request, 4096); } catch { return concealed(); }
182
+ let ticket;
183
+ try { ticket = JSON.parse(body).ticket; } catch { return concealed(); }
184
+ const payload = verified(ticket, cookieSecret);
185
+ const binding = browserBinding(request);
186
+ if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
187
+ || typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
188
+ || payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
189
+ try {
190
+ const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
191
+ if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
192
+ await report(result, 'VERIFIED');
193
+ } catch (error) { return unavailable(error?.retryAfter); }
194
+ const session = signed({ v: 1, kind: 'session', site: siteId, installation,
195
+ browser_hash: hash(binding), exp: Date.now() + 300_000 }, cookieSecret);
196
+ return new Response(null, { status: 204, headers: {
197
+ 'cache-control': 'no-store',
198
+ 'set-cookie': cookie(cookieName, session, 300),
199
+ } });
200
+ }
201
+
202
+ return { check, verify };
203
+ }
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StopneticOptions } from './guard.js';
3
+ export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
4
+ export type { StopneticOptions } from './guard.js';
5
+ export type StopneticServerOptions = StopneticOptions & {
6
+ /** Public website origin, e.g. https://example.com. Never taken from forwarded headers. */
7
+ origin: string;
8
+ };
9
+ export function createStopneticMiddleware(options: StopneticServerOptions): (
10
+ request: IncomingMessage & { originalUrl?: string }, response: ServerResponse,
11
+ next: () => unknown,
12
+ ) => Promise<unknown>;
13
+ export function createStopneticHandler(options: StopneticServerOptions): (
14
+ request: Request, next: (request: Request) => Response | Promise<Response>,
15
+ ) => Promise<Response>;
package/dist/index.js ADDED
@@ -0,0 +1,172 @@
1
+ import { createStopnetic } from './guard.js';
2
+ export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
3
+
4
+ const denied = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
5
+ const unavailable = () => new Response(null, { status: 503, headers: { 'cache-control': 'no-store' } });
6
+
7
+ function canonicalOrigin(value) {
8
+ let url;
9
+ try { url = new URL(value); } catch { throw new Error('origin must be an HTTPS origin (HTTP only on localhost)'); }
10
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash
11
+ || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
12
+ throw new Error('origin must be an HTTPS origin (HTTP only on localhost)');
13
+ }
14
+ return url.origin;
15
+ }
16
+
17
+ function target(raw, origin) {
18
+ if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') || /[\\\s#]/.test(raw)) throw new Error('Invalid request target');
19
+ const url = new URL(raw, origin);
20
+ // Do not let URL normalization or downstream decoding change the checked path.
21
+ if (url.origin !== origin || url.pathname + url.search !== raw) throw new Error('Invalid request target');
22
+ const decoded = decodeURIComponent(url.pathname);
23
+ if (/[\\\s?#%]/.test(decoded) || /[\x00-\x1f\x7f]/.test(decoded)
24
+ || decoded.split('/').some(part => part === '.' || part === '..')
25
+ || /%[0-7][0-9a-f]/i.test(url.pathname) || url.pathname.includes('//')) throw new Error('Ambiguous request path');
26
+ return url;
27
+ }
28
+
29
+ function optionsForGuard(options, origin) {
30
+ const local = origin.startsWith('http:');
31
+ if (!local && options.secureCookie === false) throw new Error('HTTPS sites require secure cookies');
32
+ // A verification request must reach the verification handler, not a bypass.
33
+ return { ...options, secureCookie: !local };
34
+ }
35
+
36
+ async function boundedWebBody(request) {
37
+ if (Number(request.headers.get('content-length')) > 4096) throw new Error('Body too large');
38
+ const reader = request.body?.getReader();
39
+ if (!reader) throw new Error('Missing body');
40
+ const chunks = [];
41
+ let size = 0;
42
+ let timer;
43
+ let finished = false;
44
+ const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Body timeout')), 5000); });
45
+ try {
46
+ for (;;) {
47
+ const { value, done } = await Promise.race([reader.read(), deadline]);
48
+ if (done) { finished = true; return Buffer.concat(chunks, size); }
49
+ size += value.byteLength;
50
+ if (size > 4096) throw new Error('Body too large');
51
+ chunks.push(Buffer.from(value));
52
+ }
53
+ } finally {
54
+ clearTimeout(timer);
55
+ if (!finished) void reader.cancel().catch(() => {});
56
+ reader.releaseLock();
57
+ }
58
+ }
59
+
60
+ /** Fetch-style adapter for Node-hosted frameworks. Always handles verification. */
61
+ export function createStopneticHandler(options) {
62
+ const origin = canonicalOrigin(options.origin);
63
+ const guard = createStopnetic(optionsForGuard(options, origin));
64
+ const verifyPath = options.verifyPath || '/__stopnetic/verify';
65
+ return async function handle(request, next) {
66
+ let response;
67
+ try {
68
+ const url = new URL(request.url);
69
+ if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return denied();
70
+ target(url.pathname + url.search, origin);
71
+ if (url.pathname === verifyPath) {
72
+ if (request.method !== 'POST' || request.headers.get('origin') !== origin
73
+ || !(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) {
74
+ return request.method === 'POST' ? denied() : new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
75
+ }
76
+ try {
77
+ const body = await boundedWebBody(request);
78
+ response = await guard.verify(new Request(request.url, { method: 'POST', headers: request.headers, body }));
79
+ } catch { return denied(); }
80
+ } else response = await guard.check(request);
81
+ } catch { return unavailable(); }
82
+ if (response) return response;
83
+ const result = await next(request);
84
+ // Copy headers, including individual cookies, without buffering the body.
85
+ const headers = new Headers(result.headers);
86
+ headers.set('cache-control', 'private, no-store');
87
+ return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
88
+ };
89
+ }
90
+
91
+ function requestHeaders(req, origin) {
92
+ const headers = new Headers();
93
+ for (const name of ['accept', 'cookie', 'origin', 'content-type', 'content-length']) {
94
+ const value = req.headers[name];
95
+ if (Array.isArray(value)) throw new Error('Ambiguous request headers');
96
+ if (value !== undefined) headers.set(name, value);
97
+ }
98
+ headers.set('host', new URL(origin).host);
99
+ return headers;
100
+ }
101
+
102
+ function verificationBody(req) {
103
+ return new Promise((resolve, reject) => {
104
+ if (req.readableEnded || req.destroyed) { reject(new Error('Verification body unavailable')); return; }
105
+ const chunks = [];
106
+ let size = 0;
107
+ const cleanup = () => {
108
+ clearTimeout(timer);
109
+ req.off('data', data); req.off('end', end); req.off('error', fail); req.off('aborted', fail);
110
+ };
111
+ const fail = () => { cleanup(); req.pause(); reject(new Error('Invalid verification body')); };
112
+ const data = chunk => {
113
+ const bytes = Buffer.from(chunk);
114
+ size += bytes.length;
115
+ if (size > 4096) { fail(); return; }
116
+ chunks.push(bytes);
117
+ };
118
+ const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
119
+ const timer = setTimeout(fail, 5000);
120
+ req.on('data', data); req.once('end', end); req.once('error', fail); req.once('aborted', fail);
121
+ });
122
+ }
123
+
124
+ async function send(response, req, res, close = false) {
125
+ res.statusCode = response.status;
126
+ response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
127
+ const cookies = response.headers.getSetCookie();
128
+ if (cookies.length) res.setHeader('set-cookie', cookies);
129
+ if (close) res.setHeader('connection', 'close');
130
+ const body = req.method === 'HEAD' ? undefined : Buffer.from(await response.arrayBuffer());
131
+ res.end(body);
132
+ }
133
+
134
+ /** Use first, at the application root, before parsers, static files and routes. */
135
+ export function createStopneticMiddleware(options) {
136
+ const origin = canonicalOrigin(options.origin);
137
+ const guard = createStopnetic(optionsForGuard(options, origin));
138
+ const verifyPath = options.verifyPath || '/__stopnetic/verify';
139
+ return async function stopnetic(req, res, next) {
140
+ let response;
141
+ let close = false;
142
+ try {
143
+ const host = req.headers.host;
144
+ if (typeof host !== 'string' || host.toLowerCase() !== new URL(origin).host) response = denied();
145
+ else {
146
+ const url = target(req.originalUrl || req.url, origin);
147
+ const headers = requestHeaders(req, origin);
148
+ const method = req.method || 'GET';
149
+ if (url.pathname === verifyPath) {
150
+ if (method !== 'POST') response = new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
151
+ else if (headers.get('origin') !== origin || !(headers.get('content-type') || '').toLowerCase().startsWith('application/json')
152
+ || Number(headers.get('content-length')) > 4096) { response = denied(); close = true; }
153
+ else {
154
+ try {
155
+ const body = await verificationBody(req);
156
+ response = await guard.verify(new Request(url, { method, headers, body }));
157
+ } catch { response = denied(); close = true; }
158
+ }
159
+ } else {
160
+ // Checking ordinary requests must leave their bodies for the application.
161
+ response = await guard.check(new Request(url, { method, headers }));
162
+ }
163
+ }
164
+ } catch { response = unavailable(); close = true; }
165
+ if (response) {
166
+ try { await send(response, req, res, close); } catch { res.destroy(); }
167
+ return;
168
+ }
169
+ res.setHeader('cache-control', 'private, no-store');
170
+ return next();
171
+ };
172
+ }
@@ -0,0 +1,29 @@
1
+ import { createPublicKey } from 'node:crypto';
2
+
3
+ function publicKey(value) {
4
+ if (typeof value !== 'string' || value.length > 2048
5
+ || !/^-----BEGIN PUBLIC KEY-----\r?\n[A-Za-z0-9+/=\r\n]+-----END PUBLIC KEY-----\s*$/.test(value)) {
6
+ throw new Error('Signing verification keys must be public PEMs');
7
+ }
8
+ const key = createPublicKey(value);
9
+ if (key.asymmetricKeyType !== 'ed25519') throw new Error('Signing verification keys must be Ed25519');
10
+ return key;
11
+ }
12
+
13
+ export function trustedSigningKeys(primary, additional) {
14
+ if (!Array.isArray(additional) || additional.length > 2) throw new Error('Use at most two additional signing public keys');
15
+ const keys = [primary, ...additional].map(publicKey);
16
+ return [...new Map(keys.map(key => [key.export({ type: 'spki', format: 'der' }).toString('hex'), key])).values()];
17
+ }
18
+
19
+ /** Parse a server environment JSON array without exposing malformed values. */
20
+ export function parseAdditionalDecisionPublicKeys(value) {
21
+ if (value === undefined || value === '') return [];
22
+ try {
23
+ if (typeof value !== 'string' || value.length > 8192) throw new Error();
24
+ const keys = JSON.parse(value);
25
+ if (!Array.isArray(keys) || keys.length > 2) throw new Error();
26
+ keys.forEach(publicKey);
27
+ return keys;
28
+ } catch { throw new Error('Invalid additional signing public keys'); }
29
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@stopnetic/js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1-pilot",
4
+ "description": "Server-side Stopnetic guard for JavaScript websites, Node.js and Express-compatible servers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Stopnetic",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/KlimentSidorov/stopin.git",
11
+ "directory": "integrations/javascript"
12
+ },
13
+ "keywords": [
14
+ "stopnetic",
15
+ "javascript",
16
+ "nodejs",
17
+ "express",
18
+ "access-control"
19
+ ],
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "tag": "pilot",
35
+ "registry": "https://registry.npmjs.org/"
36
+ },
37
+ "scripts": {
38
+ "build": "node scripts/build.mjs",
39
+ "prepack": "npm run build",
40
+ "test": "npm run build && node --test tests/*.test.mjs",
41
+ "test:package": "npm run build && node tests/package-check.mjs"
42
+ },
43
+ "devDependencies": {
44
+ "express": "^4.22.3",
45
+ "express5": "npm:express@^5.2.1"
46
+ }
47
+ }