@stopnetic/js 0.0.1-pilot → 0.0.2-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 +21 -21
- package/README.md +322 -199
- package/bin/stopnetic-js.js +34 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +21 -0
- package/dist/guard.d.ts +23 -21
- package/dist/guard.js +213 -200
- package/dist/index.d.ts +15 -15
- package/dist/index.js +172 -172
- package/dist/react.d.ts +4 -0
- package/dist/react.js +24 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +107 -0
- package/dist/setup.js +165 -0
- package/dist/signing-keys.js +37 -26
- package/package.json +54 -47
package/dist/setup.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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 clientGuide = `# Stopnetic client/server setup
|
|
39
|
+
|
|
40
|
+
Copy .env.stopnetic.example settings to the hosting platform's SERVER environment.
|
|
41
|
+
Use Node.js 22+. Never prefix private settings with VITE_ or REACT_APP_.
|
|
42
|
+
Use the same cookie secret locally and in hosting; do not commit .env.local.
|
|
43
|
+
|
|
44
|
+
## React Router (inside BrowserRouter)
|
|
45
|
+
|
|
46
|
+
import { useLocation } from 'react-router-dom';
|
|
47
|
+
import { StopneticBoundary } from '@stopnetic/js/react';
|
|
48
|
+
|
|
49
|
+
function RouteGuard({ children }) {
|
|
50
|
+
const location = useLocation();
|
|
51
|
+
return <StopneticBoundary path={location.pathname + location.search}>
|
|
52
|
+
{children}
|
|
53
|
+
</StopneticBoundary>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Wrap your Routes with RouteGuard. Other frontends can await
|
|
57
|
+
checkAccess(path) from @stopnetic/js/browser before rendering; when reason is
|
|
58
|
+
verification_required, navigate to result.verificationUrl. Handle denial/outages.
|
|
59
|
+
|
|
60
|
+
## API protection
|
|
61
|
+
|
|
62
|
+
EACH protected Netlify/Vercel Fetch-style API must use:
|
|
63
|
+
import { withStopnetic } from '@stopnetic/js/server';
|
|
64
|
+
const protectedHandler = withStopnetic(async (request, context) => {
|
|
65
|
+
// Enforce your application's login/data permissions here too.
|
|
66
|
+
return Response.json({ example: true });
|
|
67
|
+
});
|
|
68
|
+
// Netlify: export default protectedHandler;
|
|
69
|
+
// export const config = { path: '/api/private', preferStatic: false };
|
|
70
|
+
// Vercel: export default { fetch: protectedHandler };
|
|
71
|
+
|
|
72
|
+
Node/Express: call installStopnetic(app) from ./stopnetic.server.mjs BEFORE
|
|
73
|
+
parsers, static files and routes. Only expose that guarded server to visitors.
|
|
74
|
+
|
|
75
|
+
Add a Page / Automatic browser flow rule and API / Require verified session rules
|
|
76
|
+
in Stopnetic Policies. The generated setup leaves unmatched routes public.
|
|
77
|
+
Netlify's default /.netlify/functions/ aliases are rejected by the wrapper; use
|
|
78
|
+
custom config.path endpoints in React and Policies. Fetch protected data with
|
|
79
|
+
credentials: 'same-origin'. API requests cannot start
|
|
80
|
+
the browser verification flow. All control endpoints must reach their function
|
|
81
|
+
before a SPA catch-all; verify redirects and custom function directories.
|
|
82
|
+
|
|
83
|
+
Deploy once, then change rules in the dashboard. Rules apply to subsequent
|
|
84
|
+
guarded requests; loaded content is not recalled. Wrap new API handlers too.
|
|
85
|
+
For local Netlify/Vercel testing, use their full local development server
|
|
86
|
+
(netlify dev / vercel dev), not Vite alone: the server functions must run too.
|
|
87
|
+
For Node, load .env.local with node --env-file=.env.local your-server.mjs,
|
|
88
|
+
or your existing server environment loader.
|
|
89
|
+
Static HTML/JS and content bundled into the frontend remain public. A UI gate
|
|
90
|
+
does not secure that content. Put private data in guarded server endpoints.
|
|
91
|
+
No Edge/Deno/Cloudflare Workers adapter is supplied. Next.js uses @stopnetic/next.
|
|
92
|
+
Test real hosting, custom domains, cookies, caches, aliases and API enforcement.
|
|
93
|
+
`;
|
|
94
|
+
function filesFor(root, platform) {
|
|
95
|
+
const files = {
|
|
96
|
+
'stopnetic-client-guide.md': clientGuide,
|
|
97
|
+
'.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`,
|
|
98
|
+
};
|
|
99
|
+
if (platform === 'netlify') {
|
|
100
|
+
const toml = text(destination(root, 'netlify.toml'));
|
|
101
|
+
const build = toml.match(/\[build\]([^]*?)(?=\n\[|$)/)?.[1] || '';
|
|
102
|
+
if (/^\s*base\s*=/m.test(build)) throw new SetupError('Run from the Netlify base directory and review custom base configuration before using setup.');
|
|
103
|
+
const section = toml.match(/\[functions\]([^]*?)(?=\n\[|$)/)?.[1] || '';
|
|
104
|
+
const match = section.match(/^\s*directory\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);
|
|
105
|
+
if (/^\s*directory\s*=/m.test(section) && !match) throw new SetupError('Review your custom Netlify functions directory.');
|
|
106
|
+
const directory = match?.[1] || 'netlify/functions';
|
|
107
|
+
destination(root, directory);
|
|
108
|
+
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`;
|
|
109
|
+
} else if (platform === 'vercel') {
|
|
110
|
+
files['api/stopnetic/[operation].mjs'] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default { fetch: request => (access ??= createStopneticAccessHandler())(request) };\n`;
|
|
111
|
+
} else {
|
|
112
|
+
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`;
|
|
113
|
+
}
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function setup(input, { platform: supplied, dryRun = false } = {}) {
|
|
118
|
+
const root = realpathSync(resolve(input));
|
|
119
|
+
const pkg = project(root);
|
|
120
|
+
if (pkg.dependencies?.next || pkg.devDependencies?.next) throw new SetupError('Use @stopnetic/next for a Next.js project.');
|
|
121
|
+
const platform = platformFor(root, supplied);
|
|
122
|
+
const files = filesFor(root, platform);
|
|
123
|
+
const manifestPath = destination(root, manifestName);
|
|
124
|
+
if (existsSync(manifestPath)) {
|
|
125
|
+
let manifest;
|
|
126
|
+
try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
|
|
127
|
+
if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
|
|
128
|
+
}
|
|
129
|
+
// Validate every destination before making any change. No customer file is replaced.
|
|
130
|
+
for (const [name, content] of Object.entries(files)) {
|
|
131
|
+
const file = destination(root, name);
|
|
132
|
+
if (existsSync(file) && text(file) !== content) throw new SetupError(`Existing file needs manual merging: ${name}`);
|
|
133
|
+
}
|
|
134
|
+
const local = destination(root, '.env.local');
|
|
135
|
+
const ignore = destination(root, '.gitignore');
|
|
136
|
+
const env = environment(root);
|
|
137
|
+
if (dryRun) return `Dry run: ${platform}; would prepare ${Object.keys(files).join(', ')} and private cookie configuration. No files changed.`;
|
|
138
|
+
for (const [name, content] of Object.entries(files)) {
|
|
139
|
+
const file = destination(root, name);
|
|
140
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
141
|
+
if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
|
|
142
|
+
}
|
|
143
|
+
if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
|
|
144
|
+
if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
|
|
145
|
+
writeFileSync(manifestPath, JSON.stringify({ v: 1, platform, files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, digest(content)])) }, null, 2) + '\n');
|
|
146
|
+
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.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function checkSetup(input, { localHttp = false } = {}) {
|
|
150
|
+
const root = realpathSync(resolve(input));
|
|
151
|
+
let manifest;
|
|
152
|
+
try { manifest = JSON.parse(text(destination(root, manifestName))); } catch { throw new SetupError('Run stopnetic-js init first.'); }
|
|
153
|
+
if (manifest.v !== 1 || !['netlify', 'vercel', 'node'].includes(manifest.platform)) throw new SetupError('Invalid setup manifest.');
|
|
154
|
+
const expected = filesFor(root, manifest.platform);
|
|
155
|
+
for (const [name, content] of Object.entries(expected)) {
|
|
156
|
+
if (manifest.files?.[name] !== digest(content) || text(destination(root, name)) !== content) throw new SetupError(`Generated file changed or missing: ${name}`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const options = optionsFromEnv(environment(root));
|
|
160
|
+
if (!localHttp && !options.origin?.startsWith('https:')) throw new Error();
|
|
161
|
+
if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
|
|
162
|
+
createStopneticAccessHandler(options);
|
|
163
|
+
} catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
|
|
164
|
+
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.`;
|
|
165
|
+
}
|
package/dist/signing-keys.js
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
|
-
import { createPublicKey } from 'node:crypto';
|
|
2
|
-
|
|
1
|
+
import { createPublicKey } from 'node:crypto';
|
|
2
|
+
|
|
3
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)) {
|
|
4
|
+
if (typeof value !== 'string' || value.length > 2048) {
|
|
6
5
|
throw new Error('Signing verification keys must be public PEMs');
|
|
7
6
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
7
|
+
// Hosting value fields may receive the quoted/escaped dotenv representation.
|
|
8
|
+
// Normalize only formatting; retain bounded, public-only Ed25519 trust checks.
|
|
9
|
+
let pem = value.trim();
|
|
10
|
+
try { if (pem.startsWith('"')) pem = JSON.parse(pem); }
|
|
11
|
+
catch { throw new Error('Signing verification keys must be public PEMs'); }
|
|
12
|
+
if (typeof pem !== 'string') throw new Error('Signing verification keys must be public PEMs');
|
|
13
|
+
pem = pem.replaceAll('\\r\\n', '\n').replaceAll('\\n', '\n');
|
|
14
|
+
if (!/^-----BEGIN PUBLIC KEY-----\r?\n[A-Za-z0-9+/=\r\n]+-----END PUBLIC KEY-----\s*$/.test(pem)) {
|
|
15
|
+
throw new Error('Signing verification keys must be public PEMs');
|
|
16
|
+
}
|
|
17
|
+
let key;
|
|
18
|
+
try { key = createPublicKey(pem); }
|
|
19
|
+
catch { throw new Error('Signing verification keys must be public PEMs'); }
|
|
20
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new Error('Signing verification keys must be Ed25519');
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function trustedSigningKeys(primary, additional) {
|
|
25
|
+
if (!Array.isArray(additional) || additional.length > 2) throw new Error('Use at most two additional signing public keys');
|
|
26
|
+
const keys = [primary, ...additional].map(publicKey);
|
|
27
|
+
return [...new Map(keys.map(key => [key.export({ type: 'spki', format: 'der' }).toString('hex'), key])).values()];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse a server environment JSON array without exposing malformed values. */
|
|
31
|
+
export function parseAdditionalDecisionPublicKeys(value) {
|
|
32
|
+
if (value === undefined || value === '') return [];
|
|
33
|
+
try {
|
|
34
|
+
if (typeof value !== 'string' || value.length > 8192) throw new Error();
|
|
35
|
+
const keys = JSON.parse(value);
|
|
36
|
+
if (!Array.isArray(keys) || keys.length > 2) throw new Error();
|
|
37
|
+
keys.forEach(publicKey);
|
|
38
|
+
return keys;
|
|
39
|
+
} catch { throw new Error('Invalid additional signing public keys'); }
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,47 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@stopnetic/js",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@stopnetic/js",
|
|
3
|
+
"version": "0.0.2-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
|
+
"bin"
|
|
23
|
+
],
|
|
24
|
+
"bin": { "stopnetic-js": "./bin/stopnetic-js.js" },
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" },
|
|
31
|
+
"./browser": { "types": "./dist/browser.d.ts", "default": "./dist/browser.js" },
|
|
32
|
+
"./react": { "types": "./dist/react.d.ts", "default": "./dist/react.js" }
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": { "react": "*" },
|
|
35
|
+
"peerDependenciesMeta": { "react": { "optional": true } },
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"tag": "pilot",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "node scripts/build.mjs",
|
|
46
|
+
"prepack": "npm run build",
|
|
47
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
48
|
+
"test:package": "npm run build && node tests/package-check.mjs"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"express": "^4.22.3",
|
|
52
|
+
"express5": "npm:express@^5.2.1"
|
|
53
|
+
}
|
|
54
|
+
}
|