@stopnetic/js 0.0.6-pilot → 0.1.0
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 +48 -48
- package/dist/client-guide.md +6 -3
- package/dist/server.d.ts +17 -17
- package/dist/server.js +125 -123
- package/dist/setup.js +126 -125
- package/examples/frontend/angular/app/app.config.ts +8 -8
- package/examples/frontend/angular/app/app.routes.ts +20 -20
- package/examples/frontend/angular/app/app.ts +21 -21
- package/examples/frontend/angular/app/members.page.ts +21 -21
- package/examples/frontend/angular/app/stopnetic-access.service.ts +27 -27
- package/examples/frontend/angular/main.ts +4 -4
- package/examples/frontend/load-private-data.d.ts +1 -1
- package/examples/frontend/load-private-data.js +9 -9
- package/examples/frontend/react/App.tsx +48 -48
- package/examples/frontend/react/main.tsx +3 -3
- package/examples/frontend/solid/App.tsx +54 -54
- package/examples/frontend/solid/main.tsx +3 -3
- package/examples/frontend/svelte/App.svelte +56 -56
- package/examples/frontend/svelte/main.js +4 -4
- package/examples/frontend/vanilla/main.js +46 -46
- package/examples/frontend/vue/App.vue +17 -17
- package/examples/frontend/vue/Members.vue +14 -14
- package/examples/frontend/vue/main.js +4 -4
- package/examples/frontend/vue/router.js +40 -40
- package/examples/ssr/angular/app.routes.server.ts +5 -5
- package/examples/ssr/angular/server.ts +12 -12
- package/examples/ssr/astro/middleware.js +11 -11
- package/examples/ssr/astro/page.astro +4 -4
- package/examples/ssr/node/fetch.mjs +9 -9
- package/examples/ssr/node/install.mjs +9 -9
- package/examples/ssr/nuxt/middleware.mjs +10 -10
- package/examples/ssr/react/install.mjs +10 -10
- package/examples/ssr/react-router/install.mjs +22 -22
- package/examples/ssr/solidstart/middleware-v1.js +19 -19
- package/examples/ssr/solidstart/middleware-v2.js +25 -25
- package/examples/ssr/sveltekit/hooks.server.js +16 -16
- package/examples/ssr/sveltekit/page.server.js +3 -3
- package/examples/ssr/vue/install.mjs +10 -10
- package/package.json +35 -9
package/dist/setup.js
CHANGED
|
@@ -1,125 +1,126 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
-
import { resolve, relative, dirname, isAbsolute } from 'node:path';
|
|
3
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
-
import { parseEnv } from 'node:util';
|
|
5
|
-
import { createStopneticAccessHandler, optionsFromEnv } from './server.js';
|
|
6
|
-
|
|
7
|
-
export class SetupError extends Error {}
|
|
8
|
-
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
-
const manifestName = 'stopnetic.setup.json';
|
|
10
|
-
function destination(root, name) {
|
|
11
|
-
const file = resolve(root, name);
|
|
12
|
-
const inside = relative(root, file);
|
|
13
|
-
if (!inside || inside.startsWith('..') || isAbsolute(inside) || /[\x00-\x1f]/.test(name)) throw new SetupError('Setup paths must stay inside the project.');
|
|
14
|
-
for (let cursor = file; cursor !== root; cursor = dirname(cursor)) {
|
|
15
|
-
if (lstatSync(cursor, { throwIfNoEntry: false })?.isSymbolicLink()) throw new SetupError('Setup refuses symbolic links.');
|
|
16
|
-
}
|
|
17
|
-
return file;
|
|
18
|
-
}
|
|
19
|
-
function text(file) { return existsSync(file) ? readFileSync(file, 'utf8') : ''; }
|
|
20
|
-
function environment(root) {
|
|
21
|
-
try {
|
|
22
|
-
return { ...parseEnv(text(destination(root, '.env'))), ...parseEnv(text(destination(root, '.env.local'))), ...process.env };
|
|
23
|
-
} catch { throw new SetupError('Check server environment file formatting.'); }
|
|
24
|
-
}
|
|
25
|
-
function project(root) {
|
|
26
|
-
try { return JSON.parse(readFileSync(destination(root, 'package.json'), 'utf8')); }
|
|
27
|
-
catch { throw new SetupError('Run setup from a project with a valid package.json.'); }
|
|
28
|
-
}
|
|
29
|
-
function platformFor(root, supplied) {
|
|
30
|
-
if (supplied && !['netlify', 'vercel', 'node'].includes(supplied)) throw new SetupError('Choose netlify, vercel or node.');
|
|
31
|
-
if (supplied) return supplied;
|
|
32
|
-
const netlify = existsSync(destination(root, 'netlify.toml'));
|
|
33
|
-
const vercel = existsSync(destination(root, 'vercel.json'));
|
|
34
|
-
if (netlify && !vercel) return 'netlify';
|
|
35
|
-
if (vercel && !netlify) return 'vercel';
|
|
36
|
-
throw new SetupError('Choose your hosting explicitly: --platform netlify, vercel or node.');
|
|
37
|
-
}
|
|
38
|
-
const legacyGuideDigests = new Set([
|
|
39
|
-
'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
|
|
40
|
-
'55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
|
|
41
|
-
'08f919257d8c85af4e56e842705825832ae2810ed68d5c1dfc8405c8f7be7610', // published 0.0.4
|
|
42
|
-
'45bd696f196c3d1447a978987a787755b239f75e9e6a3697f4efbb210ce75597', // published 0.0.5
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!localHttp && !options.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve, relative, dirname, isAbsolute } from 'node:path';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { parseEnv } from 'node:util';
|
|
5
|
+
import { createStopneticAccessHandler, optionsFromEnv } from './server.js';
|
|
6
|
+
|
|
7
|
+
export class SetupError extends Error {}
|
|
8
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const manifestName = 'stopnetic.setup.json';
|
|
10
|
+
function destination(root, name) {
|
|
11
|
+
const file = resolve(root, name);
|
|
12
|
+
const inside = relative(root, file);
|
|
13
|
+
if (!inside || inside.startsWith('..') || isAbsolute(inside) || /[\x00-\x1f]/.test(name)) throw new SetupError('Setup paths must stay inside the project.');
|
|
14
|
+
for (let cursor = file; cursor !== root; cursor = dirname(cursor)) {
|
|
15
|
+
if (lstatSync(cursor, { throwIfNoEntry: false })?.isSymbolicLink()) throw new SetupError('Setup refuses symbolic links.');
|
|
16
|
+
}
|
|
17
|
+
return file;
|
|
18
|
+
}
|
|
19
|
+
function text(file) { return existsSync(file) ? readFileSync(file, 'utf8') : ''; }
|
|
20
|
+
function environment(root) {
|
|
21
|
+
try {
|
|
22
|
+
return { ...parseEnv(text(destination(root, '.env'))), ...parseEnv(text(destination(root, '.env.local'))), ...process.env };
|
|
23
|
+
} catch { throw new SetupError('Check server environment file formatting.'); }
|
|
24
|
+
}
|
|
25
|
+
function project(root) {
|
|
26
|
+
try { return JSON.parse(readFileSync(destination(root, 'package.json'), 'utf8')); }
|
|
27
|
+
catch { throw new SetupError('Run setup from a project with a valid package.json.'); }
|
|
28
|
+
}
|
|
29
|
+
function platformFor(root, supplied) {
|
|
30
|
+
if (supplied && !['netlify', 'vercel', 'node'].includes(supplied)) throw new SetupError('Choose netlify, vercel or node.');
|
|
31
|
+
if (supplied) return supplied;
|
|
32
|
+
const netlify = existsSync(destination(root, 'netlify.toml'));
|
|
33
|
+
const vercel = existsSync(destination(root, 'vercel.json'));
|
|
34
|
+
if (netlify && !vercel) return 'netlify';
|
|
35
|
+
if (vercel && !netlify) return 'vercel';
|
|
36
|
+
throw new SetupError('Choose your hosting explicitly: --platform netlify, vercel or node.');
|
|
37
|
+
}
|
|
38
|
+
const legacyGuideDigests = new Set([
|
|
39
|
+
'b1f149af44f9ceb08fb80c18cb5d4b521620196f536d8ad422ab94d15d1d125d', // 0.0.2
|
|
40
|
+
'55e36295b6aa87ad9558cde29786998483095adeae74e149ff5c7fe78507fad7', // published 0.0.3
|
|
41
|
+
'08f919257d8c85af4e56e842705825832ae2810ed68d5c1dfc8405c8f7be7610', // published 0.0.4
|
|
42
|
+
'45bd696f196c3d1447a978987a787755b239f75e9e6a3697f4efbb210ce75597', // published 0.0.5
|
|
43
|
+
'a5b2c1100ff704c2675feb6fecb50cf7371dda446b33d4655d43089bfba14a2e', // published 0.0.6
|
|
44
|
+
]);
|
|
45
|
+
const clientGuide = readFileSync(new URL('./client-guide.md', import.meta.url), 'utf8');
|
|
46
|
+
|
|
47
|
+
function filesFor(root, platform) {
|
|
48
|
+
const files = {
|
|
49
|
+
'stopnetic-client-guide.md': clientGuide,
|
|
50
|
+
'.env.stopnetic.example': `# SERVER settings; copy actual values from Stopnetic Integration.\nSTOPNETIC_WEBSITE_ORIGIN=https://www.example.com\nSTOPNETIC_SITE_ID=\nSTOPNETIC_API_URL=https://your-stopnetic-service/api/integration/decision\nSTOPNETIC_API_TOKEN=\nSTOPNETIC_DECISION_PUBLIC_KEY=\nSTOPNETIC_COOKIE_SECRET=\n`,
|
|
51
|
+
};
|
|
52
|
+
if (platform === 'netlify') {
|
|
53
|
+
const toml = text(destination(root, 'netlify.toml'));
|
|
54
|
+
const build = toml.match(/\[build\]([^]*?)(?=\n\[|$)/)?.[1] || '';
|
|
55
|
+
if (/^\s*base\s*=/m.test(build)) throw new SetupError('Run from the Netlify base directory and review custom base configuration before using setup.');
|
|
56
|
+
const section = toml.match(/\[functions\]([^]*?)(?=\n\[|$)/)?.[1] || '';
|
|
57
|
+
const match = section.match(/^\s*directory\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/m);
|
|
58
|
+
if (/^\s*directory\s*=/m.test(section) && !match) throw new SetupError('Review your custom Netlify functions directory.');
|
|
59
|
+
const directory = match?.[1] || 'netlify/functions';
|
|
60
|
+
destination(root, directory);
|
|
61
|
+
files[`${directory}/stopnetic.mjs`] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default request => (access ??= createStopneticAccessHandler())(request);\nexport const config = { path: ['/api/stopnetic/access', '/api/stopnetic/verify'], preferStatic: false };\n`;
|
|
62
|
+
} else if (platform === 'vercel') {
|
|
63
|
+
files['api/stopnetic/[operation].mjs'] = `import { createStopneticAccessHandler } from '@stopnetic/js/server';\nlet access;\nexport default { fetch: request => (access ??= createStopneticAccessHandler())(request) };\n`;
|
|
64
|
+
} else {
|
|
65
|
+
files['stopnetic.server.mjs'] = `import { createStopneticServer } from '@stopnetic/js/server';\nexport function installStopnetic(app) {\n const { access, guard } = createStopneticServer();\n app.use(access);\n app.use(guard);\n}\n`;
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function setup(input, { platform: supplied, dryRun = false } = {}) {
|
|
71
|
+
const root = realpathSync(resolve(input));
|
|
72
|
+
const pkg = project(root);
|
|
73
|
+
if (pkg.dependencies?.next || pkg.devDependencies?.next) throw new SetupError('Use @stopnetic/next for a Next.js project.');
|
|
74
|
+
const platform = platformFor(root, supplied);
|
|
75
|
+
const files = filesFor(root, platform);
|
|
76
|
+
const manifestPath = destination(root, manifestName);
|
|
77
|
+
let manifest;
|
|
78
|
+
if (existsSync(manifestPath)) {
|
|
79
|
+
try { manifest = JSON.parse(text(manifestPath)); } catch { throw new SetupError('Check the existing Stopnetic setup manifest.'); }
|
|
80
|
+
if (manifest.platform !== platform) throw new SetupError('A different hosting setup already exists. Review it before changing platform.');
|
|
81
|
+
}
|
|
82
|
+
const upgraded = new Set();
|
|
83
|
+
// Validate every destination before making any change. Only an unchanged
|
|
84
|
+
// older generated guide can be replaced; customer modifications are refused.
|
|
85
|
+
for (const [name, content] of Object.entries(files)) {
|
|
86
|
+
const file = destination(root, name);
|
|
87
|
+
if (existsSync(file) && text(file) !== content) {
|
|
88
|
+
const recorded = manifest?.files?.[name];
|
|
89
|
+
if (name === 'stopnetic-client-guide.md' && legacyGuideDigests.has(recorded)
|
|
90
|
+
&& digest(text(file)) === recorded) upgraded.add(name);
|
|
91
|
+
else throw new SetupError(`Existing file needs manual merging: ${name}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const local = destination(root, '.env.local');
|
|
95
|
+
const ignore = destination(root, '.gitignore');
|
|
96
|
+
const env = environment(root);
|
|
97
|
+
if (dryRun) return `Dry run: ${platform}; would prepare ${Object.keys(files).join(', ')} and private cookie configuration. No files changed.`;
|
|
98
|
+
for (const [name, content] of Object.entries(files)) {
|
|
99
|
+
const file = destination(root, name);
|
|
100
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
101
|
+
if (upgraded.has(name)) writeFileSync(file, content);
|
|
102
|
+
else if (!existsSync(file)) writeFileSync(file, content, { flag: 'wx' });
|
|
103
|
+
}
|
|
104
|
+
if (!env.STOPNETIC_COOKIE_SECRET) writeFileSync(local, `${text(local).replace(/\n?$/, '\n')}STOPNETIC_COOKIE_SECRET=${randomBytes(32).toString('base64url')}\n`, { mode: 0o600 });
|
|
105
|
+
if (!/(^|\n)\.env\.local$/.test(text(ignore).trimEnd())) writeFileSync(ignore, `${text(ignore).replace(/\n?$/, '\n')}.env.local\n`);
|
|
106
|
+
writeFileSync(manifestPath, JSON.stringify({ v: 1, platform, files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, digest(content)])) }, null, 2) + '\n');
|
|
107
|
+
return `Prepared ${platform} setup. Follow stopnetic-client-guide.md, configure server env, wrap protected APIs, build and deploy. Then run stopnetic-js check. No service requests or trial activation occurred.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function checkSetup(input, { localHttp = false } = {}) {
|
|
111
|
+
const root = realpathSync(resolve(input));
|
|
112
|
+
let manifest;
|
|
113
|
+
try { manifest = JSON.parse(text(destination(root, manifestName))); } catch { throw new SetupError('Run stopnetic-js init first.'); }
|
|
114
|
+
if (manifest.v !== 1 || !['netlify', 'vercel', 'node'].includes(manifest.platform)) throw new SetupError('Invalid setup manifest.');
|
|
115
|
+
const expected = filesFor(root, manifest.platform);
|
|
116
|
+
for (const [name, content] of Object.entries(expected)) {
|
|
117
|
+
if (manifest.files?.[name] !== digest(content) || text(destination(root, name)) !== content) throw new SetupError(`Generated file changed or missing: ${name}`);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const options = optionsFromEnv(environment(root));
|
|
121
|
+
if (!localHttp && !options.origin?.startsWith('https:')) throw new Error();
|
|
122
|
+
if (!localHttp && !options.apiUrl?.startsWith('https:')) throw new Error();
|
|
123
|
+
createStopneticAccessHandler(options);
|
|
124
|
+
} catch { throw new SetupError('Check server settings, HTTPS URLs, installation/signing keys and the separate cookie secret. No secret values are displayed.'); }
|
|
125
|
+
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.`;
|
|
126
|
+
}
|
|
@@ -1,8 +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.
|
|
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.
|
|
@@ -1,20 +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
|
-
}));
|
|
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
|
+
}));
|
|
@@ -1,21 +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); }
|
|
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); }
|
|
@@ -1,21 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,27 +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);
|
|
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);
|
|
@@ -1,4 +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));
|
|
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));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function loadPrivateData(signal?: AbortSignal): Promise<unknown>;
|
|
1
|
+
export function loadPrivateData(signal?: AbortSignal): Promise<unknown>;
|
|
@@ -1,9 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,48 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { createRoot } from 'react-dom/client';
|
|
2
|
-
import App from './App';
|
|
3
|
-
createRoot(document.getElementById('app')!).render(<App/>);
|
|
1
|
+
import { createRoot } from 'react-dom/client';
|
|
2
|
+
import App from './App';
|
|
3
|
+
createRoot(document.getElementById('app')!).render(<App/>);
|