@stopnetic/js 0.0.3-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 CHANGED
@@ -6,15 +6,18 @@ frontend build. No React/Vue/Angular dependency or version upgrade is required.
6
6
  The hosting server must run Node.js 22 or newer. This is an MIT-licensed pilot SDK;
7
7
  hosted production/provider validation is still required before paid rollout.
8
8
 
9
- Version **0.0.3-pilot is prepared locally; it is not published yet**. It adds a
10
- framework-independent generated guide, a complete Node application wrapper
11
- (`createStopneticAppHandler`) and safe migration of the unchanged older guide.
9
+ Version **0.0.4-pilot is prepared locally; it is not published yet**. It adds
10
+ complete copyable React Router, Angular Router/HttpClient, Vue Router, Svelte,
11
+ Solid Router and plain JavaScript examples to the generated guide and package.
12
+ Unchanged 0.0.2/0.0.3 guides migrate safely; custom edits require merging.
12
13
  Angular, React, Vue, Svelte, Solid and plain JavaScript share the browser/server
13
14
  contracts; no React dependency is required. See the
14
- [complete generated guide](src/client-guide.md) for SSR, static builds, Angular,
15
+ [complete generated guide](dist/client-guide.md) for SSR, static builds, frameworks,
15
16
  client navigation, API protection and runtime restrictions.
16
17
 
17
- The currently published **0.0.2-pilot** adds a reusable browser/server setup for Netlify, Vercel
18
+ The currently published **0.0.3-pilot** includes the framework-independent
19
+ server/browser setup, complete Node application wrapper and older-guide
20
+ migration. Version 0.0.2 added the reusable browser/server setup for Netlify, Vercel
18
21
  and Node/Express, a browser access check and optional React boundary. It also
19
22
  normalizes public PEM formatting and rejects placeholder or reused installation
20
23
  keys as cookie secrets. Install the published pilot with:
@@ -23,6 +26,34 @@ keys as cookie secrets. Install the published pilot with:
23
26
  npm install @stopnetic/js@pilot
24
27
  ```
25
28
 
29
+ ## Framework implementation examples
30
+
31
+ The guide and `examples/frontend` contain complete, locally tested examples:
32
+
33
+ | Frontend | Example |
34
+ | --- | --- |
35
+ | React | BrowserRouter, full-layout StopneticBoundary, Routes and effect-based API loading |
36
+ | Angular | Route guard/service, layout signals, RouterOutlet and HttpClient |
37
+ | Vue | Vue Router beforeEach/afterEach, gated App.vue and API loading |
38
+ | Svelte | onMount, History API/popstate navigation and abort cleanup |
39
+ | Solid | Router root, useLocation, reactive Show boundary and API loading |
40
+ | Plain JavaScript | DOM rendering, checked navigation and API loading |
41
+
42
+ Each example includes initial access, later navigation, verification redirect,
43
+ pending/denied UI and cancellation. Backend page/API guards remain necessary.
44
+ No framework library is added as a required SDK dependency. The generated guide
45
+ embeds the source; use its file paths and helper instructions when copying it.
46
+ The dashboard's frontend selector shows the same files. For maintenance, run
47
+ `npm run build` in this SDK, then `npm run sync:guides` in dashboard.
48
+
49
+ To rerun the frontend compilation/browser fixtures, build the SDK and run
50
+ `node tests/prepare-framework-tests.mjs` in this directory. This packs the SDK
51
+ and installs pinned test-only libraries inside ignored test-results. Then run
52
+ `.venv/Scripts/python.exe integrations/javascript/tests/framework-browser-check.py`
53
+ from the repository root. The Angular fixture uses an existing Angular install:
54
+ `angular-guide-check.py <project-path>`; it copies source into the workspace,
55
+ compiles the examples and tests browser/Node SSR without changing that project.
56
+
26
57
  For local testing, build and pack from this repository:
27
58
 
28
59
  ```sh
@@ -31,7 +62,7 @@ npm run build
31
62
  npm pack
