@stopnetic/js 0.0.2-pilot → 0.0.4-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/README.md +89 -8
- package/dist/client-guide.md +828 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +16 -0
- package/dist/setup.js +17 -59
- package/examples/frontend/angular/app/app.config.ts +8 -0
- package/examples/frontend/angular/app/app.routes.ts +20 -0
- package/examples/frontend/angular/app/app.ts +23 -0
- package/examples/frontend/angular/app/members.page.ts +21 -0
- package/examples/frontend/angular/app/stopnetic-access.service.ts +27 -0
- package/examples/frontend/angular/main.ts +4 -0
- package/examples/frontend/load-private-data.d.ts +1 -0
- package/examples/frontend/load-private-data.js +9 -0
- package/examples/frontend/react/App.tsx +48 -0
- package/examples/frontend/react/main.tsx +3 -0
- package/examples/frontend/solid/App.tsx +54 -0
- package/examples/frontend/solid/main.tsx +3 -0
- package/examples/frontend/svelte/App.svelte +58 -0
- package/examples/frontend/svelte/main.js +4 -0
- package/examples/frontend/vanilla/main.js +46 -0
- package/examples/frontend/vue/App.vue +18 -0
- package/examples/frontend/vue/Members.vue +14 -0
- package/examples/frontend/vue/main.js +4 -0
- package/examples/frontend/vue/router.js +40 -0
- package/package.json +3 -2
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
# Stopnetic setup for JavaScript applications
|
|
2
|
+
|
|
3
|
+
Angular, React, Vue, Svelte, Solid and plain JavaScript use the same server guard
|
|
4
|
+
and browser access check. React is optional. Install `@stopnetic/js` in the
|
|
5
|
+
website project; choose a hosting platform, not a frontend framework.
|
|
6
|
+
|
|
7
|
+
Jump to your implementation: [React Router](#react-router),
|
|
8
|
+
[Angular Router](#angular-router), [Vue Router](#vue-router),
|
|
9
|
+
[Svelte](#svelte-navigation), [Solid Router](#solid-router),
|
|
10
|
+
[plain JavaScript](#plain-javascript-navigation). Each section includes the
|
|
11
|
+
layout, navigation and protected API example, not just the startup function.
|
|
12
|
+
|
|
13
|
+
The generated control endpoint is only part of installation. It does not
|
|
14
|
+
automatically intercept pages, edit your router or protect existing APIs.
|
|
15
|
+
|
|
16
|
+
## 1. Choose the actual rendering and server runtime
|
|
17
|
+
|
|
18
|
+
| Application | Where the shared guard belongs |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| Node/Express SSR or static-file server | Before body parsers, static files, API handlers and rendering |
|
|
21
|
+
| Node server with a Fetch `Request` → `Response` handler | Wrap the complete application handler before it renders or returns API data |
|
|
22
|
+
| Static Angular, React, Vue, Svelte, Solid or plain HTML | A Node hosting function/middleware before HTML, plus the shared browser check below |
|
|
23
|
+
| Angular SSR, Nuxt, SvelteKit, Astro, SolidStart, React Router server mode | The deployed Node request entry, before the framework renderer; preserve framework context |
|
|
24
|
+
| Next.js | Use `@stopnetic/next` and its Next.js installation guide |
|
|
25
|
+
| Edge/Deno/Cloudflare Workers, PHP, Python, Ruby, C# or Java | This Node SDK cannot be imported there; use a Node gateway or an adapter for that runtime |
|
|
26
|
+
|
|
27
|
+
Node.js 22 or newer is required. SSR support alone does not establish runtime
|
|
28
|
+
compatibility. A Fetch API on an Edge runtime is not a Node server.
|
|
29
|
+
Prerendering runs at build time: it is not a request-time access check. Do not
|
|
30
|
+
publish protected prerendered pages or private data to a public static directory.
|
|
31
|
+
|
|
32
|
+
## 2. Save the six server environment settings
|
|
33
|
+
|
|
34
|
+
Copy actual values from Stopnetic Integration into your hosting SERVER
|
|
35
|
+
environment. Use `https://www.example.com` only as an example; replace it with
|
|
36
|
+
your website's canonical origin.
|
|
37
|
+
|
|
38
|
+
```dotenv
|
|
39
|
+
STOPNETIC_WEBSITE_ORIGIN=https://www.example.com
|
|
40
|
+
STOPNETIC_SITE_ID=your_site_id
|
|
41
|
+
STOPNETIC_API_URL=https://your-stopnetic-service/api/integration/decision
|
|
42
|
+
STOPNETIC_API_TOKEN=your_private_installation_key
|
|
43
|
+
STOPNETIC_DECISION_PUBLIC_KEY=your_complete_public_PEM
|
|
44
|
+
STOPNETIC_COOKIE_SECRET=your_separate_random_cookie_secret
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The installer creates a private cookie secret in `.env.local` when missing.
|
|
48
|
+
Reuse it across deployments. Do not commit `.env.local`. Copy complete PEM
|
|
49
|
+
header/body/footer with real newlines or literal `\n`; quote multiline values
|
|
50
|
+
in local env files. Hosting value fields do not need surrounding quotes.
|
|
51
|
+
Never put private settings in frontend environment files or prefix them with
|
|
52
|
+
`VITE_`, `REACT_APP_` or `NEXT_PUBLIC_`. Angular's `environment.ts` is browser
|
|
53
|
+
configuration, not a private server environment.
|
|
54
|
+
|
|
55
|
+
## 3. Protect the server before rendering
|
|
56
|
+
|
|
57
|
+
### Node/Express: every frontend uses the same mount
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { installStopnetic } from './stopnetic.server.mjs';
|
|
61
|
+
installStopnetic(app);
|
|
62
|
+
// Existing body parsers, static files, APIs and framework renderer follow.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Generate that helper with `stopnetic-js init --platform node`. Only expose the
|
|
66
|
+
guarded server. Keep the control endpoints `/api/stopnetic/access` and
|
|
67
|
+
`/api/stopnetic/verify` reachable before SPA fallbacks. Preserve the canonical
|
|
68
|
+
Host at your proxy. This works without a React dependency or browser boundary.
|
|
69
|
+
|
|
70
|
+
### Node Fetch application: one wrapper for pages and APIs
|
|
71
|
+
|
|
72
|
+
In SDK 0.0.3-pilot or newer:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { createStopneticAppHandler } from '@stopnetic/js/server';
|
|
76
|
+
|
|
77
|
+
let protectedApp;
|
|
78
|
+
export async function handleRequest(request, context) {
|
|
79
|
+
protectedApp ??= createStopneticAppHandler(async (checked, frameworkContext) => {
|
|
80
|
+
// Your existing Node framework handler. Return a Response.
|
|
81
|
+
return renderApplication(checked, frameworkContext);
|
|
82
|
+
});
|
|
83
|
+
return protectedApp(request, context);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Lazy initialization reads environment settings at runtime, not during a
|
|
88
|
+
prerender build. Control requests are handled by Stopnetic. Other requests are
|
|
89
|
+
checked before the application handler runs; denied responses do not render
|
|
90
|
+
the site's navigation or private API data. Context and unread request bodies
|
|
91
|
+
are preserved. Continue enforcing your application's own user/data permissions.
|
|
92
|
+
|
|
93
|
+
For a framework hook, keep its existing request/context conventions and call
|
|
94
|
+
the shared guard before its `resolve`, `next` or renderer. A Fetch wrapper is
|
|
95
|
+
for a complete handler returning a Response, not every framework middleware
|
|
96
|
+
signature. Node/Express can mount the generated helper around any renderer.
|
|
97
|
+
|
|
98
|
+
### Angular SSR on Node
|
|
99
|
+
|
|
100
|
+
For a Fetch-style Angular SSR entry in `src/server.ts`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { AngularAppEngine, createRequestHandler } from '@angular/ssr';
|
|
104
|
+
import { createStopneticAppHandler } from '@stopnetic/js/server';
|
|
105
|
+
|
|
106
|
+
const engine = new AngularAppEngine();
|
|
107
|
+
let guarded: ReturnType<typeof createStopneticAppHandler> | undefined;
|
|
108
|
+
async function handle(request: Request): Promise<Response> {
|
|
109
|
+
guarded ??= createStopneticAppHandler(async checked =>
|
|
110
|
+
await engine.handle(checked) ?? new Response(null, { status: 404 }));
|
|
111
|
+
return guarded(request);
|
|
112
|
+
}
|
|
113
|
+
export const reqHandler = createRequestHandler(handle);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Keep existing Angular host configuration and request context if your entry
|
|
117
|
+
uses them. For an Express-based Angular entry, mount `installStopnetic(app)`
|
|
118
|
+
before `express.static` and `AngularNodeAppEngine.handle` instead.
|
|
119
|
+
|
|
120
|
+
Protected routes in `src/app/app.routes.server.ts` must use request-time
|
|
121
|
+
rendering rather than `RenderMode.Prerender`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { RenderMode, ServerRoute } from '@angular/ssr';
|
|
125
|
+
export const serverRoutes: ServerRoute[] = [
|
|
126
|
+
// All routes are rendered on incoming requests in this example.
|
|
127
|
+
{ path: '**', renderMode: RenderMode.Server },
|
|
128
|
+
];
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Netlify's automatic Angular SSR adapter currently uses Edge Functions. Do not
|
|
132
|
+
import this Node SDK into that Edge entry, and do not assume a Netlify Node
|
|
133
|
+
function runs before the Edge renderer. Use a Node SSR deployment, a Node
|
|
134
|
+
gateway that cannot be bypassed, or a static Angular build with Node page
|
|
135
|
+
handling. Switching to static rendering changes SSR behavior; choose deliberately.
|
|
136
|
+
|
|
137
|
+
### Static apps on Netlify / Vercel
|
|
138
|
+
|
|
139
|
+
The generated Netlify control file serves `/api/stopnetic/access` and
|
|
140
|
+
`/api/stopnetic/verify`; Vercel uses `api/stopnetic/[operation].mjs`. They do not
|
|
141
|
+
guard the original page request by themselves. Add a Node page function or
|
|
142
|
+
Node routing middleware before serving HTML if you need real page HTTP 404s.
|
|
143
|
+
The full hosting guide is at your Stopnetic dashboard's `/docs#javascript`.
|
|
144
|
+
|
|
145
|
+
Use your actual build output. Vite often uses `dist`; Angular commonly uses
|
|
146
|
+
`dist/<project>/browser`. Angular scripts/styles may be at that directory's
|
|
147
|
+
root rather than `/assets`. Bundle the correct HTML and assets, preserve their
|
|
148
|
+
MIME types, and do not use the Vite-only file paths unchanged for Angular.
|
|
149
|
+
Reject direct entry-HTML and function aliases that would bypass protected paths.
|
|
150
|
+
|
|
151
|
+
## 4. Browser startup: the same code for every frontend
|
|
152
|
+
|
|
153
|
+
For a browser-only/static app, move the existing startup code from `src/main.ts`
|
|
154
|
+
or `src/main.js` into `src/bootstrap.ts` or `src/bootstrap.js`. Keep its original
|
|
155
|
+
framework imports and bootstrap code. Replace the browser entry with:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
159
|
+
|
|
160
|
+
async function start() {
|
|
161
|
+
try {
|
|
162
|
+
const result = await checkAccess(location.pathname + location.search);
|
|
163
|
+
if (result.allowed) {
|
|
164
|
+
await import('./bootstrap'); // Your existing app startup, unchanged.
|
|
165
|
+
} else if (result.reason === 'verification_required' && result.verificationUrl) {
|
|
166
|
+
location.replace(result.verificationUrl);
|
|
167
|
+
} else {
|
|
168
|
+
document.body.replaceChildren(document.createTextNode(
|
|
169
|
+
result.reason === 'blocked' ? 'Not found.' : 'Access unavailable.'));
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
document.body.replaceChildren(document.createTextNode('Access unavailable.'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
void start();
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This is browser code, not a server hook. The startup file can use any framework:
|
|
179
|
+
|
|
180
|
+
| Frontend | Existing startup retained in `bootstrap` |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| Angular | `bootstrapApplication(App, appConfig)` or your existing NgModule bootstrap |
|
|
183
|
+
| React | `createRoot(...).render(...)` |
|
|
184
|
+
| Vue | `createApp(...).use(router).mount(...)` |
|
|
185
|
+
| Svelte | Your existing `mount(...)` or older constructor |
|
|
186
|
+
| Solid | Your existing `render(...)` |
|
|
187
|
+
| Plain JavaScript | Your existing DOM rendering code |
|
|
188
|
+
|
|
189
|
+
Use ordinary `<a href>` document links for guarded navigation: each navigation
|
|
190
|
+
then reaches the server before a new page is shown. Client router navigation
|
|
191
|
+
does not rerun the startup file. If keeping SPA navigation, call `checkAccess`
|
|
192
|
+
in that router's before-navigation guard for every target and handle denial,
|
|
193
|
+
verification redirects, outages and stale/cancelled requests. A UI check cannot
|
|
194
|
+
set the original HTTP status or make public bundles private.
|
|
195
|
+
|
|
196
|
+
<!-- BEGIN FRONTEND EXAMPLES -->
|
|
197
|
+
|
|
198
|
+
### Choose your frontend implementation
|
|
199
|
+
|
|
200
|
+
The package includes copyable source files in `examples/frontend`. The same
|
|
201
|
+
examples appear below and in the dashboard frontend selector. Choose the
|
|
202
|
+
example for your app; the shared startup above is an alternative. These are
|
|
203
|
+
complete small layouts with sample routes. Merge the guard into your own
|
|
204
|
+
layout and replace the sample pages/paths with your actual pages. Keep all
|
|
205
|
+
navigation, routes and footer inside the gate. The server setup remains required.
|
|
206
|
+
|
|
207
|
+
These router/layout examples run in the browser. Framework SSR request
|
|
208
|
+
handlers remain in section 3; keep browser checks out of server rendering.
|
|
209
|
+
|
|
210
|
+
Copy the selected folder under `src/<frontend>/` and the shared data helper
|
|
211
|
+
to `src/load-private-data.js` (plus its `.d.ts` file for TypeScript). This keeps
|
|
212
|
+
the relative imports below valid. Point your browser entry at the example
|
|
213
|
+
`main` file, or import that file from your existing entry when trying the demo.
|
|
214
|
+
React/Vue/Svelte/Solid/plain JS entries expect `<div id="app"></div>`; Angular
|
|
215
|
+
uses `<app-root></app-root>`. Use your own mount target when adapting the code.
|
|
216
|
+
Do not mount your old app outside the gate or mount two apps at once.
|
|
217
|
+
|
|
218
|
+
- React Router
|
|
219
|
+
- Angular Router
|
|
220
|
+
- Vue Router
|
|
221
|
+
- Svelte navigation
|
|
222
|
+
- Solid Router
|
|
223
|
+
- Plain JavaScript navigation
|
|
224
|
+
|
|
225
|
+
Shared browser API helper: `src/load-private-data.js`. `/api/private` must
|
|
226
|
+
be guarded on its own server. Angular demonstrates HttpClient instead.
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
// Browser example. The /api/private SERVER handler must also be guarded.
|
|
231
|
+
export async function loadPrivateData(signal) {
|
|
232
|
+
const response = await fetch('/api/private', {
|
|
233
|
+
credentials: 'same-origin', cache: 'no-store', signal,
|
|
234
|
+
headers: { Accept: 'application/json' },
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok) throw new Error(`Request failed (HTTP ${response.status}).`);
|
|
237
|
+
return response.json();
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
TypeScript declaration: `src/load-private-data.d.ts`.
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
export function loadPrivateData(signal?: AbortSignal): Promise<unknown>;
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### React Router
|
|
249
|
+
|
|
250
|
+
Use BrowserRouter and StopneticBoundary around the full header, routes and footer. Client Link navigation triggers another access check. React Router 6+ style; adapt your existing routes rather than replacing them. Add react-router-dom if your app does not have it.
|
|
251
|
+
|
|
252
|
+
#### src/react/App.tsx
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
257
|
+
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom';
|
|
258
|
+
import { StopneticBoundary } from '@stopnetic/js/react';
|
|
259
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
260
|
+
|
|
261
|
+
function RouteGuard({ children }: { children: ReactNode }) {
|
|
262
|
+
const location = useLocation();
|
|
263
|
+
return <StopneticBoundary path={location.pathname + location.search}
|
|
264
|
+
pending={<p>Checking access...</p>}
|
|
265
|
+
blocked={<p>Access unavailable.</p>}>
|
|
266
|
+
{children}
|
|
267
|
+
</StopneticBoundary>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function Members() {
|
|
271
|
+
const [data, setData] = useState('Loading data...');
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
loadPrivateData(controller.signal)
|
|
275
|
+
.then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
|
|
276
|
+
.catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
|
|
277
|
+
return () => controller.abort();
|
|
278
|
+
}, []);
|
|
279
|
+
return <main><h1>Members</h1><p>{data}</p></main>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function Layout() {
|
|
283
|
+
return <>
|
|
284
|
+
<header><nav>
|
|
285
|
+
<Link to="/">Home</Link>{' '}<Link to="/members">Members</Link>{' '}
|
|
286
|
+
<Link to="/slow">Slow</Link>{' '}<Link to="/blocked">Blocked</Link>{' '}
|
|
287
|
+
<Link to="/offline">Offline</Link>
|
|
288
|
+
</nav></header>
|
|
289
|
+
<Routes>
|
|
290
|
+
<Route caseSensitive path="/" element={<main><h1>Home</h1></main>}/>
|
|
291
|
+
<Route caseSensitive path="/members" element={<Members/>}/>
|
|
292
|
+
<Route caseSensitive path="/slow" element={<main><h1>Slow</h1></main>}/>
|
|
293
|
+
<Route caseSensitive path="/blocked" element={<main><h1>Blocked</h1></main>}/>
|
|
294
|
+
<Route caseSensitive path="/offline" element={<main><h1>Offline</h1></main>}/>
|
|
295
|
+
<Route path="*" element={<main><h1>Not found</h1></main>}/>
|
|
296
|
+
</Routes>
|
|
297
|
+
<footer>Example site</footer>
|
|
298
|
+
</>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default function App() {
|
|
302
|
+
return <BrowserRouter><RouteGuard><Layout/></RouteGuard></BrowserRouter>;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### src/react/main.tsx
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
import { createRoot } from 'react-dom/client';
|
|
311
|
+
import App from './App';
|
|
312
|
+
createRoot(document.getElementById('app')!).render(<App/>);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Angular Router
|
|
316
|
+
|
|
317
|
+
The service records checking/allowed/denied state. Apply the route guard to every page and gate the full layout. Members uses HttpClient after route access succeeds. Merge providers into your existing appConfig, keeping SSR/hydration settings. This standalone example uses modern Angular syntax.
|
|
318
|
+
|
|
319
|
+
#### src/angular/app/app.config.ts
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
import { ApplicationConfig } from '@angular/core';
|
|
324
|
+
import { provideHttpClient } from '@angular/common/http';
|
|
325
|
+
import { provideRouter } from '@angular/router';
|
|
326
|
+
import { routes } from './app.routes';
|
|
327
|
+
export const appConfig: ApplicationConfig = {
|
|
328
|
+
providers: [provideRouter(routes), provideHttpClient()],
|
|
329
|
+
};
|
|
330
|
+
// Merge these providers into your existing appConfig; retain its other settings.
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
#### src/angular/app/app.routes.ts
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { Component } from '@angular/core';
|
|
338
|
+
import { Routes } from '@angular/router';
|
|
339
|
+
import { MembersPage } from './members.page';
|
|
340
|
+
import { stopneticRouteGuard } from './stopnetic-access.service';
|
|
341
|
+
|
|
342
|
+
@Component({ template: '<main><h1>Home</h1></main>' })
|
|
343
|
+
class HomePage {}
|
|
344
|
+
@Component({ template: '<main><h1>Slow</h1></main>' })
|
|
345
|
+
class OtherPage {}
|
|
346
|
+
|
|
347
|
+
const pages: Routes = [
|
|
348
|
+
{ path: '', pathMatch: 'full', component: HomePage },
|
|
349
|
+
{ path: 'members', component: MembersPage },
|
|
350
|
+
{ path: 'slow', component: OtherPage },
|
|
351
|
+
{ path: 'blocked', component: OtherPage },
|
|
352
|
+
{ path: 'offline', component: OtherPage },
|
|
353
|
+
];
|
|
354
|
+
export const routes: Routes = pages.map(route => ({
|
|
355
|
+
...route, canActivate: [stopneticRouteGuard], runGuardsAndResolvers: 'always' as const,
|
|
356
|
+
}));
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
#### src/angular/app/app.ts
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
import { Component, inject } from '@angular/core';
|
|
364
|
+
import { RouterLink, RouterOutlet } from '@angular/router';
|
|
365
|
+
import { StopneticAccess } from './stopnetic-access.service';
|
|
366
|
+
|
|
367
|
+
@Component({
|
|
368
|
+
selector: 'app-root', imports: [RouterLink, RouterOutlet],
|
|
369
|
+
template: `
|
|
370
|
+
@if (access.state() === 'allowed') {
|
|
371
|
+
<header><nav>
|
|
372
|
+
<a routerLink="/">Home</a> <a routerLink="/members">Members</a>
|
|
373
|
+
<a routerLink="/slow">Slow</a> <a routerLink="/blocked">Blocked</a>
|
|
374
|
+
<a routerLink="/offline">Offline</a>
|
|
375
|
+
</nav></header>
|
|
376
|
+
<router-outlet/>
|
|
377
|
+
<footer>Example site</footer>
|
|
378
|
+
} @else if (access.state() === 'checking') {
|
|
379
|
+
<p role="status">Checking access...</p>
|
|
380
|
+
} @else {
|
|
381
|
+
<p role="alert">Access unavailable.</p>
|
|
382
|
+
}
|
|
383
|
+
`,
|
|
384
|
+
})
|
|
385
|
+
export class App { readonly access = inject(StopneticAccess); }
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
#### src/angular/app/members.page.ts
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
393
|
+
import { Component, DestroyRef, inject, OnInit, PLATFORM_ID, signal } from '@angular/core';
|
|
394
|
+
import { HttpClient } from '@angular/common/http';
|
|
395
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
396
|
+
|
|
397
|
+
@Component({ template: '<main><h1>Members</h1><p>{{ data() }}</p></main>' })
|
|
398
|
+
export class MembersPage implements OnInit {
|
|
399
|
+
readonly data = signal('Loading data...');
|
|
400
|
+
private readonly http = inject(HttpClient);
|
|
401
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
402
|
+
private readonly platform = inject(PLATFORM_ID);
|
|
403
|
+
ngOnInit() {
|
|
404
|
+
// This example loads data in the browser. SSR fetches need request cookies
|
|
405
|
+
// forwarded explicitly, plus server authorization; do not bypass the guard.
|
|
406
|
+
if (!isPlatformBrowser(this.platform)) return;
|
|
407
|
+
this.http.get('/api/private', { withCredentials: true })
|
|
408
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
409
|
+
.subscribe({ next: value => this.data.set(JSON.stringify(value)),
|
|
410
|
+
error: () => this.data.set('API access unavailable.') });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
#### src/angular/app/stopnetic-access.service.ts
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
420
|
+
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
|
421
|
+
import { CanActivateFn } from '@angular/router';
|
|
422
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
423
|
+
|
|
424
|
+
@Injectable({ providedIn: 'root' })
|
|
425
|
+
export class StopneticAccess {
|
|
426
|
+
readonly state = signal<'checking' | 'allowed' | 'denied'>('checking');
|
|
427
|
+
private readonly platform = inject(PLATFORM_ID);
|
|
428
|
+
private current?: AbortController;
|
|
429
|
+
async check(path: string): Promise<boolean> {
|
|
430
|
+
// Node SSR must be authorized by the SERVER guard before Angular renders.
|
|
431
|
+
if (!isPlatformBrowser(this.platform)) { this.state.set('allowed'); return true; }
|
|
432
|
+
this.current?.abort();
|
|
433
|
+
const controller = new AbortController(); this.current = controller;
|
|
434
|
+
this.state.set('checking');
|
|
435
|
+
try {
|
|
436
|
+
const result = await checkAccess(path.split('#')[0], { signal: controller.signal });
|
|
437
|
+
if (controller.signal.aborted) return false;
|
|
438
|
+
if (result.allowed) { this.state.set('allowed'); return true; }
|
|
439
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
440
|
+
} catch { if (controller.signal.aborted) return false; }
|
|
441
|
+
this.state.set('denied'); return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
export const stopneticRouteGuard: CanActivateFn = (_route, state) =>
|
|
445
|
+
inject(StopneticAccess).check(state.url);
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### src/angular/main.ts
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
453
|
+
import { App } from './app/app';
|
|
454
|
+
import { appConfig } from './app/app.config';
|
|
455
|
+
bootstrapApplication(App, appConfig).catch(error => console.error(error));
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Vue Router
|
|
459
|
+
|
|
460
|
+
Vue 3 and Vue Router 4: beforeEach checks the target and cancels stale checks. App.vue hides the entire layout while checking or denied. afterEach shows it only after a successful navigation. Add vue-router if needed.
|
|
461
|
+
|
|
462
|
+
#### src/vue/App.vue
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
```vue
|
|
466
|
+
<script setup>
|
|
467
|
+
import { RouterLink, RouterView } from 'vue-router';
|
|
468
|
+
import { accessState } from './router.js';
|
|
469
|
+
</script>
|
|
470
|
+
|
|
471
|
+
<template>
|
|
472
|
+
<template v-if="accessState === 'allowed'">
|
|
473
|
+
<header><nav>
|
|
474
|
+
<RouterLink to="/">Home</RouterLink> <RouterLink to="/members">Members</RouterLink>
|
|
475
|
+
<RouterLink to="/slow">Slow</RouterLink> <RouterLink to="/blocked">Blocked</RouterLink>
|
|
476
|
+
<RouterLink to="/offline">Offline</RouterLink>
|
|
477
|
+
</nav></header>
|
|
478
|
+
<RouterView/>
|
|
479
|
+
<footer>Example site</footer>
|
|
480
|
+
</template>
|
|
481
|
+
<p v-else-if="accessState === 'checking'" role="status">Checking access...</p>
|
|
482
|
+
<p v-else role="alert">Access unavailable.</p>
|
|
483
|
+
</template>
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
#### src/vue/main.js
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
```js
|
|
490
|
+
import { createApp } from 'vue';
|
|
491
|
+
import App from './App.vue';
|
|
492
|
+
import { router } from './router.js';
|
|
493
|
+
createApp(App).use(router).mount('#app');
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
#### src/vue/Members.vue
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
```vue
|
|
500
|
+
<script setup>
|
|
501
|
+
import { onMounted, onUnmounted, ref } from 'vue';
|
|
502
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
503
|
+
const data = ref('Loading data...');
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
onMounted(() => {
|
|
506
|
+
loadPrivateData(controller.signal)
|
|
507
|
+
.then(value => { if (!controller.signal.aborted) data.value = JSON.stringify(value); })
|
|
508
|
+
.catch(() => { if (!controller.signal.aborted) data.value = 'API access unavailable.'; });
|
|
509
|
+
});
|
|
510
|
+
onUnmounted(() => controller.abort());
|
|
511
|
+
</script>
|
|
512
|
+
|
|
513
|
+
<template><main><h1>Members</h1><p>{{ data }}</p></main></template>
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
#### src/vue/router.js
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
```js
|
|
520
|
+
import { h, ref } from 'vue';
|
|
521
|
+
import { createRouter, createWebHistory } from 'vue-router';
|
|
522
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
523
|
+
import Members from './Members.vue';
|
|
524
|
+
|
|
525
|
+
export const accessState = ref('checking');
|
|
526
|
+
const page = title => ({ render: () => h('main', h('h1', title)) });
|
|
527
|
+
export const router = createRouter({
|
|
528
|
+
history: createWebHistory(),
|
|
529
|
+
routes: [
|
|
530
|
+
{ path: '/', component: page('Home') },
|
|
531
|
+
{ path: '/members', component: Members },
|
|
532
|
+
{ path: '/slow', component: page('Slow') },
|
|
533
|
+
{ path: '/blocked', component: page('Blocked') },
|
|
534
|
+
{ path: '/offline', component: page('Offline') },
|
|
535
|
+
],
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
let current;
|
|
539
|
+
let approvedPath = '';
|
|
540
|
+
router.beforeEach(async to => {
|
|
541
|
+
current?.abort();
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
current = controller;
|
|
544
|
+
approvedPath = '';
|
|
545
|
+
accessState.value = 'checking';
|
|
546
|
+
try {
|
|
547
|
+
const result = await checkAccess(to.fullPath.split('#')[0], { signal: controller.signal });
|
|
548
|
+
if (controller.signal.aborted) return false;
|
|
549
|
+
if (result.allowed) { approvedPath = to.fullPath; return true; }
|
|
550
|
+
if (result.reason === 'verification_required' && result.verificationUrl) {
|
|
551
|
+
location.assign(result.verificationUrl);
|
|
552
|
+
}
|
|
553
|
+
accessState.value = 'denied';
|
|
554
|
+
} catch { if (!controller.signal.aborted) accessState.value = 'denied'; }
|
|
555
|
+
return false;
|
|
556
|
+
});
|
|
557
|
+
router.afterEach((to, _from, failure) => {
|
|
558
|
+
if (!failure && approvedPath === to.fullPath) accessState.value = 'allowed';
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### Svelte navigation
|
|
563
|
+
|
|
564
|
+
Svelte 5 example with onMount, History API navigation, popstate and abort cleanup. The full layout stays hidden until access succeeds. It does not require a third-party router. For SvelteKit SSR, retain server-side enforcement and integrate its own navigation lifecycle; this is a standalone Svelte app.
|
|
565
|
+
|
|
566
|
+
#### src/svelte/App.svelte
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
```svelte
|
|
570
|
+
<script>
|
|
571
|
+
import { onMount } from 'svelte';
|
|
572
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
573
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
574
|
+
|
|
575
|
+
let path = '';
|
|
576
|
+
let state = 'checking';
|
|
577
|
+
let data = '';
|
|
578
|
+
let current;
|
|
579
|
+
async function showPage() {
|
|
580
|
+
current?.abort();
|
|
581
|
+
const controller = new AbortController();
|
|
582
|
+
current = controller;
|
|
583
|
+
path = location.pathname;
|
|
584
|
+
state = 'checking'; data = '';
|
|
585
|
+
try {
|
|
586
|
+
const result = await checkAccess(path + location.search, { signal: controller.signal });
|
|
587
|
+
if (controller.signal.aborted) return;
|
|
588
|
+
if (!result.allowed) {
|
|
589
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
590
|
+
state = 'denied'; return;
|
|
591
|
+
}
|
|
592
|
+
state = 'allowed';
|
|
593
|
+
if (path === '/members') {
|
|
594
|
+
data = 'Loading data...';
|
|
595
|
+
try {
|
|
596
|
+
const value = await loadPrivateData(controller.signal);
|
|
597
|
+
if (!controller.signal.aborted) data = JSON.stringify(value);
|
|
598
|
+
} catch { if (!controller.signal.aborted) data = 'API access unavailable.'; }
|
|
599
|
+
}
|
|
600
|
+
} catch { if (!controller.signal.aborted) state = 'denied'; }
|
|
601
|
+
}
|
|
602
|
+
function navigate(event) {
|
|
603
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
604
|
+
event.preventDefault();
|
|
605
|
+
history.pushState(null, '', event.currentTarget.href);
|
|
606
|
+
void showPage();
|
|
607
|
+
}
|
|
608
|
+
onMount(() => {
|
|
609
|
+
void showPage();
|
|
610
|
+
addEventListener('popstate', showPage);
|
|
611
|
+
return () => { current?.abort(); removeEventListener('popstate', showPage); };
|
|
612
|
+
});
|
|
613
|
+
</script>
|
|
614
|
+
|
|
615
|
+
{#if state === 'allowed'}
|
|
616
|
+
<header><nav>
|
|
617
|
+
<a href="/" onclick={navigate}>Home</a> <a href="/members" onclick={navigate}>Members</a>
|
|
618
|
+
<a href="/slow" onclick={navigate}>Slow</a> <a href="/blocked" onclick={navigate}>Blocked</a>
|
|
619
|
+
<a href="/offline" onclick={navigate}>Offline</a>
|
|
620
|
+
</nav></header>
|
|
621
|
+
<main><h1>{path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found'}</h1><p>{data}</p></main>
|
|
622
|
+
<footer>Example site</footer>
|
|
623
|
+
{:else if state === 'checking'}
|
|
624
|
+
<p role="status">Checking access...</p>
|
|
625
|
+
{:else}
|
|
626
|
+
<p role="alert">Access unavailable.</p>
|
|
627
|
+
{/if}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
#### src/svelte/main.js
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
// Svelte 5 entry. Svelte 4 apps retain their existing `new App(...)` entry.
|
|
635
|
+
import { mount } from 'svelte';
|
|
636
|
+
import App from './App.svelte';
|
|
637
|
+
mount(App, { target: document.getElementById('app') });
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### Solid Router
|
|
641
|
+
|
|
642
|
+
Solid Router root layout uses useLocation and reactive checks. The Show boundary creates the full layout only for the checked path, and cleanup aborts stale checks. Add @solidjs/router if needed. This browser example does not replace SolidStart server authorization.
|
|
643
|
+
|
|
644
|
+
#### src/solid/App.tsx
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
import { createEffect, createSignal, onCleanup, Show, type ParentComponent } from 'solid-js';
|
|
649
|
+
import { A, Route, Router, useLocation } from '@solidjs/router';
|
|
650
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
651
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
652
|
+
|
|
653
|
+
const GuardedLayout: ParentComponent = props => {
|
|
654
|
+
const location = useLocation();
|
|
655
|
+
const path = () => location.pathname + location.search;
|
|
656
|
+
const [result, setResult] = createSignal({ path: '', allowed: false, pending: true });
|
|
657
|
+
createEffect(() => {
|
|
658
|
+
const target = path();
|
|
659
|
+
const controller = new AbortController();
|
|
660
|
+
setResult({ path: target, allowed: false, pending: true });
|
|
661
|
+
checkAccess(target, { signal: controller.signal }).then(value => {
|
|
662
|
+
if (controller.signal.aborted) return;
|
|
663
|
+
if (!value.allowed && value.reason === 'verification_required' && value.verificationUrl) locationRedirect(value.verificationUrl);
|
|
664
|
+
setResult({ path: target, allowed: value.allowed, pending: false });
|
|
665
|
+
}).catch(() => {
|
|
666
|
+
if (!controller.signal.aborted) setResult({ path: target, allowed: false, pending: false });
|
|
667
|
+
});
|
|
668
|
+
onCleanup(() => controller.abort());
|
|
669
|
+
});
|
|
670
|
+
return <Show when={result().allowed && result().path === path() ? result() : undefined}
|
|
671
|
+
fallback={<Show when={result().pending || result().path !== path()}
|
|
672
|
+
fallback={<p role="alert">Access unavailable.</p>}><p role="status">Checking access...</p></Show>}>
|
|
673
|
+
{_checked => <>
|
|
674
|
+
<header><nav><A href="/">Home</A>{' '}<A href="/members">Members</A>{' '}
|
|
675
|
+
<A href="/slow">Slow</A>{' '}<A href="/blocked">Blocked</A>{' '}<A href="/offline">Offline</A>
|
|
676
|
+
</nav></header>
|
|
677
|
+
{props.children}
|
|
678
|
+
<footer>Example site</footer>
|
|
679
|
+
</>}
|
|
680
|
+
</Show>;
|
|
681
|
+
};
|
|
682
|
+
const locationRedirect = (url: string) => window.location.assign(url);
|
|
683
|
+
|
|
684
|
+
function Members() {
|
|
685
|
+
const [data, setData] = createSignal('Loading data...');
|
|
686
|
+
const controller = new AbortController();
|
|
687
|
+
loadPrivateData(controller.signal)
|
|
688
|
+
.then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
|
|
689
|
+
.catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
|
|
690
|
+
onCleanup(() => controller.abort());
|
|
691
|
+
return <main><h1>Members</h1><p>{data()}</p></main>;
|
|
692
|
+
}
|
|
693
|
+
export default function App() {
|
|
694
|
+
return <Router root={GuardedLayout}>
|
|
695
|
+
<Route path="/" component={() => <main><h1>Home</h1></main>}/>
|
|
696
|
+
<Route path="/members" component={Members}/>
|
|
697
|
+
<Route path="/slow" component={() => <main><h1>Slow</h1></main>}/>
|
|
698
|
+
<Route path="/blocked" component={() => <main><h1>Blocked</h1></main>}/>
|
|
699
|
+
<Route path="/offline" component={() => <main><h1>Offline</h1></main>}/>
|
|
700
|
+
</Router>;
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
#### src/solid/main.tsx
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
```tsx
|
|
708
|
+
import { render } from 'solid-js/web';
|
|
709
|
+
import App from './App';
|
|
710
|
+
render(() => <App/>, document.getElementById('app')!);
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Plain JavaScript navigation
|
|
714
|
+
|
|
715
|
+
Create the full layout only after checkAccess allows the target. Clicks and browser back/forward rerun the check. Abort old checks and data loads. Bundle this module with your browser build; native browsers need bundled imports or an import map.
|
|
716
|
+
|
|
717
|
+
#### src/vanilla/main.js
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
```js
|
|
721
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
722
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
723
|
+
const root = document.getElementById('app');
|
|
724
|
+
let current;
|
|
725
|
+
async function showPage() {
|
|
726
|
+
current?.abort();
|
|
727
|
+
const controller = new AbortController(); current = controller;
|
|
728
|
+
root.replaceChildren(document.createTextNode('Checking access...'));
|
|
729
|
+
try {
|
|
730
|
+
const path = location.pathname;
|
|
731
|
+
const result = await checkAccess(path + location.search, { signal: controller.signal });
|
|
732
|
+
if (controller.signal.aborted) return;
|
|
733
|
+
if (!result.allowed) {
|
|
734
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
735
|
+
const alert = document.createElement('p'); alert.setAttribute('role', 'alert');
|
|
736
|
+
alert.textContent = 'Access unavailable.'; root.replaceChildren(alert); return;
|
|
737
|
+
}
|
|
738
|
+
const header = document.createElement('header');
|
|
739
|
+
const nav = document.createElement('nav'); header.append(nav);
|
|
740
|
+
for (const [url, name] of [['/', 'Home'], ['/members', 'Members'], ['/slow', 'Slow'], ['/blocked', 'Blocked'], ['/offline', 'Offline']]) {
|
|
741
|
+
const link = document.createElement('a'); link.href = url; link.textContent = name;
|
|
742
|
+
link.addEventListener('click', event => {
|
|
743
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
744
|
+
event.preventDefault(); history.pushState(null, '', url); void showPage();
|
|
745
|
+
});
|
|
746
|
+
nav.append(link, document.createTextNode(' '));
|
|
747
|
+
}
|
|
748
|
+
const main = document.createElement('main'); const heading = document.createElement('h1');
|
|
749
|
+
heading.textContent = path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found';
|
|
750
|
+
const data = document.createElement('p'); main.append(heading, data);
|
|
751
|
+
const footer = document.createElement('footer'); footer.textContent = 'Example site';
|
|
752
|
+
root.replaceChildren(header, main, footer);
|
|
753
|
+
if (path === '/members') {
|
|
754
|
+
data.textContent = 'Loading data...';
|
|
755
|
+
try {
|
|
756
|
+
const value = await loadPrivateData(controller.signal);
|
|
757
|
+
if (!controller.signal.aborted) data.textContent = JSON.stringify(value);
|
|
758
|
+
} catch { if (!controller.signal.aborted) data.textContent = 'API access unavailable.'; }
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
if (!controller.signal.aborted) root.replaceChildren(document.createTextNode('Access unavailable.'));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
addEventListener('popstate', showPage);
|
|
765
|
+
addEventListener('pagehide', () => current?.abort());
|
|
766
|
+
void showPage();
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
<!-- END FRONTEND EXAMPLES -->
|
|
770
|
+
|
|
771
|
+
## 5. Protect APIs independently
|
|
772
|
+
|
|
773
|
+
If the full application wrapper is in front of an API, it checks that API too.
|
|
774
|
+
Separate Netlify/Vercel API functions need their own wrapper:
|
|
775
|
+
|
|
776
|
+
```js
|
|
777
|
+
import { withStopnetic } from '@stopnetic/js/server';
|
|
778
|
+
const handler = withStopnetic(async (request, context) => {
|
|
779
|
+
// Also enforce your application's login/data permissions.
|
|
780
|
+
return Response.json({ example: true });
|
|
781
|
+
});
|
|
782
|
+
// Netlify: export default handler;
|
|
783
|
+
// export const config = { path: '/api/private', preferStatic: false };
|
|
784
|
+
// Vercel: export default { fetch: handler };
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Use the public custom `/api/private` path in all clients and Policies, not
|
|
788
|
+
Netlify's default `/.netlify/functions/` alias. Fetch same-origin data with
|
|
789
|
+
`credentials: 'same-origin'`. API requests cannot run a verification page;
|
|
790
|
+
verify on an HTML page first. An API on another origin needs its own server
|
|
791
|
+
protection and session, or a guarded same-origin server proxy. CORS alone does
|
|
792
|
+
not authorize data, and the current SDK does not share sessions across hosts.
|
|
793
|
+
|
|
794
|
+
## 6. Rules, deployment and testing
|
|
795
|
+
|
|
796
|
+
Save the actual page as Page / Automatic browser flow and its APIs as API /
|
|
797
|
+
Require verified session in Policies. First matching rule wins. Unmatched paths
|
|
798
|
+
are public in the generated site's whole-site mode. Public and block rules still
|
|
799
|
+
require a valid signed policy response. Verification can be completed by capable
|
|
800
|
+
automated browsers; retain your own authentication and authorization.
|
|
801
|
+
|
|
802
|
+
Run `stopnetic-js check`, build and deploy. Configure server env in each hosted
|
|
803
|
+
context and redeploy after changing it. For local Netlify/Vercel functions use
|
|
804
|
+
the full local server, not just `ng serve` or Vite. For Node use
|
|
805
|
+
`node --env-file=.env.local your-server.mjs` or your server environment loader.
|
|
806
|
+
|
|
807
|
+
Test raw document requests, verified browser requests, direct private API calls,
|
|
808
|
+
denial and service failure. Check status/body, session cookies, JS/CSS MIME types,
|
|
809
|
+
client navigation, entry HTML, aliases, prerender outputs and caches. A denied
|
|
810
|
+
request must not execute the renderer or data handler. The offline CLI check
|
|
811
|
+
validates configuration and generated files, not deployed coverage.
|
|
812
|
+
|
|
813
|
+
Ordinary policy changes apply on subsequent guarded requests without reinstalling
|
|
814
|
+
or redeploying. Already loaded content is not recalled. Public HTML/JS/assets and
|
|
815
|
+
content bundled into frontend code remain public; keep private data in guarded
|
|
816
|
+
server endpoints.
|
|
817
|
+
|
|
818
|
+
## Updating an existing generated guide
|
|
819
|
+
|
|
820
|
+
After installing the new SDK, rerun `stopnetic-js init --platform <same-platform>`
|
|
821
|
+
(preview with `--dry-run`). An unchanged 0.0.2/0.0.3 generated guide is upgraded
|
|
822
|
+
with its setup manifest. Your key, cookie secret and server files are preserved.
|
|
823
|
+
Customized guides or handlers require manual merging; switching platforms also
|
|
824
|
+
requires review. This command does not publish, deploy or contact Stopnetic.
|
|
825
|
+
|
|
826
|
+
References: https://angular.dev/guide/ssr,
|
|
827
|
+
https://angular.dev/guide/routing/route-guards,
|
|
828
|
+
https://docs.netlify.com/build/frameworks/framework-setup-guides/angular/.
|