@stopnetic/js 0.0.1-pilot → 0.0.3-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/dist/index.js CHANGED
@@ -1,172 +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
- }
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,4 @@
1
+ import type { ReactNode } from 'react';
2
+ export function StopneticBoundary(props: {
3
+ path: string; children?: ReactNode; pending?: ReactNode; blocked?: ReactNode; redirect?: boolean;
4
+ }): ReactNode;
package/dist/react.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createElement, useEffect, useState } from 'react';
2
+ import { checkAccess } from './browser.js';
3
+
4
+ /** UI boundary only. Keep private data in independently guarded server endpoints. */
5
+ export function StopneticBoundary(props) {
6
+ return createElement(CheckedBoundary, { ...props, key: props.path });
7
+ }
8
+
9
+ function CheckedBoundary({ path, children, pending = 'Checking access…', blocked = 'Access unavailable.', redirect = true }) {
10
+ const [result, setResult] = useState(null);
11
+ useEffect(() => {
12
+ const controller = new AbortController();
13
+ checkAccess(path, { signal: controller.signal }).then(value => {
14
+ if (!controller.signal.aborted) {
15
+ setResult({ ...value, path });
16
+ if (redirect && value.reason === 'verification_required') window.location.assign(value.verificationUrl);
17
+ }
18
+ }).catch(() => { if (!controller.signal.aborted) setResult({ allowed: false, reason: 'unavailable', path }); });
19
+ return () => controller.abort();
20
+ }, [path, redirect]);
21
+ if (!result || result.path !== path) return createElement('div', { role: 'status' }, pending);
22
+ if (!result.allowed) return createElement('div', { role: 'alert' }, blocked);
23
+ return children;
24
+ }
@@ -0,0 +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
+ };
package/dist/server.js ADDED
@@ -0,0 +1,123 @@
1
+ import { createStopneticHandler, createStopneticMiddleware, parseAdditionalDecisionPublicKeys } from './index.js';
2
+
3
+ export const accessBase = '/api/stopnetic';
4
+
5
+ /** Server environment only. Never import this module into browser code. */
6
+ export function optionsFromEnv(env = process.env) {
7
+ return {
8
+ origin: env.STOPNETIC_WEBSITE_ORIGIN,
9
+ siteId: env.STOPNETIC_SITE_ID,
10
+ apiUrl: env.STOPNETIC_API_URL,
11
+ apiToken: env.STOPNETIC_API_TOKEN,
12
+ decisionPublicKey: env.STOPNETIC_DECISION_PUBLIC_KEY?.replaceAll('\\n', '\n'),
13
+ additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
14
+ cookieSecret: env.STOPNETIC_COOKIE_SECRET,
15
+ guardMode: 'site',
16
+ verifyPath: `${accessBase}/verify`,
17
+ };
18
+ }
19
+
20
+ const empty = status => new Response(null, { status, headers: { 'cache-control': 'private, no-store' } });
21
+ function checkedPath(path, origin) {
22
+ if (typeof path !== 'string' || path.length > 2048 || !path.startsWith('/') || path.startsWith('//')
23
+ || /[\\\s#]/.test(path)) throw new Error('Invalid page path');
24
+ const url = new URL(path, origin);
25
+ if (url.origin !== origin || url.pathname + url.search !== path
26
+ || url.pathname === accessBase || url.pathname.startsWith(`${accessBase}/`)) throw new Error('Invalid page path');
27
+ return url;
28
+ }
29
+
30
+ /** Same-origin UI checks. These never authorize a separate data handler. */
31
+ export function createStopneticAccessHandler(options = optionsFromEnv()) {
32
+ const origin = new URL(options.origin).origin;
33
+ const verifyPath = `${accessBase}/verify`;
34
+ const protect = createStopneticHandler({ ...options, verifyPath });
35
+ return async function access(request) {
36
+ const url = new URL(request.url);
37
+ if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return empty(404);
38
+ if (url.pathname === verifyPath) return protect(request, () => empty(404));
39
+ if (url.pathname !== `${accessBase}/access`) return empty(404);
40
+ if (request.method !== 'GET') return new Response(null, { status: 405, headers: { allow: 'GET', 'cache-control': 'private, no-store' } });
41
+ let target;
42
+ try { target = checkedPath(url.searchParams.get('path'), origin); } catch { return empty(400); }
43
+ if (url.searchParams.getAll('path').length !== 1 || url.searchParams.getAll('mode').length > 1
44
+ || [...url.searchParams.keys()].some(key => !['path', 'mode'].includes(key))
45
+ || (url.searchParams.has('mode') && url.searchParams.get('mode') !== 'check')) return empty(400);
46
+ // Navigation checks use the real page path and cookie, not the control URL.
47
+ const headers = new Headers(request.headers);
48
+ headers.set('accept', 'text/html');
49
+ const check = url.searchParams.get('mode') === 'check';
50
+ const response = await protect(new Request(target, { headers }), () => check
51
+ ? Response.json({ allowed: true })
52
+ : new Response(null, { status: 303, headers: { location: target.pathname + target.search } }));
53
+ if (!check || response.ok) return response;
54
+ const challenge = response.status === 404 && response.headers.get('content-type')?.startsWith('text/html');
55
+ const resultHeaders = new Headers({ 'cache-control': 'private, no-store' });
56
+ for (const cookie of response.headers.getSetCookie()) resultHeaders.append('set-cookie', cookie);
57
+ await response.body?.cancel();
58
+ return Response.json({ allowed: false, reason: challenge ? 'verification_required' : response.status === 404 ? 'blocked' : 'unavailable' },
59
+ { status: response.status, headers: resultHeaders });
60
+ };
61
+ }
62
+
63
+ /** Wrap EACH protected Fetch-style API; the UI's allowed flag is never trusted. */
64
+ export function withStopnetic(handler, options = optionsFromEnv()) {
65
+ const protect = createStopneticHandler(options);
66
+ return (request, ...context) => {
67
+ // Netlify's default function alias must not bypass a custom API path's rule.
68
+ // Declare a custom config.path for these functions and use it in Policies.
69
+ if (new URL(request.url).pathname.startsWith('/.netlify/functions/')) return Promise.resolve(empty(404));
70
+ return protect(request, checked => handler(checked, ...context));
71
+ };
72
+ }
73
+
74
+ /** Guard a complete Fetch-style application before its renderer or API runs.
75
+ * The application must run in Node.js; accepting Request/Response alone does
76
+ * not make an Edge runtime compatible. Context arguments are preserved.
77
+ */
78
+ export function createStopneticAppHandler(handler, options = optionsFromEnv()) {
79
+ if (typeof handler !== 'function') throw new TypeError('An application handler is required');
80
+ const settings = { ...options, verifyPath: `${accessBase}/verify` };
81
+ const access = createStopneticAccessHandler(settings);
82
+ const application = withStopnetic(handler, settings);
83
+ return (request, ...context) => {
84
+ const path = new URL(request.url).pathname;
85
+ if (path === accessBase || path.startsWith(`${accessBase}/`)) return access(request);
86
+ return application(request, ...context);
87
+ };
88
+ }
89
+
90
+ /** Node/Express: mount first, then the root guard, then parsers/static/API handlers. */
91
+ export function createStopneticAccessMiddleware(options = optionsFromEnv()) {
92
+ const access = createStopneticAccessHandler(options);
93
+ const verification = createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` });
94
+ return async (req, res, next) => {
95
+ const path = (req.originalUrl || req.url || '').split('?')[0];
96
+ if (path !== `${accessBase}/access` && path !== `${accessBase}/verify`) return next();
97
+ if (path === `${accessBase}/verify`) return verification(req, res, next);
98
+ try {
99
+ const headers = new Headers();
100
+ for (const [name, value] of Object.entries(req.headers)) {
101
+ if (Array.isArray(value)) { res.writeHead(400, { 'cache-control': 'no-store' }); res.end(); return; }
102
+ if (value !== undefined) headers.set(name, value);
103
+ }
104
+ const request = new Request(new URL(req.originalUrl || req.url, options.origin), {
105
+ method: req.method, headers,
106
+ ...(['GET', 'HEAD'].includes(req.method) ? {} : { body: req, duplex: 'half' }),
107
+ });
108
+ const response = await access(request);
109
+ res.statusCode = response.status;
110
+ response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
111
+ const cookies = response.headers.getSetCookie();
112
+ if (cookies.length) res.setHeader('set-cookie', cookies);
113
+ res.end(Buffer.from(await response.arrayBuffer()));
114
+ } catch { res.writeHead(503, { 'cache-control': 'private, no-store' }); res.end(); }
115
+ };
116
+ }
117
+
118
+ export function createStopneticServer(options = optionsFromEnv()) {
119
+ return {
120
+ access: createStopneticAccessMiddleware(options),
121
+ guard: createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` }),
122
+ };
123
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,119 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
2
+ import { resolve, relative, dirname, isAbsolute } from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { parseEnv } from 'node:util';
5
+ import { createStopneticAccessHandler, optionsFromEnv } from './server.js';
6
+
7
+ export class SetupError extends Error {}
8
+ const digest = value => createHash('sha256').update(value).digest('hex');
9
+ const manifestName = 'stopnetic.setup.json';
10
+ function destination(root, name) {
11
+ const file = resolve(root, name);
12
+ const inside = relative(root, file);
13
+ if (!inside || inside.startsWith('..') || isAbsolute(inside) || /[\x00-\x1f]/.test(name)) throw new SetupError('Setup paths must stay inside the project.');
14
+ for (let cursor = file; cursor !== root; cursor = dirname(cursor)) {
15
+ if (lstatSync(cursor, { throwIfNoEntry: false })?.isSymbolicLink()) throw new SetupError('Setup refuses symbolic links.');
16
+ }
17
+ return file;
18
+ }
19
+ function text(file) { return existsSync(file) ? readFileSync(file, 'utf8') : ''; }
20
+ function environment(root) {
21
+ try {
22
+ return { ...parseEnv(text(destination(root, '.env'))), ...parseEnv(text(destination(root, '.env.local'))), ...process.env };
23
+ } catch { throw new SetupError('Check server environment file formatting.'); }
24
+ }
25
+ function project(root) {
26
+ try { return JSON.parse(readFileSync(destination(root, 'package.json'), 'utf8')); }
27
+ catch { throw new SetupError('Run setup from a project with a valid package.json.'); }
28
+ }
29
+ function platformFor(root, supplied) {
30
+ if (supplied && !['netlify', 'vercel', 'node'].includes(supplied)) throw new SetupError('Choose netlify, vercel or node.');
31
+ if (supplied) return supplied;
32
+ const netlify = existsSync(destination(root, 'netlify.toml'));
33
+ const vercel = existsSync(destination(root, 'vercel.json'));
34
+ if (netlify && !vercel) return 'netlify';
35
+ if (vercel && !netlify) return 'vercel';
36
+ throw new SetupError('Choose your hosting explicitly: --platform netlify, vercel or node.');
37
+ }
38
+ const legacyGuideDigest = 'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d';
39
+ const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
40
+
41
+ function filesFor(root, platform) {
42
+ const files = {
43
+ 'stopnetic-client-guide.md': clientGuide,
44
+ '.env.stopnetic.example': `# SERVER settings; copy actual values from Stopnetic Integration.\nSTOPNETIC_WEBSITE_ORIGIN=https://www.example.com\nSTOPNETIC_SITE_ID=\nSTOPNETIC_API_URL=https://your-stopnetic-service/api/integration/decision\nSTOPNETIC_API_TOKEN=\nSTOPNETIC_DECISION_PUBLIC_KEY=\nSTOPNETIC_COOKIE_SECRET=\n`,
45
+ };
46
+ if (platform === 'netlify') {
47
+ const toml = text(destination(root, 'netlify.toml'));
48
+ const build = toml.match(/\[build\]([^]*?)(?=\n\[|$)/)?.[1] || '';
49
+ if (/^\s*base\s*=/m.test(build)) throw new SetupError('Run from the Netlify base directory and review custom base configuration before using setup.');
50
+ const section = toml.match(/\[functions\]([^]*?)(?=\n\[|$)/)?.[1] || '';
51
+ const match = section.match(/^\s*directory\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);
52
+ if (/^\s*directory\s*=/m.test(section) && !match) throw new SetupError('Review your custom Netlify functions directory.');
53
+ const directory = match?.[1] || 'netlify/functions';
54
+ destination(root, directory);
55
+ files[`${directory}/stopnetic.mjs`] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default request => (access ??= createStopneticAccessHandler())(request);\nexport const config = { path: ['/api/stopnetic/access', '/api/stopnetic/verify'], preferStatic: false };\n`;
56
+ } else if (platform === 'vercel') {
57
+ files['api/stopnetic/[operation].mjs'] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default { fetch: request => (access ??= createStopneticAccessHandler())(request) };\n`;
58
+ } else {
59
+ files['stopnetic.server.mjs'] = `import { createStopneticServer } from '@stopnetic/js/server';\nexport function installStopnetic(app) {\n const { access, guard } = createStopneticServer();\n app.use(access);\n app.use(guard);\n}\n`;
60
+ }
61
+ return files;
62
+ }
63
+
64
+ export function setup(input, { platform: supplied, dryRun = false } = {}) {
65
+ const root = realpathSync(resolve(input));
66
+ const pkg = project(root);
67
+ if (pkg.dependencies?.next || pkg.devDependencies?.next) throw new SetupError('Use @stopnetic/next for a Next.js project.');
68
+ const platform = platformFor(root, supplied);
69
+ const files = filesFor(root, platform);
70
+ const manifestPath = destination(root, manifestName);
71
+ let manifest;
72
+ if (existsSync(manifestPath)) {
73
+ try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
74
+ if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
75
+ }
76
+ const upgraded = new Set();
77
+ // Validate every destination before making any change. Only an unchanged
78
+ // older generated guide can be replaced; customer modifications are refused.
79
+ for (const [name, content] of Object.entries(files)) {
80
+ const file = destination(root, name);
81
+ if (existsSync(file) && text(file) !== content) {
82
+ if (name === 'stopnetic-client-guide.md' && manifest?.files?.[name] === legacyGuideDigest
83
+ && digest(text(file)) === legacyGuideDigest) upgraded.add(name);
84
+ else throw new SetupError(`Existing file needs manual merging: ${name}`);
85
+ }
86
+ }
87
+ const local = destination(root, '.env.local');
88
+ const ignore = destination(root, '.gitignore');
89
+ const env = environment(root);
90
+ if (dryRun) return `Dry run: ${platform}; would prepare ${Object.keys(files).join(', ')} and private cookie configuration. No files changed.`;
91
+ for (const [name, content] of Object.entries(files)) {
92
+ const file = destination(root, name);
93
+ mkdirSync(dirname(file), { recursive: true });
94
+ if (upgraded.has(name)) writeFileSync(file, content);
95
+ else if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
96
+ }
97
+ if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
98
+ if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
99
+ writeFileSync(manifestPath, JSON.stringify({ v: 1, platform, files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, digest(content)])) }, null, 2) + '\n');
100
+ return `Prepared ${platform} setup. Follow stopnetic-client-guide.md, configure server env, wrap protected APIs, build and deploy. Then run stopnetic-js check. No service requests or trial activation occurred.`;
101
+ }
102
+
103
+ export function checkSetup(input, { localHttp = false } = {}) {
104
+ const root = realpathSync(resolve(input));
105
+ let manifest;
106
+ try { manifest = JSON.parse(text(destination(root, manifestName))); } catch { throw new SetupError('Run stopnetic-js init first.'); }
107
+ if (manifest.v !== 1 || !['netlify', 'vercel', 'node'].includes(manifest.platform)) throw new SetupError('Invalid setup manifest.');
108
+ const expected = filesFor(root, manifest.platform);
109
+ for (const [name, content] of Object.entries(expected)) {
110
+ if (manifest.files?.[name] !== digest(content) || text(destination(root, name)) !== content) throw new SetupError(`Generated file changed or missing: ${name}`);
111
+ }
112
+ try {
113
+ const options = optionsFromEnv(environment(root));
114
+ if (!localHttp && !options.origin?.startsWith('https:')) throw new Error();
115
+ if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
116
+ createStopneticAccessHandler(options);
117
+ } catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
118
+ return `Checked ${manifest.platform} generated files and server configuration offline. Confirm the browser boundary, API wrappers and hosting routing with a deployed test. This does not prove coverage or start a trial.`;
119
+ }