@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 +37 -6
- package/dist/client-guide.md +570 -18
- package/dist/setup.js +7 -3
- 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/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.
|
|
10
|
-
|
|
11
|
-
|
|
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](
|
|
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.
|
|
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.
|
|
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.
|
package/dist/client-guide.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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 @@
|
|
|
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": {
|