32
63
  ```
33
64
 
34
- Install the resulting `stopnetic-js-0.0.3-pilot.tgz` into the website project using
65
+ Install the resulting `stopnetic-js-0.0.4-pilot.tgz` into the website project using
35
66
  `npm install <absolute-path-to-tarball>`. The installed package has no Next.js or
36
67
  other runtime dependency. The build packages the same signed decision implementation
37
68
  as the Next.js adapter. Keep `@stopnetic/next` for its Next.js 16 guided installer.
@@ -4,6 +4,12 @@ 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
 
@@ -187,34 +193,580 @@ in that router's before-navigation guard for every target and handle denial,
187
193
  verification redirects, outages and stale/cancelled requests. A UI check cannot
188
194
  set the original HTTP status or make public bundles private.
189
195
 
190
- ### Angular client router, if retaining routerLink
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
+
191
390
 
192
391
  ```ts
193
392
  import { isPlatformBrowser } from '@angular/common';
194
- import { inject, PLATFORM_ID } from '@angular/core';
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';
195
421
  import { CanActivateFn } from '@angular/router';
196
422
  import { checkAccess } from '@stopnetic/js/browser';
197
423
 
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);
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'; }
204
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>;
205
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
+ }
206
702
  ```
207
703
 
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.
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
+ ```
214
768
 
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.
769
+ <!-- END FRONTEND EXAMPLES -->
218
770
 
219
771
  ## 5. Protect APIs independently
220
772
 
@@ -266,7 +818,7 @@ server endpoints.
266
818
  ## Updating an existing generated guide
267
819
 
268
820
  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
821
+ (preview with `--dry-run`). An unchanged 0.0.2/0.0.3 generated guide is upgraded
270
822
  with its setup manifest. Your key, cookie secret and server files are preserved.
271
823
  Customized guides or handlers require manual merging; switching platforms also
272
824
  requires review. This command does not publish, deploy or contact Stopnetic.
package/dist/setup.js CHANGED
@@ -35,7 +35,10 @@ function platformFor(root, supplied) {
35
35
  if (vercel && !netlify) return 'vercel';
36
36
  throw new SetupError('Choose your hosting explicitly: --platform netlify, vercel or node.');
37
37
  }
38
- const legacyGuideDigest = 'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d';
38
+ const legacyGuideDigests = new Set([
39
+ 'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
40
+ '55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
41
+ ]);
39
42
  const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
40
43
 
