@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/dist/setup.js CHANGED
@@ -35,7 +35,11 @@ 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
+ '08f919257d8c85af4e56e842705825832ae2810ed68d5c1dfc8405c8f7be7610', // published 0.0.4
42
+ ]);
39
43
  const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
40
44
 
41
45
  function filesFor(root, platform) {
@@ -79,8 +83,9 @@ export function setup(input, { platform: supplied, dryRun = false } = {}) {
79
83
  for (const [name, content] of Object.entries(files)) {
80
84
  const file = destination(root, name);
81
85
  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);
86
+ const recorded = manifest?.files?.[name];
87
+ if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
88
+ && digest(text(file)) === recorded) upgraded.add(name);
84
89
  else throw new SetupError(`Existing file needs manual merging: ${name}`);
85
90
  }
86
91
  }
@@ -115,5 +120,5 @@ export function checkSetup(input, { localHttp = false } = {}) {
115
120
  if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
116
121
  createStopneticAccessHandler(options);
117
122
  } catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
118
- return `Checked ${manifest.platform} generated files and server configuration offline. Confirm the browser boundary, API wrappers and hosting routing with a deployed test. This does not prove coverage or start a trial.`;
123
+ return `Checked ${manifest.platform} generated files and server configuration offline. Confirm server page enforcement, your chosen navigation mode, API wrappers and hosting routing with a deployed test. Preserve existing SSR; browser gates are optional. This does not prove coverage or start a trial.`;
119
124
  }
@@ -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,21 @@
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() === 'denied') {
17
+ <p role="alert">Access unavailable.</p>
18
+ }
19
+ `,
20
+ })
21
+ 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={null}
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
+ <p role="alert">Access unavailable.</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,56 @@
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 === 'denied'}
55
+ <p role="alert">Access unavailable.</p>
56
+ {/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();
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,17 @@
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 === 'denied'" role="alert">Access unavailable.</p>
17
+ </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.5-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": {