@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.
Files changed (43) hide show
  1. package/README.md +75 -48
  2. package/dist/client-guide.md +6 -3
  3. package/dist/guard.d.ts +7 -0
  4. package/dist/guard.js +44 -20
  5. package/dist/index.js +7 -0
  6. package/dist/server.d.ts +17 -17
  7. package/dist/server.js +128 -123
  8. package/dist/setup.js +126 -125
  9. package/dist/traffic-context.js +26 -0
  10. package/examples/frontend/angular/app/app.config.ts +8 -8
  11. package/examples/frontend/angular/app/app.routes.ts +20 -20
  12. package/examples/frontend/angular/app/app.ts +21 -21
  13. package/examples/frontend/angular/app/members.page.ts +21 -21
  14. package/examples/frontend/angular/app/stopnetic-access.service.ts +27 -27
  15. package/examples/frontend/angular/main.ts +4 -4
  16. package/examples/frontend/load-private-data.d.ts +1 -1
  17. package/examples/frontend/load-private-data.js +9 -9
  18. package/examples/frontend/react/App.tsx +48 -48
  19. package/examples/frontend/react/main.tsx +3 -3
  20. package/examples/frontend/solid/App.tsx +54 -54
  21. package/examples/frontend/solid/main.tsx +3 -3
  22. package/examples/frontend/svelte/App.svelte +56 -56
  23. package/examples/frontend/svelte/main.js +4 -4
  24. package/examples/frontend/vanilla/main.js +46 -46
  25. package/examples/frontend/vue/App.vue +17 -17
  26. package/examples/frontend/vue/Members.vue +14 -14
  27. package/examples/frontend/vue/main.js +4 -4
  28. package/examples/frontend/vue/router.js +40 -40
  29. package/examples/ssr/angular/app.routes.server.ts +5 -5
  30. package/examples/ssr/angular/server.ts +12 -12
  31. package/examples/ssr/astro/middleware.js +11 -11
  32. package/examples/ssr/astro/page.astro +4 -4
  33. package/examples/ssr/node/fetch.mjs +9 -9
  34. package/examples/ssr/node/install.mjs +9 -9
  35. package/examples/ssr/nuxt/middleware.mjs +10 -10
  36. package/examples/ssr/react/install.mjs +10 -10
  37. package/examples/ssr/react-router/install.mjs +22 -22
  38. package/examples/ssr/solidstart/middleware-v1.js +19 -19
  39. package/examples/ssr/solidstart/middleware-v2.js +25 -25
  40. package/examples/ssr/sveltekit/hooks.server.js +16 -16
  41. package/examples/ssr/sveltekit/page.server.js +3 -3
  42. package/examples/ssr/vue/install.mjs +10 -10
  43. package/package.json +35 -9
