@stopnetic/js 0.0.0-stage → 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 -0
- package/README.md +321 -2
- 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 -0
- package/dist/guard.js +216 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +172 -0
- 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 +40 -0
- package/package.json +54 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stopnetic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,322 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stopnetic/js — JavaScript website guard
|
|
2
|
+
|
|
3
|
+
Stopnetic checks access **on your server before sending pages, files or API data**.
|
|
4
|
+
Your frontend can be Angular, Vue, React, Svelte, plain JavaScript, or an older
|
|
5
|
+
frontend build. No React/Vue/Angular dependency or version upgrade is required.
|
|
6
|
+
The hosting server must run Node.js 22 or newer. This is an MIT-licensed pilot SDK;
|
|
7
|
+
hosted production/provider validation is still required before paid rollout.
|
|
8
|
+
|
|
9
|
+
Version **0.0.1-pilot is published** on npm. This repository prepares
|
|
10
|
+
**0.0.2-pilot**, which adds a reusable browser/server setup for Netlify, Vercel
|
|
11
|
+
and Node/Express, a browser access check and optional React boundary. It also
|
|
12
|
+
normalizes public PEM formatting and rejects placeholder or reused installation
|
|
13
|
+
keys as cookie secrets. The new version is not published yet. Install the
|
|
14
|
+
currently published manual server pilot with:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install @stopnetic/js@pilot
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For local testing, build and pack from this repository:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
cd integrations/javascript
|
|
24
|
+
npm run build
|
|
25
|
+
npm pack
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install the resulting `stopnetic-js-0.0.2-pilot.tgz` into the website project using
|
|
29
|
+
`npm install <absolute-path-to-tarball>`. The installed package has no Next.js or
|
|
30
|
+
other runtime dependency. The build packages the same signed decision implementation
|
|
31
|
+
as the Next.js adapter. Keep `@stopnetic/next` for its Next.js 16 guided installer.
|
|
32
|
+
|
|
33
|
+
## Reusable client/server setup (requires 0.0.2-pilot)
|
|
34
|
+
|
|
35
|
+
Use the local tarball above until this version is published. Installing the current
|
|
36
|
+
registry pilot (0.0.1) does not provide these new commands or subpath exports.
|
|
37
|
+
|
|
38
|
+
Run setup at the app's package.json, choose hosting, and preview before writing:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm exec --no -- stopnetic-js init --platform netlify --dry-run
|
|
42
|
+
npm exec --no -- stopnetic-js init --platform netlify
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use `--platform vercel` or `--platform node` for the other adapters. Netlify/Vercel
|
|
46
|
+
configuration files can select a platform automatically; ambiguous projects need
|
|
47
|
+
explicit selection. Next.js projects use `@stopnetic/next`. This SDK needs a
|
|
48
|
+
Node.js 22+ runtime; it does not run as a Deno/Workers/Edge function.
|
|
49
|
+
|
|
50
|
+
Setup prepares a server control endpoint, `.env.stopnetic.example`, an ignored
|
|
51
|
+
`.env.local` cookie secret, a setup manifest and `stopnetic-client-guide.md`.
|
|
52
|
+
It does not install dependencies, edit your React app, overwrite existing files,
|
|
53
|
+
contact providers or deploy. Changed generated files require manual merging.
|
|
54
|
+
Custom Netlify base directories require review; custom functions directories are
|
|
55
|
+
preserved. Vercel configuration is preserved. Put runtime settings in the hosting
|
|
56
|
+
provider's server/Functions environment; plain `netlify.toml` build env is not a
|
|
57
|
+
Functions secret store. Copy the generated cookie secret without committing it.
|
|
58
|
+
|
|
59
|
+
Wire browser route checks and EACH protected API following the generated guide.
|
|
60
|
+
Run `npm exec --no -- stopnetic-js check` after setting credentials (`--local-http`
|
|
61
|
+
for a loopback pilot). This check validates configuration and generated files
|
|
62
|
+
offline; it cannot prove deployment routing, API coverage or browser integration.
|
|
63
|
+
Deploy once, then dashboard changes apply on subsequent guarded requests.
|
|
64
|
+
|
|
65
|
+
Test locally with `netlify dev` or `vercel dev`, which must run both the frontend
|
|
66
|
+
and server functions; Vite alone does not provide the generated server endpoints.
|
|
67
|
+
For Node, use `node --env-file=.env.local your-server.mjs` or your existing
|
|
68
|
+
server environment loader. Production credentials belong in runtime settings.
|
|
69
|
+
|
|
70
|
+
### React Router
|
|
71
|
+
|
|
72
|
+
The optional React entry uses Hooks. The core/browser modules need no React.
|
|
73
|
+
Place this component inside BrowserRouter, wrapping your Routes:
|
|
74
|
+
|
|
75
|
+
```jsx
|
|
76
|
+
import { useLocation } from 'react-router-dom';
|
|
77
|
+
import { StopneticBoundary } from '@stopnetic/js/react';
|
|
78
|
+
|
|
79
|
+
function RouteGuard({ children }) {
|
|
80
|
+
const location = useLocation();
|
|
81
|
+
return <StopneticBoundary path={location.pathname + location.search}>
|
|
82
|
+
{children}
|
|
83
|
+
</StopneticBoundary>;
|
|
84
|
+
}
|
|
85
|
+
// <BrowserRouter><RouteGuard><Routes>...</Routes></RouteGuard></BrowserRouter>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The boundary hides children while checking, ignores canceled/stale checks and
|
|
89
|
+
redirects to same-origin verification when required. Block/outage displays a
|
|
90
|
+
denial; customize `pending` and `blocked` with your UI. Vue, Angular, Svelte and
|
|
91
|
+
plain JS can await `checkAccess(path)` from `@stopnetic/js/browser` and use
|
|
92
|
+
`result.allowed`, `result.reason` and `result.verificationUrl` in their router.
|
|
93
|
+
This is a UI check, never an authorization credential for a data request.
|
|
94
|
+
|
|
95
|
+
### Netlify / Vercel APIs
|
|
96
|
+
|
|
97
|
+
Import server helpers only in server files. Wrap every protected function before
|
|
98
|
+
it loads/returns data, keeping your application's own login/data permissions:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { withStopnetic } from '@stopnetic/js/server';
|
|
102
|
+
const api = withStopnetic(async request => {
|
|
103
|
+
// Enforce application permissions, then retrieve private data here.
|
|
104
|
+
return Response.json({ example: true });
|
|
105
|
+
});
|
|
106
|
+
// Netlify function:
|
|
107
|
+
export default api;
|
|
108
|
+
export const config = { path: '/api/private', preferStatic: false };
|
|
109
|
+
// Vercel api/private.mjs instead: export default { fetch: api };
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Netlify's default `/.netlify/functions/` aliases are rejected by this wrapper
|
|
113
|
+
so they cannot bypass rules on a custom path. Use custom `config.path` endpoints
|
|
114
|
+
in your frontend and Policies. Keep the generated `/api/stopnetic/access` and
|
|
115
|
+
`/api/stopnetic/verify` endpoints reachable before any SPA catch-all. The access
|
|
116
|
+
endpoint checks the requested page using signed decisions, establishes browser
|
|
117
|
+
verification and redirects back to the same origin. It never returns private
|
|
118
|
+
content or an API authorization token. API wrappers independently check every
|
|
119
|
+
request and preserve ordinary request bodies. Denials/outages return empty
|
|
120
|
+
404/503 responses; approved data responses use `private, no-store`.
|
|
121
|
+
|
|
122
|
+
### Node servers on other hosts
|
|
123
|
+
|
|
124
|
+
Import `installStopnetic` from generated `stopnetic.server.mjs`, then call
|
|
125
|
+
`installStopnetic(app)` before Express parsers, static files, SPA fallback and
|
|
126
|
+
API handlers. This checks the control endpoints and the rest of the server.
|
|
127
|
+
It can run on any host supporting Node.js 22+, not just Netlify/Vercel. For a
|
|
128
|
+
custom Fetch-based Node framework, use the shared `createStopneticAccessHandler`
|
|
129
|
+
and `withStopnetic` in its request hooks. Existing low-level adapters remain
|
|
130
|
+
available below with their original default-blocking behavior.
|
|
131
|
+
|
|
132
|
+
### Rules and the protection boundary
|
|
133
|
+
|
|
134
|
+
The generated setup opts into dashboard-managed mode: unmatched guarded requests
|
|
135
|
+
are public. Add explicit Page / Automatic browser flow rules and API / Require
|
|
136
|
+
verified session rules. Keep private data exclusively in guarded APIs. An API
|
|
137
|
+
fetch does not start browser verification; it needs the established same-origin
|
|
138
|
+
cookie. Static HTML/JavaScript, routes without a browser boundary, external APIs,
|
|
139
|
+
unwrapped handlers, alternate hosts and previously loaded content are not made
|
|
140
|
+
private by this setup. No installer can hide content already in a public bundle.
|
|
141
|
+
Browser automation can still complete verification. Test your actual provider,
|
|
142
|
+
custom-domain routing, redirects, cookies, caches and direct API access before
|
|
143
|
+
rollout. Hosting contracts are simulated locally; live provider tests are pending.
|
|
144
|
+
|
|
145
|
+
## Which installation fits my site?
|
|
146
|
+
|
|
147
|
+
| Site | Where to install |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| Angular, Vue, React, Svelte SPA or plain HTML/JS | Node/Express server serving its built output, before static files and SPA fallback |
|
|
150
|
+
| Existing Node/Express server, including Express 4 and 5 | First root middleware, before body parsers, routes and data handlers |
|
|
151
|
+
| Node-hosted framework with Web `Request`/`Response` hooks | `createStopneticHandler` before the framework renders or serves data |
|
|
152
|
+
| Next.js 16 App Router | Published `@stopnetic/next@pilot` and its guided installer |
|
|
153
|
+
| Older Next.js with a customer-owned Node server | Generic middleware before the Next request handler; native older Next installers are not supplied |
|
|
154
|
+
| Static-only hosting / browser-only scripts | Needs a server or compatible edge/proxy in front, or a separately protected content API |
|
|
155
|
+
|
|
156
|
+
Do not import this SDK in browser components or add a public `<script>` containing
|
|
157
|
+
your installation key. Hiding a screen cannot protect HTML, JS bundles or data that
|
|
158
|
+
the hosting provider already serves publicly. This package uses `node:crypto`; it
|
|
159
|
+
is not a Cloudflare Workers/browser/Edge-runtime adapter. Framework-native SSR
|
|
160
|
+
adapters and serverless platform wiring need their own deployment validation.
|
|
161
|
+
|
|
162
|
+
## Server configuration
|
|
163
|
+
|
|
164
|
+
Add a site in Stopnetic, verify its domain, and create an installation key in
|
|
165
|
+
Integration. Site ID identifies your website; the private installation key
|
|
166
|
+
authenticates it. Copy the service public signing key from Integration too.
|
|
167
|
+
Set these **server-only** environment variables, using your actual URLs and keys:
|
|
168
|
+
|
|
169
|
+
```dotenv
|
|
170
|
+
STOPNETIC_WEBSITE_ORIGIN=https://www.example.com
|
|
171
|
+
STOPNETIC_SITE_ID=<generated-site-id>
|
|
172
|
+
STOPNETIC_API_URL=https://<your-stopnetic-service>/api/integration/decision
|
|
173
|
+
STOPNETIC_API_TOKEN=<your-one-time-installation-key>
|
|
174
|
+
STOPNETIC_DECISION_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n<service-public-key>\n-----END PUBLIC KEY-----\n"
|
|
175
|
+
STOPNETIC_COOKIE_SECRET=<separate-random-secret-at-least-32-characters>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Generate the cookie secret with
|
|
179
|
+
`node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`.
|
|
180
|
+
Never use frontend prefixes such as `VITE_`, `NEXT_PUBLIC_` or `REACT_APP_` for secrets.
|
|
181
|
+
The optional `STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS` is a JSON array of at most
|
|
182
|
+
two explicitly trusted public PEMs for staged signing-key rotation.
|
|
183
|
+
|
|
184
|
+
The origin is the public website's protocol and hostname, with an optional port.
|
|
185
|
+
No `/home`, credentials, query or fragment. HTTPS is required except on localhost.
|
|
186
|
+
Configure your TLS proxy to preserve the public `Host` header and keep the backend
|
|
187
|
+
private. The adapter uses the configured origin and never trusts forwarded host or
|
|
188
|
+
protocol headers. HTTPS origins always produce secure cookies; HTTP localhost
|
|
189
|
+
origins automatically use development cookies.
|
|
190
|
+
|
|
191
|
+
## Node/Express middleware
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import express from 'express';
|
|
195
|
+
import { createStopneticMiddleware, parseAdditionalDecisionPublicKeys } from '@stopnetic/js';
|
|
196
|
+
|
|
197
|
+
const app = express();
|
|
198
|
+
app.set('case sensitive routing', true);
|
|
199
|
+
app.set('strict routing', true);
|
|
200
|
+
app.use(createStopneticMiddleware({
|
|
201
|
+
origin: process.env.STOPNETIC_WEBSITE_ORIGIN,
|
|
202
|
+
siteId: process.env.STOPNETIC_SITE_ID,
|
|
203
|
+
apiUrl: process.env.STOPNETIC_API_URL,
|
|
204
|
+
apiToken: process.env.STOPNETIC_API_TOKEN,
|
|
205
|
+
decisionPublicKey: process.env.STOPNETIC_DECISION_PUBLIC_KEY?.replaceAll('\\n', '\n'),
|
|
206
|
+
additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(process.env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
|
|
207
|
+
cookieSecret: process.env.STOPNETIC_COOKIE_SECRET,
|
|
208
|
+
}));
|
|
209
|
+
// Your existing application follows the guard.
|
|
210
|
+
app.use(express.json());
|
|
211
|
+
app.post('/api/private', yourExistingApiHandler);
|
|
212
|
+
app.use(express.static('dist', { dotfiles: 'deny', cacheControl: false }));
|
|
213
|
+
// Add your existing guarded SPA fallback here.
|
|
214
|
+
app.listen(8080);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Install `express` separately in your website if needed. The repository includes
|
|
218
|
+
`integrations/javascript/examples/express-server.mjs` for a complete SPA server.
|
|
219
|
+
Serve only the build output directory (`dist`, `build`, or Angular's browser output),
|
|
220
|
+
never your project root. A framework-independent fallback after your static middleware:
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
app.use((req, res) => {
|
|
224
|
+
if (req.method === 'GET' && req.get('accept')?.includes('text/html')
|
|
225
|
+
&& !req.path.startsWith('/api/') && !req.path.split('/').some(p => p.startsWith('.'))) {
|
|
226
|
+
res.sendFile('index.html', { root: absoluteBuildDirectory, cacheControl: false });
|
|
227
|
+
} else res.status(404).end();
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Place the guard **first at the root**, before body parsers and before anything can
|
|
232
|
+
send bytes. It checks every request except explicitly configured `publicPaths`.
|
|
233
|
+
Ordinary POST bodies stay intact for your application's parser. The same middleware
|
|
234
|
+
handles `/__stopnetic/verify`; do not mount it only on `/members`.
|
|
235
|
+
|
|
236
|
+
With Node's built-in HTTP server, the same middleware works without Express:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
import { createServer } from 'node:http';
|
|
240
|
+
const protect = createStopneticMiddleware(options);
|
|
241
|
+
createServer((req, res) => {
|
|
242
|
+
void protect(req, res, () => existingApplication(req, res)).catch(() => res.destroy());
|
|
243
|
+
}).listen(8080);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
For an older CommonJS application, initialize at startup with
|
|
247
|
+
`const { createStopneticMiddleware } = await import('@stopnetic/js')` inside an async
|
|
248
|
+
function. Only the server runtime needs to meet the minimum version; the frontend
|
|
249
|
+
framework can remain unchanged.
|
|
250
|
+
|
|
251
|
+
## Request/Response hook
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
import { createStopneticHandler } from '@stopnetic/js';
|
|
255
|
+
const protect = createStopneticHandler(options); // same options, including origin
|
|
256
|
+
|
|
257
|
+
export async function handle({ request, resolve }) {
|
|
258
|
+
return protect(request, checkedRequest => resolve(checkedRequest));
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Wire this function to the Node-hosted framework's actual request hook. For SvelteKit,
|
|
263
|
+
the hook receives an `event`, so use
|
|
264
|
+
`protect(event.request, () => resolve(event))` in `hooks.server.js`. The request URL
|
|
265
|
+
must contain the configured public origin. Configure the framework's trusted proxy
|
|
266
|
+
settings explicitly. This is a generic hook example, not a tested matrix of all
|
|
267
|
+
SvelteKit/Nuxt/framework releases.
|
|
268
|
+
|
|
269
|
+
## Policies, failures and hosting
|
|
270
|
+
|
|
271
|
+
In Policies, choose public, automatic browser flow, verified session, or blocked
|
|
272
|
+
routes. Automatic flow serves a short verification page first, then a cookie-bound
|
|
273
|
+
session; requests from ordinary fetchers and non-navigation clients get no protected
|
|
274
|
+
bytes. Browser-capable automation may complete the challenge. Your application must
|
|
275
|
+
still enforce its own sign-in, membership and purchase permissions. Entitlement
|
|
276
|
+
rules are not implemented and withhold content.
|
|
277
|
+
|
|
278
|
+
Both adapters automatically route verification, bound its body to 4 KiB and five
|
|
279
|
+
seconds, and reject foreign origins. Protected requests fail closed with empty
|
|
280
|
+
404/503 responses on denial, malformed signed decisions or service outages. Approved
|
|
281
|
+
application responses receive `Cache-Control: private, no-store`; middleware users
|
|
282
|
+
must keep that header in their downstream handlers. Prevent CDN, prerendered files,
|
|
283
|
+
service workers or another origin from serving protected content before the guard.
|
|
284
|
+
WebSockets/upgrades are not covered; protect them separately or disable them.
|
|
285
|
+
|
|
286
|
+
By default **all paths are checked**, including assets and APIs. Optional local
|
|
287
|
+
`publicPaths: ['/health', '/public/*']` bypass the policy service even during outages.
|
|
288
|
+
Use only paths deliberately intended to remain public. Ambiguous encoded separators,
|
|
289
|
+
dot segments, encoded ASCII characters, repeated slashes and malformed targets are
|
|
290
|
+
rejected by the Node middleware. Policy paths are case sensitive. Match your router's
|
|
291
|
+
case/slash behavior to the policies, cover directory-index and other aliases, and
|
|
292
|
+
keep the service's default blocking behavior. Avoid a broad public catch-all that
|
|
293
|
+
could expose an alternative path to protected content.
|
|
294
|
+
|
|
295
|
+
An installed guard's first valid request starts the site's account trial. Key
|
|
296
|
+
rotation/revocation and domain/account/subscription gates are checked by the service.
|
|
297
|
+
Access reports are best effort and cannot establish delivery guarantees.
|
|
298
|
+
With the updated dashboard, prepared 0.0.2 links same-page challenges, verification
|
|
299
|
+
and the first reported access result. The Challenge outcomes panel distinguishes
|
|
300
|
+
allowed, blocked, waiting, not completed and verified without an access result;
|
|
301
|
+
older evidence may be unavailable. It does not identify humans or bots. Install
|
|
302
|
+
the updated SDK on your server after deploying the dashboard update.
|
|
2
303
|
|
|
3
|
-
|
|
304
|
+
Validate your actual hosting, cache rules, browser flow, API paths and provider configuration
|
|
305
|
+
before onboarding paying customers.
|
|
306
|
+
|
|
307
|
+
## Maintainer checks and release
|
|
308
|
+
|
|
309
|
+
From this folder: `npm ci`, `npm test`, `npm run test:package`. Tests include real
|
|
310
|
+
Node HTTP traffic, Express 4/5 static/SPA/API serving, challenges, body preservation,
|
|
311
|
+
signed decision failures and package consumption from ESM, CommonJS and TypeScript.
|
|
312
|
+
The tarball includes only `dist`, README, MIT license and manifest. No secrets,
|
|
313
|
+
examples, tests, build scripts or dashboard are published. Build from the repository
|
|
314
|
+
before packing; published consumers do not need the repository or build scripts.
|
|
315
|
+
|
|
316
|
+
Publishing is a separate owner step after these checks:
|
|
317
|
+
|
|
318
|
+
```sh
|
|
319
|
+
npm publish --access public --tag pilot --registry=https://registry.npmjs.org/
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Publishing this package does not update the existing `@stopnetic/next` release.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setup, checkSetup, SetupError } from '../dist/setup.js';
|
|
3
|
+
const help = `Stopnetic JavaScript setup — Node.js 22+
|
|
4
|
+
stopnetic-js init --platform netlify|vercel|node [--dry-run]
|
|
5
|
+
stopnetic-js check [--local-http]
|
|
6
|
+
|
|
7
|
+
Netlify/Vercel config files can select the platform automatically. Explicit
|
|
8
|
+
selection is otherwise required. Setup creates a server control endpoint,
|
|
9
|
+
browser/API integration guide and a private cookie secret, preserving customer
|
|
10
|
+
files. Follow the guide to wire route checks and EACH protected API. The package
|
|
11
|
+
does not make bundled frontend content private. Server settings stay out of
|
|
12
|
+
browser code. No network calls, package installation or deployment occurs.
|
|
13
|
+
`;
|
|
14
|
+
try {
|
|
15
|
+
const [operation, ...args] = process.argv.slice(2);
|
|
16
|
+
if (!operation || ['help', '-h', '--help'].includes(operation)) process.stdout.write(help);
|
|
17
|
+
else {
|
|
18
|
+
if (!['init', 'check'].includes(operation)) throw new SetupError('Choose init or check. Use --help for usage.');
|
|
19
|
+
const options = {};
|
|
20
|
+
for (let index = 0; index < args.length; index++) {
|
|
21
|
+
const flag = args[index];
|
|
22
|
+
if (flag === '--platform' && operation === 'init' && !options.platform) {
|
|
23
|
+
options.platform = args[++index];
|
|
24
|
+
if (!options.platform) throw new SetupError('Choose netlify, vercel or node after --platform.');
|
|
25
|
+
} else if (flag === '--dry-run' && operation === 'init') options.dryRun = true;
|
|
26
|
+
else if (flag === '--local-http' && operation === 'check') options.localHttp = true;
|
|
27
|
+
else throw new SetupError('Unsupported option. Use --help for usage.');
|
|
28
|
+
}
|
|
29
|
+
process.stdout.write((operation === 'init' ? setup(process.cwd(), options) : checkSetup(process.cwd(), options)) + '\n');
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
process.stderr.write((error instanceof SetupError ? error.message : 'Setup failed. Check project files and permissions.') + '\n');
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type AccessResult = { allowed: true } | {
|
|
2
|
+
allowed: false; reason: 'blocked' | 'unavailable' | 'verification_required'; verificationUrl?: string;
|
|
3
|
+
};
|
|
4
|
+
export function checkAccess(path: string, options?: { signal?: AbortSignal; fetchImpl?: typeof fetch }): Promise<AccessResult>;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Browser-only entry: no Node imports, installation keys or cookie secrets.
|
|
2
|
+
export async function checkAccess(path, { signal, fetchImpl = globalThis.fetch } = {}) {
|
|
3
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || /[\\\s#]/.test(path)) throw new Error('Use a same-origin page path');
|
|
4
|
+
const url = `/api/stopnetic/access?${new URLSearchParams({ path, mode: 'check' })}`;
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const abort = () => controller.abort();
|
|
7
|
+
if (signal?.aborted) abort();
|
|
8
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
9
|
+
const timer = setTimeout(abort, 10000);
|
|
10
|
+
let response, result;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetchImpl(url, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal, headers: { Accept: 'application/json' } });
|
|
13
|
+
result = await response.json();
|
|
14
|
+
} catch (error) { if (signal?.aborted) throw error; return { allowed: false, reason: 'unavailable' }; }
|
|
15
|
+
finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
|
|
16
|
+
if (response.status === 200 && result?.allowed === true) return { allowed: true };
|
|
17
|
+
if (response.status === 404 && result?.reason === 'verification_required') {
|
|
18
|
+
return { allowed: false, reason: 'verification_required', verificationUrl: `/api/stopnetic/access?${new URLSearchParams({ path })}` };
|
|
19
|
+
}
|
|
20
|
+
return { allowed: false, reason: response.status === 404 ? 'blocked' : 'unavailable' };
|
|
21
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type StopneticOptions = {
|
|
2
|
+
siteId: string;
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
apiToken: string;
|
|
5
|
+
decisionPublicKey: string;
|
|
6
|
+
/** Explicitly trusted replacement/retiring public PEMs, at most two. */
|
|
7
|
+
additionalDecisionPublicKeys?: string[];
|
|
8
|
+
cookieSecret: string;
|
|
9
|
+
verifyPath?: string;
|
|
10
|
+
cookieName?: string;
|
|
11
|
+
secureCookie?: boolean;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
publicPaths?: string[];
|
|
15
|
+
reportEvents?: boolean;
|
|
16
|
+
/** Whole-site mode: unmatched routes public; requires the updated service. */
|
|
17
|
+
guardMode?: 'selected' | 'site';
|
|
18
|
+
};
|
|
19
|
+
export function parseAdditionalDecisionPublicKeys(value?: string): string[];
|
|
20
|
+
export function createStopnetic(options: StopneticOptions): {
|
|
21
|
+
check(request: Request): Promise<Response | null>;
|
|
22
|
+
verify(request: Request): Promise<Response>;
|
|
23
|
+
};
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
|
|
2
|
+
import { trustedSigningKeys } from './signing-keys.js';
|
|
3
|
+
export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
|
|
4
|
+
|
|
5
|
+
const unavailable = retryAfter => new Response(null, { status: 503, headers: {
|
|
6
|
+
'cache-control': 'no-store', ...(retryAfter ? { 'retry-after': String(retryAfter) } : {}),
|
|
7
|
+
} });
|
|
8
|
+
const concealed = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
|
|
9
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
|
|
11
|
+
function signed(payload, secret) {
|
|
12
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
13
|
+
const mac = createHmac('sha256', secret).update(data).digest('base64url');
|
|
14
|
+
return `${data}.${mac}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function verified(token, secret) {
|
|
18
|
+
if (!token || typeof token !== 'string' || token.length > 8192) return null;
|
|
19
|
+
const [data, mac, extra] = token.split('.');
|
|
20
|
+
if (!data || !mac || extra) return null;
|
|
21
|
+
const expected = createHmac('sha256', secret).update(data).digest();
|
|
22
|
+
let supplied;
|
|
23
|
+
try { supplied = Buffer.from(mac, 'base64url'); } catch { return null; }
|
|
24
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return null;
|
|
25
|
+
try {
|
|
26
|
+
const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
|
|
27
|
+
return payload?.v === 1 && Number.isSafeInteger(payload.exp) && payload.exp > Date.now() ? payload : null;
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cookieValue(request, name) {
|
|
32
|
+
const found = (request.headers.get('cookie') || '').split(';').map(part => part.trim())
|
|
33
|
+
.find(part => part.startsWith(`${name}=`));
|
|
34
|
+
return found ? found.slice(name.length + 1) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function limitedBody(request, maxBytes) {
|
|
38
|
+
const reader = request.body?.getReader();
|
|
39
|
+
if (!reader) throw new Error('Missing body');
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let length = 0;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const { value, done } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
length += value.byteLength;
|
|
46
|
+
if (length > maxBytes) { await reader.cancel(); throw new Error('Body too large'); }
|
|
47
|
+
chunks.push(Buffer.from(value));
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks, length).toString('utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bootstrap(ticket, verifyPath) {
|
|
53
|
+
const nonce = randomBytes(16).toString('base64');
|
|
54
|
+
const script = `fetch(${JSON.stringify(verifyPath)}, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({ticket:${JSON.stringify(ticket)}})}).then(r=>{if(r.ok)location.reload()})`;
|
|
55
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Checking access</title></head><body><p>Checking access...</p><script nonce="${nonce}">${script}</script></body></html>`;
|
|
56
|
+
return new Response(html, { status: 404, headers: {
|
|
57
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'vary': 'Cookie',
|
|
58
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'`,
|
|
59
|
+
'x-content-type-options': 'nosniff',
|
|
60
|
+
} });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pilot guard for server-side Next.js Proxy and Route Handlers. */
|
|
64
|
+
export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
|
|
65
|
+
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected' }) {
|
|
66
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
|
|
67
|
+
const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
|
|
68
|
+
if (!credential) throw new Error('apiToken must be an issued installation key');
|
|
69
|
+
const installationId = credential[1];
|
|
70
|
+
if (typeof cookieSecret !== 'string' || cookieSecret.trim().length < 32
|
|
71
|
+
|| /^<[^]*>$/.test(cookieSecret.trim())) throw new Error('cookieSecret must be at least 32 characters');
|
|
72
|
+
if (cookieSecret === apiToken) throw new Error('cookieSecret must be independent of apiToken');
|
|
73
|
+
const endpoint = new URL(apiUrl);
|
|
74
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
|
|
75
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
|
|
76
|
+
throw new Error('apiUrl must use HTTPS outside localhost');
|
|
77
|
+
}
|
|
78
|
+
if (!/^\/[a-zA-Z0-9/_-]+$/.test(verifyPath)) throw new Error('Invalid verifyPath');
|
|
79
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
|
|
80
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
|
|
81
|
+
if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
|
|
82
|
+
if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
|
|
83
|
+
if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
|
|
84
|
+
const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
|
|
85
|
+
const verifyEndpoint = new URL('./verify', endpoint);
|
|
86
|
+
const eventsEndpoint = new URL('./events', endpoint);
|
|
87
|
+
const browserCookieName = `${cookieName}_browser`;
|
|
88
|
+
const installation = hash(apiToken);
|
|
89
|
+
|
|
90
|
+
function browserBinding(request) {
|
|
91
|
+
const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
|
|
92
|
+
return payload?.kind === 'browser' && payload.site === siteId && payload.installation === installation
|
|
93
|
+
&& /^[A-Za-z0-9_-]{43}$/.test(payload.binding || '') ? payload.binding : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cookie(name, value, age) {
|
|
97
|
+
return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax${secureCookie ? '; Secure' : ''}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function remote(purpose, input) {
|
|
101
|
+
const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
|
|
102
|
+
const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
|
|
103
|
+
method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
|
|
104
|
+
headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ ...input, ...context }), cache: 'no-store',
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const retry = response.headers.get('retry-after') || '';
|
|
109
|
+
const retryAfter = response.status === 429 && /^[1-9][0-9]?$/.test(retry) && Number(retry) <= 60 ? Number(retry) : undefined;
|
|
110
|
+
await response.body?.cancel();
|
|
111
|
+
throw Object.assign(new Error('Policy service unavailable'), { retryAfter });
|
|
112
|
+
}
|
|
113
|
+
const envelope = JSON.parse(await limitedBody(response, 16_384));
|
|
114
|
+
if (typeof envelope?.decision !== 'string' || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(envelope.decision)) throw new Error('Invalid signature envelope');
|
|
115
|
+
const [data, signature] = envelope.decision.split('.');
|
|
116
|
+
if (!publicKeys.some(key => verifySignature(null, Buffer.from(data), key, Buffer.from(signature, 'base64url')))) throw new Error('Invalid signature');
|
|
117
|
+
const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (payload?.v !== 1 || payload.purpose !== purpose || payload.key_id !== installationId || Object.entries(context).some(([name, value]) => payload[name] !== value)
|
|
120
|
+
|| !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
|
|
121
|
+
|| payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
|
|
122
|
+
|| payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
|
|
123
|
+
return { ...payload, receipt: envelope.decision };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function report(policy, outcome, session = false) {
|
|
127
|
+
if (!reportEvents) return;
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetchImpl(eventsEndpoint, {
|
|
130
|
+
method: 'POST', signal: AbortSignal.timeout(Math.min(timeoutMs, 500)), redirect: 'error', cache: 'no-store',
|
|
131
|
+
headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ receipt: policy.receipt, outcome, session_verified: !!session }),
|
|
133
|
+
});
|
|
134
|
+
await response.body?.cancel();
|
|
135
|
+
} catch { /* Reporting cannot change a validated access decision. */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validSession(request) {
|
|
139
|
+
const value = cookieValue(request, cookieName);
|
|
140
|
+
const payload = verified(value, cookieSecret);
|
|
141
|
+
const binding = browserBinding(request);
|
|
142
|
+
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
143
|
+
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function check(request) {
|
|
147
|
+
const path = new URL(request.url).pathname;
|
|
148
|
+
if (path === verifyPath) return null;
|
|
149
|
+
if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
|
|
150
|
+
const session = validSession(request);
|
|
151
|
+
// Only link access to the page that originally requested this challenge.
|
|
152
|
+
// Sessions still authorize other configured routes without attributing them
|
|
153
|
+
// to this page's verification outcome. Legacy sessions remain usable.
|
|
154
|
+
const challenge = session?.challenge_path === path && /^[A-Za-z0-9_-]{43}$/.test(session.challenge_id || '')
|
|
155
|
+
? session.challenge_id : null;
|
|
156
|
+
const eligible = request.method === 'GET' && (request.headers.get('accept') || '').includes('text/html');
|
|
157
|
+
const binding = challenge ? browserBinding(request)
|
|
158
|
+
: !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
|
|
159
|
+
let policy;
|
|
160
|
+
try {
|
|
161
|
+
policy = await remote('decision', { path, method: request.method, ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
162
|
+
...(challenge ? { session_challenge: challenge } : {}) });
|
|
163
|
+
} catch (error) { return unavailable(error?.retryAfter); }
|
|
164
|
+
if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
|
|
165
|
+
// An older service must not silently apply selected-path semantics here.
|
|
166
|
+
if (guardMode === 'site' && policy.guard_mode !== 'site') return unavailable();
|
|
167
|
+
if (policy.action === 'public') { await report(policy, 'ALLOW', session); return null; }
|
|
168
|
+
if (policy.action === 'block' || policy.action === 'entitled' || policy.action === 'default') { await report(policy, 'BLOCK', session); return concealed(); }
|
|
169
|
+
if (session) { await report(policy, 'ALLOW', true); return null; }
|
|
170
|
+
if (policy.action === 'verified' || !eligible) { await report(policy, 'BLOCK'); return concealed(); }
|
|
171
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(policy.challenge_id || '')) return unavailable();
|
|
172
|
+
const ticket = signed({ v: 1, kind: 'challenge', site: siteId, path, exp: Date.now() + 60_000,
|
|
173
|
+
challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
|
|
174
|
+
const response = bootstrap(ticket, verifyPath);
|
|
175
|
+
response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
|
|
176
|
+
binding, installation, exp: Date.now() + 600_000 }, cookieSecret), 600));
|
|
177
|
+
await report(policy, 'CHALLENGE');
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function verify(request) {
|
|
182
|
+
if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
|
|
183
|
+
const origin = request.headers.get('origin');
|
|
184
|
+
if (!origin) return concealed();
|
|
185
|
+
// Next.js may use an internal hostname in request.url. Host is the browser's
|
|
186
|
+
// destination; retain the request URL's scheme to reject cross-scheme calls.
|
|
187
|
+
const destination = new URL(request.url);
|
|
188
|
+
const host = request.headers.get('host') || destination.host;
|
|
189
|
+
if (origin !== `${destination.protocol}//${host}`) return concealed();
|
|
190
|
+
if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) return concealed();
|
|
191
|
+
if ((Number(request.headers.get('content-length')) || 0) > 4096) return concealed();
|
|
192
|
+
let body;
|
|
193
|
+
try { body = await limitedBody(request, 4096); } catch { return concealed(); }
|
|
194
|
+
let ticket;
|
|
195
|
+
try { ticket = JSON.parse(body).ticket; } catch { return concealed(); }
|
|
196
|
+
const payload = verified(ticket, cookieSecret);
|
|
197
|
+
const binding = browserBinding(request);
|
|
198
|
+
if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
|
|
199
|
+
|| typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
|
|
200
|
+
|| payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
|
|
201
|
+
try {
|
|
202
|
+
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
|
|
203
|
+
if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
|
|
204
|
+
await report(result, 'VERIFIED');
|
|
205
|
+
} catch (error) { return unavailable(error?.retryAfter); }
|
|
206
|
+
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
207
|
+
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
208
|
+
exp: Date.now() + 300_000 }, cookieSecret);
|
|
209
|
+
return new Response(null, { status: 204, headers: {
|
|
210
|
+
'cache-control': 'no-store',
|
|
211
|
+
'set-cookie': cookie(cookieName, session, 300),
|
|
212
|
+
} });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { check, verify };
|
|
216
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StopneticOptions } from './guard.js';
|
|
3
|
+
export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
|
|
4
|
+
export type { StopneticOptions } from './guard.js';
|
|
5
|
+
export type StopneticServerOptions = StopneticOptions & {
|
|
6
|
+
/** Public website origin, e.g. https://example.com. Never taken from forwarded headers. */
|
|
7
|
+
origin: string;
|
|
8
|
+
};
|
|
9
|
+
export function createStopneticMiddleware(options: StopneticServerOptions): (
|
|
10
|
+
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse,
|
|
11
|
+
next: () => unknown,
|
|
12
|
+
) => Promise<unknown>;
|
|
13
|
+
export function createStopneticHandler(options: StopneticServerOptions): (
|
|
14
|
+
request: Request, next: (request: Request) => Response | Promise<Response>,
|
|
15
|
+
) => Promise<Response>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createStopnetic } from './guard.js';
|
|
2
|
+
export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
|
|
3
|
+
|
|
4
|
+
const denied = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
|
|
5
|
+
const unavailable = () => new Response(null, { status: 503, headers: { 'cache-control': 'no-store' } });
|
|
6
|
+
|
|
7
|
+
function canonicalOrigin(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try { url = new URL(value); } catch { throw new Error('origin must be an HTTPS origin (HTTP only on localhost)'); }
|
|
10
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash
|
|
11
|
+
|| !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
12
|
+
throw new Error('origin must be an HTTPS origin (HTTP only on localhost)');
|
|
13
|
+
}
|
|
14
|
+
return url.origin;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function target(raw, origin) {
|
|
18
|
+
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') || /[\\\s#]/.test(raw)) throw new Error('Invalid request target');
|
|
19
|
+
const url = new URL(raw, origin);
|
|
20
|
+
// Do not let URL normalization or downstream decoding change the checked path.
|
|
21
|
+
if (url.origin !== origin || url.pathname + url.search !== raw) throw new Error('Invalid request target');
|
|
22
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
23
|
+
if (/[\\\s?#%]/.test(decoded) || /[\x00-\x1f\x7f]/.test(decoded)
|
|
24
|
+
|| decoded.split('/').some(part => part === '.' || part === '..')
|
|
25
|
+
|| /%[0-7][0-9a-f]/i.test(url.pathname) || url.pathname.includes('//')) throw new Error('Ambiguous request path');
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionsForGuard(options, origin) {
|
|
30
|
+
const local = origin.startsWith('http:');
|
|
31
|
+
if (!local && options.secureCookie === false) throw new Error('HTTPS sites require secure cookies');
|
|
32
|
+
// A verification request must reach the verification handler, not a bypass.
|
|
33
|
+
return { ...options, secureCookie: !local };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function boundedWebBody(request) {
|
|
37
|
+
if (Number(request.headers.get('content-length')) > 4096) throw new Error('Body too large');
|
|
38
|
+
const reader = request.body?.getReader();
|
|
39
|
+
if (!reader) throw new Error('Missing body');
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
let timer;
|
|
43
|
+
let finished = false;
|
|
44
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Body timeout')), 5000); });
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
const { value, done } = await Promise.race([reader.read(), deadline]);
|
|
48
|
+
if (done) { finished = true; return Buffer.concat(chunks, size); }
|
|
49
|
+
size += value.byteLength;
|
|
50
|
+
if (size > 4096) throw new Error('Body too large');
|
|
51
|
+
chunks.push(Buffer.from(value));
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (!finished) void reader.cancel().catch(() => {});
|
|
56
|
+
reader.releaseLock();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fetch-style adapter for Node-hosted frameworks. Always handles verification. */
|
|
61
|
+
export function createStopneticHandler(options) {
|
|
62
|
+
const origin = canonicalOrigin(options.origin);
|
|
63
|
+
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
64
|
+
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
65
|
+
return async function handle(request, next) {
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return denied();
|
|
70
|
+
target(url.pathname + url.search, origin);
|
|
71
|
+
if (url.pathname === verifyPath) {
|
|
72
|
+
if (request.method !== 'POST' || request.headers.get('origin') !== origin
|
|
73
|
+
|| !(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) {
|
|
74
|
+
return request.method === 'POST' ? denied() : new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const body = await boundedWebBody(request);
|
|
78
|
+
response = await guard.verify(new Request(request.url, { method: 'POST', headers: request.headers, body }));
|
|
79
|
+
} catch { return denied(); }
|
|
80
|
+
} else response = await guard.check(request);
|
|
81
|
+
} catch { return unavailable(); }
|
|
82
|
+
if (response) return response;
|
|
83
|
+
const result = await next(request);
|
|
84
|
+
// Copy headers, including individual cookies, without buffering the body.
|
|
85
|
+
const headers = new Headers(result.headers);
|
|
86
|
+
headers.set('cache-control', 'private, no-store');
|
|
87
|
+
return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requestHeaders(req, origin) {
|
|
92
|
+
const headers = new Headers();
|
|
93
|
+
for (const name of ['accept', 'cookie', 'origin', 'content-type', 'content-length']) {
|
|
94
|
+
const value = req.headers[name];
|
|
95
|
+
if (Array.isArray(value)) throw new Error('Ambiguous request headers');
|
|
96
|
+
if (value !== undefined) headers.set(name, value);
|
|
97
|
+
}
|
|
98
|
+
headers.set('host', new URL(origin).host);
|
|
99
|
+
return headers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function verificationBody(req) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
if (req.readableEnded || req.destroyed) { reject(new Error('Verification body unavailable')); return; }
|
|
105
|
+
const chunks = [];
|
|
106
|
+
let size = 0;
|
|
107
|
+
const cleanup = () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
req.off('data', data); req.off('end', end); req.off('error', fail); req.off('aborted', fail);
|
|
110
|
+
};
|
|
111
|
+
const fail = () => { cleanup(); req.pause(); reject(new Error('Invalid verification body')); };
|
|
112
|
+
const data = chunk => {
|
|
113
|
+
const bytes = Buffer.from(chunk);
|
|
114
|
+
size += bytes.length;
|
|
115
|
+
if (size > 4096) { fail(); return; }
|
|
116
|
+
chunks.push(bytes);
|
|
117
|
+
};
|
|
118
|
+
const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
|
|
119
|
+
const timer = setTimeout(fail, 5000);
|
|
120
|
+
req.on('data', data); req.once('end', end); req.once('error', fail); req.once('aborted', fail);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function send(response, req, res, close = false) {
|
|
125
|
+
res.statusCode = response.status;
|
|
126
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
|
|
127
|
+
const cookies = response.headers.getSetCookie();
|
|
128
|
+
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
129
|
+
if (close) res.setHeader('connection', 'close');
|
|
130
|
+
const body = req.method === 'HEAD' ? undefined : Buffer.from(await response.arrayBuffer());
|
|
131
|
+
res.end(body);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Use first, at the application root, before parsers, static files and routes. */
|
|
135
|
+
export function createStopneticMiddleware(options) {
|
|
136
|
+
const origin = canonicalOrigin(options.origin);
|
|
137
|
+
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
138
|
+
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
139
|
+
return async function stopnetic(req, res, next) {
|
|
140
|
+
let response;
|
|
141
|
+
let close = false;
|
|
142
|
+
try {
|
|
143
|
+
const host = req.headers.host;
|
|
144
|
+
if (typeof host !== 'string' || host.toLowerCase() !== new URL(origin).host) response = denied();
|
|
145
|
+
else {
|
|
146
|
+
const url = target(req.originalUrl || req.url, origin);
|
|
147
|
+
const headers = requestHeaders(req, origin);
|
|
148
|
+
const method = req.method || 'GET';
|
|
149
|
+
if (url.pathname === verifyPath) {
|
|
150
|
+
if (method !== 'POST') response = new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
151
|
+
else if (headers.get('origin') !== origin || !(headers.get('content-type') || '').toLowerCase().startsWith('application/json')
|
|
152
|
+
|| Number(headers.get('content-length')) > 4096) { response = denied(); close = true; }
|
|
153
|
+
else {
|
|
154
|
+
try {
|
|
155
|
+
const body = await verificationBody(req);
|
|
156
|
+
response = await guard.verify(new Request(url, { method, headers, body }));
|
|
157
|
+
} catch { response = denied(); close = true; }
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// Checking ordinary requests must leave their bodies for the application.
|
|
161
|
+
response = await guard.check(new Request(url, { method, headers }));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch { response = unavailable(); close = true; }
|
|
165
|
+
if (response) {
|
|
166
|
+
try { await send(response, req, res, close); } catch { res.destroy(); }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.setHeader('cache-control', 'private, no-store');
|
|
170
|
+
return next();
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/react.d.ts
ADDED
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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
export function createStopneticAccessMiddleware(options?: StopneticServerOptions): (
|
|
9
|
+
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse, next: () => unknown,
|
|
10
|
+
) => Promise<unknown>;
|
|
11
|
+
export function createStopneticServer(options?: StopneticServerOptions): {
|
|
12
|
+
access: ReturnType<typeof createStopneticAccessMiddleware>;
|
|
13
|
+
guard: ReturnType<typeof import('./index.js').createStopneticMiddleware>;
|
|
14
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
/** Node/Express: mount first, then the root guard, then parsers/static/API handlers. */
|
|
75
|
+
export function createStopneticAccessMiddleware(options = optionsFromEnv()) {
|
|
76
|
+
const access = createStopneticAccessHandler(options);
|
|
77
|
+
const verification = createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` });
|
|
78
|
+
return async (req, res, next) => {
|
|
79
|
+
const path = (req.originalUrl || req.url || '').split('?')[0];
|
|
80
|
+
if (path !== `${accessBase}/access` && path !== `${accessBase}/verify`) return next();
|
|
81
|
+
if (path === `${accessBase}/verify`) return verification(req, res, next);
|
|
82
|
+
try {
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
85
|
+
if (Array.isArray(value)) { res.writeHead(400, { 'cache-control': 'no-store' }); res.end(); return; }
|
|
86
|
+
if (value !== undefined) headers.set(name, value);
|
|
87
|
+
}
|
|
88
|
+
const request = new Request(new URL(req.originalUrl || req.url, options.origin), {
|
|
89
|
+
method: req.method, headers,
|
|
90
|
+
...(['GET', 'HEAD'].includes(req.method) ? {} : { body: req, duplex: 'half' }),
|
|
91
|
+
});
|
|
92
|
+
const response = await access(request);
|
|
93
|
+
res.statusCode = response.status;
|
|
94
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
|
|
95
|
+
const cookies = response.headers.getSetCookie();
|
|
96
|
+
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
97
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
98
|
+
} catch { res.writeHead(503, { 'cache-control': 'private, no-store' }); res.end(); }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createStopneticServer(options = optionsFromEnv()) {
|
|
103
|
+
return {
|
|
104
|
+
access: createStopneticAccessMiddleware(options),
|
|
105
|
+
guard: createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` }),
|
|
106
|
+
};
|
|
107
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createPublicKey } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function publicKey(value) {
|
|
4
|
+
if (typeof value !== 'string' || value.length > 2048) {
|
|
5
|
+
throw new Error('Signing verification keys must be public PEMs');
|
|
6
|
+
}
|
|
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,6 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@stopnetic/js",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
}
|