@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.
@@ -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
- Netlify's automatic Angular SSR adapter currently uses Edge Functions. Do not
126
- import this Node SDK into that Edge entry, and do not assume a Netlify Node
127
- function runs before the Edge renderer. Use a Node SSR deployment, a Node
128
- gateway that cannot be bypassed, or a static Angular build with Node page
129
- handling. Switching to static rendering changes SSR behavior; choose deliberately.
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. Browser startup: the same code for every frontend
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
- Use ordinary `<a href>` document links for guarded navigation: each navigation
184
- then reaches the server before a new page is shown. Client router navigation
185
- does not rerun the startup file. If keeping SPA navigation, call `checkAccess`
186
- in that router's before-navigation guard for every target and handle denial,
187
- verification redirects, outages and stale/cancelled requests. A UI check cannot
188
- set the original HTTP status or make public bundles private.
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
- export const stopneticRouteGuard: CanActivateFn = async (_route, state) => {
199
- if (!isPlatformBrowser(inject(PLATFORM_ID))) return true;
200
- // SSR is authorized by the SERVER wrapper above, never by this true value.
201
- const result = await checkAccess(state.url.split('#')[0]);
202
- if (result.allowed) return true;
203
- if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
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
- Apply `canActivate: [stopneticRouteGuard]` and
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
- React users may additionally use `StopneticBoundary` from `@stopnetic/js/react`
216
- inside BrowserRouter around the entire layout; that optional module is not
217
- required for Angular, Vue, Svelte, Solid or plain JavaScript.
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-pilot generated guide is upgraded
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.