@stopnetic/js 0.0.2-pilot → 0.0.4-pilot
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -8
- package/dist/client-guide.md +828 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +16 -0
- package/dist/setup.js +17 -59
- package/examples/frontend/angular/app/app.config.ts +8 -0
- package/examples/frontend/angular/app/app.routes.ts +20 -0
- package/examples/frontend/angular/app/app.ts +23 -0
- package/examples/frontend/angular/app/members.page.ts +21 -0
- package/examples/frontend/angular/app/stopnetic-access.service.ts +27 -0
- package/examples/frontend/angular/main.ts +4 -0
- package/examples/frontend/load-private-data.d.ts +1 -0
- package/examples/frontend/load-private-data.js +9 -0
- package/examples/frontend/react/App.tsx +48 -0
- package/examples/frontend/react/main.tsx +3 -0
- package/examples/frontend/solid/App.tsx +54 -0
- package/examples/frontend/solid/main.tsx +3 -0
- package/examples/frontend/svelte/App.svelte +58 -0
- package/examples/frontend/svelte/main.js +4 -0
- package/examples/frontend/vanilla/main.js +46 -0
- package/examples/frontend/vue/App.vue +18 -0
- package/examples/frontend/vue/Members.vue +14 -0
- package/examples/frontend/vue/main.js +4 -0
- package/examples/frontend/vue/router.js +40 -0
- package/package.json +3 -2
package/dist/server.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export function optionsFromEnv(env?: Record<string, string | undefined>): Stopne
|
|
|
5
5
|
export function createStopneticAccessHandler(options?: StopneticServerOptions): (request: Request) => Promise<Response>;
|
|
6
6
|
export function withStopnetic<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
7
7
|
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
8
|
+
/** Node.js application wrapper: control endpoints, pages and APIs before rendering. */
|
|
9
|
+
export function createStopneticAppHandler<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
10
|
+
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
8
11
|
export function createStopneticAccessMiddleware(options?: StopneticServerOptions): (
|
|
9
12
|
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse, next: () => unknown,
|
|
10
13
|
) => Promise<unknown>;
|
package/dist/server.js
CHANGED
|
@@ -71,6 +71,22 @@ export function withStopnetic(handler, options = optionsFromEnv()) {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Guard a complete Fetch-style application before its renderer or API runs.
|
|
75
|
+
* The application must run in Node.js; accepting Request/Response alone does
|
|
76
|
+
* not make an Edge runtime compatible. Context arguments are preserved.
|
|
77
|
+
*/
|
|
78
|
+
export function createStopneticAppHandler(handler, options = optionsFromEnv()) {
|
|
79
|
+
if (typeof handler !== 'function') throw new TypeError('An application handler is required');
|
|
80
|
+
const settings = { ...options, verifyPath: `${accessBase}/verify` };
|
|
81
|
+
const access = createStopneticAccessHandler(settings);
|
|
82
|
+
const application = withStopnetic(handler, settings);
|
|
83
|
+
return (request, ...context) => {
|
|
84
|
+
const path = new URL(request.url).pathname;
|
|
85
|
+
if (path === accessBase || path.startsWith(`${accessBase}/`)) return access(request);
|
|
86
|
+
return application(request, ...context);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
/** Node/Express: mount first, then the root guard, then parsers/static/API handlers. */
|
|
75
91
|
export function createStopneticAccessMiddleware(options = optionsFromEnv()) {
|
|
76
92
|
const access = createStopneticAccessHandler(options);
|
package/dist/setup.js
CHANGED
|
@@ -35,62 +35,12 @@ 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
|
|
38
|
+
const legacyGuideDigests = new Set([
|
|
39
|
+
'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
|
|
40
|
+
'55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
|
|
41
|
+
]);
|
|
42
|
+
const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
|
|
39
43
|
|
|
40
|
-
Copy .env.stopnetic.example settings to the hosting platform's SERVER environment.
|
|
41
|
-
Use Node.js 22+. Never prefix private settings with VITE_ or REACT_APP_.
|
|
42
|
-
Use the same cookie secret locally and in hosting; do not commit .env.local.
|
|
43
|
-
|
|
44
|
-
## React Router (inside BrowserRouter)
|
|
45
|
-
|
|
46
|
-
import { useLocation } from 'react-router-dom';
|
|
47
|
-
import { StopneticBoundary } from '@stopnetic/js/react';
|
|
48
|
-
|
|
49
|
-
function RouteGuard({ children }) {
|
|
50
|
-
const location = useLocation();
|
|
51
|
-
return <StopneticBoundary path={location.pathname + location.search}>
|
|
52
|
-
{children}
|
|
53
|
-
</StopneticBoundary>;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
Wrap your Routes with RouteGuard. Other frontends can await
|
|
57
|
-
checkAccess(path) from @stopnetic/js/browser before rendering; when reason is
|
|
58
|
-
verification_required, navigate to result.verificationUrl. Handle denial/outages.
|
|
59
|
-
|
|
60
|
-
## API protection
|
|
61
|
-
|
|
62
|
-
EACH protected Netlify/Vercel Fetch-style API must use:
|
|
63
|
-
import { withStopnetic } from '@stopnetic/js/server';
|
|
64
|
-
const protectedHandler = withStopnetic(async (request, context) => {
|
|
65
|
-
// Enforce your application's login/data permissions here too.
|
|
66
|
-
return Response.json({ example: true });
|
|
67
|
-
});
|
|
68
|
-
// Netlify: export default protectedHandler;
|
|
69
|
-
// export const config = { path: '/api/private', preferStatic: false };
|
|
70
|
-
// Vercel: export default { fetch: protectedHandler };
|
|
71
|
-
|
|
72
|
-
Node/Express: call installStopnetic(app) from ./stopnetic.server.mjs BEFORE
|
|
73
|
-
parsers, static files and routes. Only expose that guarded server to visitors.
|
|
74
|
-
|
|
75
|
-
Add a Page / Automatic browser flow rule and API / Require verified session rules
|
|
76
|
-
in Stopnetic Policies. The generated setup leaves unmatched routes public.
|
|
77
|
-
Netlify's default /.netlify/functions/ aliases are rejected by the wrapper; use
|
|
78
|
-
custom config.path endpoints in React and Policies. Fetch protected data with
|
|
79
|
-
credentials: 'same-origin'. API requests cannot start
|
|
80
|
-
the browser verification flow. All control endpoints must reach their function
|
|
81
|
-
before a SPA catch-all; verify redirects and custom function directories.
|
|
82
|
-
|
|
83
|
-
Deploy once, then change rules in the dashboard. Rules apply to subsequent
|
|
84
|
-
guarded requests; loaded content is not recalled. Wrap new API handlers too.
|
|
85
|
-
For local Netlify/Vercel testing, use their full local development server
|
|
86
|
-
(netlify dev / vercel dev), not Vite alone: the server functions must run too.
|
|
87
|
-
For Node, load .env.local with node --env-file=.env.local your-server.mjs,
|
|
88
|
-
or your existing server environment loader.
|
|
89
|
-
Static HTML/JS and content bundled into the frontend remain public. A UI gate
|
|
90
|
-
does not secure that content. Put private data in guarded server endpoints.
|
|
91
|
-
No Edge/Deno/Cloudflare Workers adapter is supplied. Next.js uses @stopnetic/next.
|
|
92
|
-
Test real hosting, custom domains, cookies, caches, aliases and API enforcement.
|
|
93
|
-
`;
|
|
94
44
|
function filesFor(root, platform) {
|
|
95
45
|
const files = {
|
|
96
46
|
'stopnetic-client-guide.md': clientGuide,
|
|
@@ -121,15 +71,22 @@ export function setup(input, { platform: supplied, dryRun = false } = {}) {
|
|
|
121
71
|
const platform = platformFor(root, supplied);
|
|
122
72
|
const files = filesFor(root, platform);
|
|
123
73
|
const manifestPath = destination(root, manifestName);
|
|
74
|
+
let manifest;
|
|
124
75
|
if (existsSync(manifestPath)) {
|
|
125
|
-
let manifest;
|
|
126
76
|
try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
|
|
127
77
|
if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
|
|
128
78
|
}
|
|
129
|
-
|
|
79
|
+
const upgraded = new Set();
|
|
80
|
+
// Validate every destination before making any change. Only an unchanged
|
|
81
|
+
// older generated guide can be replaced; customer modifications are refused.
|
|
130
82
|
for (const [name, content] of Object.entries(files)) {
|
|
131
83
|
const file = destination(root, name);
|
|
132
|
-
if (existsSync(file) && text(file) !== content)
|
|
84
|
+
if (existsSync(file) && text(file) !== content) {
|
|
85
|
+
const recorded = manifest?.files?.[name];
|
|
86
|
+
if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
|
|
87
|
+
&& digest(text(file)) === recorded) upgraded.add(name);
|
|
88
|
+
else throw new SetupError(`Existing file needs manual merging: ${name}`);
|
|
89
|
+
}
|
|
133
90
|
}
|
|
134
91
|
const local = destination(root, '.env.local');
|
|
135
92
|
const ignore = destination(root, '.gitignore');
|
|
@@ -138,7 +95,8 @@ export function setup(input, { platform: supplied, dryRun = false } = {}) {
|
|
|
138
95
|
for (const [name, content] of Object.entries(files)) {
|
|
139
96
|
const file = destination(root, name);
|
|
140
97
|
mkdirSync(dirname(file), { recursive: true });
|
|
141
|
-
if (
|
|
98
|
+
if (upgraded.has(name)) writeFileSync(file, content);
|
|
99
|
+
else if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
|
|
142
100
|
}
|
|
143
101
|
if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
|
|
144
102
|
if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
|
|
@@ -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 @@
|
|
|
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,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,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,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,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
|
+
"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": {
|