@stopnetic/js 0.0.3-pilot → 0.0.5-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 +61 -18
- package/dist/client-guide.md +696 -30
- package/dist/setup.js +9 -4
- 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 +21 -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 +56 -0
- package/examples/frontend/svelte/main.js +4 -0
- package/examples/frontend/vanilla/main.js +46 -0
- package/examples/frontend/vue/App.vue +17 -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
package/dist/client-guide.md
CHANGED
|
@@ -4,11 +4,70 @@ Angular, React, Vue, Svelte, Solid and plain JavaScript use the same server guar
|
|
|
4
4
|
and browser access check. React is optional. Install `@stopnetic/js` in the
|
|
5
5
|
website project; choose a hosting platform, not a frontend framework.
|
|
6
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
|
+
|
|
7
13
|
The generated control endpoint is only part of installation. It does not
|
|
8
14
|
automatically intercept pages, edit your router or protect existing APIs.
|
|
9
15
|
|
|
16
|
+
## Integration contract — read before editing an existing app
|
|
17
|
+
|
|
18
|
+
1. Inspect the existing server entry, rendering modes, hosting adapter and router.
|
|
19
|
+
Preserve SSR, hydration, public pages, routes and application features.
|
|
20
|
+
Installing Stopnetic is not authorization to convert SSR to CSR, delete server
|
|
21
|
+
files, replace the application's router or copy the demo over an existing app.
|
|
22
|
+
2. Choose ONE of the two rendering paths below. Keep the app's current rendering
|
|
23
|
+
by default. A hosting/runtime incompatibility must be explained to the owner;
|
|
24
|
+
do not resolve it by silently removing SSR or changing hosting.
|
|
25
|
+
3. Install the guard before the existing server renders or serves HTML. Keep the
|
|
26
|
+
existing browser bootstrap unchanged for the default document-navigation mode.
|
|
27
|
+
Do not add a global `Checking access...` screen or a browser startup gate.
|
|
28
|
+
4. Save protected paths in Stopnetic Policies. Do not add the demo paths, protect
|
|
29
|
+
every page, copy dashboard rules into a frontend path list, or change defaults
|
|
30
|
+
without the owner's instruction. Guards evaluate requests; they do not make
|
|
31
|
+
every evaluated request require verification.
|
|
32
|
+
5. Use normal document navigation for the simplest server-enforced integration.
|
|
33
|
+
If retaining client-side SPA navigation is required, use the OPTIONAL router
|
|
34
|
+
examples and explain their asynchronous checks before adding them.
|
|
35
|
+
6. Independently guard private API handlers. Validate public, protected and denied
|
|
36
|
+
pages plus SSR output before declaring integration complete.
|
|
37
|
+
|
|
38
|
+
## What visitors should see
|
|
39
|
+
|
|
40
|
+
For example, suppose only `/members` is Page / Automatic browser flow:
|
|
41
|
+
|
|
42
|
+
| Request | Normal behavior |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `/`, `/about`, `/contact` with no matching rule | Public page; normal layout, no Stopnetic checking screen or verification |
|
|
45
|
+
| `/members` without a valid session | Server verification flow before returning the page |
|
|
46
|
+
| `/members` with a valid session | Normal page content |
|
|
47
|
+
| A Page / Block route | Empty HTTP 404 before any site layout renders |
|
|
48
|
+
| A protected API without a valid session | HTTP 404; no JSON data and no browser challenge |
|
|
49
|
+
|
|
50
|
+
Generated site mode leaves unmatched paths public; explicit broader rules can
|
|
51
|
+
change that. Public requests still need a valid signed policy response. A service
|
|
52
|
+
failure returns 503 rather than bypassing policy. Rules belong in the dashboard;
|
|
53
|
+
the browser cannot know whether a target is public until the server checks it.
|
|
54
|
+
|
|
10
55
|
## 1. Choose the actual rendering and server runtime
|
|
11
56
|
|
|
57
|
+
**Option A — preserve SSR.** Keep the existing renderer, server entry and
|
|
58
|
+
hydration. Add the Node guard before the renderer (section 3). Use server
|
|
59
|
+
document navigation (section 4). Public pages render normally; protected pages
|
|
60
|
+
render only after the dashboard decision permits them.
|
|
61
|
+
|
|
62
|
+
**Option B — retain a static/CSR frontend.** Keep the existing browser bootstrap
|
|
63
|
+
and build. Add a Node hosting page guard before HTML (section 3), then use server
|
|
64
|
+
document navigation (section 4). A Node function serving built `index.html` is
|
|
65
|
+
static page handling, not SSR. Choose this option for an existing static app or
|
|
66
|
+
only after an explicit decision to convert rendering.
|
|
67
|
+
|
|
68
|
+
Neither option requires a global browser boundary. Optional SPA navigation is
|
|
69
|
+
a separate choice, not a third rendering mode.
|
|
70
|
+
|
|
12
71
|
| Application | Where the shared guard belongs |
|
|
13
72
|
| --- | --- |
|
|
14
73
|
| Node/Express SSR or static-file server | Before body parsers, static files, API handlers and rendering |
|
|
@@ -122,11 +181,21 @@ export const serverRoutes: ServerRoute[] = [
|
|
|
122
181
|
];
|
|
123
182
|
```
|
|
124
183
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
This is an all-request-time demonstration, not an instruction to replace an
|
|
185
|
+
existing hybrid route configuration. Preserve public rendering modes where
|
|
186
|
+
appropriate. Any path the dashboard may protect must reach the guard before
|
|
187
|
+
HTML is served; publicly cached/prerendered HTML bypassing it cannot be protected
|
|
188
|
+
by adding a dashboard rule alone. Plan server coverage for those paths first.
|
|
189
|
+
|
|
190
|
+
Netlify's automatic Angular SSR adapter currently uses Edge Functions. This
|
|
191
|
+
combination does not have a drop-in integration with the current Node SDK.
|
|
192
|
+
Do not import the SDK into that Edge entry or assume a Node function intercepts
|
|
193
|
+
the Edge renderer. If SSR must stay, keep the SSR files and explain that a
|
|
194
|
+
supported Node SSR deployment or a Node gateway that cannot be bypassed is
|
|
195
|
+
needed. A custom Node SSR adapter requires separate deployment/routing work;
|
|
196
|
+
the static page-function example is not that adapter. Stop and obtain a rendering
|
|
197
|
+
or deployment decision if the current runtime is incompatible. Do not delete
|
|
198
|
+
`server.ts`, `main.server.ts`, SSR providers or hydration as a workaround.
|
|
130
199
|
|
|
131
200
|
### Static apps on Netlify / Vercel
|
|
132
201
|
|
|
@@ -142,7 +211,55 @@ root rather than `/assets`. Bundle the correct HTML and assets, preserve their
|
|
|
142
211
|
MIME types, and do not use the Vite-only file paths unchanged for Angular.
|
|
143
212
|
Reject direct entry-HTML and function aliases that would bypass protected paths.
|
|
144
213
|
|
|
145
|
-
## 4.
|
|
214
|
+
## 4. Navigation — server enforcement by default
|
|
215
|
+
|
|
216
|
+
For BOTH rendering options, leave the existing browser entry and app layout
|
|
217
|
+
alone. Use document links for page navigation so every target reaches the
|
|
218
|
+
guarded server before rendering. No global `checkAccess`, `StopneticBoundary`,
|
|
219
|
+
startup import gate, root access service or loading message is required.
|
|
220
|
+
|
|
221
|
+
| Frontend | Document navigation |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| Angular | `<a href="/members">Members</a>` instead of `routerLink` for this navigation |
|
|
224
|
+
| React | `<a href="/members">Members</a>` instead of router `<Link>` |
|
|
225
|
+
| Vue | `<a href="/members">Members</a>` instead of `<RouterLink>` |
|
|
226
|
+
| Svelte / SvelteKit | `<a href="/members" data-sveltekit-reload>Members</a>`; disable any custom interception |
|
|
227
|
+
| Solid | `<a href="/members" target="_self">Members</a>` instead of router `<A>` |
|
|
228
|
+
| Plain JavaScript | `<a href="/members">Members</a>`; do not intercept it with `pushState` |
|
|
229
|
+
|
|
230
|
+
Replace programmatic SPA transitions with `location.assign('/members')` when
|
|
231
|
+
using this mode. Turn off router prefetch/preload of protected page data that
|
|
232
|
+
would bypass the guarded document request. Existing routers may still resolve
|
|
233
|
+
the current URL after loading. Normal pages keep their usual loading behavior;
|
|
234
|
+
Stopnetic's verification HTML is sent only when the server decision requires it.
|
|
235
|
+
|
|
236
|
+
For SSR, hydrate the HTML already authorized by the server. Do not replace it
|
|
237
|
+
with a browser-only loading screen or run a redundant startup check. Private API
|
|
238
|
+
requests remain independently checked by their server handler.
|
|
239
|
+
|
|
240
|
+
### Optional SPA navigation — explicit asynchronous tradeoff
|
|
241
|
+
|
|
242
|
+
Retain SPA navigation only when needed. SPA transitions do not make a new
|
|
243
|
+
document request, so check each target in the router before showing its content.
|
|
244
|
+
The current browser API returns access after a server request; it does not
|
|
245
|
+
provide a synchronous dashboard route manifest. Public transitions also need
|
|
246
|
+
that check to discover the current policy. A brief empty pending state is
|
|
247
|
+
possible; it is not a challenge and is not advertised as instant public rendering.
|
|
248
|
+
Use document navigation above if this delay is unacceptable. Do not bypass
|
|
249
|
+
checks with a hardcoded public/protected list, cached `allowed` flag, or client
|
|
250
|
+
user-agent detection. Keep full target content hidden until the check succeeds.
|
|
251
|
+
|
|
252
|
+
The six optional demos below intentionally check their sample router targets.
|
|
253
|
+
They are not instructions to protect all those paths in Policies. Their pending
|
|
254
|
+
state contains no `Checking access...` text. They do not provide server SSR
|
|
255
|
+
integration or HTTP status changes. Do not install both a startup check and a
|
|
256
|
+
router gate; do not apply these browser demos to an existing hydrated SSR root.
|
|
257
|
+
|
|
258
|
+
### Optional browser startup gate for a browser-only app
|
|
259
|
+
|
|
260
|
+
This alternative is useful only if browser mounting must wait for an access
|
|
261
|
+
check. Skip it when the server already guards HTML and document navigation is
|
|
262
|
+
used. It is not required by either rendering option above.
|
|
146
263
|
|
|
147
264
|
For a browser-only/static app, move the existing startup code from `src/main.ts`
|
|
148
265
|
or `src/main.js` into `src/bootstrap.ts` or `src/bootstrap.js`. Keep its original
|
|
@@ -180,41 +297,582 @@ This is browser code, not a server hook. The startup file can use any framework:
|
|
|
180
297
|
| Solid | Your existing `render(...)` |
|
|
181
298
|
| Plain JavaScript | Your existing DOM rendering code |
|
|
182
299
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
300
|
+
Client router navigation does not rerun this optional startup file. A UI check
|
|
301
|
+
cannot set the original HTTP status or make public bundles private.
|
|
302
|
+
|
|
303
|
+
<!-- BEGIN FRONTEND EXAMPLES -->
|
|
304
|
+
|
|
305
|
+
### Optional SPA demos — choose your frontend implementation
|
|
306
|
+
|
|
307
|
+
The package includes copyable source files in `examples/frontend`. The same
|
|
308
|
+
examples appear below and in the dashboard frontend selector. Skip this
|
|
309
|
+
section for the default server/document-navigation integration. These are
|
|
310
|
+
optional browser-only demos, not replacements for an existing app or SSR
|
|
311
|
+
entry. Retaining SPA navigation requires asynchronous target checks, even
|
|
312
|
+
for public targets. Pending content is empty; no global checking text is
|
|
313
|
+
required. Keep the target layout hidden until allowed to avoid exposing it
|
|
314
|
+
on denial. Only dashboard rules decide which paths require verification.
|
|
315
|
+
|
|
316
|
+
If explicitly choosing this mode, merge the relevant hooks into your own
|
|
317
|
+
browser router and adapt the sample paths. Do not add every demo path to
|
|
318
|
+
Policies. Do not combine these gates with the optional startup gate or
|
|
319
|
+
apply them to a hydrated SSR root. SSR uses section 3 and document navigation.
|
|
320
|
+
|
|
321
|
+
Copy the selected folder under `src/<frontend>/` and the shared data helper
|
|
322
|
+
to `src/load-private-data.js` (plus its `.d.ts` file for TypeScript). This keeps
|
|
323
|
+
the relative imports below valid. Point your browser entry at the example
|
|
324
|
+
`main` file, or import that file from your existing entry when trying the demo.
|
|
325
|
+
React/Vue/Svelte/Solid/plain JS entries expect `<div id="app"></div>`; Angular
|
|
326
|
+
uses `<app-root></app-root>`. Use your own mount target when adapting the code.
|
|
327
|
+
Do not mount your old app outside the gate or mount two apps at once.
|
|
328
|
+
|
|
329
|
+
- React Router
|
|
330
|
+
- Angular Router
|
|
331
|
+
- Vue Router
|
|
332
|
+
- Svelte navigation
|
|
333
|
+
- Solid Router
|
|
334
|
+
- Plain JavaScript navigation
|
|
335
|
+
|
|
336
|
+
Shared browser API helper: `src/load-private-data.js`. `/api/private` must
|
|
337
|
+
be guarded on its own server. Angular demonstrates HttpClient instead.
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
// Browser example. The /api/private SERVER handler must also be guarded.
|
|
342
|
+
export async function loadPrivateData(signal) {
|
|
343
|
+
const response = await fetch('/api/private', {
|
|
344
|
+
credentials: 'same-origin', cache: 'no-store', signal,
|
|
345
|
+
headers: { Accept: 'application/json' },
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok) throw new Error(`Request failed (HTTP ${response.status}).`);
|
|
348
|
+
return response.json();
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
TypeScript declaration: `src/load-private-data.d.ts`.
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
export function loadPrivateData(signal?: AbortSignal): Promise<unknown>;
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### React Router
|
|
360
|
+
|
|
361
|
+
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.
|
|
362
|
+
|
|
363
|
+
#### src/react/App.tsx
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
368
|
+
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom';
|
|
369
|
+
import { StopneticBoundary } from '@stopnetic/js/react';
|
|
370
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
371
|
+
|
|
372
|
+
function RouteGuard({ children }: { children: ReactNode }) {
|
|
373
|
+
const location = useLocation();
|
|
374
|
+
return <StopneticBoundary path={location.pathname + location.search}
|
|
375
|
+
pending={null}
|
|
376
|
+
blocked={<p>Access unavailable.</p>}>
|
|
377
|
+
{children}
|
|
378
|
+
</StopneticBoundary>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function Members() {
|
|
382
|
+
const [data, setData] = useState('Loading data...');
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
const controller = new AbortController();
|
|
385
|
+
loadPrivateData(controller.signal)
|
|
386
|
+
.then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
|
|
387
|
+
.catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
|
|
388
|
+
return () => controller.abort();
|
|
389
|
+
}, []);
|
|
390
|
+
return <main><h1>Members</h1><p>{data}</p></main>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function Layout() {
|
|
394
|
+
return <>
|
|
395
|
+
<header><nav>
|
|
396
|
+
<Link to="/">Home</Link>{' '}<Link to="/members">Members</Link>{' '}
|
|
397
|
+
<Link to="/slow">Slow</Link>{' '}<Link to="/blocked">Blocked</Link>{' '}
|
|
398
|
+
<Link to="/offline">Offline</Link>
|
|
399
|
+
</nav></header>
|
|
400
|
+
<Routes>
|
|
401
|
+
<Route caseSensitive path="/" element={<main><h1>Home</h1></main>}/>
|
|
402
|
+
<Route caseSensitive path="/members" element={<Members/>}/>
|
|
403
|
+
<Route caseSensitive path="/slow" element={<main><h1>Slow</h1></main>}/>
|
|
404
|
+
<Route caseSensitive path="/blocked" element={<main><h1>Blocked</h1></main>}/>
|
|
405
|
+
<Route caseSensitive path="/offline" element={<main><h1>Offline</h1></main>}/>
|
|
406
|
+
<Route path="*" element={<main><h1>Not found</h1></main>}/>
|
|
407
|
+
</Routes>
|
|
408
|
+
<footer>Example site</footer>
|
|
409
|
+
</>;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export default function App() {
|
|
413
|
+
return <BrowserRouter><RouteGuard><Layout/></RouteGuard></BrowserRouter>;
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### src/react/main.tsx
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
import { createRoot } from 'react-dom/client';
|
|
422
|
+
import App from './App';
|
|
423
|
+
createRoot(document.getElementById('app')!).render(<App/>);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Angular Router
|
|
427
|
+
|
|
428
|
+
Optional browser-only SPA demo: the service records checking/allowed/denied state. Checks evaluate policy; only dashboard rules require verification. Members uses HttpClient after route access succeeds. Merge providers into your existing browser appConfig. Preserve SSR/hydration with the server/document-navigation option instead of applying this demo to a hydrated root. Modern standalone Angular syntax.
|
|
429
|
+
|
|
430
|
+
#### src/angular/app/app.config.ts
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { ApplicationConfig } from '@angular/core';
|
|
435
|
+
import { provideHttpClient } from '@angular/common/http';
|
|
436
|
+
import { provideRouter } from '@angular/router';
|
|
437
|
+
import { routes } from './app.routes';
|
|
438
|
+
export const appConfig: ApplicationConfig = {
|
|
439
|
+
providers: [provideRouter(routes), provideHttpClient()],
|
|
440
|
+
};
|
|
441
|
+
// Merge these providers into your existing appConfig; retain its other settings.
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### src/angular/app/app.routes.ts
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { Component } from '@angular/core';
|
|
449
|
+
import { Routes } from '@angular/router';
|
|
450
|
+
import { MembersPage } from './members.page';
|
|
451
|
+
import { stopneticRouteGuard } from './stopnetic-access.service';
|
|
452
|
+
|
|
453
|
+
@Component({ template: '<main><h1>Home</h1></main>' })
|
|
454
|
+
class HomePage {}
|
|
455
|
+
@Component({ template: '<main><h1>Slow</h1></main>' })
|
|
456
|
+
class OtherPage {}
|
|
457
|
+
|
|
458
|
+
const pages: Routes = [
|
|
459
|
+
{ path: '', pathMatch: 'full', component: HomePage },
|
|
460
|
+
{ path: 'members', component: MembersPage },
|
|
461
|
+
{ path: 'slow', component: OtherPage },
|
|
462
|
+
{ path: 'blocked', component: OtherPage },
|
|
463
|
+
{ path: 'offline', component: OtherPage },
|
|
464
|
+
];
|
|
465
|
+
export const routes: Routes = pages.map(route => ({
|
|
466
|
+
...route, canActivate: [stopneticRouteGuard], runGuardsAndResolvers: 'always' as const,
|
|
467
|
+
}));
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
#### src/angular/app/app.ts
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { Component, inject } from '@angular/core';
|
|
475
|
+
import { RouterLink, RouterOutlet } from '@angular/router';
|
|
476
|
+
import { StopneticAccess } from './stopnetic-access.service';
|
|
477
|
+
|
|
478
|
+
@Component({
|
|
479
|
+
selector: 'app-root', imports: [RouterLink, RouterOutlet],
|
|
480
|
+
template: `
|
|
481
|
+
@if (access.state() === 'allowed') {
|
|
482
|
+
<header><nav>
|
|
483
|
+
<a routerLink="/">Home</a> <a routerLink="/members">Members</a>
|
|
484
|
+
<a routerLink="/slow">Slow</a> <a routerLink="/blocked">Blocked</a>
|
|
485
|
+
<a routerLink="/offline">Offline</a>
|
|
486
|
+
</nav></header>
|
|
487
|
+
<router-outlet/>
|
|
488
|
+
<footer>Example site</footer>
|
|
489
|
+
} @else if (access.state() === 'denied') {
|
|
490
|
+
<p role="alert">Access unavailable.</p>
|
|
491
|
+
}
|
|
492
|
+
`,
|
|
493
|
+
})
|
|
494
|
+
export class App { readonly access = inject(StopneticAccess); }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### src/angular/app/members.page.ts
|
|
189
498
|
|
|
190
|
-
### Angular client router, if retaining routerLink
|
|
191
499
|
|
|
192
500
|
```ts
|
|
193
501
|
import { isPlatformBrowser } from '@angular/common';
|
|
194
|
-
import { inject, PLATFORM_ID } from '@angular/core';
|
|
502
|
+
import { Component, DestroyRef, inject, OnInit, PLATFORM_ID, signal } from '@angular/core';
|
|
503
|
+
import { HttpClient } from '@angular/common/http';
|
|
504
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
505
|
+
|
|
506
|
+
@Component({ template: '<main><h1>Members</h1><p>{{ data() }}</p></main>' })
|
|
507
|
+
export class MembersPage implements OnInit {
|
|
508
|
+
readonly data = signal('Loading data...');
|
|
509
|
+
private readonly http = inject(HttpClient);
|
|
510
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
511
|
+
private readonly platform = inject(PLATFORM_ID);
|
|
512
|
+
ngOnInit() {
|
|
513
|
+
// This example loads data in the browser. SSR fetches need request cookies
|
|
514
|
+
// forwarded explicitly, plus server authorization; do not bypass the guard.
|
|
515
|
+
if (!isPlatformBrowser(this.platform)) return;
|
|
516
|
+
this.http.get('/api/private', { withCredentials: true })
|
|
517
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
518
|
+
.subscribe({ next: value => this.data.set(JSON.stringify(value)),
|
|
519
|
+
error: () => this.data.set('API access unavailable.') });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### src/angular/app/stopnetic-access.service.ts
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
529
|
+
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
|
195
530
|
import { CanActivateFn } from '@angular/router';
|
|
196
531
|
import { checkAccess } from '@stopnetic/js/browser';
|
|
197
532
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
533
|
+
@Injectable({ providedIn: 'root' })
|
|
534
|
+
export class StopneticAccess {
|
|
535
|
+
readonly state = signal<'checking' | 'allowed' | 'denied'>('checking');
|
|
536
|
+
private readonly platform = inject(PLATFORM_ID);
|
|
537
|
+
private current?: AbortController;
|
|
538
|
+
async check(path: string): Promise<boolean> {
|
|
539
|
+
// Node SSR must be authorized by the SERVER guard before Angular renders.
|
|
540
|
+
if (!isPlatformBrowser(this.platform)) { this.state.set('allowed'); return true; }
|
|
541
|
+
this.current?.abort();
|
|
542
|
+
const controller = new AbortController(); this.current = controller;
|
|
543
|
+
this.state.set('checking');
|
|
544
|
+
try {
|
|
545
|
+
const result = await checkAccess(path.split('#')[0], { signal: controller.signal });
|
|
546
|
+
if (controller.signal.aborted) return false;
|
|
547
|
+
if (result.allowed) { this.state.set('allowed'); return true; }
|
|
548
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
549
|
+
} catch { if (controller.signal.aborted) return false; }
|
|
550
|
+
this.state.set('denied'); return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
export const stopneticRouteGuard: CanActivateFn = (_route, state) =>
|
|
554
|
+
inject(StopneticAccess).check(state.url);
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### src/angular/main.ts
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
562
|
+
import { App } from './app/app';
|
|
563
|
+
import { appConfig } from './app/app.config';
|
|
564
|
+
bootstrapApplication(App, appConfig).catch(error => console.error(error));
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Vue Router
|
|
568
|
+
|
|
569
|
+
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.
|
|
570
|
+
|
|
571
|
+
#### src/vue/App.vue
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
```vue
|
|
575
|
+
<script setup>
|
|
576
|
+
import { RouterLink, RouterView } from 'vue-router';
|
|
577
|
+
import { accessState } from './router.js';
|
|
578
|
+
</script>
|
|
579
|
+
|
|
580
|
+
<template>
|
|
581
|
+
<template v-if="accessState === 'allowed'">
|
|
582
|
+
<header><nav>
|
|
583
|
+
<RouterLink to="/">Home</RouterLink> <RouterLink to="/members">Members</RouterLink>
|
|
584
|
+
<RouterLink to="/slow">Slow</RouterLink> <RouterLink to="/blocked">Blocked</RouterLink>
|
|
585
|
+
<RouterLink to="/offline">Offline</RouterLink>
|
|
586
|
+
</nav></header>
|
|
587
|
+
<RouterView/>
|
|
588
|
+
<footer>Example site</footer>
|
|
589
|
+
</template>
|
|
590
|
+
<p v-else-if="accessState === 'denied'" role="alert">Access unavailable.</p>
|
|
591
|
+
</template>
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### src/vue/main.js
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
import { createApp } from 'vue';
|
|
599
|
+
import App from './App.vue';
|
|
600
|
+
import { router } from './router.js';
|
|
601
|
+
createApp(App).use(router).mount('#app');
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### src/vue/Members.vue
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
```vue
|
|
608
|
+
<script setup>
|
|
609
|
+
import { onMounted, onUnmounted, ref } from 'vue';
|
|
610
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
611
|
+
const data = ref('Loading data...');
|
|
612
|
+
const controller = new AbortController();
|
|
613
|
+
onMounted(() => {
|
|
614
|
+
loadPrivateData(controller.signal)
|
|
615
|
+
.then(value => { if (!controller.signal.aborted) data.value = JSON.stringify(value); })
|
|
616
|
+
.catch(() => { if (!controller.signal.aborted) data.value = 'API access unavailable.'; });
|
|
617
|
+
});
|
|
618
|
+
onUnmounted(() => controller.abort());
|
|
619
|
+
</script>
|
|
620
|
+
|
|
621
|
+
<template><main><h1>Members</h1><p>{{ data }}</p></main></template>
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
#### src/vue/router.js
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
```js
|
|
628
|
+
import { h, ref } from 'vue';
|
|
629
|
+
import { createRouter, createWebHistory } from 'vue-router';
|
|
630
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
631
|
+
import Members from './Members.vue';
|
|
632
|
+
|
|
633
|
+
export const accessState = ref('checking');
|
|
634
|
+
const page = title => ({ render: () => h('main', h('h1', title)) });
|
|
635
|
+
export const router = createRouter({
|
|
636
|
+
history: createWebHistory(),
|
|
637
|
+
routes: [
|
|
638
|
+
{ path: '/', component: page('Home') },
|
|
639
|
+
{ path: '/members', component: Members },
|
|
640
|
+
{ path: '/slow', component: page('Slow') },
|
|
641
|
+
{ path: '/blocked', component: page('Blocked') },
|
|
642
|
+
{ path: '/offline', component: page('Offline') },
|
|
643
|
+
],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
let current;
|
|
647
|
+
let approvedPath = '';
|
|
648
|
+
router.beforeEach(async to => {
|
|
649
|
+
current?.abort();
|
|
650
|
+
const controller = new AbortController();
|
|
651
|
+
current = controller;
|
|
652
|
+
approvedPath = '';
|
|
653
|
+
accessState.value = 'checking';
|
|
654
|
+
try {
|
|
655
|
+
const result = await checkAccess(to.fullPath.split('#')[0], { signal: controller.signal });
|
|
656
|
+
if (controller.signal.aborted) return false;
|
|
657
|
+
if (result.allowed) { approvedPath = to.fullPath; return true; }
|
|
658
|
+
if (result.reason === 'verification_required' && result.verificationUrl) {
|
|
659
|
+
location.assign(result.verificationUrl);
|
|
660
|
+
}
|
|
661
|
+
accessState.value = 'denied';
|
|
662
|
+
} catch { if (!controller.signal.aborted) accessState.value = 'denied'; }
|
|
204
663
|
return false;
|
|
664
|
+
});
|
|
665
|
+
router.afterEach((to, _from, failure) => {
|
|
666
|
+
if (!failure && approvedPath === to.fullPath) accessState.value = 'allowed';
|
|
667
|
+
});
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### Svelte navigation
|
|
671
|
+
|
|
672
|
+
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.
|
|
673
|
+
|
|
674
|
+
#### src/svelte/App.svelte
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
```svelte
|
|
678
|
+
<script>
|
|
679
|
+
import { onMount } from 'svelte';
|
|
680
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
681
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
682
|
+
|
|
683
|
+
let path = '';
|
|
684
|
+
let state = 'checking';
|
|
685
|
+
let data = '';
|
|
686
|
+
let current;
|
|
687
|
+
async function showPage() {
|
|
688
|
+
current?.abort();
|
|
689
|
+
const controller = new AbortController();
|
|
690
|
+
current = controller;
|
|
691
|
+
path = location.pathname;
|
|
692
|
+
state = 'checking'; data = '';
|
|
693
|
+
try {
|
|
694
|
+
const result = await checkAccess(path + location.search, { signal: controller.signal });
|
|
695
|
+
if (controller.signal.aborted) return;
|
|
696
|
+
if (!result.allowed) {
|
|
697
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
698
|
+
state = 'denied'; return;
|
|
699
|
+
}
|
|
700
|
+
state = 'allowed';
|
|
701
|
+
if (path === '/members') {
|
|
702
|
+
data = 'Loading data...';
|
|
703
|
+
try {
|
|
704
|
+
const value = await loadPrivateData(controller.signal);
|
|
705
|
+
if (!controller.signal.aborted) data = JSON.stringify(value);
|
|
706
|
+
} catch { if (!controller.signal.aborted) data = 'API access unavailable.'; }
|
|
707
|
+
}
|
|
708
|
+
} catch { if (!controller.signal.aborted) state = 'denied'; }
|
|
709
|
+
}
|
|
710
|
+
function navigate(event) {
|
|
711
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
712
|
+
event.preventDefault();
|
|
713
|
+
history.pushState(null, '', event.currentTarget.href);
|
|
714
|
+
void showPage();
|
|
715
|
+
}
|
|
716
|
+
onMount(() => {
|
|
717
|
+
void showPage();
|
|
718
|
+
addEventListener('popstate', showPage);
|
|
719
|
+
return () => { current?.abort(); removeEventListener('popstate', showPage); };
|
|
720
|
+
});
|
|
721
|
+
</script>
|
|
722
|
+
|
|
723
|
+
{#if state === 'allowed'}
|
|
724
|
+
<header><nav>
|
|
725
|
+
<a href="/" onclick={navigate}>Home</a> <a href="/members" onclick={navigate}>Members</a>
|
|
726
|
+
<a href="/slow" onclick={navigate}>Slow</a> <a href="/blocked" onclick={navigate}>Blocked</a>
|
|
727
|
+
<a href="/offline" onclick={navigate}>Offline</a>
|
|
728
|
+
</nav></header>
|
|
729
|
+
<main><h1>{path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found'}</h1><p>{data}</p></main>
|
|
730
|
+
<footer>Example site</footer>
|
|
731
|
+
{:else if state === 'denied'}
|
|
732
|
+
<p role="alert">Access unavailable.</p>
|
|
733
|
+
{/if}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
#### src/svelte/main.js
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
```js
|
|
740
|
+
// Svelte 5 entry. Svelte 4 apps retain their existing `new App(...)` entry.
|
|
741
|
+
import { mount } from 'svelte';
|
|
742
|
+
import App from './App.svelte';
|
|
743
|
+
mount(App, { target: document.getElementById('app') });
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### Solid Router
|
|
747
|
+
|
|
748
|
+
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.
|
|
749
|
+
|
|
750
|
+
#### src/solid/App.tsx
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
```tsx
|
|
754
|
+
import { createEffect, createSignal, onCleanup, Show, type ParentComponent } from 'solid-js';
|
|
755
|
+
import { A, Route, Router, useLocation } from '@solidjs/router';
|
|
756
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
757
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
758
|
+
|
|
759
|
+
const GuardedLayout: ParentComponent = props => {
|
|
760
|
+
const location = useLocation();
|
|
761
|
+
const path = () => location.pathname + location.search;
|
|
762
|
+
const [result, setResult] = createSignal({ path: '', allowed: false, pending: true });
|
|
763
|
+
createEffect(() => {
|
|
764
|
+
const target = path();
|
|
765
|
+
const controller = new AbortController();
|
|
766
|
+
setResult({ path: target, allowed: false, pending: true });
|
|
767
|
+
checkAccess(target, { signal: controller.signal }).then(value => {
|
|
768
|
+
if (controller.signal.aborted) return;
|
|
769
|
+
if (!value.allowed && value.reason === 'verification_required' && value.verificationUrl) locationRedirect(value.verificationUrl);
|
|
770
|
+
setResult({ path: target, allowed: value.allowed, pending: false });
|
|
771
|
+
}).catch(() => {
|
|
772
|
+
if (!controller.signal.aborted) setResult({ path: target, allowed: false, pending: false });
|
|
773
|
+
});
|
|
774
|
+
onCleanup(() => controller.abort());
|
|
775
|
+
});
|
|
776
|
+
return <Show when={result().allowed && result().path === path() ? result() : undefined}
|
|
777
|
+
fallback={<Show when={!result().pending && result().path === path()}>
|
|
778
|
+
<p role="alert">Access unavailable.</p></Show>}>
|
|
779
|
+
{_checked => <>
|
|
780
|
+
<header><nav><A href="/">Home</A>{' '}<A href="/members">Members</A>{' '}
|
|
781
|
+
<A href="/slow">Slow</A>{' '}<A href="/blocked">Blocked</A>{' '}<A href="/offline">Offline</A>
|
|
782
|
+
</nav></header>
|
|
783
|
+
{props.children}
|
|
784
|
+
<footer>Example site</footer>
|
|
785
|
+
</>}
|
|
786
|
+
</Show>;
|
|
205
787
|
};
|
|
788
|
+
const locationRedirect = (url: string) => window.location.assign(url);
|
|
789
|
+
|
|
790
|
+
function Members() {
|
|
791
|
+
const [data, setData] = createSignal('Loading data...');
|
|
792
|
+
const controller = new AbortController();
|
|
793
|
+
loadPrivateData(controller.signal)
|
|
794
|
+
.then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
|
|
795
|
+
.catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
|
|
796
|
+
onCleanup(() => controller.abort());
|
|
797
|
+
return <main><h1>Members</h1><p>{data()}</p></main>;
|
|
798
|
+
}
|
|
799
|
+
export default function App() {
|
|
800
|
+
return <Router root={GuardedLayout}>
|
|
801
|
+
<Route path="/" component={() => <main><h1>Home</h1></main>}/>
|
|
802
|
+
<Route path="/members" component={Members}/>
|
|
803
|
+
<Route path="/slow" component={() => <main><h1>Slow</h1></main>}/>
|
|
804
|
+
<Route path="/blocked" component={() => <main><h1>Blocked</h1></main>}/>
|
|
805
|
+
<Route path="/offline" component={() => <main><h1>Offline</h1></main>}/>
|
|
806
|
+
</Router>;
|
|
807
|
+
}
|
|
206
808
|
```
|
|
207
809
|
|
|
208
|
-
|
|
209
|
-
`runGuardsAndResolvers: 'always'` on each page route, including root, or use an
|
|
210
|
-
equivalent parent `canActivateChild` guard. Keep the full header/router/footer
|
|
211
|
-
inside the gated layout. Render a visible unavailable/denied state when a
|
|
212
|
-
navigation is cancelled; returning false alone retains the previous view.
|
|
213
|
-
This guard is UX only. It does not authorize API data or replace server checks.
|
|
810
|
+
#### src/solid/main.tsx
|
|
214
811
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
812
|
+
|
|
813
|
+
```tsx
|
|
814
|
+
import { render } from 'solid-js/web';
|
|
815
|
+
import App from './App';
|
|
816
|
+
render(() => <App/>, document.getElementById('app')!);
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Plain JavaScript navigation
|
|
820
|
+
|
|
821
|
+
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.
|
|
822
|
+
|
|
823
|
+
#### src/vanilla/main.js
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
```js
|
|
827
|
+
import { checkAccess } from '@stopnetic/js/browser';
|
|
828
|
+
import { loadPrivateData } from '../load-private-data.js';
|
|
829
|
+
const root = document.getElementById('app');
|
|
830
|
+
let current;
|
|
831
|
+
async function showPage() {
|
|
832
|
+
current?.abort();
|
|
833
|
+
const controller = new AbortController(); current = controller;
|
|
834
|
+
root.replaceChildren();
|
|
835
|
+
try {
|
|
836
|
+
const path = location.pathname;
|
|
837
|
+
const result = await checkAccess(path + location.search, { signal: controller.signal });
|
|
838
|
+
if (controller.signal.aborted) return;
|
|
839
|
+
if (!result.allowed) {
|
|
840
|
+
if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
|
|
841
|
+
const alert = document.createElement('p'); alert.setAttribute('role', 'alert');
|
|
842
|
+
alert.textContent = 'Access unavailable.'; root.replaceChildren(alert); return;
|
|
843
|
+
}
|
|
844
|
+
const header = document.createElement('header');
|
|
845
|
+
const nav = document.createElement('nav'); header.append(nav);
|
|
846
|
+
for (const [url, name] of [['/', 'Home'], ['/members', 'Members'], ['/slow', 'Slow'], ['/blocked', 'Blocked'], ['/offline', 'Offline']]) {
|
|
847
|
+
const link = document.createElement('a'); link.href = url; link.textContent = name;
|
|
848
|
+
link.addEventListener('click', event => {
|
|
849
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
850
|
+
event.preventDefault(); history.pushState(null, '', url); void showPage();
|
|
851
|
+
});
|
|
852
|
+
nav.append(link, document.createTextNode(' '));
|
|
853
|
+
}
|
|
854
|
+
const main = document.createElement('main'); const heading = document.createElement('h1');
|
|
855
|
+
heading.textContent = path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found';
|
|
856
|
+
const data = document.createElement('p'); main.append(heading, data);
|
|
857
|
+
const footer = document.createElement('footer'); footer.textContent = 'Example site';
|
|
858
|
+
root.replaceChildren(header, main, footer);
|
|
859
|
+
if (path === '/members') {
|
|
860
|
+
data.textContent = 'Loading data...';
|
|
861
|
+
try {
|
|
862
|
+
const value = await loadPrivateData(controller.signal);
|
|
863
|
+
if (!controller.signal.aborted) data.textContent = JSON.stringify(value);
|
|
864
|
+
} catch { if (!controller.signal.aborted) data.textContent = 'API access unavailable.'; }
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
if (!controller.signal.aborted) root.replaceChildren(document.createTextNode('Access unavailable.'));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
addEventListener('popstate', showPage);
|
|
871
|
+
addEventListener('pagehide', () => current?.abort());
|
|
872
|
+
void showPage();
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
<!-- END FRONTEND EXAMPLES -->
|
|
218
876
|
|
|
219
877
|
## 5. Protect APIs independently
|
|
220
878
|
|
|
@@ -258,6 +916,14 @@ client navigation, entry HTML, aliases, prerender outputs and caches. A denied
|
|
|
258
916
|
request must not execute the renderer or data handler. The offline CLI check
|
|
259
917
|
validates configuration and generated files, not deployed coverage.
|
|
260
918
|
|
|
919
|
+
With only `/members` protected, confirm `/about` and `/contact` render without
|
|
920
|
+
a Stopnetic checking screen. Change a dashboard rule and make a new document
|
|
921
|
+
request: the change must apply without editing a client route list. For SSR,
|
|
922
|
+
confirm permitted HTML contains server-rendered page content before JavaScript
|
|
923
|
+
runs, hydration still works, and the renderer is never called on denial. Verify
|
|
924
|
+
the deployed request actually reaches Node; an SSR build alone proves nothing
|
|
925
|
+
about a provider's deployed runtime. Optional SPA checks may have a pending delay.
|
|
926
|
+
|
|
261
927
|
Ordinary policy changes apply on subsequent guarded requests without reinstalling
|
|
262
928
|
or redeploying. Already loaded content is not recalled. Public HTML/JS/assets and
|
|
263
929
|
content bundled into frontend code remain public; keep private data in guarded
|
|
@@ -266,7 +932,7 @@ server endpoints.
|
|
|
266
932
|
## Updating an existing generated guide
|
|
267
933
|
|
|
268
934
|
After installing the new SDK, rerun `stopnetic-js init --platform <same-platform>`
|
|
269
|
-
(preview with `--dry-run`). An unchanged 0.0.2
|
|
935
|
+
(preview with `--dry-run`). An unchanged 0.0.2/0.0.3/0.0.4 generated guide is upgraded
|
|
270
936
|
with its setup manifest. Your key, cookie secret and server files are preserved.
|
|
271
937
|
Customized guides or handlers require manual merging; switching platforms also
|
|
272
938
|
requires review. This command does not publish, deploy or contact Stopnetic.
|