package/dist/server.js CHANGED
@@ -1,123 +1,128 @@
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
- }
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
+ // The shared parser decodes quotes BEFORE escaped newlines. Decoding here
13
+ // would turn a valid JSON-quoted PEM into invalid JSON with raw newlines.
14
+ decisionPublicKey: env.STOPNETIC_DECISION_PUBLIC_KEY,
15
+ additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
16
+ cookieSecret: env.STOPNETIC_COOKIE_SECRET,
17
+ guardMode: 'site',
18
+ trafficInsights: env.STOPNETIC_TRAFFIC_INSIGHTS === 'true',
19
+ countryBlocking: env.STOPNETIC_COUNTRY_BLOCKING === 'true',
20
+ countrySource: env.STOPNETIC_COUNTRY_SOURCE || 'none',
21
+ verifyPath: `${accessBase}/verify`,
22
+ };
23
+ }
24
+
25
+ const empty = status => new Response(null, { status, headers: { 'cache-control': 'private, no-store' } });
26
+ function checkedPath(path, origin) {
27
+ if (typeof path !== 'string' || path.length > 2048 || !path.startsWith('/') || path.startsWith('//')
28
+ || /[\\\s#]/.test(path)) throw new Error('Invalid page path');
29
+ const url = new URL(path, origin);
30
+ if (url.origin !== origin || url.pathname + url.search !== path
31
+ || url.pathname === accessBase || url.pathname.startsWith(`${accessBase}/`)) throw new Error('Invalid page path');
32
+ return url;
33
+ }
34
+
35
+ /** Same-origin UI checks. These never authorize a separate data handler. */
36
+ export function createStopneticAccessHandler(options = optionsFromEnv()) {
37
+ const origin = new URL(options.origin).origin;
38
+ const verifyPath = `${accessBase}/verify`;
39
+ const protect = createStopneticHandler({ ...options, verifyPath });
40
+ return async function access(request) {
41
+ const url = new URL(request.url);
42
+ if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return empty(404);
43
+ if (url.pathname === verifyPath) return protect(request, () => empty(404));
44
+ if (url.pathname !== `${accessBase}/access`) return empty(404);
45
+ if (request.method !== 'GET') return new Response(null, { status: 405, headers: { allow: 'GET', 'cache-control': 'private, no-store' } });
46
+ let target;
47
+ try { target = checkedPath(url.searchParams.get('path'), origin); } catch { return empty(400); }
48
+ if (url.searchParams.getAll('path').length !== 1 || url.searchParams.getAll('mode').length > 1
49
+ || [...url.searchParams.keys()].some(key => !['path', 'mode'].includes(key))
50
+ || (url.searchParams.has('mode') && url.searchParams.get('mode') !== 'check')) return empty(400);
51
+ // Navigation checks use the real page path and cookie, not the control URL.
52
+ const headers = new Headers(request.headers);
53
+ headers.set('accept', 'text/html');
54
+ const check = url.searchParams.get('mode') === 'check';
55
+ const response = await protect(new Request(target, { headers }), () => check
56
+ ? Response.json({ allowed: true })
57
+ : new Response(null, { status: 303, headers: { location: target.pathname + target.search } }));
58
+ if (!check || response.ok) return response;
59
+ const challenge = response.status === 404 && response.headers.get('content-type')?.startsWith('text/html');
60
+ const resultHeaders = new Headers({ 'cache-control': 'private, no-store' });
61
+ for (const cookie of response.headers.getSetCookie()) resultHeaders.append('set-cookie', cookie);
62
+ await response.body?.cancel();
63
+ return Response.json({ allowed: false, reason: challenge ? 'verification_required' : response.status === 404 ? 'blocked' : 'unavailable' },
64
+ { status: response.status, headers: resultHeaders });
65
+ };
66
+ }
67
+
68
+ /** Wrap EACH protected Fetch-style API; the UI's allowed flag is never trusted. */
69
+ export function withStopnetic(handler, options = optionsFromEnv()) {
70
+ const protect = createStopneticHandler(options);
71
+ return (request, ...context) => {
72
+ // Netlify's default function alias must not bypass a custom API path's rule.
73
+ // Declare a custom config.path for these functions and use it in Policies.
74
+ if (new URL(request.url).pathname.startsWith('/.netlify/functions/')) return Promise.resolve(empty(404));
75
+ return protect(request, checked => handler(checked, ...context));
76
+ };
77
+ }
78
+
79
+ /** Guard a complete Fetch-style application before its renderer or API runs.
80
+ * The application must run in Node.js; accepting Request/Response alone does
81
+ * not make an Edge runtime compatible. Context arguments are preserved.
82
+ */
83
+ export function createStopneticAppHandler(handler, options = optionsFromEnv()) {
84
+ if (typeof handler !== 'function') throw new TypeError('An application handler is required');
85
+ const settings = { ...options, verifyPath: `${accessBase}/verify` };
86
+ const access = createStopneticAccessHandler(settings);
87
+ const application = withStopnetic(handler, settings);
88
+ return (request, ...context) => {
89
+ const path = new URL(request.url).pathname;
90
+ if (path === accessBase || path.startsWith(`${accessBase}/`)) return access(request);
91
+ return application(request, ...context);
92
+ };
93
+ }
94
+
95
+ /** Node/Express: mount first, then the root guard, then parsers/static/API handlers. */
96
+ export function createStopneticAccessMiddleware(options = optionsFromEnv()) {
97
+ const access = createStopneticAccessHandler(options);
98
+ const verification = createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` });
99
+ return async (req, res, next) => {
100
+ const path = (req.originalUrl || req.url || '').split('?')[0];
101
+ if (path !== `${accessBase}/access` && path !== `${accessBase}/verify`) return next();
102
+ if (path === `${accessBase}/verify`) return verification(req, res, next);
103
+ try {
104
+ const headers = new Headers();
105
+ for (const [name, value] of Object.entries(req.headers)) {
106
+ if (Array.isArray(value)) { res.writeHead(400, { 'cache-control': 'no-store' }); res.end(); return; }
107
+ if (value !== undefined) headers.set(name, value);
108
+ }
109
+ const request = new Request(new URL(req.originalUrl || req.url, options.origin), {
110
+ method: req.method, headers,
111
+ ...(['GET', 'HEAD'].includes(req.method) ? {} : { body: req, duplex: 'half' }),
112
+ });
113
+ const response = await access(request);
114
+ res.statusCode = response.status;
115
+ response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
116
+ const cookies = response.headers.getSetCookie();
117
+ if (cookies.length) res.setHeader('set-cookie', cookies);
118
+ res.end(Buffer.from(await response.arrayBuffer()));
119
+ } catch { res.writeHead(503, { 'cache-control': 'private, no-store' }); res.end(); }
120
+ };
121
+ }
122
+
123
+ export function createStopneticServer(options = optionsFromEnv()) {
124
+ return {
125
+ access: createStopneticAccessMiddleware(options),
126
+ guard: createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` }),
127
+ };
128
+ }
package/dist/setup.js CHANGED
@@ -1,125 +1,126 @@
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 legacyGuideDigests = new Set([
39
- 'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
40
- '55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
41
- '08f919257d8c85af4e56e842705825832ae2810ed68d5c1dfc8405c8f7be7610', // published 0.0.4
42
- '45bd696f196c3d1447a978987a787755b239f75e9e6a3697f4efbb210ce75597', // published 0.0.5
43
- ]);
44
- const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
45
-
46
- function filesFor(root, platform) {
47
- const files = {
48
- 'stopnetic-client-guide.md': clientGuide,
49
- '.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`,
50
- };
51
- if (platform === 'netlify') {
52
- const toml = text(destination(root, 'netlify.toml'));
53
- const build = toml.match(/\[build\]([^]*?)(?=\n\[|$)/)?.[1] || '';
54
- if (/^\s*base\s*=/m.test(build)) throw new SetupError('Run from the Netlify base directory and review custom base configuration before using setup.');
55
- const section = toml.match(/\[functions\]([^]*?)(?=\n\[|$)/)?.[1] || '';
56
- const match = section.match(/^\s*directory\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);
57
- if (/^\s*directory\s*=/m.test(section) && !match) throw new SetupError('Review your custom Netlify functions directory.');
58
- const directory = match?.[1] || 'netlify/functions';
59
- destination(root, directory);
60
- 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`;
61
- } else if (platform === 'vercel') {
62
- files['api/stopnetic/[operation].mjs'] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default { fetch: request => (access ??= createStopneticAccessHandler())(request) };\n`;
63
- } else {
64
- 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`;
65
- }
66
- return files;
67
- }
68
-
69
- export function setup(input, { platform: supplied, dryRun = false } = {}) {
70
- const root = realpathSync(resolve(input));
71
- const pkg = project(root);
72
- if (pkg.dependencies?.next || pkg.devDependencies?.next) throw new SetupError('Use @stopnetic/next for a Next.js project.');
73
- const platform = platformFor(root, supplied);
74
- const files = filesFor(root, platform);
75
- const manifestPath = destination(root, manifestName);
76
- let manifest;
77
- if (existsSync(manifestPath)) {
78
- try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
79
- if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
80
- }
81
- const upgraded = new Set();
82
- // Validate every destination before making any change. Only an unchanged
83
- // older generated guide can be replaced; customer modifications are refused.
84
- for (const [name, content] of Object.entries(files)) {
85
- const file = destination(root, name);
86
- if (existsSync(file) && text(file) !== content) {
87
- const recorded = manifest?.files?.[name];
88
- if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
89
- && digest(text(file)) === recorded) upgraded.add(name);
90
- else throw new SetupError(`Existing file needs manual merging: ${name}`);
91
- }
92
- }
93
- const local = destination(root, '.env.local');
94
- const ignore = destination(root, '.gitignore');
95
- const env = environment(root);
96
- if (dryRun) return `Dry run: ${platform}; would prepare ${Object.keys(files).join(', ')} and private cookie configuration. No files changed.`;
97
- for (const [name, content] of Object.entries(files)) {
98
- const file = destination(root, name);
99
- mkdirSync(dirname(file), { recursive: true });
100
- if (upgraded.has(name)) writeFileSync(file, content);
101
- else if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
102
- }
103
- if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
104
- if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
105
- writeFileSync(manifestPath, JSON.stringify({ v: 1, platform, files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, digest(content)])) }, null, 2) + '\n');
106
- 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.`;
107
- }
108
-
109
- export function checkSetup(input, { localHttp = false } = {}) {
110
- const root = realpathSync(resolve(input));
111
- let manifest;
112
- try { manifest = JSON.parse(text(destination(root, manifestName))); } catch { throw new SetupError('Run stopnetic-js init first.'); }
113
- if (manifest.v !== 1 || !['netlify', 'vercel', 'node'].includes(manifest.platform)) throw new SetupError('Invalid setup manifest.');
114
- const expected = filesFor(root, manifest.platform);
115
- for (const [name, content] of Object.entries(expected)) {
116
- if (manifest.files?.[name] !== digest(content) || text(destination(root, name)) !== content) throw new SetupError(`Generated file changed or missing: ${name}`);
117
- }
118
- try {
119
- const options = optionsFromEnv(environment(root));
120
- if (!localHttp && !options.origin?.startsWith('https:')) throw new Error();
121
- if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
122
- createStopneticAccessHandler(options);
123
- } catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
124
- return `Checked ${manifest.platform} generated files and server configuration offline. Confirm server page enforcement, your chosen navigation mode, API wrappers and hosting routing with a deployed test. Preserve existing SSR; browser gates are optional. This does not prove coverage or start a trial.`;
125
- }
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 legacyGuideDigests = new Set([
39
+ 'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
40
+ '55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
41
+ '08f919257d8c85af4e56e842705825832ae2810ed68d5c1dfc8405c8f7be7610', // published 0.0.4
42
+ '45bd696f196c3d1447a978987a787755b239f75e9e6a3697f4efbb210ce75597', // published 0.0.5
43
+ 'a5b2c1100ff704c2675feb6fecb50cf7371dda446b33d4655d43089bfba14a2e', // published 0.0.6
44
+ ]);
45
+ const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
46
+
47
+ function filesFor(root, platform) {
48
+ const files = {
49
+ 'stopnetic-client-guide.md': clientGuide,
50
+ '.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`,
51
+ };
52
+ if (platform === 'netlify') {
53
+ const toml = text(destination(root, 'netlify.toml'));
54
+ const build = toml.match(/\[build\]([^]*?)(?=\n\[|$)/)?.[1] || '';
55
+ if (/^\s*base\s*=/m.test(build)) throw new SetupError('Run from the Netlify base directory and review custom base configuration before using setup.');
56
+ const section = toml.match(/\[functions\]([^]*?)(?=\n\[|$)/)?.[1] || '';
57
+ const match = section.match(/^\s*directory\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);
58
+ if (/^\s*directory\s*=/m.test(section) && !match) throw new SetupError('Review your custom Netlify functions directory.');
59
+ const directory = match?.[1] || 'netlify/functions';
60
+ destination(root, directory);
61
+ 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`;
62
+ } else if (platform === 'vercel') {
63
+ files['api/stopnetic/[operation].mjs'] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default { fetch: request => (access ??= createStopneticAccessHandler())(request) };\n`;
64
+ } else {
65
+ 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`;
66
+ }
67
+ return files;
68
+ }
69
+
70
+ export function setup(input, { platform: supplied, dryRun = false } = {}) {
71
+ const root = realpathSync(resolve(input));
72
+ const pkg = project(root);
73
+ if (pkg.dependencies?.next || pkg.devDependencies?.next) throw new SetupError('Use @stopnetic/next for a Next.js project.');
74
+ const platform = platformFor(root, supplied);
75
+ const files = filesFor(root, platform);
76
+ const manifestPath = destination(root, manifestName);
77
+ let manifest;
78
+ if (existsSync(manifestPath)) {
79
+ try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
80
+ if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
81
+ }
82
+ const upgraded = new Set();
83
+ // Validate every destination before making any change. Only an unchanged
84
+ // older generated guide can be replaced; customer modifications are refused.
85
+ for (const [name, content] of Object.entries(files)) {
86
+ const file = destination(root, name);
87
+ if (existsSync(file) && text(file) !== content) {
88
+ const recorded = manifest?.files?.[name];
89
+ if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
90
+ && digest(text(file)) === recorded) upgraded.add(name);
91
+ else throw new SetupError(`Existing file needs manual merging: ${name}`);
92
+ }
93
+ }
94
+ const local = destination(root, '.env.local');
95
+ const ignore = destination(root, '.gitignore');
96
+ const env = environment(root);
97
+ if (dryRun) return `Dry run: ${platform}; would prepare ${Object.keys(files).join(', ')} and private cookie configuration. No files changed.`;
98
+ for (const [name, content] of Object.entries(files)) {
99
+ const file = destination(root, name);
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ if (upgraded.has(name)) writeFileSync(file, content);
102
+ else if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
103
+ }
104
+ if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
105
+ if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
106
+ writeFileSync(manifestPath, JSON.stringify({ v: 1, platform, files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, digest(content)])) }, null, 2) + '\n');
107
+ 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.`;
108
+ }
109
+
110
+ export function checkSetup(input, { localHttp = false } = {}) {
111
+ const root = realpathSync(resolve(input));
112
+ let manifest;
113
+ try { manifest = JSON.parse(text(destination(root, manifestName))); } catch { throw new SetupError('Run stopnetic-js init first.'); }
114
+ if (manifest.v !== 1 || !['netlify', 'vercel', 'node'].includes(manifest.platform)) throw new SetupError('Invalid setup manifest.');
115
+ const expected = filesFor(root, manifest.platform);
116
+ for (const [name, content] of Object.entries(expected)) {
117
+ if (manifest.files?.[name] !== digest(content) || text(destination(root, name)) !== content) throw new SetupError(`Generated file changed or missing: ${name}`);
118
+ }
119
+ try {
120
+ const options = optionsFromEnv(environment(root));
121
+ if (!localHttp && !options.origin?.startsWith('https:')) throw new Error();
122
+ if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
123
+ createStopneticAccessHandler(options);
124
+ } catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
125
+ return `Checked ${manifest.platform} generated files and server configuration offline. Confirm server page enforcement, your chosen navigation mode, API wrappers and hosting routing with a deployed test. Preserve existing SSR; browser gates are optional. This does not prove coverage or start a trial.`;
126
+ }
@@ -0,0 +1,26 @@
1
+ // Only broad categories leave the customer's server. No IP or raw user-agent.
2
+ export const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
3
+ const countries = new Set(countryCodes);
4
+
5
+ function deviceCategory(value) {
6
+ if (!value || value.length > 1024 || /bot|crawler|spider|curl|wget|python|headless/i.test(value)) return 'unknown';
7
+ if (/ipad|tablet|kindle|silk\/|playbook/i.test(value) || (/android/i.test(value) && !/mobile/i.test(value))) return 'tablet';
8
+ if (/mobi|iphone|ipod|windows phone/i.test(value)) return 'mobile';
9
+ if (/mozilla\//i.test(value) && /windows nt|macintosh|x11|cros/i.test(value)) return 'desktop';
10
+ return 'unknown';
11
+ }
12
+
13
+ export function trafficContext(request, enabled, source) {
14
+ if (!enabled) return undefined;
15
+ try {
16
+ // Selecting a source is an explicit deployment trust decision. Never auto-detect
17
+ // providers from visitor-supplied headers. Origins behind Cloudflare must reject
18
+ // direct traffic or strip/rewrite these headers at their trusted ingress.
19
+ const value = source === 'vercel' ? request.headers.get('x-vercel-ip-country')
20
+ : source === 'cloudflare' ? request.headers.get('cf-ipcountry') : null;
21
+ const country = typeof value === 'string' && value.length === 2 && countries.has(value.toUpperCase()) ? value.toUpperCase() : null;
22
+ return { country, device: deviceCategory(request.headers.get('user-agent')), country_source: country ? source : 'unknown' };
23
+ } catch {
24
+ return { country: null, device: 'unknown', country_source: 'unknown' };
25
+ }
26
+ }
@@ -1,8 +1,8 @@
1
- import { ApplicationConfig } from '@angular/core';
2
- import { provideHttpClient } from '@angular/common/http';
3
- import { provideRouter } from '@angular/router';
4
- import { routes } from './app.routes';
5
- export const appConfig: ApplicationConfig = {
6
- providers: [provideRouter(routes), provideHttpClient()],
7
- };
8
- // Merge these providers into your existing appConfig; retain its other settings.
1
+ import { ApplicationConfig } from '@angular/core';
2
+ import { provideHttpClient } from '@angular/common/http';
3
+ import { provideRouter } from '@angular/router';
4
+ import { routes } from './app.routes';
5
+ export const appConfig: ApplicationConfig = {
6
+ providers: [provideRouter(routes), provideHttpClient()],
7
+ };
8
+ // Merge these providers into your existing appConfig; retain its other settings.