41
44
  function filesFor(root, platform) {
@@ -79,8 +82,9 @@ export function setup(input, { platform: supplied, dryRun = false } = {}) {
79
82
  for (const [name, content] of Object.entries(files)) {
80
83
  const file = destination(root, name);
81
84
  if (existsSync(file) && text(file) !== content) {
82
- if (name === 'stopnetic-client-guide.md' && manifest?.files?.[name] === legacyGuideDigest
83
- && digest(text(file)) === legacyGuideDigest) upgraded.add(name);
85
+ const recorded = manifest?.files?.[name];
86
+ if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
87
+ && digest(text(file)) === recorded) upgraded.add(name);
84
88
  else throw new SetupError(`Existing file needs manual merging: ${name}`);
85
89
  }
86
90
  }
@@ -0,0 +1,8 @@
1
+ import { ApplicationConfig } from '@angular/core';
2
+ import { provideHttpClient } from '@angular/common/http';
3
+ import { provideRouter } from '@angular/router';
4
+ import { routes } from './app.routes';
5
+ export const appConfig: ApplicationConfig = {
6
+ providers: [provideRouter(routes), provideHttpClient()],
7
+ };
8
+ // Merge these providers into your existing appConfig; retain its other settings.
@@ -0,0 +1,20 @@
1
+ import { Component } from '@angular/core';
2
+ import { Routes } from '@angular/router';
3
+ import { MembersPage } from './members.page';
4
+ import { stopneticRouteGuard } from './stopnetic-access.service';
5
+
6
+ @Component({ template: '<main><h1>Home</h1></main>' })
7
+ class HomePage {}
8
+ @Component({ template: '<main><h1>Slow</h1></main>' })
9
+ class OtherPage {}
10
+
11
+ const pages: Routes = [
12
+ { path: '', pathMatch: 'full', component: HomePage },
13
+ { path: 'members', component: MembersPage },
14
+ { path: 'slow', component: OtherPage },
15
+ { path: 'blocked', component: OtherPage },
16
+ { path: 'offline', component: OtherPage },
17
+ ];
18
+ export const routes: Routes = pages.map(route => ({
19
+ ...route, canActivate: [stopneticRouteGuard], runGuardsAndResolvers: 'always' as const,
20
+ }));
@@ -0,0 +1,23 @@
1
+ import { Component, inject } from '@angular/core';
2
+ import { RouterLink, RouterOutlet } from '@angular/router';
3
+ import { StopneticAccess } from './stopnetic-access.service';
4
+
5
+ @Component({
6
+ selector: 'app-root', imports: [RouterLink, RouterOutlet],
7
+ template: `
8
+ @if (access.state() === 'allowed') {
9
+ <header><nav>
10
+ <a routerLink="/">Home</a> <a routerLink="/members">Members</a>
11
+ <a routerLink="/slow">Slow</a> <a routerLink="/blocked">Blocked</a>
12
+ <a routerLink="/offline">Offline</a>
13
+ </nav></header>
14
+ <router-outlet/>
15
+ <footer>Example site</footer>
16
+ } @else if (access.state() === 'checking') {
17
+ <p role="status">Checking access...</p>
18
+ } @else {
19
+ <p role="alert">Access unavailable.</p>
20
+ }
21
+ `,
22
+ })
23
+ export class App { readonly access = inject(StopneticAccess); }
@@ -0,0 +1,21 @@
1
+ import { isPlatformBrowser } from '@angular/common';
2
+ import { Component, DestroyRef, inject, OnInit, PLATFORM_ID, signal } from '@angular/core';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
+
6
+ @Component({ template: '<main><h1>Members</h1><p>{{ data() }}</p></main>' })
7
+ export class MembersPage implements OnInit {
8
+ readonly data = signal('Loading data...');
9
+ private readonly http = inject(HttpClient);
10
+ private readonly destroyRef = inject(DestroyRef);
11
+ private readonly platform = inject(PLATFORM_ID);
12
+ ngOnInit() {
13
+ // This example loads data in the browser. SSR fetches need request cookies
14
+ // forwarded explicitly, plus server authorization; do not bypass the guard.
15
+ if (!isPlatformBrowser(this.platform)) return;
16
+ this.http.get('/api/private', { withCredentials: true })
17
+ .pipe(takeUntilDestroyed(this.destroyRef))
18
+ .subscribe({ next: value => this.data.set(JSON.stringify(value)),
19
+ error: () => this.data.set('API access unavailable.') });
20
+ }
21
+ }
@@ -0,0 +1,27 @@
1
+ import { isPlatformBrowser } from '@angular/common';
2
+ import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
3
+ import { CanActivateFn } from '@angular/router';
4
+ import { checkAccess } from '@stopnetic/js/browser';
5
+
6
+ @Injectable({ providedIn: 'root' })
7
+ export class StopneticAccess {
8
+ readonly state = signal<'checking' | 'allowed' | 'denied'>('checking');
9
+ private readonly platform = inject(PLATFORM_ID);
10
+ private current?: AbortController;
11
+ async check(path: string): Promise<boolean> {
12
+ // Node SSR must be authorized by the SERVER guard before Angular renders.
13
+ if (!isPlatformBrowser(this.platform)) { this.state.set('allowed'); return true; }
14
+ this.current?.abort();
15
+ const controller = new AbortController(); this.current = controller;
16
+ this.state.set('checking');
17
+ try {
18
+ const result = await checkAccess(path.split('#')[0], { signal: controller.signal });
19
+ if (controller.signal.aborted) return false;
20
+ if (result.allowed) { this.state.set('allowed'); return true; }
21
+ if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
22
+ } catch { if (controller.signal.aborted) return false; }
23
+ this.state.set('denied'); return false;
24
+ }
25
+ }
26
+ export const stopneticRouteGuard: CanActivateFn = (_route, state) =>
27
+ inject(StopneticAccess).check(state.url);
@@ -0,0 +1,4 @@
1
+ import { bootstrapApplication } from '@angular/platform-browser';
2
+ import { App } from './app/app';
3
+ import { appConfig } from './app/app.config';
4
+ bootstrapApplication(App, appConfig).catch(error => console.error(error));
@@ -0,0 +1 @@
1
+ export function loadPrivateData(signal?: AbortSignal): Promise<unknown>;
@@ -0,0 +1,9 @@
1
+ // Browser example. The /api/private SERVER handler must also be guarded.
2
+ export async function loadPrivateData(signal) {
3
+ const response = await fetch('/api/private', {
4
+ credentials: 'same-origin', cache: 'no-store', signal,
5
+ headers: { Accept: 'application/json' },
6
+ });
7
+ if (!response.ok) throw new Error(`Request failed (HTTP ${response.status}).`);
8
+ return response.json();
9
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
+ import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom';
3
+ import { StopneticBoundary } from '@stopnetic/js/react';
4
+ import { loadPrivateData } from '../load-private-data.js';
5
+
6
+ function RouteGuard({ children }: { children: ReactNode }) {
7
+ const location = useLocation();
8
+ return <StopneticBoundary path={location.pathname + location.search}
9
+ pending={<p>Checking access...</p>}
10
+ blocked={<p>Access unavailable.</p>}>
11
+ {children}
12
+ </StopneticBoundary>;
13
+ }
14
+
15
+ function Members() {
16
+ const [data, setData] = useState('Loading data...');
17
+ useEffect(() => {
18
+ const controller = new AbortController();
19
+ loadPrivateData(controller.signal)
20
+ .then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
21
+ .catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
22
+ return () => controller.abort();
23
+ }, []);
24
+ return <main><h1>Members</h1><p>{data}</p></main>;
25
+ }
26
+
27
+ function Layout() {
28
+ return <>
29
+ <header><nav>
30
+ <Link to="/">Home</Link>{' '}<Link to="/members">Members</Link>{' '}
31
+ <Link to="/slow">Slow</Link>{' '}<Link to="/blocked">Blocked</Link>{' '}
32
+ <Link to="/offline">Offline</Link>
33
+ </nav></header>
34
+ <Routes>
35
+ <Route caseSensitive path="/" element={<main><h1>Home</h1></main>}/>
36
+ <Route caseSensitive path="/members" element={<Members/>}/>
37
+ <Route caseSensitive path="/slow" element={<main><h1>Slow</h1></main>}/>
38
+ <Route caseSensitive path="/blocked" element={<main><h1>Blocked</h1></main>}/>
39
+ <Route caseSensitive path="/offline" element={<main><h1>Offline</h1></main>}/>
40
+ <Route path="*" element={<main><h1>Not found</h1></main>}/>
41
+ </Routes>
42
+ <footer>Example site</footer>
43
+ </>;
44
+ }
45
+
46
+ export default function App() {
47
+ return <BrowserRouter><RouteGuard><Layout/></RouteGuard></BrowserRouter>;
48
+ }
@@ -0,0 +1,3 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import App from './App';
3
+ createRoot(document.getElementById('app')!).render(<App/>);
@@ -0,0 +1,54 @@
1
+ import { createEffect, createSignal, onCleanup, Show, type ParentComponent } from 'solid-js';
2
+ import { A, Route, Router, useLocation } from '@solidjs/router';
3
+ import { checkAccess } from '@stopnetic/js/browser';
4
+ import { loadPrivateData } from '../load-private-data.js';
5
+
6
+ const GuardedLayout: ParentComponent = props => {
7
+ const location = useLocation();
8
+ const path = () => location.pathname + location.search;
9
+ const [result, setResult] = createSignal({ path: '', allowed: false, pending: true });
10
+ createEffect(() => {
11
+ const target = path();
12
+ const controller = new AbortController();
13
+ setResult({ path: target, allowed: false, pending: true });
14
+ checkAccess(target, { signal: controller.signal }).then(value => {
15
+ if (controller.signal.aborted) return;
16
+ if (!value.allowed && value.reason === 'verification_required' && value.verificationUrl) locationRedirect(value.verificationUrl);
17
+ setResult({ path: target, allowed: value.allowed, pending: false });
18
+ }).catch(() => {
19
+ if (!controller.signal.aborted) setResult({ path: target, allowed: false, pending: false });
20
+ });
21
+ onCleanup(() => controller.abort());
22
+ });
23
+ return <Show when={result().allowed && result().path === path() ? result() : undefined}
24
+ fallback={<Show when={result().pending || result().path !== path()}
25
+ fallback={<p role="alert">Access unavailable.</p>}><p role="status">Checking access...</p></Show>}>
26
+ {_checked => <>
27
+ <header><nav><A href="/">Home</A>{' '}<A href="/members">Members</A>{' '}
28
+ <A href="/slow">Slow</A>{' '}<A href="/blocked">Blocked</A>{' '}<A href="/offline">Offline</A>
29
+ </nav></header>
30
+ {props.children}
31
+ <footer>Example site</footer>
32
+ </>}
33
+ </Show>;
34
+ };
35
+ const locationRedirect = (url: string) => window.location.assign(url);
36
+
37
+ function Members() {
38
+ const [data, setData] = createSignal('Loading data...');
39
+ const controller = new AbortController();
40
+ loadPrivateData(controller.signal)
41
+ .then(value => { if (!controller.signal.aborted) setData(JSON.stringify(value) ?? 'No data.'); })
42
+ .catch(() => { if (!controller.signal.aborted) setData('API access unavailable.'); });
43
+ onCleanup(() => controller.abort());
44
+ return <main><h1>Members</h1><p>{data()}</p></main>;
45
+ }
46
+ export default function App() {
47
+ return <Router root={GuardedLayout}>
48
+ <Route path="/" component={() => <main><h1>Home</h1></main>}/>
49
+ <Route path="/members" component={Members}/>
50
+ <Route path="/slow" component={() => <main><h1>Slow</h1></main>}/>
51
+ <Route path="/blocked" component={() => <main><h1>Blocked</h1></main>}/>
52
+ <Route path="/offline" component={() => <main><h1>Offline</h1></main>}/>
53
+ </Router>;
54
+ }
@@ -0,0 +1,3 @@
1
+ import { render } from 'solid-js/web';
2
+ import App from './App';
3
+ render(() => <App/>, document.getElementById('app')!);
@@ -0,0 +1,58 @@
1
+ <script>
2
+ import { onMount } from 'svelte';
3
+ import { checkAccess } from '@stopnetic/js/browser';
4
+ import { loadPrivateData } from '../load-private-data.js';
5
+
6
+ let path = '';
7
+ let state = 'checking';
8
+ let data = '';
9
+ let current;
10
+ async function showPage() {
11
+ current?.abort();
12
+ const controller = new AbortController();
13
+ current = controller;
14
+ path = location.pathname;
15
+ state = 'checking'; data = '';
16
+ try {
17
+ const result = await checkAccess(path + location.search, { signal: controller.signal });
18
+ if (controller.signal.aborted) return;
19
+ if (!result.allowed) {
20
+ if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
21
+ state = 'denied'; return;
22
+ }
23
+ state = 'allowed';
24
+ if (path === '/members') {
25
+ data = 'Loading data...';
26
+ try {
27
+ const value = await loadPrivateData(controller.signal);
28
+ if (!controller.signal.aborted) data = JSON.stringify(value);
29
+ } catch { if (!controller.signal.aborted) data = 'API access unavailable.'; }
30
+ }
31
+ } catch { if (!controller.signal.aborted) state = 'denied'; }
32
+ }
33
+ function navigate(event) {
34
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
35
+ event.preventDefault();
36
+ history.pushState(null, '', event.currentTarget.href);
37
+ void showPage();
38
+ }
39
+ onMount(() => {
40
+ void showPage();
41
+ addEventListener('popstate', showPage);
42
+ return () => { current?.abort(); removeEventListener('popstate', showPage); };
43
+ });
44
+ </script>
45
+
46
+ {#if state === 'allowed'}
47
+ <header><nav>
48
+ <a href="/" onclick={navigate}>Home</a> <a href="/members" onclick={navigate}>Members</a>
49
+ <a href="/slow" onclick={navigate}>Slow</a> <a href="/blocked" onclick={navigate}>Blocked</a>
50
+ <a href="/offline" onclick={navigate}>Offline</a>
51
+ </nav></header>
52
+ <main><h1>{path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found'}</h1><p>{data}</p></main>
53
+ <footer>Example site</footer>
54
+ {:else if state === 'checking'}
55
+ <p role="status">Checking access...</p>
56
+ {:else}
57
+ <p role="alert">Access unavailable.</p>
58
+ {/if}
@@ -0,0 +1,4 @@
1
+ // Svelte 5 entry. Svelte 4 apps retain their existing `new App(...)` entry.
2
+ import { mount } from 'svelte';
3
+ import App from './App.svelte';
4
+ mount(App, { target: document.getElementById('app') });
@@ -0,0 +1,46 @@
1
+ import { checkAccess } from '@stopnetic/js/browser';
2
+ import { loadPrivateData } from '../load-private-data.js';
3
+ const root = document.getElementById('app');
4
+ let current;
5
+ async function showPage() {
6
+ current?.abort();
7
+ const controller = new AbortController(); current = controller;
8
+ root.replaceChildren(document.createTextNode('Checking access...'));
9
+ try {
10
+ const path = location.pathname;
11
+ const result = await checkAccess(path + location.search, { signal: controller.signal });
12
+ if (controller.signal.aborted) return;
13
+ if (!result.allowed) {
14
+ if (result.reason === 'verification_required' && result.verificationUrl) location.assign(result.verificationUrl);
15
+ const alert = document.createElement('p'); alert.setAttribute('role', 'alert');
16
+ alert.textContent = 'Access unavailable.'; root.replaceChildren(alert); return;
17
+ }
18
+ const header = document.createElement('header');
19
+ const nav = document.createElement('nav'); header.append(nav);
20
+ for (const [url, name] of [['/', 'Home'], ['/members', 'Members'], ['/slow', 'Slow'], ['/blocked', 'Blocked'], ['/offline', 'Offline']]) {
21
+ const link = document.createElement('a'); link.href = url; link.textContent = name;
22
+ link.addEventListener('click', event => {
23
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
24
+ event.preventDefault(); history.pushState(null, '', url); void showPage();
25
+ });
26
+ nav.append(link, document.createTextNode(' '));
27
+ }
28
+ const main = document.createElement('main'); const heading = document.createElement('h1');
29
+ heading.textContent = path === '/members' ? 'Members' : path === '/' ? 'Home' : path === '/slow' ? 'Slow' : 'Not found';
30
+ const data = document.createElement('p'); main.append(heading, data);
31
+ const footer = document.createElement('footer'); footer.textContent = 'Example site';
32
+ root.replaceChildren(header, main, footer);
33
+ if (path === '/members') {
34
+ data.textContent = 'Loading data...';
35
+ try {
36
+ const value = await loadPrivateData(controller.signal);
37
+ if (!controller.signal.aborted) data.textContent = JSON.stringify(value);
38
+ } catch { if (!controller.signal.aborted) data.textContent = 'API access unavailable.'; }
39
+ }
40
+ } catch {
41
+ if (!controller.signal.aborted) root.replaceChildren(document.createTextNode('Access unavailable.'));
42
+ }
43
+ }
44
+ addEventListener('popstate', showPage);
45
+ addEventListener('pagehide', () => current?.abort());
46
+ void showPage();
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ import { RouterLink, RouterView } from 'vue-router';
3
+ import { accessState } from './router.js';
4
+ </script>
5
+
6
+ <template>
7
+ <template v-if="accessState === 'allowed'">
8
+ <header><nav>
9
+ <RouterLink to="/">Home</RouterLink> <RouterLink to="/members">Members</RouterLink>
10
+ <RouterLink to="/slow">Slow</RouterLink> <RouterLink to="/blocked">Blocked</RouterLink>
11
+ <RouterLink to="/offline">Offline</RouterLink>
12
+ </nav></header>
13
+ <RouterView/>
14
+ <footer>Example site</footer>
15
+ </template>
16
+ <p v-else-if="accessState === 'checking'" role="status">Checking access...</p>
17
+ <p v-else role="alert">Access unavailable.</p>
18
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup>
2
+ import { onMounted, onUnmounted, ref } from 'vue';
3
+ import { loadPrivateData } from '../load-private-data.js';
4
+ const data = ref('Loading data...');
5
+ const controller = new AbortController();
6
+ onMounted(() => {
7
+ loadPrivateData(controller.signal)
8
+ .then(value => { if (!controller.signal.aborted) data.value = JSON.stringify(value); })
9
+ .catch(() => { if (!controller.signal.aborted) data.value = 'API access unavailable.'; });
10
+ });
11
+ onUnmounted(() => controller.abort());
12
+ </script>
13
+
14
+ <template><main><h1>Members</h1><p>{{ data }}</p></main></template>
@@ -0,0 +1,4 @@
1
+ import { createApp } from 'vue';
2
+ import App from './App.vue';
3
+ import { router } from './router.js';
4
+ createApp(App).use(router).mount('#app');
@@ -0,0 +1,40 @@
1
+ import { h, ref } from 'vue';
2
+ import { createRouter, createWebHistory } from 'vue-router';
3
+ import { checkAccess } from '@stopnetic/js/browser';
4
+ import Members from './Members.vue';
5
+
6
+ export const accessState = ref('checking');
7
+ const page = title => ({ render: () => h('main', h('h1', title)) });
8
+ export const router = createRouter({
9
+ history: createWebHistory(),
10
+ routes: [
11
+ { path: '/', component: page('Home') },
12
+ { path: '/members', component: Members },
13
+ { path: '/slow', component: page('Slow') },
14
+ { path: '/blocked', component: page('Blocked') },
15
+ { path: '/offline', component: page('Offline') },
16
+ ],
17
+ });
18
+
19
+ let current;
20
+ let approvedPath = '';
21
+ router.beforeEach(async to => {
22
+ current?.abort();
23
+ const controller = new AbortController();
24
+ current = controller;
25
+ approvedPath = '';
26
+ accessState.value = 'checking';
27
+ try {
28
+ const result = await checkAccess(to.fullPath.split('#')[0], { signal: controller.signal });
29
+ if (controller.signal.aborted) return false;
30
+ if (result.allowed) { approvedPath = to.fullPath; return true; }
31
+ if (result.reason === 'verification_required' && result.verificationUrl) {
32
+ location.assign(result.verificationUrl);
33
+ }
34
+ accessState.value = 'denied';
35
+ } catch { if (!controller.signal.aborted) accessState.value = 'denied'; }
36
+ return false;
37
+ });
38
+ router.afterEach((to, _from, failure) => {
39
+ if (!failure && approvedPath === to.fullPath) accessState.value = 'allowed';
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stopnetic/js",
3
- "version": "0.0.3-pilot",
3
+ "version": "0.0.4-pilot",
4
4
  "description": "Server-side Stopnetic guard for JavaScript websites, Node.js and Express-compatible servers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,8 @@
19
19
  ],
20
20
  "files": [
21
21
  "dist",
22
- "bin"
22
+ "bin",
23
+ "examples/frontend"
23
24
  ],
24
25
  "bin": { "stopnetic-js": "./bin/stopnetic-js.js" },
25
26
  "exports": {